Files
HealthLog/src/__tests__/source-size-ratchet.test.ts
T
Marc-André Bombeck 2604c3e3c6 test(guards): ratchet source size, test realism and version-stamp comments
Add a ratchet mechanism (helpers/ratchet.ts): per-key counts against a JSON
baseline under src/__tests__/baselines/. A new key or a higher count fails;
a lower count fails until it is written back with
`RATCHET_WRITE=1 pnpm vitest run <guard>`, which only ever lowers numbers.
Raising one is a hand edit in a reviewed change. The baselines are
machine-written, so prettier leaves them alone.

helpers/source-metrics.ts measures the production tree through the
TypeScript compiler: the file list (dot directories included, tests and
generated code out), line counts, every function node with a stable
"outer > inner" name and its length, and the runtime import graph.

Guards and their baselines, measured on this tree:

- source-size-ratchet: 129 files over 800 lines and 415 functions over 200
  lines (catalog and data files exempt, each named with its reason; slack
  25 and 10 lines before a shrink must be written back).
- test-realism-ratchet: 321 route tests mocking @/lib/db wholesale, 138
  stubbing @/lib/api-handler, 6014 `as never` casts in 570 test files, and
  400 test files that read a source file by path (reads through readSource
  are not counted).
- version-stamp-ratchet: 10634 comment lines in 2985 files that open with a
  release version; none may be added.

Proof each can fail: a planted 953-line file with a 253-line function failed
both size ratchets, and one line appended to app/page-client.tsx failed the
file ratchet (2326 → 2327); a planted route test with vi.mock("@/lib/db")
and one `as never` failed both of those ratchets; a planted comment
"// v1.45.0 — probe stamp" failed the stamp ratchet. Raising one as-never
baseline entry by 5 failed with the write-back message, and RATCHET_WRITE=1
restored the file byte for byte. All probes were removed again.
2026-10-22 04:29:47 +02:00

169 lines
6.0 KiB
TypeScript

