From a0e1a39acc2f741a72b26c6ca5c36470ce077713 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 12:35:08 +0200 Subject: [PATCH] ci(server): ratchet the open shapes in every schema a DTO binds contracts:open in shared only counts exports named *RequestSchema, but the name is a convention: 37 body DTOs wrap a schema called *BodySchema, *InputSchema or plain *Schema, and tags.dto.ts declares its schemas locally. pluginInstanceConfigUpdateSchema (a record of unknown behind PluginConfigDto), createAlbumLinkSchema with five open shapes and a dozen more were never counted, and a new open body under one of those names passed both checks. tests/unit/contracts/dto-open-shapes.test.ts walks the other way round. It finds every class that extends createZodDto under src/, imports it, and counts the open shapes in the schema the Zod pipe validates with shared's walk. Each class is held to scripts/dto-open-shapes-baseline.json, 61 DTOs with 101 open shapes today, and a new DTO may hold none. It fails closed: a createZodDto call that is not an exported class, two DTO classes with one name, an empty tree or an unreadable baseline stop it. It runs with the unit tests; npm run contracts:dto-open runs it on its own and --update only lowers the baseline. The five DTO classes declared inside roadtrip controllers are exported so the walk can reach their schemas. The selector test picks up prettier's wrapping of the cases added before. --- server/CLAUDE.md | 3 + server/package.json | 1 + server/scripts/dto-open-shapes-baseline.json | 63 +++++ server/scripts/dto-open-shapes.mjs | 48 ++++ .../roadtrip/day-boundaries.controller.ts | 2 +- .../nest/roadtrip/google-route.controller.ts | 4 +- .../roadtrip-preferences.controller.ts | 2 +- .../roadtrip/roadtrip-search.controller.ts | 2 +- .../unit/contracts/dto-open-shapes.test.ts | 238 ++++++++++++++++++ .../restricted-syntax-selectors.test.ts | 14 +- shared/CLAUDE.md | 2 +- 11 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 server/scripts/dto-open-shapes-baseline.json create mode 100644 server/scripts/dto-open-shapes.mjs create mode 100644 server/tests/unit/contracts/dto-open-shapes.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1d8f47bf2..c0222d812 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -19,10 +19,13 @@ node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratch npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any) npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate) npm run lint:boundaries # import cycles and cross-domain reach-ins may only shrink (CI gate) +npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests) ``` **`lint:boundaries`** (`scripts/import-boundaries.mjs`) parses every import under `src/` and holds five lists in `scripts/import-boundaries-baseline.json`: load-time file cycles (type-only and lazy imports, and the MikroORM entity relations, do not count), dependencies between two `nest/` folders that already depend on each other, shared-kernel files (`nest/common`, `nest/database`, `nest/app-config`) importing a domain, a domain importing another domain's internals, and `src/db` importing `src/nest`. A domain's public surface is its `*.module`, `*.service`, `*.guard`, `*.decorator`, `*.types`, `*.interface`, `*.contract`, `*.constants` and `*.logger` files plus the short `PUBLIC_FILES` list in the script; anything else is an internal. A new entry fails the check; `npm run lint:boundaries -- --update` drops the ones that no longer occur and never adds one. +**`contracts:dto-open`** (`tests/unit/contracts/dto-open-shapes.test.ts`, wrapped by `scripts/dto-open-shapes.mjs`) finds every `export class X extends createZodDto(...)` under `src/`, imports it and counts the open shapes (loose object, record of unknown, `z.unknown()`/`z.any()`, `z.custom()`) in the schema the Zod pipe validates, with shared's `contracts:open` walk. Each DTO class is held to its entry in `scripts/dto-open-shapes-baseline.json`, and a new one may hold none, whatever its schema is called and wherever it is declared. A `createZodDto` call that is not an exported class, or two DTO classes with one name, fail the check. After narrowing a schema, `npm run contracts:dto-open -- --update` lowers the baseline (it never raises or adds an entry). It needs `shared/` built. + **`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. After a split, `npm run lint:size -- --update` lowers the baseline (it never raises or adds an entry). Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. diff --git a/server/package.json b/server/package.json index f750938ff..fcc939995 100644 --- a/server/package.json +++ b/server/package.json @@ -14,6 +14,7 @@ "db:call-graph": "node scripts/db-call-graph.mjs", "lint:size": "node scripts/size-lint.mjs", "lint:boundaries": "node scripts/import-boundaries.mjs", + "contracts:dto-open": "node scripts/dto-open-shapes.mjs", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", "gen:entities": "node --import tsx scripts/generate-entities.ts", diff --git a/server/scripts/dto-open-shapes-baseline.json b/server/scripts/dto-open-shapes-baseline.json new file mode 100644 index 000000000..791de06ec --- /dev/null +++ b/server/scripts/dto-open-shapes-baseline.json @@ -0,0 +1,63 @@ +{ + "AccommodationUpdateDto": 1, + "AddTripPhotosDto": 3, + "AdminAddonUpdateDto": 1, + "AdminDefaultUserSettingsDto": 1, + "AdminPermissionsDto": 1, + "AdminTestNotificationDto": 2, + "AppSettingsUpdateDto": 1, + "AutoBackupSettingsDto": 1, + "BookingImportConfirmDto": 1, + "CollabPollCreateDto": 1, + "CollectionImportDto": 1, + "CollectionImportIntoDto": 1, + "CreateAlbumLinkDto": 5, + "ImmichSearchDto": 1, + "ImmichSettingsDto": 2, + "ImmichTestDto": 1, + "JourneyAddTripDto": 2, + "JourneyContributorAddDto": 3, + "JourneyContributorUpdateDto": 2, + "JourneyCreateDto": 4, + "JourneyEntryCreateDto": 2, + "JourneyEntryPhotoUploadDto": 2, + "JourneyEntryUpdateDto": 1, + "JourneyGalleryVideoDto": 2, + "JourneyLinkPhotoDto": 3, + "JourneyPhotoUpdateDto": 1, + "JourneyPreferencesDto": 1, + "JourneyProviderPhotosDto": 8, + "JourneyReorderEntriesDto": 2, + "JourneyShareLinkDto": 5, + "JourneyUpdateDto": 1, + "LlmLocalPullDto": 1, + "OauthClientCreateDto": 1, + "PackingImportDto": 1, + "PasskeyLoginVerifyDto": 1, + "PasskeyRegisterVerifyDto": 2, + "PasskeyRenameDto": 1, + "PlaceCreateDto": 1, + "PlaceEnrichmentDto": 1, + "PlaceUpdateDto": 1, + "PluginActivateDto": 1, + "PluginConfigDto": 1, + "PluginEgressHostsDto": 2, + "PluginInstallDto": 1, + "PluginLinkDto": 1, + "PluginRetrustDto": 1, + "PluginRouteDto": 1, + "PluginUninstallDto": 1, + "PluginUpdateDto": 1, + "PluginUserSettingsUpdateDto": 2, + "RemoveTripPhotoDto": 1, + "ReservationCreateDto": 1, + "ReservationUpdateDto": 1, + "SettingsBulkDto": 1, + "SettingUpsertDto": 1, + "SetTripPhotoSharingDto": 2, + "SynologySearchDto": 1, + "SynologySettingsDto": 1, + "SynologyTestDto": 1, + "TagCreateDto": 3, + "TagUpdateDto": 3 +} diff --git a/server/scripts/dto-open-shapes.mjs b/server/scripts/dto-open-shapes.mjs new file mode 100644 index 000000000..295298f11 --- /dev/null +++ b/server/scripts/dto-open-shapes.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/* + * contracts:dto-open: runs tests/unit/contracts/dto-open-shapes.test.ts, the + * ratchet over the open shapes in every createZodDto class under src/. + * + * The check has to import the TypeScript DTO files, so it lives in that test + * and runs with the unit tests in CI; this wrapper only gives it a command and + * the lowering mode: + * + * npm run contracts:dto-open check against scripts/dto-open-shapes-baseline.json + * npm run contracts:dto-open -- --update lower the baseline to today's counts; it never + * raises or adds an entry + * + * The shared package must be built first (the DTOs import @trek/shared). + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SERVER_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const TEST = 'tests/unit/contracts/dto-open-shapes.test.ts'; + +function main(argv) { + const unknown = argv.filter((arg) => arg !== '--update'); + if (unknown.length) throw new Error(`unknown argument(s): ${unknown.join(' ')}`); + if (!existsSync(join(SERVER_ROOT, TEST))) throw new Error(`${TEST} is missing`); + const vitest = join(dirname(createRequire(import.meta.url).resolve('vitest/package.json')), 'vitest.mjs'); + const env = { ...process.env, DTO_OPEN_SHAPES_UPDATE: argv.includes('--update') ? '1' : '0' }; + const result = spawnSync(process.execPath, [vitest, 'run', TEST], { cwd: SERVER_ROOT, env, stdio: 'inherit' }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +// Compared by real path, so the check still runs when the script is started through a symlink. +const isCli = + Boolean(process.argv[1]) && + existsSync(process.argv[1]) && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (err) { + console.error(`FAIL ${err.message}`); + process.exitCode = 1; + } +} diff --git a/server/src/nest/roadtrip/day-boundaries.controller.ts b/server/src/nest/roadtrip/day-boundaries.controller.ts index 191b99062..fff30d746 100644 --- a/server/src/nest/roadtrip/day-boundaries.controller.ts +++ b/server/src/nest/roadtrip/day-boundaries.controller.ts @@ -9,7 +9,7 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g import { RealtimeService } from '../realtime/realtime.service'; import { DayBoundariesService } from './day-boundaries.service'; -class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {} +export class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {} @Controller('api/trips/:tripId/roadtrip/day-boundaries') @UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) diff --git a/server/src/nest/roadtrip/google-route.controller.ts b/server/src/nest/roadtrip/google-route.controller.ts index 978f51423..8a86ff3bb 100644 --- a/server/src/nest/roadtrip/google-route.controller.ts +++ b/server/src/nest/roadtrip/google-route.controller.ts @@ -9,8 +9,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { TripAccessGuard } from '../permissions/trip-access.guard'; import { GoogleRouteService } from './google-route.service'; -class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} -class ImportDto extends createZodDto(googleRouteImportSchema) {} +export class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} +export class ImportDto extends createZodDto(googleRouteImportSchema) {} @Controller('api') @UseGuards(AddonGuard, JwtAuthGuard) diff --git a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts index 5f99fcf39..433b92851 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts @@ -9,7 +9,7 @@ import { roadtripPreferencesUpdateSchema } from '@trek/shared'; import { createZodDto } from 'nestjs-zod'; -class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {} +export class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {} @Controller('api/trips/:tripId/roadtrip/preferences') @UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) diff --git a/server/src/nest/roadtrip/roadtrip-search.controller.ts b/server/src/nest/roadtrip/roadtrip-search.controller.ts index 7c5777706..f2dab890c 100644 --- a/server/src/nest/roadtrip/roadtrip-search.controller.ts +++ b/server/src/nest/roadtrip/roadtrip-search.controller.ts @@ -8,7 +8,7 @@ import { RequireAddon } from '../addons/require-addon.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RoadtripSearchService } from './roadtrip-search.service'; -class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} +export class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} @Controller('api/roadtrip/search-area') @UseGuards(JwtAuthGuard, AddonGuard) diff --git a/server/tests/unit/contracts/dto-open-shapes.test.ts b/server/tests/unit/contracts/dto-open-shapes.test.ts new file mode 100644 index 000000000..1133b7a32 --- /dev/null +++ b/server/tests/unit/contracts/dto-open-shapes.test.ts @@ -0,0 +1,238 @@ +/** + * contracts:dto-open: the contracts the server really binds may only get tighter. + * + * shared's contracts:open counts the open shapes in every export named + * *RequestSchema, but the name is a convention, not what the server binds: + * dozens of DTOs wrap a schema named *BodySchema, *InputSchema or just + * *Schema, and some wrap a schema declared in the dto file itself. This walks + * the other way round. It finds every `class X extends createZodDto(...)` under + * src/, imports the file, and counts the open shapes (the same walk as + * shared/scripts/open-request-schemas.mjs) in the schema that class hands the + * global ZodValidationPipe. The count per class is held against + * scripts/dto-open-shapes-baseline.json: a new DTO may hold none, an existing + * one no more than its entry. + * + * npm run contracts:dto-open this file, as CI runs it in the unit tests + * npm run contracts:dto-open -- --update lower the baseline to today's counts; it + * never raises or adds an entry + * + * It fails closed: a missing or malformed baseline, a createZodDto call that is + * not an exported class, two DTO classes with one name, or a tree without a + * single DTO stop it instead of passing with nothing checked. + */ +import { createZodDto } from 'nestjs-zod'; +import { isZodDto } from 'nestjs-zod/dto'; +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const SERVER_ROOT = path.resolve(__dirname, '../../..'); +const SRC = path.join(SERVER_ROOT, 'src'); +const BASELINE = path.join(SERVER_ROOT, 'scripts/dto-open-shapes-baseline.json'); +const OPEN_SHAPES_SCRIPT = path.resolve(SERVER_ROOT, '../shared/scripts/open-request-schemas.mjs'); + +type Counts = Record; + +interface OpenShapesModule { + openShapes(root: unknown): string[]; + compare(baseline: Counts, counts: Counts): { grown: [string, number][]; lowerable: [string, number][] }; + lowerBaseline(baseline: Counts, counts: Counts): Counts; +} + +interface DtoDeclaration { + file: string; + name: string; +} + +const DTO_CLASS = /\b(export\s+)?class\s+(\w+)\s+extends\s+createZodDto\s*\(/g; +const DTO_CALL = /\bcreateZodDto\s*\(/g; + +function sourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) sourceFiles(full, files); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) files.push(full); + } + return files; +} + +/** The source without its comments, so a createZodDto named in prose is not counted as a call. */ +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +/** + * Every DTO class declared under `root`, and the problems that keep the walk + * from seeing one: a createZodDto call that is not `export class X extends + * createZodDto(...)` (its schema could not be reached through the module), or + * a class name used twice (the baseline is keyed by name). + */ +function findDtoClasses(root: string): { dtos: DtoDeclaration[]; problems: string[] } { + const dtos: DtoDeclaration[] = []; + const problems: string[] = []; + const firstFile = new Map(); + for (const full of sourceFiles(root)) { + const code = stripComments(readFileSync(full, 'utf8')); + const file = path.relative(SERVER_ROOT, full).split(path.sep).join('/'); + const calls = code.match(DTO_CALL)?.length ?? 0; + const classes = [...code.matchAll(DTO_CLASS)]; + if (calls !== classes.length) { + problems.push( + `${file}: ${calls - classes.length} createZodDto call(s) outside \`export class X extends createZodDto(...)\``, + ); + } + for (const [, exported, name] of classes) { + if (!exported) problems.push(`${file}: ${name} is not exported, so its schema cannot be counted`); + const seen = firstFile.get(name); + if (seen) problems.push(`${name} is declared in ${seen} and ${file}: DTO class names must be unique`); + firstFile.set(name, file); + dtos.push({ file, name }); + } + } + return { dtos, problems }; +} + +/** The baseline as committed. Missing, unreadable or malformed is an error, never an empty baseline. */ +function readBaseline(file = BASELINE): Counts { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`scripts/dto-open-shapes-baseline.json cannot be read: ${(err as Error).message}`, { cause: err }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('scripts/dto-open-shapes-baseline.json must be an object of DTO class names to counts'); + } + for (const [name, n] of Object.entries(parsed)) { + if (!Number.isInteger(n) || (n as number) < 1) { + throw new Error( + `scripts/dto-open-shapes-baseline.json: ${name} holds ${JSON.stringify(n)}, expected a whole number above 0`, + ); + } + } + return parsed as Counts; +} + +/** Open-shape counts for every ZodDto class among a module's exports. */ +function countDtoExports(namespace: Record, names: string[], shapes: OpenShapesModule): Counts { + const counts: Counts = {}; + for (const name of names) { + const dto = namespace[name]; + if (typeof dto !== 'function' || !isZodDto(dto)) { + throw new Error(`${name} is not an exported createZodDto class`); + } + counts[name] = shapes.openShapes(dto.schema).length; + } + return counts; +} + +let shapes: OpenShapesModule; +beforeAll(async () => { + shapes = (await import(pathToFileURL(OPEN_SHAPES_SCRIPT).href)) as OpenShapesModule; +}); + +describe('open shapes in the DTO contracts', () => { + let counts: Counts; + let problems: string[]; + beforeAll(async () => { + const found = findDtoClasses(SRC); + problems = found.problems; + counts = {}; + const byFile = new Map(); + for (const { file, name } of found.dtos) byFile.set(file, [...(byFile.get(file) ?? []), name]); + for (const [file, names] of byFile) { + const namespace = (await import(pathToFileURL(path.join(SERVER_ROOT, file)).href)) as Record; + for (const name of names) { + try { + Object.assign(counts, countDtoExports(namespace, [name], shapes)); + } catch (err) { + // Reported once by DTO-001, together with the reason the scan found. + if (!problems.some((p) => p.includes(` ${name} `))) problems.push(`${file}: ${(err as Error).message}`); + } + } + } + }, 120_000); + + it('DTO-001: every createZodDto call is an exported, uniquely named class, and there are some', () => { + expect(problems).toEqual([]); + expect( + Object.keys(counts).length, + 'no DTO class found under src/: the check would look at nothing', + ).toBeGreaterThan(0); + }); + + it('DTO-002: no DTO holds more open shapes than the baseline allows', () => { + const { grown } = shapes.compare(readBaseline(), counts); + expect( + grown, + 'type the fields instead of accepting any object (shared/CLAUDE.md, "Rules for new contracts")', + ).toEqual([]); + }); + + it('DTO-003: the baseline names only DTOs that exist and are still open', () => { + let baseline = readBaseline(); + if (process.env.DTO_OPEN_SHAPES_UPDATE === '1') { + baseline = shapes.lowerBaseline(baseline, counts); + writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + '\n'); + } + const { lowerable } = shapes.compare(baseline, counts); + expect(lowerable, 'run `npm run contracts:dto-open -- --update` after narrowing a schema').toEqual([]); + }); +}); + +describe('the DTO walk itself', () => { + it('DTO-004: counts an open body whatever its schema is called and fails on a new one', () => { + // A body named like most of the tree (fooBodySchema, pluginConfigSchema), not *RequestSchema. + const pluginConfigSchema = z.record(z.string(), z.unknown()); + const fooBodySchema = z.looseObject({ name: z.string(), extra: z.unknown() }); + class PluginConfigDto extends createZodDto(pluginConfigSchema) {} + class FooBodyDto extends createZodDto(fooBodySchema) {} + class ClosedDto extends createZodDto(z.strictObject({ a: z.string() })) {} + + const counts = countDtoExports( + { PluginConfigDto, FooBodyDto, ClosedDto }, + ['PluginConfigDto', 'FooBodyDto', 'ClosedDto'], + shapes, + ); + expect(counts).toEqual({ PluginConfigDto: 1, FooBodyDto: 2, ClosedDto: 0 }); + expect(shapes.compare({}, counts).grown).toEqual([ + ['PluginConfigDto', 1], + ['FooBodyDto', 2], + ]); + expect(shapes.compare({ FooBodyDto: 1, PluginConfigDto: 1 }, counts).grown).toEqual([['FooBodyDto', 2]]); + expect(() => countDtoExports({}, ['GoneDto'], shapes)).toThrow(/not an exported createZodDto class/); + }); + + it('DTO-005: refuses a DTO it cannot reach and a name used twice', () => { + const root = path.join(tmpdir(), `trek-dto-walk-${process.pid}-${Date.now()}`); + const nested = path.join(root, 'b'); + try { + mkdirSync(nested, { recursive: true }); + writeFileSync( + path.join(root, 'a.dto.ts'), + [ + '// createZodDto( in a comment is not a call', + 'export class ADto extends createZodDto(a) {}', + 'class HiddenDto extends createZodDto(b) {}', + 'const Loose = createZodDto(c);', + ].join('\r\n'), + ); + writeFileSync(path.join(nested, 'b.dto.ts'), 'export class ADto extends createZodDto(d) {}\n'); + const { dtos, problems } = findDtoClasses(root); + expect(dtos.map((d) => d.name).sort()).toEqual(['ADto', 'ADto', 'HiddenDto']); + expect(problems.some((p) => p.includes('1 createZodDto call(s) outside'))).toBe(true); + expect(problems.some((p) => p.includes('HiddenDto is not exported'))).toBe(true); + expect(problems.some((p) => p.includes('ADto is declared in'))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('DTO-006: a missing or malformed baseline is an error, never an empty baseline', () => { + expect(() => readBaseline(path.join(SERVER_ROOT, 'scripts/definitely-not-here.json'))).toThrow(/cannot be read/); + expect(() => readBaseline(path.join(SERVER_ROOT, 'package.json'))).toThrow(/expected a whole number/); + }); +}); diff --git a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts index 614acfd9c..f3360ef91 100644 --- a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts +++ b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts @@ -168,8 +168,14 @@ declare const repo: { run(sql: string, params?: unknown[]): Promise; ge for (const file of exempt) { const options = await selectorsFor(file); expect(hits(options, "void connection.execute('PRAGMA wal_checkpoint(TRUNCATE)');"), file).toEqual([]); - expect(messages(options, `${PRELUDE} -void safeFetch(url);`), file).toContain(TIMEOUT); + expect( + messages( + options, + `${PRELUDE} +void safeFetch(url);`, + ), + file, + ).toContain(TIMEOUT); } }); @@ -185,7 +191,9 @@ void safeFetch(url);`), file).toContain(TIMEOUT); EXEMPT_SQL_STRINGS, ); for (const [file, n] of Object.entries(counts)) { - expect(n, `${file} holds no SQL string any more: drop it from the ignores in eslint.config.mjs`).toBeGreaterThan(0); + expect(n, `${file} holds no SQL string any more: drop it from the ignores in eslint.config.mjs`).toBeGreaterThan( + 0, + ); } }); diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 8d06013ad..d757c3719 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -36,7 +36,7 @@ One folder per domain exporting Zod schemas plus inferred types (`export type X The parity rule governs **existing** routes; it is not a license to mint new debt: - **The contract describes the wire, not the storage engine.** New booleans are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a `z.union([z.boolean(), z.number()])`. New ids use `idSchema`/`idParamSchema`, never `number | string`. Request and response types for a field must agree. -- **Use the shared primitives** instead of re-declaring bare `z.number()`/`z.string()` per domain. Narrow an existing open object rather than adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection. `contracts:open` (`scripts/open-request-schemas.mjs`) counts the open shapes in every exported `*RequestSchema` (a loose object, a record of unknown, a `z.unknown()`/`z.any()` field or body) against `scripts/open-request-schemas-baseline.json`: a new request schema may hold none, an existing one no more than its entry. It reads the built `dist/`; `src/open-request-schemas.spec.ts` runs the same check over `src/` in `npm test`. After narrowing a schema, `npm run contracts:open -- --update` lowers the baseline (it never raises or adds an entry). +- **Use the shared primitives** instead of re-declaring bare `z.number()`/`z.string()` per domain. Narrow an existing open object rather than adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection. `contracts:open` (`scripts/open-request-schemas.mjs`) counts the open shapes in every exported `*RequestSchema` (a loose object, a record of unknown, a `z.unknown()`/`z.any()` field or body, a `z.custom()`/`z.instanceof()`, each counted once per path it sits at) against `scripts/open-request-schemas-baseline.json`: a new request schema may hold none, an existing one no more than its entry. It reads the built `dist/`; `src/open-request-schemas.spec.ts` runs the same check over `src/` in `npm test`. After narrowing a schema, `npm run contracts:open -- --update` lowers the baseline (it never raises or adds an entry). The suffix is only a convention, so the server holds the schemas it really binds to the same count: `npm run contracts:dto-open --workspace=server` walks every `createZodDto` class, whatever its schema is called, against `server/scripts/dto-open-shapes-baseline.json`. - **Add a schema, add a spec** — especially for lenient parsers fed by untrusted or LLM input. - **Stay lean and isomorphic**: zero imports from client/server, no `node:` APIs, effectively no new runtime dependencies (the contract layer approaches `zod`-only; i18n is a separable concern — don't couple new contract code to it). - **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `src/i18n/languages.ts` is the one registry — derive barrels, loaders and spec maps from it.