Files
TREK/server/scripts/test-new-service-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

177 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/*
* lint:test-new-service: hand-built services and repositories in tests/ may
* only shrink.
*
* `new XService(a, b, c, ...)` in a test pins the constructor's parameter
* order: every dependency a service gains is a change to every suite that
* builds it (and to every suite that builds one of its consumers), which is
* why the harness files are among the most churned in the repository. A suite
* takes its subject from a Nest testing module instead (tests/helpers/
* test-module.ts: createTestModule with overrides), and a booted app hands
* the rest out through `app.get(...)`.
*
* Every `new <Name>Service(` and `new <Name>Repository(` under tests/ outside a
* comment counts (an import alias or `new (Name)(` counts as the class it
* names), per file, against scripts/test-new-service-baseline.json; a file without an
* entry may hold none (scripts/lib/count-ratchet.mjs has the rules). The
* allowlist is derived, not kept by hand: a class whose constructor takes no
* parameters (no constructor of its own, or an empty one, down its chain of
* `extends` inside src/ and tests/helpers/) has no wiring to pin, so building
* it with `new` does not count. RealtimeService and FakeRealtimeService are
* the common case.
*
* npm run lint:test-new-service check against the baseline (CI)
* npm run lint:test-new-service -- --update lower the counts; never raises or adds an entry
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { cliMain, stripComments } from './lib/count-ratchet.mjs';
const CLASS_DECL = /\bclass\s+([A-Za-z_$][\w$]*)(?:\s*<[^{]*?>)?(?:\s+extends\s+([A-Za-z_$][\w$.]*))?[^{]*\{/g;
/** The trees whose classes the allowlist is derived from, relative to the server root. */
const CLASS_ROOTS = ['src', 'tests/helpers'];
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, files);
else if (/\.[cm]?ts$/.test(name)) files.push(path);
}
return files;
}
/** The index just past the bracket that closes the one at `open`, skipping strings and comments. */
function closeOf(text, open) {
const pairs = { '(': ')', '{': '}', '[': ']' };
const stack = [];
for (let i = open; i < text.length; i++) {
const c = text[i];
if (c === '/' && text[i + 1] === '/') {
i = text.indexOf('\n', i);
if (i < 0) return text.length;
continue;
}
if (c === '/' && text[i + 1] === '*') {
i = text.indexOf('*/', i + 2) + 1;
if (i <= 0) return text.length;
continue;
}
if (c === '"' || c === "'" || c === '`') {
for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++;
continue;
}
if (pairs[c]) stack.push(pairs[c]);
else if (c === stack[stack.length - 1]) {
stack.pop();
if (!stack.length) return i + 1;
}
}
return text.length;
}
/**
* Every class declared in `text`: its parent, and whether its own constructor
* takes parameters (`null` when it declares none).
*/
export function parseClasses(text) {
const classes = [];
for (const match of text.matchAll(CLASS_DECL)) {
const bodyOpen = match.index + match[0].length - 1;
const body = text.slice(bodyOpen, closeOf(text, bodyOpen));
const ctor = /\bconstructor\s*\(/.exec(body);
let ctorHasParams = null;
if (ctor) {
const open = ctor.index + ctor[0].length - 1;
const params = body
.slice(open + 1, closeOf(body, open) - 1)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.trim();
ctorHasParams = params.length > 0;
}
classes.push({ name: match[1], parent: match[2] ?? null, ctorHasParams });
}
return classes;
}
/**
* The names whose constructor takes no parameters, resolved down the extends
* chain. A parent declared outside the scanned trees (a library class) is
* taken to need arguments, so a subclass without a constructor of its own counts.
*/
export function zeroArgClasses(classes) {
const byName = new Map();
for (const c of classes) if (!byName.has(c.name)) byName.set(c.name, c);
const memo = new Map();
const resolve = (name, seen = new Set()) => {
if (memo.has(name)) return memo.get(name);
const c = byName.get(name);
let zero;
if (!c || seen.has(name)) zero = false;
else if (c.ctorHasParams !== null) zero = !c.ctorHasParams;
else zero = c.parent === null ? true : resolve(c.parent, new Set([...seen, name]));
memo.set(name, zero);
return zero;
};
return new Set([...byName.keys()].filter((name) => resolve(name)));
}
// `new X(`, `new X<T>(` and `new (X)(`: the name is checked after aliases resolve.
const ANY_NEW = /\bnew(?:\s+|\s*\(\s*)([A-Za-z_$][\w$]*)\s*\)?\s*[(<]/g;
// `import { DaysService as Days }` (and `type`-less re-imports of the same shape).
const IMPORT_BLOCK = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\b/g;
const ALIAS = /\b([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g;
const SERVICE_NAME = /^[A-Z][A-Za-z0-9_]*(?:Service|Repository)$/;
/** Local name -> imported name, for every renamed import in the text. */
function importAliases(text) {
const aliases = new Map();
for (const block of text.matchAll(IMPORT_BLOCK)) {
for (const m of block[1].matchAll(ALIAS)) aliases.set(m[2], m[1]);
}
return aliases;
}
/**
* The hand-built services and repositories in one file's text, `allowed` left
* out. Comments do not count; an import alias (`DaysService as Days`) and a
* parenthesised class (`new (DaysService)(`) count as the class they name.
*/
export function countNewServices(text, allowed = new Set()) {
const code = stripComments(text);
const aliases = importAliases(code);
let n = 0;
for (const match of code.matchAll(ANY_NEW)) {
const name = aliases.get(match[1]) ?? match[1];
if (SERVICE_NAME.test(name) && !allowed.has(name)) n++;
}
return n;
}
export const check = {
name: 'test-new-service',
script: 'lint:test-new-service',
root: 'tests',
baseline: 'scripts/test-new-service-baseline.json',
unit: 'hand-built service(s) or repository(ies)',
advice:
'Take the subject from createTestModule (tests/helpers/test-module.ts) with overrides for its ' +
'collaborators, or from a booted app with app.get(), instead of calling its constructor.',
prepare: (serverDir) => {
const classes = [];
for (const root of CLASS_ROOTS) {
const dir = join(serverDir, root);
if (!existsSync(dir)) continue;
for (const path of walk(dir)) classes.push(...parseClasses(readFileSync(path, 'utf8')));
}
return zeroArgClasses(classes);
},
include: (file) => /\.[cm]?[jt]s$/.test(file),
count: (text, _file, allowed) => countNewServices(text, allowed),
};
cliMain(check, import.meta.url);