/**
* File and function size may only shrink.
*
* Two caps over the production source (see `helpers/source-metrics.ts` for
* what counts): a file over 800 lines, and a function over 200 lines. Most of
* the large files are one large function, so both are measured, through the
* TypeScript compiler.
*
* Every file and function over its cap today is in a baseline under
* `baselines/` with its current length. From here:
*
* - a new file or function over the cap fails;
* - a baselined one that grows past its recorded length fails. If the
* growth is deliberate, raising the number is a hand edit of the JSON in
* a reviewed change, with the reason in the PR;
* - a baselined one that shrank by more than the slack (or fell under the
* cap) fails until the lower number is written back with
* `RATCHET_WRITE=1 pnpm vitest run src/__tests__/source-size-ratchet.test.ts`.
* The slack (25 lines for a file, 10 for a function) keeps a small edit
* from forcing a baseline commit; a real split always crosses it.
*
* Catalog and data files are exempt from both caps: their length is the
* length of the thing they list, and splitting them changes navigation, not
* cohesion. Each is named below with its reason, and a named file that no
* longer exists fails, so the list cannot outlive its entries.
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { requireFloor } from "./helpers/source-files";
import {
SRC,
lineCount,
measureFunctions,
parse,
productionSources,
readSrc,
} from "./helpers/source-metrics";
import { runRatchet, writeBackHint } from "./helpers/ratchet";
const FILE_CAP = 800;
const FUNCTION_CAP = 200;
const GUARD = "src/__tests__/source-size-ratchet.test.ts";
/**
* Exempt from both caps. A key ending in `/` covers the folder. Paths are
* relative to `src/`.
*/
const CATALOGS: Readonly<Record<string, string>> = {
"lib/openapi/routes/":
"the OpenAPI contract: one schema and one path entry per route, grows with the API surface",
"lib/signals/registry.ts": "the SIGNALS record, one entry per signal",
"lib/ai/coach/eval/golden-cases.ts": "Coach evaluation fixtures",
"lib/ai/coach/system-prompt.ts": "prompt text",
"lib/measurements/apple-health-mapping.ts":
"the HealthKit identifier mapping table",
"lib/labs/biomarker-catalog.ts": "the biomarker catalogue",
"app/privacy/page.tsx":
"legal copy rendered as React text, one long static component",
};
function isCatalog(rel: string): boolean {
return Object.keys(CATALOGS).some((key) =>
key.endsWith("/") ? rel.startsWith(key) : rel === key,
);
}
function measure() {
const files = productionSources();
const fileLines: Record<string, number> = {};
const functionLines: Record<string, number> = {};
let functionsSeen = 0;
for (const rel of files) {
if (isCatalog(rel)) continue;
const text = readSrc(rel);
const lines = lineCount(text);
if (lines > FILE_CAP) fileLines[rel] = lines;
for (const fn of measureFunctions(parse(rel, text))) {
functionsSeen += 1;
if (fn.lines > FUNCTION_CAP)
functionLines[`${rel} :: ${fn.name}`] = fn.lines;
}
}
return { files: files.length, functionsSeen, fileLines, functionLines };
}
describe("source size ratchet", () => {
const m = measure();
it("reads the whole tree", () => {
expect(m.files).toBeGreaterThan(3000);
expect(m.functionsSeen).toBeGreaterThan(20_000);
requireFloor("files over the cap", Object.keys(m.fileLines), 100);
requireFloor("functions over the cap", Object.keys(m.functionLines), 300);
});
it(`no file over ${FILE_CAP} lines grows, and none is added`, () => {
const r = runRatchet("source-file-lines.json", m.fileLines, {
slack: 25,
comment: `Production files over ${FILE_CAP} lines and their length. Maintained by ${GUARD}.`,
});
expect(
r.grew,
`Files over ${FILE_CAP} lines that are new or longer than their baseline. Split the file, or raise its number in src/__tests__/baselines/source-file-lines.json in a reviewed change with the reason.`,
).toEqual([]);
expect(r.stale, writeBackHint(GUARD)).toEqual([]);
});
it(`no function over ${FUNCTION_CAP} lines grows, and none is added`, () => {
const r = runRatchet("source-function-lines.json", m.functionLines, {
slack: 10,
comment: `Production functions over ${FUNCTION_CAP} lines, keyed "file :: name", and their length. Maintained by ${GUARD}.`,
});
expect(
r.grew,
`Functions over ${FUNCTION_CAP} lines that are new or longer than their baseline. Extract a part, or raise its number in src/__tests__/baselines/source-function-lines.json in a reviewed change with the reason.`,
).toEqual([]);
expect(r.stale, writeBackHint(GUARD)).toEqual([]);
});
it("names no catalog that is gone", () => {
const missing = Object.keys(CATALOGS).filter(
(key) => !existsSync(join(SRC, key)),
);
expect(missing).toEqual([]);
});
});
describe("the function measure", () => {
const fns = (text: string) => measureFunctions(parse("probe.ts", text));
it("names declarations, assignments, methods and call arguments", () => {
const names = fns(
[
"export function a() {}",
"const b = () => {};",
"const o = { c() {}, d: function () {} };",
"class K { constructor() {} e() {} }",
"wrap(async () => {});",
"function f() { return () => 1; }",
].join("\n"),
).map((f) => f.name);
expect(names).toEqual([
"a",
"b",
"c",
"d",
"constructor",
"e",
"wrap(arg)",
"f",
"f > (anonymous)",
]);
});
it("counts lines from the first to the last line of the node", () => {
const [fn] = fns("function a() {\n x();\n y();\n}\n");
expect(fn.lines).toBe(4);
expect(fn.line).toBe(1);
});
it("keeps two same-named functions apart", () => {
const names = fns("call(() => 1);\ncall(() => 2);").map((f) => f.name);
expect(names).toEqual(["call(arg)", "call(arg) #2"]);
});
});