mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user