chore(server): lint:mcp-zod and lint:service-http hold inline tool zod and error envelopes to a shrinking baseline

This commit is contained in:
Maurice
2026-10-10 16:32:27 +02:00
parent c353302e3f
commit e7c1acfb47
11 changed files with 505 additions and 2 deletions
+12
View File
@@ -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.
+3 -1
View File
@@ -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).
+4
View File
@@ -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`.
+2
View File
@@ -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",
+132
View File
@@ -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": { "<server-relative path>": 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=<path> 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;
}
}
@@ -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
}
}
+39
View File
@@ -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);
+33
View File
@@ -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
}
}
+52
View File
@@ -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);
+11 -1
View File
@@ -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
@@ -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<string, string>, 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);
});
});