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.
This commit is contained in:
Maurice
2026-10-10 19:56:54 +02:00
parent ef68c62d80
commit ac918d362f
6 changed files with 106 additions and 23 deletions
+37
View File
@@ -24,6 +24,43 @@ function walk(dir, files = []) {
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);
-3
View File
@@ -19,7 +19,6 @@
"tests/e2e/feeds.e2e.test.ts": 1,
"tests/e2e/files.e2e.test.ts": 1,
"tests/e2e/google-quota.e2e.test.ts": 1,
"tests/e2e/harness.ts": 1,
"tests/e2e/journey.e2e.test.ts": 1,
"tests/e2e/mailer.e2e.test.ts": 1,
"tests/e2e/maps.e2e.test.ts": 1,
@@ -54,8 +53,6 @@
"tests/e2e/trips.e2e.test.ts": 1,
"tests/e2e/vacay.e2e.test.ts": 1,
"tests/e2e/weather.e2e.test.ts": 1,
"tests/helpers/db-mock.ts": 3,
"tests/helpers/test-db.ts": 1,
"tests/integration/admin.test.ts": 1,
"tests/integration/airtrail-sync-request-context.test.ts": 1,
"tests/integration/api-docs.test.ts": 1,
+7 -6
View File
@@ -16,24 +16,25 @@
* to the code under test; the remaining `src/db/database`
* mocks are the legacy this counts down
*
* Every `vi.mock(` / `vi.doMock(` whose path ends in one of those modules
* counts, per file, against scripts/test-mocks-baseline.json; a file without
* Every `vi.mock(` / `vi.doMock(` outside a comment whose path (a string, or
* `import('...')`) ends in one of those modules counts, per file, against scripts/test-mocks-baseline.json; a file without
* an entry may hold none (scripts/lib/count-ratchet.mjs has the rules).
*
* npm run lint:test-mocks check against the baseline (CI)
* npm run lint:test-mocks -- --update lower the counts; never raises or adds an entry
*/
import { cliMain } from './lib/count-ratchet.mjs';
import { cliMain, stripComments } from './lib/count-ratchet.mjs';
const MOCK_CALL = /\bvi\.(?:mock|doMock)\(\s*(['"`])([^'"`]+)\1/g;
// The path as a string, or wrapped in import(), the typed form vi.mock also takes.
const MOCK_CALL = /\bvi\.(?:mock|doMock)\(\s*(?:import\(\s*)?(['"`])([^'"`]+)\1/g;
/** A mocked path that names one of the held modules, with or without an extension. */
const HELD_TARGET = /(?:^|\/)src\/(?:config|websocket|db(?:\/[^'"`]*)?|nest\/realtime(?:\/[^'"`]*)?)$/;
/** The module mocks of held targets in one file's text. */
/** The module mocks of held targets in one file's text; a mock quoted in a comment does not count. */
export function countHeldMocks(text) {
let n = 0;
for (const match of text.matchAll(MOCK_CALL)) {
for (const match of stripComments(text).matchAll(MOCK_CALL)) {
const target = match[2].replace(/\.(?:[cm]?[jt]s)$/, '');
if (HELD_TARGET.test(target)) n++;
}
@@ -1,16 +1,13 @@
{
"counts": {
"tests/helpers/accommodations-service.ts": 8,
"tests/helpers/budget-repos.ts": 1,
"tests/helpers/fake-realtime.ts": 1,
"tests/helpers/maps-service.ts": 1,
"tests/helpers/notifications.ts": 10,
"tests/helpers/place-import.ts": 1,
"tests/helpers/plugin-host.ts": 45,
"tests/helpers/sessions.ts": 1,
"tests/helpers/share-repos.ts": 1,
"tests/helpers/storage-fixture.ts": 1,
"tests/helpers/test-addons.ts": 2,
"tests/helpers/test-addons.ts": 1,
"tests/helpers/test-app.ts": 1,
"tests/helpers/test-uow.ts": 3,
"tests/integration/dayReorder.test.ts": 3,
@@ -59,7 +56,7 @@
"tests/unit/nest/budget.service.db.test.ts": 6,
"tests/unit/nest/budget.service.test.ts": 1,
"tests/unit/nest/calendar.service.test.ts": 6,
"tests/unit/nest/categories.service.test.ts": 2,
"tests/unit/nest/categories.service.test.ts": 1,
"tests/unit/nest/channel-override.test.ts": 2,
"tests/unit/nest/charging.test.ts": 6,
"tests/unit/nest/collab.service.test.ts": 6,
@@ -122,7 +119,7 @@
"tests/unit/nest/place-enrichment.description.test.ts": 1,
"tests/unit/nest/place-enrichment.service.test.ts": 1,
"tests/unit/nest/place-photos/place-photo-cache.job.test.ts": 2,
"tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 2,
"tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 1,
"tests/unit/nest/place-regions-repair.test.ts": 1,
"tests/unit/nest/place-shadow.service.test.ts": 1,
"tests/unit/nest/places.directions.test.ts": 6,
@@ -144,7 +141,7 @@
"tests/unit/nest/roadtrip-search.test.ts": 3,
"tests/unit/nest/roadtrip.service.test.ts": 1,
"tests/unit/nest/route-usage.service.test.ts": 1,
"tests/unit/nest/school-holidays.service.test.ts": 2,
"tests/unit/nest/school-holidays.service.test.ts": 1,
"tests/unit/nest/sessions/sessions.service.test.ts": 1,
"tests/unit/nest/settings.service.test.ts": 1,
"tests/unit/nest/share.service.test.ts": 3,
@@ -157,7 +154,7 @@
"tests/unit/nest/synology.service.test.ts": 1,
"tests/unit/nest/system-notices.branch-coverage.test.ts": 1,
"tests/unit/nest/system-notices.service.test.ts": 1,
"tests/unit/nest/tags.service.test.ts": 2,
"tests/unit/nest/tags.service.test.ts": 1,
"tests/unit/nest/thumbnail.service.test.ts": 1,
"tests/unit/nest/todo.service.test.ts": 5,
"tests/unit/nest/token.service.test.ts": 1,
+31 -6
View File
@@ -11,8 +11,9 @@
* 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/ counts,
* per file, against scripts/test-new-service-baseline.json; a file without an
* 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
@@ -25,9 +26,8 @@
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { cliMain } from './lib/count-ratchet.mjs';
import { cliMain, stripComments } from './lib/count-ratchet.mjs';
const NEW_CALL = /\bnew\s+([A-Z][A-Za-z0-9_]*(?:Service|Repository))\s*[(<]/g;
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. */
@@ -119,10 +119,35 @@ export function zeroArgClasses(classes) {
return new Set([...byName.keys()].filter((name) => resolve(name)));
}
/** The hand-built services and repositories in one file's text, `allowed` left out. */
// `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 text.matchAll(NEW_CALL)) if (!allowed.has(match[1])) n++;
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;
}
@@ -77,6 +77,19 @@ describe('lint:test-mocks', () => {
expect(countHeldMocks(text)).toBe(0);
});
it('TEST-RATCHET-005: counts the import() form of a mock, and not a mock quoted in a comment', () => {
const text = [
`${MOCK}import('../../src/config'), () => ({}));`,
`${DO_MOCK}import("../../src/db/database"), async () => ({}));`,
'/**',
` * ${MOCK}'../../src/db/database', async () =>`,
' */',
`// ${MOCK}'../../src/config', () => ({}));`,
`const path = "// not a comment"; ${MOCK}'../../src/nest/realtime/realtime.service', () => ({}));`,
].join('\n');
expect(countHeldMocks(text)).toBe(3);
});
it('TEST-RATCHET-003: fails a new mock, passes at the baseline and fails a stale entry until --update', () => {
const mock = `${MOCK}'../../src/config', () => ({}));\n`;
const grown = run(
@@ -151,6 +164,19 @@ describe('lint:test-new-service', () => {
expect(countNewServices(text, new Set(['RealtimeService']))).toBe(3);
});
it('TEST-RATCHET-016: an import alias and a parenthesised class count as the service they name, a comment does not', () => {
const text = [
"import { DaysService as Days, TodoService } from '../../src/nest/days/days.service';",
"import { RealtimeService as Rt } from '../../src/nest/realtime/realtime.service';",
`const a = ${NEW}Days(repo, uow);`,
`const b = ${['ne', 'w'].join('')}(TodoService)(permissions);`,
`const c = ${NEW}Rt();`,
`// const d = ${NEW}DaysService(repo, uow);`,
`const e = ${NEW}Map<string, Days>();`,
].join('\n');
expect(countNewServices(text, new Set(['RealtimeService']))).toBe(2);
});
it('TEST-RATCHET-013: derives the allowlist from src/ and tests/helpers/ when it runs', () => {
const files = {
'src/plain.service.ts': 'export class PlainService { ping() {} }',