diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86bee9632..cab2dfd29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -317,6 +317,18 @@ jobs: # code uses Kysely for join reads and the entity methods for CRUD. run: cd server && npm run lint:query-api + - name: Inline zod in the MCP tools may only shrink + # Counts the z. calls per *.mcp.ts against + # scripts/mcp-inline-zod-baseline.json. A tool field derives from the + # request schema in @trek/shared or a shared primitive (idSchema). + run: cd server && npm run lint:mcp-zod + + - name: Error envelopes may only shrink + # Counts status: 4xx/5xx literals and new HttpException in services, and + # if (result.error) branches in controllers and MCP tools, against + # scripts/service-http-baseline.json. A service throws a DomainError. + run: cd server && npm run lint:service-http + - name: Open request schemas may only shrink # Needs the shared build above. Counts open shapes (records of # unknown, passthrough, any) in shared's request schemas. diff --git a/CLAUDE.md b/CLAUDE.md index bef14f338..8f9bd8df4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ cd server && npm run probe:pg # dialect helpers + the repos cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate) cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate) cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate) +cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate) +cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate) cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` @@ -127,7 +129,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql` and `lint:query-api`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:query-api`, `lint:mcp-zod` and `lint:service-http`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. - **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`. - **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e25863eca..01cf76f79 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -29,6 +29,8 @@ npm run probe:pg # dialect helpers + the repository statements it reach npm run lint:tx # methods writing more than once outside one transaction may only shrink (CI gate) npm run lint:test-sql # raw better-sqlite3 statements in tests/ may only shrink (CI gate) npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate) +npm run lint:mcp-zod # inline z. calls per *.mcp.ts may only shrink; tool fields derive from @trek/shared (CI gate) +npm run lint:service-http # error-envelope idioms (status: 4xx in services, if (result.error) in callers) may only shrink (CI gate) npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests) ``` @@ -46,6 +48,8 @@ npm run contracts:dto-open # open shapes in the createZodDto schemas may only sh **`lint:query-api`** (`scripts/query-api-ratchet.mjs`) counts every `.qb(` and `createQueryBuilder(` under `src/db/repositories/` per file (the `TrekRepository` base that defines `qb` is left out) against `scripts/query-api-baseline.json`: a file may hold at most its entry, a file without one none, and an entry above what its file holds now fails until `npm run lint:query-api -- --update` lowers it (it never raises or adds one). The rule it holds is "Which query API" under SQL dialect below. +**`lint:mcp-zod`** (`scripts/mcp-inline-zod.mjs`) and **`lint:service-http`** (`scripts/service-http-ratchet.mjs`) share the per-file count rules of `scripts/lib/count-ratchet.mjs`: a file may hold at most its entry in its baseline (`scripts/mcp-inline-zod-baseline.json`, `scripts/service-http-baseline.json`), a file without one none, and an entry above what its file holds now fails until `-- --update` lowers it (never raises or adds). `lint:mcp-zod` counts every `z.` in a `*.mcp.ts`: a tool field derives from the request schema in `@trek/shared` or a shared primitive (`idSchema`); a bound REST deliberately lacks stays inline and counted. `lint:service-http` counts, in a `*.service.ts`, the `status: 4xx`/`5xx` literals of a returned `{ error, status }` and every `new HttpException(`, and in a `*.controller.ts` or `*.mcp.ts` every branch on a returned error (`if (result.error)`, `if ('error' in r)`). A service refuses by throwing a `DomainError` (`src/nest/common/domain-error.ts`) instead; `src/nest/README.md` ("Refusals") has the shape. + Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. **Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). `npm run build` succeeding does **not** mean the code typechecks — run `npm run typecheck`. diff --git a/server/package.json b/server/package.json index 9a0a610eb..dd5c7cd01 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,8 @@ "lint:tx": "node scripts/tx-writes.mjs", "lint:test-sql": "node scripts/test-sql-ratchet.mjs", "lint:query-api": "node scripts/query-api-ratchet.mjs", + "lint:mcp-zod": "node scripts/mcp-inline-zod.mjs", + "lint:service-http": "node scripts/service-http-ratchet.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", diff --git a/server/scripts/lib/count-ratchet.mjs b/server/scripts/lib/count-ratchet.mjs new file mode 100644 index 000000000..ad70565b0 --- /dev/null +++ b/server/scripts/lib/count-ratchet.mjs @@ -0,0 +1,132 @@ +/* + * The per-file count ratchet shared by lint:mcp-zod and lint:service-http. + * + * A check names the tree it walks, which files in it count and how to count one + * file's text. Every file is held to its entry in the check's baseline JSON + * ({ "counts": { "": n } }); a file without an entry may + * hold none. An entry above what its file holds now, or for a file that is + * gone, fails as well until --update lowers it, so a fixed hit cannot come back + * unseen. --update never raises an entry and never adds one. + * + * --dir= points a check at another server root (the unit tests use it). + */ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, files); + else files.push(path); + } + return files; +} + +/** Counts keyed by the server-relative POSIX path, files without any left out. */ +export function scan(serverDir, check) { + const dir = join(serverDir, check.root); + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw new Error( + `${check.root}/ does not exist under ${serverDir}: the check would pass without looking at anything`, + ); + } + const counts = {}; + for (const path of walk(dir)) { + const file = relative(serverDir, path).split('\\').join('/'); + if (!check.include(file)) continue; + const n = check.count(readFileSync(path, 'utf8'), file); + if (n > 0) counts[file] = n; + } + return counts; +} + +/** The baseline as committed; missing or malformed stops the run instead of reading as empty. */ +export function readBaseline(path, name) { + let parsed; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')); + } catch (err) { + throw new Error(`${name} cannot be read: ${err.message}`); + } + const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + if (!isObject(parsed) || !isObject(parsed.counts)) + throw new Error(`${name} must be an object with a "counts" object`); + for (const [file, n] of Object.entries(parsed.counts)) { + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${name}: ${file} holds ${JSON.stringify(n)}, expected a whole number above 0`); + } + } + return parsed; +} + +/** The counts lowered to what the files hold now. Never raises an entry, never adds one. */ +export function lowerCounts(baseline, counts) { + const lowered = {}; + for (const [file, allowed] of Object.entries(baseline.counts)) { + const now = counts[file] ?? 0; + if (now > 0) lowered[file] = Math.min(allowed, now); + } + return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b))); +} + +/** Files over their allowance, and entries above what their file holds now. */ +export function compare(baseline, counts) { + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline.counts[file] ?? 0)); + const lowered = lowerCounts(baseline, counts); + const stale = Object.entries(baseline.counts).filter(([file, n]) => lowered[file] !== n); + return { grown, stale }; +} + +/** Runs a check as its CLI does; answers the exit code. */ +export function runCheck(check, argv, serverDirDefault) { + const dirArg = argv.find((a) => a.startsWith('--dir=')); + const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : serverDirDefault; + const baselinePath = join(serverDir, check.baseline); + + const counts = scan(serverDir, check); + const baseline = readBaseline(baselinePath, check.baseline); + if (argv.includes('--update')) { + baseline.counts = lowerCounts(baseline, counts); + writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n'); + } + + const { grown, stale } = compare(baseline, counts); + for (const [file, n] of grown) { + const entry = baseline.counts[file]; + console.error( + `FAIL ${file}: ${n} ${check.unit}, ` + + (entry ? `its baseline is ${entry}. ` : 'a file without a baseline entry may hold none. ') + + check.advice, + ); + } + for (const [file, entry] of stale) { + console.error( + `FAIL ${file} is held at ${entry} in ${check.baseline}, ` + + (file in counts ? `but it has ${counts[file]} now.` : 'but it has none left (or the file is gone).'), + ); + } + if (stale.length) { + console.error( + `Run npm run ${check.script} -- --update to lower the baseline with the change that made it smaller.`, + ); + } + const total = Object.values(counts).reduce((sum, n) => sum + n, 0); + console.log(`${check.name}: ${total} ${check.unit} in ${Object.keys(counts).length} file(s) held at their baseline`); + return grown.length || stale.length ? 1 : 0; +} + +/** Starts `check` when `moduleUrl` is the script node was started with (compared by real path). */ +export function cliMain(check, moduleUrl) { + const isCli = + Boolean(process.argv[1]) && + existsSync(process.argv[1]) && + realpathSync(process.argv[1]) === fileURLToPath(moduleUrl); + if (!isCli) return; + try { + process.exitCode = runCheck(check, process.argv.slice(2), fileURLToPath(new URL('..', moduleUrl))); + } catch (err) { + console.error(`FAIL ${err.message}`); + process.exitCode = 1; + } +} diff --git a/server/scripts/mcp-inline-zod-baseline.json b/server/scripts/mcp-inline-zod-baseline.json new file mode 100644 index 000000000..7130307d1 --- /dev/null +++ b/server/scripts/mcp-inline-zod-baseline.json @@ -0,0 +1,38 @@ +{ + "counts": { + "src/nest/accommodations/accommodations.mcp.ts": 28, + "src/nest/airports/airports.mcp.ts": 3, + "src/nest/assignments/assignments.mcp.ts": 10, + "src/nest/atlas/atlas.mcp.ts": 22, + "src/nest/budget/budget.mcp.ts": 36, + "src/nest/collab/collab.mcp.ts": 20, + "src/nest/collections/collections.mcp.ts": 14, + "src/nest/day-notes/day-notes.mcp.ts": 10, + "src/nest/days/days.mcp.ts": 2, + "src/nest/files/files.mcp.ts": 4, + "src/nest/help/help.mcp.ts": 3, + "src/nest/integrations/airtrail.mcp.ts": 2, + "src/nest/integrations/dawarich.mcp.ts": 13, + "src/nest/journey/journey.mcp.ts": 66, + "src/nest/maps/maps.mcp.ts": 18, + "src/nest/memories/memories.mcp.ts": 8, + "src/nest/notifications/notifications.mcp.ts": 2, + "src/nest/packing/packing.mcp.ts": 15, + "src/nest/places/places.mcp.ts": 68, + "src/nest/plugins/contributions/plugin-pois.mcp.ts": 4, + "src/nest/plugins/contributions/plugin-search.mcp.ts": 5, + "src/nest/reservations/reservations.mcp.ts": 64, + "src/nest/roadtrip/day-boundaries.mcp.ts": 1, + "src/nest/roadtrip/google-route.mcp.ts": 2, + "src/nest/roadtrip/roadtrip.mcp.ts": 16, + "src/nest/school-holidays/school-holidays.mcp.ts": 2, + "src/nest/settings/settings.mcp.ts": 21, + "src/nest/share/share.mcp.ts": 7, + "src/nest/tags/tags.mcp.ts": 4, + "src/nest/todo/todo.mcp.ts": 14, + "src/nest/transit/transit.mcp.ts": 14, + "src/nest/trips/trips.mcp.ts": 24, + "src/nest/vacay/vacay.mcp.ts": 25, + "src/nest/weather/weather.mcp.ts": 8 + } +} diff --git a/server/scripts/mcp-inline-zod.mjs b/server/scripts/mcp-inline-zod.mjs new file mode 100644 index 000000000..edbae4bb7 --- /dev/null +++ b/server/scripts/mcp-inline-zod.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/* + * lint:mcp-zod: inline zod in the MCP tool files may only shrink. + * + * An MCP tool's input is meant to derive from the request contract in + * @trek/shared (`xRequestSchema.shape.field`, `.pick()`, `.extend()`, + * `.describe()`), or from a shared primitive such as idSchema, so a bound that + * changes on the REST side changes on the tool too. Every `z.` written in a + * `*.mcp.ts` under src/nest is a field spelled by hand instead. Where REST is + * deliberately looser (an open body, a field without a length) the tool keeps + * its own bound, and its count says so. + * + * Each file is held to its entry in scripts/mcp-inline-zod-baseline.json, and a + * new tool file holds none (scripts/lib/count-ratchet.mjs has the rules). + * + * npm run lint:mcp-zod check against the baseline (CI) + * npm run lint:mcp-zod -- --update lower the counts; never raises or adds an entry + */ +import { cliMain } from './lib/count-ratchet.mjs'; + +const ZOD_CALL = /\bz\./g; + +/** The inline zod calls in one file's text, comments included. */ +export function countInlineZod(text) { + return text.match(ZOD_CALL)?.length ?? 0; +} + +export const check = { + name: 'mcp-zod', + script: 'lint:mcp-zod', + root: 'src/nest', + baseline: 'scripts/mcp-inline-zod-baseline.json', + unit: 'inline z. call(s)', + advice: 'Derive the field from the request schema in @trek/shared or a shared primitive (idSchema) instead.', + include: (file) => file.endsWith('.mcp.ts'), + count: (text) => countInlineZod(text), +}; + +cliMain(check, import.meta.url); diff --git a/server/scripts/service-http-baseline.json b/server/scripts/service-http-baseline.json new file mode 100644 index 000000000..4b19bd7f2 --- /dev/null +++ b/server/scripts/service-http-baseline.json @@ -0,0 +1,33 @@ +{ + "counts": { + "src/nest/auth/auth-public.controller.ts": 1, + "src/nest/auth/auth.service.ts": 5, + "src/nest/auth/passkey.controller.ts": 1, + "src/nest/auth/passkey.service.ts": 2, + "src/nest/auth/registration-invites.service.ts": 1, + "src/nest/collab/collab.controller.ts": 1, + "src/nest/collab/collab.mcp.ts": 3, + "src/nest/doc-sync/doc-sync-config.service.ts": 2, + "src/nest/llm-parse/llm-local.service.ts": 7, + "src/nest/memories/immich.controller.ts": 6, + "src/nest/memories/immich.service.ts": 15, + "src/nest/memories/memories.mcp.ts": 6, + "src/nest/memories/synology.controller.ts": 1, + "src/nest/memories/synology.service.ts": 5, + "src/nest/memories/unified.controller.ts": 7, + "src/nest/notifications/push/push.controller.ts": 1, + "src/nest/oauth/oauth-api.controller.ts": 4, + "src/nest/oauth/oauth-public.controller.ts": 1, + "src/nest/oauth/oauth.service.ts": 22, + "src/nest/oidc/oidc.controller.ts": 3, + "src/nest/oidc/oidc.service.ts": 1, + "src/nest/photos/photos.controller.ts": 1, + "src/nest/place-import/place-import.service.ts": 1, + "src/nest/plugins/contributions/plugin-pois.controller.ts": 1, + "src/nest/plugins/contributions/plugin-pois.mcp.ts": 1, + "src/nest/plugins/contributions/plugin-pois.service.ts": 4, + "src/nest/reservations/reservations.mcp.ts": 4, + "src/nest/tokens/token.service.ts": 2, + "src/nest/vacay/vacay.mcp.ts": 1 + } +} diff --git a/server/scripts/service-http-ratchet.mjs b/server/scripts/service-http-ratchet.mjs new file mode 100644 index 000000000..a02b99464 --- /dev/null +++ b/server/scripts/service-http-ratchet.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/* + * lint:service-http: the result-envelope idioms may only shrink. + * + * A service refuses by throwing a DomainError (src/nest/common/domain-error.ts) + * with the status and the exact text the client sees. The global filter turns it + * into REST's `{ error }` and the MCP registry into the tool's errorResult, so no + * caller translates it. What this counts is the older way, where every caller did: + * + * - in a *.service.ts: a `status: 4xx` or `status: 5xx` literal (the + * `return { error, status }` result) and `new HttpException(` (HTTP in the + * domain layer); + * - in a *.controller.ts or *.mcp.ts: a branch on a returned error, + * `if (result.error)`, `if ('error' in result)`. + * + * Each file is held to its entry in scripts/service-http-baseline.json, and a + * file without an entry holds none (scripts/lib/count-ratchet.mjs has the rules). + * + * npm run lint:service-http check against the baseline (CI) + * npm run lint:service-http -- --update lower the counts; never raises or adds an entry + */ +import { cliMain } from './lib/count-ratchet.mjs'; + +const STATUS_LITERAL = /\bstatus:\s*[45]\d\d\b/g; +const HTTP_EXCEPTION = /\bnew HttpException\(/g; +const RESULT_ERROR_BRANCH = + /\bif\s*\(\s*(?:'error'\s+in\s+[\w$.]+|"error"\s+in\s+[\w$.]+|[\w$]+(?:\.[\w$]+)*\.error\s*\))/g; + +const hits = (text, pattern) => text.match(pattern)?.length ?? 0; + +/** The envelope idioms in one service file. */ +export function countServiceIdioms(text) { + return hits(text, STATUS_LITERAL) + hits(text, HTTP_EXCEPTION); +} + +/** The branches on a returned error in one controller or MCP file. */ +export function countResultBranches(text) { + return hits(text, RESULT_ERROR_BRANCH); +} + +export const check = { + name: 'service-http', + script: 'lint:service-http', + root: 'src/nest', + baseline: 'scripts/service-http-baseline.json', + unit: 'error-envelope idiom(s)', + advice: 'Throw a DomainError from the service (src/nest/common/domain-error.ts) and let REST and MCP answer it.', + include: (file) => /\.(service|controller|mcp)\.ts$/.test(file), + count: (text, file) => (file.endsWith('.service.ts') ? countServiceIdioms(text) : countResultBranches(text)), +}; + +cliMain(check, import.meta.url); diff --git a/server/src/nest/README.md b/server/src/nest/README.md index d3fc59644..217c4161b 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -177,7 +177,17 @@ and the MCP registry's error mapper (`trekMcpErrorMapper` in `src/mcp/nest-mcp-policy.ts`) answers the tool call with `errorResult` of the same text, so the controller and the tool need no branch of their own. A caller that has to act on a refusal before passing it on (an audit row, a timing pad) -uses `catchDomainError`. +uses `catchDomainError`. `npm run lint:service-http` holds the old idioms +(`status: 4xx` in a service, `new HttpException` in a service, `if (result.error)` +in a controller or tool) to a count that only shrinks. + +A trip-scoped write that both surfaces offer is one service method per use +case (`AccommodationsService.createStay`, `PackingWritesService.updateItem`): +the trip gate (`requireTripWrite` in `common/trip-writer.ts`), the reference +checks, the transaction and every event. The controller hands it a REST +writer, the tool `McpToolGuardsService.tripWriter`; the writer says how the +events travel, the use case which ones go out, so a client sees the same +events whichever surface made the change. ## Tests diff --git a/server/tests/unit/scripts/error-envelope-ratchets.test.ts b/server/tests/unit/scripts/error-envelope-ratchets.test.ts new file mode 100644 index 000000000..796a1b654 --- /dev/null +++ b/server/tests/unit/scripts/error-envelope-ratchets.test.ts @@ -0,0 +1,179 @@ +/** + * scripts/mcp-inline-zod.mjs (lint:mcp-zod) and scripts/service-http-ratchet.mjs + * (lint:service-http) on their shared rules in scripts/lib/count-ratchet.mjs. + * Both run as the real scripts against throwaway server roots (--dir) and once + * on this repository, so the committed baselines are checked by the unit suite. + */ +import { countInlineZod } from '../../../scripts/mcp-inline-zod.mjs'; +import { countResultBranches, countServiceIdioms } from '../../../scripts/service-http-ratchet.mjs'; + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPTS = path.join(__dirname, '../../../scripts'); +const SERVER_ROOT = path.join(__dirname, '../../..'); +const NEST = 'src/nest'; + +const roots: string[] = []; + +function serverRoot(files: Record, baselineName: string, baseline: unknown = { counts: {} }): string { + const dir = mkdtempSync(path.join(tmpdir(), 'trek-envelope-')); + roots.push(dir); + mkdirSync(path.join(dir, NEST), { recursive: true }); + mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), text); + } + if (baseline !== null) { + writeFileSync( + path.join(dir, 'scripts', baselineName), + typeof baseline === 'string' ? baseline : JSON.stringify(baseline), + ); + } + return dir; +} + +function run(script: string, dir: string, ...args: string[]): { status: number | null; out: string } { + try { + const stdout = execFileSync(process.execPath, [path.join(SCRIPTS, script), `--dir=${dir}`, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { status: 0, out: stdout }; + } catch (err) { + const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string }; + return { status, out: `${stdout}${stderr}` }; + } +} + +afterEach(() => { + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +// Spelled in pieces so the files read as what they are, fixtures. +const Z = ['z', '.'].join(''); +const zodField = (n: number) => ` name: ${Z}string(),\n`.repeat(n); +const STATUS = ['status', ': 404'].join(''); +const HTTP = ['new Http', 'Exception('].join(''); + +describe('the counters', () => { + it('ENV-RATCHET-001: counts every z. call, and nothing that only ends in z', () => { + expect(countInlineZod(`${Z}object({ a: ${Z}string(), b: idSchema, c: fizz.x })`)).toBe(2); + }); + + it('ENV-RATCHET-002: counts the result envelope and HTTP in a service, not a 2xx status', () => { + expect( + countServiceIdioms(`return { error: 'x', ${STATUS} };\nthrow ${HTTP}{}, 400);\nreturn { status: 200 };`), + ).toBe(2); + expect(countServiceIdioms(`return { error: 'x', status: 503 };`)).toBe(1); + }); + + it('ENV-RATCHET-003: counts the branches on a returned error, in each spelling', () => { + const text = [ + 'if (result.error) {', + "if ('error' in outcome) throw x;", + 'if (res.error) return errorResult(res.error);', + 'if (this.state.last.error) {', + 'if (!parsed.success) {', + 'if (error) {', + ].join('\n'); + expect(countResultBranches(text)).toBe(4); + }); +}); + +describe('mcp-inline-zod.mjs', () => { + const BASE = 'mcp-inline-zod-baseline.json'; + + it('ENV-RATCHET-010: passes a tree whose tools derive every field', () => { + const { status, out } = run( + 'mcp-inline-zod.mjs', + serverRoot({ [`${NEST}/a/a.mcp.ts`]: 'tripId: idSchema,\n' }, BASE), + ); + expect(status).toBe(0); + expect(out).toContain('mcp-zod: 0 inline z. call(s) in 0 file(s)'); + }); + + it('ENV-RATCHET-011: fails a new tool file spelling a field inline, and leaves other files alone', () => { + const dir = serverRoot({ [`${NEST}/a/a.mcp.ts`]: zodField(1), [`${NEST}/a/a.service.ts`]: zodField(5) }, BASE); + const { status, out } = run('mcp-inline-zod.mjs', dir); + expect(status).toBe(1); + expect(out).toContain( + `FAIL ${NEST}/a/a.mcp.ts: 1 inline z. call(s), a file without a baseline entry may hold none.`, + ); + expect(out).not.toContain('a.service.ts'); + }); + + it('ENV-RATCHET-012: holds a file to its entry, and --update only lowers', () => { + const file = `${NEST}/a/a.mcp.ts`; + expect( + run('mcp-inline-zod.mjs', serverRoot({ [file]: zodField(3) }, BASE, { counts: { [file]: 2 } })).out, + ).toContain('its baseline is 2.'); + const dir = serverRoot({ [file]: zodField(1) }, BASE, { counts: { [file]: 3, [`${NEST}/gone.mcp.ts`]: 1 } }); + const stale = run('mcp-inline-zod.mjs', dir); + expect(stale.status).toBe(1); + expect(stale.out).toContain(`FAIL ${file} is held at 3`); + expect(stale.out).toContain('npm run lint:mcp-zod -- --update'); + run('mcp-inline-zod.mjs', dir, '--update'); + expect(JSON.parse(readFileSync(path.join(dir, 'scripts', BASE), 'utf8'))).toEqual({ counts: { [file]: 1 } }); + expect(run('mcp-inline-zod.mjs', dir).status).toBe(0); + }); + + it('ENV-RATCHET-013: a missing or malformed baseline, or no src/nest, stops the run', () => { + expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, null)).out).toContain('cannot be read'); + expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, '{"counts":{"x":0}}')).out).toContain( + 'expected a whole number above 0', + ); + expect(run('mcp-inline-zod.mjs', serverRoot({}, BASE, '[]')).out).toContain( + 'must be an object with a "counts" object', + ); + const empty = mkdtempSync(path.join(tmpdir(), 'trek-envelope-')); + roots.push(empty); + expect(run('mcp-inline-zod.mjs', empty).out).toContain('does not exist'); + }); + + it('ENV-RATCHET-014: this repository holds its committed baseline', () => { + const { status, out } = run('mcp-inline-zod.mjs', SERVER_ROOT); + expect(out).toContain('mcp-zod:'); + expect(status).toBe(0); + }); +}); + +describe('service-http-ratchet.mjs', () => { + const BASE = 'service-http-baseline.json'; + + it('ENV-RATCHET-020: counts services, controllers and tools, nothing else', () => { + const dir = serverRoot( + { + [`${NEST}/a/a.service.ts`]: `return { error: 'x', ${STATUS} };\n`, + [`${NEST}/a/a.controller.ts`]: 'if (result.error) {\n', + [`${NEST}/a/a.mcp.ts`]: "if ('error' in r) return x;\n", + [`${NEST}/a/a.helpers.ts`]: `return { error: 'x', ${STATUS} };\n`, + }, + BASE, + ); + const { status, out } = run('service-http-ratchet.mjs', dir); + expect(status).toBe(1); + for (const file of ['a.service.ts', 'a.controller.ts', 'a.mcp.ts']) { + expect(out).toContain( + `FAIL ${NEST}/a/${file}: 1 error-envelope idiom(s), a file without a baseline entry may hold none.`, + ); + } + expect(out).not.toContain('a.helpers.ts'); + expect(out).toContain('Throw a DomainError'); + }); + + it('ENV-RATCHET-021: a service that throws DomainError passes', () => { + const dir = serverRoot({ [`${NEST}/a/a.service.ts`]: "throw new DomainError(404, 'Trip not found');\n" }, BASE); + expect(run('service-http-ratchet.mjs', dir).status).toBe(0); + }); + + it('ENV-RATCHET-022: this repository holds its committed baseline', () => { + const { status, out } = run('service-http-ratchet.mjs', SERVER_ROOT); + expect(out).toContain('service-http:'); + expect(status).toBe(0); + }); +});