Files
TREK/server/scripts/lib/count-ratchet.mjs
T
Maurice ac918d362f build(server): the test-mock and hand-built-service ratchets read code, not comments, and see the import() and alias forms
lint:test-mocks missed vi.mock(import('../src/config'), ...), the typed form Vitest takes, and counted mocks quoted in doc comments, which left slack a real mock could take. lint:test-new-service missed a service built under an import alias or as new (Name)(...). Both now strip comments first and recognise those forms; the baselines lose the entries that were only comments.
2026-10-10 20:16:14 +02:00

174 lines
6.5 KiB
JavaScript

/*
* The per-file count ratchet shared by lint:mcp-zod, lint:service-http,
* lint:test-mocks and lint:test-new-service.
*
* 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;
}
/**
* The text with every // and /* comment blanked out (newlines kept, so line
* numbers hold), strings and template literals left as they are. A count
* that reads code, not prose, runs over this: a call quoted in a doc comment
* is not a call.
*/
export function stripComments(text) {
let out = '';
let i = 0;
while (i < text.length) {
const c = text[i];
const next = text[i + 1];
if (c === '/' && next === '/') {
const end = text.indexOf('\n', i);
i = end < 0 ? text.length : end;
continue;
}
if (c === '/' && next === '*') {
const end = text.indexOf('*/', i + 2);
const stop = end < 0 ? text.length : end + 2;
out += text.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop;
continue;
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1;
while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1;
out += text.slice(i, j + 1);
i = j + 1;
continue;
}
out += c;
i++;
}
return out;
}
/** 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`,
);
}
// A check that needs to read the tree first (a list of class names, say)
// builds that context once here; count() gets it with every file.
const context = check.prepare ? check.prepare(serverDir) : undefined;
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, context);
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;
}
}