From c0d387025d661358545e1a3702cd70631c8b70dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:09:03 +0200 Subject: [PATCH] test(guards): strip comments the way the compiler reads them, in every guard Forty-two guard files carried their own comment stripper, block comments first in one non-greedy pass, so a /* inside a line comment opened a block that hid the code after it, and the stricter copies hid code behind a /* inside a string, which every e2e route glob has. They all read through the shared stripComments now, and it scans the way the parser does: strings, template literals and regular expressions keep every character, a comment inside a template expression goes, and line numbers are kept. A parity test strips every source file both ways, with the scanner and with the TypeScript parser, and fails on the first that differs. No guard reports anything new. Each one fails when the stripper returns nothing; three of them passed on an empty file and assert that the code they ban is read at all now. --- .../account-payload-consumer-guard.test.ts | 11 +- src/__tests__/ai-call-timeout-guard.test.ts | 8 +- src/__tests__/ai-client-abort-guard.test.ts | 8 +- .../ai-egress-capability-guard.test.ts | 9 +- .../ai-reasoning-admin-guard.test.ts | 8 +- .../ai-reasoning-effort-guard.test.ts | 8 +- .../background-reasoning-inventory.test.ts | 8 +- .../bearer-scope-enforcement-guard.test.ts | 5 +- .../client-fetch-limit-guard.test.ts | 37 +-- src/__tests__/client-random-id-guard.test.ts | 10 +- .../consent-grant-explicit-guard.test.ts | 7 +- .../dashboard-summary-read-plan-guard.test.ts | 5 +- src/__tests__/datetime-helper-guard.test.ts | 8 +- ...doctor-report-control-gating-guard.test.ts | 6 +- .../e2e-conditional-skip-guard.test.ts | 11 +- .../e2e-critical-no-api-stub-guard.test.ts | 27 +- src/__tests__/e2e-route-cleanup-guard.test.ts | 11 +- .../e2e-shared-session-guard.test.ts | 10 +- src/__tests__/helpers/source-files.ts | 230 ++++++++++++++---- src/__tests__/job-queue-runtime-guard.test.ts | 6 +- ...ar-fit-single-implementation-guard.test.ts | 5 +- src/__tests__/list-cap-reader-guard.test.ts | 6 +- src/__tests__/list-page-guard.test.ts | 6 +- .../openapi-route-coverage-guard.test.ts | 5 +- .../pulse-day-mean-reader-guard.test.ts | 10 +- src/__tests__/pwa-mobile-shell-guard.test.ts | 13 +- .../query-error-error-prop-guard.test.ts | 6 +- ...d-fence-headerless-transport-guard.test.ts | 14 +- .../report-selection-egress-guard.test.ts | 6 +- .../restore-lock-consolidation-guard.test.ts | 5 +- .../session-user-projection-guard.test.ts | 8 +- .../step-up-web-caller-guard.test.ts | 12 +- src/__tests__/strip-comments-parity.test.ts | 130 ++++++++++ .../success-affordance-guard.test.ts | 6 +- src/__tests__/tombstone-read-guard.test.ts | 6 +- src/__tests__/write-rate-limit-guard.test.ts | 6 +- .../read-routes-write-nothing.test.ts | 3 +- .../auth/native/__tests__/invariants.test.ts | 8 +- .../__tests__/day-door-coverage-guard.test.ts | 12 +- .../coach-history-reach-guard.test.ts | 3 +- .../coach-locale-no-german-default.test.ts | 10 +- .../encrypted-column-writers.test.ts | 8 +- src/lib/dashboard/__tests__/snapshot.test.ts | 7 +- .../ai-job-capability-inventory.test.ts | 5 +- 44 files changed, 392 insertions(+), 341 deletions(-) create mode 100644 src/__tests__/strip-comments-parity.test.ts diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 6adc921bf..647af281a 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -69,6 +69,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, it, expect } from "vitest"; +import { stripComments } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const ME_ROUTE = join(SRC, "app", "api", "auth", "me", "route.ts"); @@ -201,16 +202,6 @@ function payloadFields(source: string): string[] { return [...new Set(fields)]; } -/** - * Comments are stripped before matching. A doc comment naming a field is a - * claim about a consumer, not a consumer — `lastReportPracticeName` had one of - * those for its whole dead life, and counting it would make this guard agree - * with the very prose that was wrong. - */ -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); -} - /** * A property access (`user.glucoseUnit`) or a destructuring / object position * (`{ glucoseUnit }`, `{ glucoseUnit: unit }`). diff --git a/src/__tests__/ai-call-timeout-guard.test.ts b/src/__tests__/ai-call-timeout-guard.test.ts index 686b8a225..c8f9b097e 100644 --- a/src/__tests__/ai-call-timeout-guard.test.ts +++ b/src/__tests__/ai-call-timeout-guard.test.ts @@ -38,17 +38,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const AI_DIR = join(SRC, "lib", "ai"); -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - function code(rel: string): string { return stripComments(readFileSync(join(SRC, rel), "utf8")); } diff --git a/src/__tests__/ai-client-abort-guard.test.ts b/src/__tests__/ai-client-abort-guard.test.ts index 4fabbc194..5a577e22e 100644 --- a/src/__tests__/ai-client-abort-guard.test.ts +++ b/src/__tests__/ai-client-abort-guard.test.ts @@ -23,16 +23,10 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - /** The text of the call that starts at `open` (the index of its `(`). */ function callText(source: string, open: number): string { let depth = 0; diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 403e64687..34beefbe9 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -22,7 +22,7 @@ import { join } from "node:path"; import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types"; import type { runStatusCompletion } from "@/lib/insights/status-provider"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -38,13 +38,6 @@ const EGRESS_CALL = const CAPABILITY_CHECK = /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/; -/** Comments name the helpers too; only code counts. */ -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) .filter((p) => !p.startsWith("generated/")) diff --git a/src/__tests__/ai-reasoning-admin-guard.test.ts b/src/__tests__/ai-reasoning-admin-guard.test.ts index 221419db5..dcd96078c 100644 --- a/src/__tests__/ai-reasoning-admin-guard.test.ts +++ b/src/__tests__/ai-reasoning-admin-guard.test.ts @@ -38,7 +38,7 @@ import { type ReasoningSurface, } from "@/lib/ai/reasoning/resolve"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -115,12 +115,6 @@ const SETTERS = [ const RESOLVER_IMPORT = /from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/; -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - describe("every call that carries reasoning took it from the resolver", () => { const files = walkSourceFiles(SRC, { floor: 3000 }) .filter((p) => !p.startsWith("generated/")) diff --git a/src/__tests__/ai-reasoning-effort-guard.test.ts b/src/__tests__/ai-reasoning-effort-guard.test.ts index 2c4fac64e..ab888cc3b 100644 --- a/src/__tests__/ai-reasoning-effort-guard.test.ts +++ b/src/__tests__/ai-reasoning-effort-guard.test.ts @@ -33,16 +33,10 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - function code(rel: string): string { return stripComments(readFileSync(join(SRC, rel), "utf8")); } diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts index 0354a6a87..e1a70977a 100644 --- a/src/__tests__/background-reasoning-inventory.test.ts +++ b/src/__tests__/background-reasoning-inventory.test.ts @@ -31,7 +31,7 @@ import { describe, expect, it } from "vitest"; import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -62,12 +62,6 @@ const NEVER = [ "lib/jobs/workout-insight-generate.ts", ]; -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - /** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */ const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g; diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 0d8b5315e..8f089e0e6 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -19,6 +19,7 @@ import { guardSourceOf } from "./helpers/auth-guards"; import { readSource, readSourceText, + stripComments, walkSourceFiles, } from "./helpers/source-files"; @@ -90,10 +91,6 @@ function argsFrom(code: string, open: number): string { return code.slice(open + 1, i); } -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - /** Split an argument list at its top-level commas. */ function topLevelArgs(args: string): string[] { const parts: string[] = []; diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 87b9a9f6b..95e2363c7 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -30,7 +30,11 @@ import { describe, expect, it } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSourceText, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -98,31 +102,13 @@ const LIMIT_PATTERNS: readonly RegExp[] = [ const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/; const PAGER_LINES = 3; -/** The line with every block comment that opens and closes on it removed. */ -function withoutInlineBlocks(text: string): string { - return text.replace(/\/\*.*?\*\//g, ""); -} - -/** A whole-line comment: it describes code, it sends nothing. */ -function isCommentLine(text: string): boolean { - const trimmed = withoutInlineBlocks(text).trim(); - return ( - trimmed.startsWith("//") || - trimmed.startsWith("/*") || - trimmed.startsWith("*") - ); -} - /** - * The line as code a pager could live in: a trailing `// …` comment cut off - * (only after whitespace, so a `https://` inside a string survives), and - * every offset pinned to a literal zero removed, in the URL spelling and - * both `URLSearchParams` spellings. + * The line as code a pager could live in: every offset pinned to a literal + * zero removed, in the URL spelling and both `URLSearchParams` spellings. + * The lines come comment-free from `stripComments`. */ function pagerCode(text: string): string { - if (isCommentLine(text)) return ""; - return withoutInlineBlocks(text) - .replace(/\s\/\/.*$/, "") + return text .replace(/[?&]offset=0(?!\d)/g, "") .replace(/\.set\(\s*["']offset["']\s*,\s*["']0["']\s*\)/g, "") .replace(/\boffset:\s*["']?0["']?(?![\d.])/g, ""); @@ -151,12 +137,11 @@ interface Hit { /** Every literal limit in the given source, with whether a pager is beside it. */ function findLimitHits(file: string, source: string): Hit[] { - const lines = source.split("\n"); + // Comments describe old shapes; they send nothing. + const lines = stripComments(source, file).split("\n"); const infinite = /\buseInfiniteQuery\b/.test(source); const hits: Hit[] = []; lines.forEach((text, index) => { - // Comments describe old shapes; they send nothing. - if (isCommentLine(text)) return; for (const pattern of LIMIT_PATTERNS) { for (const match of text.matchAll(pattern)) { const near = lines diff --git a/src/__tests__/client-random-id-guard.test.ts b/src/__tests__/client-random-id-guard.test.ts index af8da88f1..d271f5d60 100644 --- a/src/__tests__/client-random-id-guard.test.ts +++ b/src/__tests__/client-random-id-guard.test.ts @@ -24,6 +24,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const ROOT = join(__dirname, "..", ".."); const SRC = join(ROOT, "src"); @@ -54,15 +55,6 @@ const SERVER_SIDE_EXEMPT = new Set([ /** A direct call, tolerant of whitespace between the parts. */ const DIRECT_CALL = /\bcrypto\s*\.\s*randomUUID\s*\(/; -/** - * Comments are prose, not calls. Without this the guard fires on the very - * doc comments that explain why the call was removed — a guard that its own - * explanation trips is one the next reader deletes. - */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); -} - /** Every `.ts(x)` under `src/`, as a repo-relative POSIX path. */ function sourceFiles(dir: string = SRC, out: string[] = []): string[] { for (const name of readdirSync(dir)) { diff --git a/src/__tests__/consent-grant-explicit-guard.test.ts b/src/__tests__/consent-grant-explicit-guard.test.ts index c09cbdb9d..110e9c2ad 100644 --- a/src/__tests__/consent-grant-explicit-guard.test.ts +++ b/src/__tests__/consent-grant-explicit-guard.test.ts @@ -27,6 +27,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const ROOT = process.cwd(); const SRC = join(ROOT, "src"); @@ -67,12 +68,6 @@ function rel(file: string): string { return relative(ROOT, file).split(sep).join("/"); } -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) - .replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1"); -} - /** The source spans of every `useEffect(` / `useLayoutEffect(` call. */ function effectSpans(source: string): Array<[number, number]> { const spans: Array<[number, number]> = []; diff --git a/src/__tests__/dashboard-summary-read-plan-guard.test.ts b/src/__tests__/dashboard-summary-read-plan-guard.test.ts index 6b582f7fe..869d71e30 100644 --- a/src/__tests__/dashboard-summary-read-plan-guard.test.ts +++ b/src/__tests__/dashboard-summary-read-plan-guard.test.ts @@ -23,6 +23,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { stripComments } from "./helpers/source-files"; const READS = join(process.cwd(), "src/lib/dashboard/summary-reads.ts"); /** The summary's builder, moved out of its route; it makes the reads. */ @@ -35,9 +36,7 @@ const ROUTE = join(process.cwd(), "src/lib/dashboard/summary/build.ts"); * shape being warned about. */ function code(path: string): string { - return readFileSync(path, "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return stripComments(readFileSync(path, "utf8"), path); } describe("dashboard summary reads — latest reading per type", () => { diff --git a/src/__tests__/datetime-helper-guard.test.ts b/src/__tests__/datetime-helper-guard.test.ts index 42012eb8c..7e8779752 100644 --- a/src/__tests__/datetime-helper-guard.test.ts +++ b/src/__tests__/datetime-helper-guard.test.ts @@ -18,7 +18,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const HELPER = "lib/validations/datetime.ts"; @@ -30,12 +30,6 @@ function sourceFiles(): string[] { .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); } -function stripComments(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); -} - const DIRECT_DATETIME = /\.\s*datetime\s*\(/g; const HELPER_CALL = /\bisoDateTime\s*\(/g; diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 1c2d51c0c..65a6ecb38 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -44,6 +44,7 @@ import { type ReportLeafId, } from "@/lib/report-selection/catalogue"; import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import { stripComments } from "./helpers/source-files"; const REPO_ROOT = join(__dirname, "..", ".."); const COLLECT_PATH = join( @@ -65,11 +66,6 @@ const PICKER_PATH = join( const collectSource = readFileSync(COLLECT_PATH, "utf8"); const pickerSource = readFileSync(PICKER_PATH, "utf8"); -/** Strip comments so a leaf named only in prose does not count as read. */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below diff --git a/src/__tests__/e2e-conditional-skip-guard.test.ts b/src/__tests__/e2e-conditional-skip-guard.test.ts index 46541e6e9..a889f0400 100644 --- a/src/__tests__/e2e-conditional-skip-guard.test.ts +++ b/src/__tests__/e2e-conditional-skip-guard.test.ts @@ -22,6 +22,7 @@ import { globSync, readFileSync } from "node:fs"; import { join, sep } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const E2E = join(process.cwd(), "e2e"); @@ -33,16 +34,6 @@ function specFiles(): string[] { .sort(); } -/** Blank out comments, keeping offsets and newlines so line numbers hold. */ -function stripComments(src: string): string { - return src - .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) - .replace( - /(^|[^:"'`])\/\/.*$/gm, - (m, lead: string) => lead + " ".repeat(m.length - lead.length), - ); -} - /** * The top-level arguments of the call whose `(` sits at `open`, as trimmed * source text. Strings and template literals are skipped over whole, so a diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts index 983d71d8b..5b0023d4d 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -38,6 +38,7 @@ import { describe, expect, it } from "vitest"; import { readSourceText, + stripComments, requireFloor, walkSourceFiles, } from "./helpers/source-files"; @@ -59,20 +60,6 @@ const CRITICAL_TEST = /** A route interception call; the first argument is examined separately. */ const ROUTE_CALL = /\.route(?:FromHAR)?\(\s*/g; -/** - * The source without its comment LINES: a line whose first non-blank text is - * `//`, and a block that opens at the start of a line. Deliberately not the - * shared `stripComments`, which opens a block at any `/*` — and route globs - * (`"**\/api/day/**"`, `"https://host/**"`) are full of them, so it would - * swallow the code after the first glob, including any `.route(` there. Prose - * about this rule lives in comment lines, which is what has to go. - */ -function stripCommentLines(source: string): string { - return source - .replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, "") - .replace(/^[ \t]*\/\/.*$/gm, ""); -} - interface StubFinding { /** Where the interception lives (the spec, or the helper it imports). */ where: string; @@ -108,7 +95,7 @@ function firstArgument( /** Every `/api` (or unprovable) interception in one source text. */ function apiStubsIn(source: string): string[] { - const code = stripCommentLines(source); + const code = stripComments(source); const hits: string[] = []; for (const m of code.matchAll(ROUTE_CALL)) { const arg = firstArgument(code, (m.index ?? 0) + m[0].length); @@ -125,7 +112,7 @@ function apiStubsIn(source: string): string[] { function relativeImports( source: string, ): Array<{ names: string[]; from: string }> { - const code = stripCommentLines(source); + const code = stripComments(source); const out: Array<{ names: string[]; from: string }> = []; for (const m of code.matchAll( /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']+)["']/g, @@ -150,7 +137,7 @@ function relativeImports( * cut at each top-level `export`, so each slice is one export's body. */ function stubbingExports(helperSource: string): string[] { - const code = stripCommentLines(helperSource); + const code = stripComments(helperSource); const starts = [...code.matchAll(/^export\s+/gm)].map((m) => m.index ?? 0); const names: string[] = []; starts.forEach((start, i) => { @@ -206,7 +193,7 @@ function specFiles(): string[] { function criticalTests(): Array<{ file: string; title: string }> { const tests: Array<{ file: string; title: string }> = []; for (const file of specFiles()) { - const code = stripCommentLines(readSourceText(join(E2E, file))); + const code = stripComments(readSourceText(join(E2E, file))); for (const m of code.matchAll(CRITICAL_TEST)) { tests.push({ file, title: m[2] }); } @@ -224,9 +211,7 @@ describe("@critical e2e journeys run against the real API", () => { // A file that mentions the tag outside a test title (a describe title, a // `grep` option) is held to the same rule. for (const file of specFiles()) { - if ( - stripCommentLines(readSourceText(join(E2E, file))).includes("@critical") - ) + if (stripComments(readSourceText(join(E2E, file))).includes("@critical")) files.add(file); } const offenders = [...files].flatMap((file) => diff --git a/src/__tests__/e2e-route-cleanup-guard.test.ts b/src/__tests__/e2e-route-cleanup-guard.test.ts index 0a9d7af58..0d78ba8d0 100644 --- a/src/__tests__/e2e-route-cleanup-guard.test.ts +++ b/src/__tests__/e2e-route-cleanup-guard.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const E2E = join(process.cwd(), "e2e"); @@ -42,15 +42,6 @@ function read(rel: string): string { return readFileSync(join(E2E, rel), "utf8"); } -/** - * The file with its comments removed. The entry point documents the very API - * calls it makes, so a naive substring match would be satisfied by the prose - * alone — a fixture gutted to a no-op would still read as compliant. - */ -function stripComments(src: string): string { - return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - /** * Every `from ""` import statement in a file, as * `{ module, specifiers }` — `specifiers` is the raw brace body, or `"*"` for diff --git a/src/__tests__/e2e-shared-session-guard.test.ts b/src/__tests__/e2e-shared-session-guard.test.ts index 037c8980e..87ade7303 100644 --- a/src/__tests__/e2e-shared-session-guard.test.ts +++ b/src/__tests__/e2e-shared-session-guard.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync, globSync } from "node:fs"; import { join, sep } from "node:path"; +import { stripComments } from "./helpers/source-files"; const E2E = join(process.cwd(), "e2e"); @@ -44,15 +45,6 @@ function read(rel: string): string { return readFileSync(join(E2E, rel), "utf8"); } -/** - * The file with comments removed. This guard's own subject matter is worth - * explaining in prose inside the specs, and a naive substring match would be - * satisfied by that prose alone. - */ -function stripComments(src: string): string { - return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - function switchingSpecs(): string[] { return specFiles().filter((rel) => { const src = stripComments(read(rel)); diff --git a/src/__tests__/helpers/source-files.ts b/src/__tests__/helpers/source-files.ts index 8ba2f27f1..aa4ea0735 100644 --- a/src/__tests__/helpers/source-files.ts +++ b/src/__tests__/helpers/source-files.ts @@ -144,70 +144,192 @@ export function walkSourceFiles( } /** - * A source file with its comments removed, line comments FIRST. + * A source file with its comments removed, read the way the compiler reads it. * - * The order is the whole reason this is shared rather than re-spelled per - * guard. Every private copy in this directory strips block comments first, - * with one non-greedy `/\*[\s\S]*?\*\//` pass, and that pass cannot tell a - * real block opener from a `/*` that happens to sit inside a line comment. - * This tree has one: `src/app/mood/page-client.tsx` explains its gate with - * "Every `/api/mood-entries/*` route also enforces the gate", and the `/*` in - * that path opened a fake block that swallowed the next 2.2 kB of real code — - * the module gate three lines below it included. A guard reading that source - * was searching a file with a hole in it and reporting a clean sweep. + * Shared rather than re-spelled per guard because the per-guard copies were + * one or two regular expressions, and every spelling was wrong somewhere: + * - block comments stripped first, with one non-greedy pass, cannot tell a + * real opener from a `/*` inside a line comment. `src/app/mood/ + * page-client.tsx` explains its gate with "Every `/api/mood-entries/*` + * route also enforces the gate", and that `/*` opened a fake block that + * swallowed the next 2.2 kB of real code, the module gate included; + * - a scanner that takes the first opener on a line still opens a block at + * a `/*` inside a string, which every e2e route glob (`"**\/api/*"`) has; + * - a line comment matched only at the start of a line keeps a trailing + * `// …` after code, and one matched anywhere eats `https://`. * - * So each line is walked once, left to right: whichever opener comes first - * wins, a line comment ends the line, and a block comment carries its state to - * the next one. Line-comment detection needs `//` at the start of a line or - * after whitespace, so a URL inside a string survives. + * So the text is scanned once, left to right, knowing what the parser knows: + * strings, template literals (with their `${…}` expressions, which may hold + * comments), and regular expressions keep every character, and a `/*` or + * `//` inside any of them opens nothing. A slash starts a regular expression + * where the language allows one: after an operator, an opening bracket, a + * keyword such as `return`, or at the start. Line breaks are kept, so a line + * number read off the result is the file's own. * - * Its honest limit: it is a scanner, not a parser. A `/*` inside a string - * literal still opens a block, and a guard that needs more than this needs a - * real parse. + * It is a scanner, not the parser, and is held to the parser instead of + * trusted: `strip-comments-parity.test.ts` strips every source file both + * ways and fails on the first that differs. `//` right after a `:` is read + * as part of a URL in JSX text, never as a comment. CSS, which has no line + * comments, is stripped of its block comments only (pass its `fileName`). */ -export function stripComments(source: string): string { - const lines: string[] = []; - let inBlock = false; - - for (const line of source.split("\n")) { - let rest = line; - let kept = ""; - - while (rest.length > 0) { - if (inBlock) { - const closes = rest.indexOf("*/"); - if (closes === -1) break; - rest = rest.slice(closes + 2); - inBlock = false; - continue; - } - - const lineComment = rest.search(/(^|\s)\/\//); - const blockComment = rest.indexOf("/*"); - - if ( - blockComment !== -1 && - (lineComment === -1 || blockComment < lineComment) +export function stripComments(source: string, fileName?: string): string { + if (fileName?.endsWith(".css")) { + return source.replace(/\/\*[\s\S]*?\*\//g, blankKeepingLines); + } + const text = source; + const n = text.length; + let out = ""; + let i = 0; + // Template nesting: "template" for a template body, a number for the brace + // depth of the `${…}` expression being read, restored when it closes. + const stack: Array<"template" | number> = []; + let depth = 0; + while (i < n) { + const top = stack[stack.length - 1]; + if (top === "template") { + let j = i; + while ( + j < n && + text[j] !== "`" && + !(text[j] === "$" && text[j + 1] === "{") ) { - kept += rest.slice(0, blockComment); - rest = rest.slice(blockComment + 2); - inBlock = true; + j += text[j] === "\\" ? 2 : 1; + } + out += text.slice(i, Math.min(j, n)); + if (j >= n) break; + if (text[j] === "`") { + stack.pop(); + out += "`"; + i = j + 1; continue; } - - if (lineComment !== -1) { - kept += rest.slice(0, rest.indexOf("//", lineComment)); - break; - } - - kept += rest; - break; + stack.push(depth); + depth = 0; + out += "${"; + i = j + 2; + continue; } - lines.push(kept); - } + SPECIAL.lastIndex = i; + const found = SPECIAL.exec(text); + if (!found) { + out += text.slice(i); + break; + } + const j = found.index; + out += text.slice(i, j); + const c = text[j]; + const next = text[j + 1]; - return lines.join("\n"); + if (c === "/" && next === "/" && text[j - 1] !== ":") { + const end = text.indexOf("\n", j); + i = end === -1 ? n : end; + continue; + } + if (c === "/" && next === "*") { + const close = text.indexOf("*/", j + 2); + const end = close === -1 ? n : close + 2; + out += blankKeepingLines(text.slice(j, end)); + i = end; + continue; + } + if (c === "'" || c === '"') { + let end = j + 1; + while (end < n && text[end] !== c && text[end] !== "\n") { + end += text[end] === "\\" ? 2 : 1; + } + if (text[end] === c) end += 1; + out += text.slice(j, end); + i = end; + continue; + } + if (c === "`") { + stack.push("template"); + out += "`"; + i = j + 1; + continue; + } + if (c === "/") { + const end = regexMayStart(out) ? regexEnd(text, j) : -1; + if (end !== -1) { + out += text.slice(j, end); + i = end; + continue; + } + out += "/"; + i = j + 1; + continue; + } + if (c === "{") { + depth += 1; + } else if (depth === 0 && typeof top === "number") { + depth = stack.pop() as number; + } else { + depth -= 1; + } + out += c; + i = j + 1; + } + return out; +} + +const SPECIAL = /[/'"`{}]/g; +const WORD = /[A-Za-z_$0-9]/; +const REGEX_AFTER = new Set("(,=:[!&|?{};+-*%<>~^".split("")); +const REGEX_KEYWORDS = new Set([ + "return", + "typeof", + "case", + "do", + "else", + "in", + "of", + "void", + "yield", + "await", + "delete", + "instanceof", + "new", + "throw", +]); + +function blankKeepingLines(comment: string): string { + return comment.replace(/[^\n]/g, ""); +} + +/** Whether a `/` after this code starts a regular expression. */ +function regexMayStart(code: string): boolean { + let k = code.length - 1; + while (k >= 0 && /\s/.test(code[k]!)) k -= 1; + if (k < 0) return true; + const last = code[k]!; + if (REGEX_AFTER.has(last)) return true; + if (!WORD.test(last)) return false; + let start = k; + while (start > 0 && WORD.test(code[start - 1]!)) start -= 1; + return REGEX_KEYWORDS.has(code.slice(start, k + 1)); +} + +/** The end of the regular expression opening at `start`, or -1 if none. */ +function regexEnd(text: string, start: number): number { + let end = start + 1; + let inClass = false; + while (end < text.length && text[end] !== "\n") { + const ch = text[end]; + if (ch === "\\") { + end += 2; + continue; + } + if (ch === "[") inClass = true; + else if (ch === "]") inClass = false; + else if (ch === "/" && !inClass) { + end += 1; + while (end < text.length && /[a-z]/i.test(text[end]!)) end += 1; + return end; + } + end += 1; + } + return -1; } /** diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts index 90b14d8ec..14c86c4e5 100644 --- a/src/__tests__/job-queue-runtime-guard.test.ts +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -34,7 +34,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; import { DEFAULT_JOB_EXPIRE_SECONDS, QUEUE_RUNTIME, @@ -60,9 +60,7 @@ const source = new Map( /** Strip comments so documentation that quotes code never matches. */ function code(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return stripComments(text); } /** Index just past the bracket that closes the one at `open`. */ diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts index 2f149383e..76abab445 100644 --- a/src/__tests__/linear-fit-single-implementation-guard.test.ts +++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from "vitest"; import { readSource, readSourceText, + stripComments, walkSourceFiles, } from "./helpers/source-files"; @@ -25,10 +26,6 @@ const ALLOWED: Record = { const HAND_ROLLED_SLOPE = /\bn\s*\*\s*sum_?xy\s*-\s*sum_?x\s*\*\s*sum_?y\b|\bsxy\s*\/\s*sxx\b|\bsumXy\s*-\s*\(\s*sumX\s*\*\s*sumY\s*\)\s*\/\s*n\b/i; -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - describe("one least-squares line", () => { it("the matcher fires on a hand-rolled slope", () => { expect( diff --git a/src/__tests__/list-cap-reader-guard.test.ts b/src/__tests__/list-cap-reader-guard.test.ts index 94c5517fa..8a4e61507 100644 --- a/src/__tests__/list-cap-reader-guard.test.ts +++ b/src/__tests__/list-cap-reader-guard.test.ts @@ -18,7 +18,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -28,10 +28,6 @@ const NO_LIST_ON_SCREEN: Record = { "The web reads it for one decorative badge (a record set in the last 30 days), not as a list; the native client owns the list.", }; -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - /** `app/api/a/[id]/b/route.ts` → `/api/a/[id]/b`. */ function routePathOf(file: string): string { return `/${file.replace(/^app\//, "").replace(/\/route\.tsx?$/, "")}`; diff --git a/src/__tests__/list-page-guard.test.ts b/src/__tests__/list-page-guard.test.ts index 1865e038e..55427c504 100644 --- a/src/__tests__/list-page-guard.test.ts +++ b/src/__tests__/list-page-guard.test.ts @@ -25,7 +25,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -37,10 +37,6 @@ const LIST_MODULES = new Set([ "lib/mcp/capped.ts", ]); -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - /** The text of a call's argument list, from the `(` after `name` to its match. */ function callBodies(code: string, name: string): string[] { const out: string[] = []; diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 9c849be27..6a01790a2 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -114,6 +114,7 @@ import { describe, expect, it } from "vitest"; import { openApiPaths } from "@/lib/openapi/routes"; import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; +import { stripComments } from "./helpers/source-files"; const ROOT = process.cwd(); @@ -378,10 +379,6 @@ const UNPUBLISHED: Readonly> = { }, }; -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); -} - /** `src/app/api/foo/[id]/route.ts` → `/api/foo/{id}`. */ function toApiPath(file: string): string { return ( diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 049725d57..5019092b8 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -42,7 +42,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -50,14 +50,6 @@ const SRC = join(process.cwd(), "src"); const HELPER_IMPORT = /from\s+["'](?:@\/lib\/measurements\/|\.\/)day-(?:mean|statistic)["']/; -/** Comments go first: a mean named in prose is not a mean computed. */ -function stripComments(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/^\s*\/\/.*$/gm, "") - .replace(/^\s*--.*$/gm, ""); -} - /** * Database means over a measurement value: SQL `AVG(value)`, the per-day folds * `SUM(total) / SUM(cnt)` and `SUM(count * mean)`, and Prisma's diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index f756a599f..42133a9db 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -19,15 +19,14 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const ROOT = resolve(__dirname, "../.."); const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8"); /** Drop `//` and block comments so prose about a class cannot satisfy a check. */ -function code(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); +function code(source: string, fileName?: string): string { + return stripComments(source, fileName); } describe("the shell owns the safe area", () => { @@ -63,7 +62,7 @@ describe("the shell owns the safe area", () => { it("bottom toasts clear the bar under the same query the bar follows", () => { // Sonner's own mobile offset stops at 600 px; the bar shows up to `md` // and on a phone held sideways, so the lift keys on `shell-mobile`. - const css = code(read("src/app/globals.css")); + const css = code(read("src/app/globals.css"), "globals.css"); expect(css).toMatch( /html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/, ); @@ -81,7 +80,7 @@ describe("the shell owns the safe area", () => { it("a bottom sheet on the keyboard drops its home-indicator padding", () => { // Standing on the keyboard, the safe-area inset left a ~34 px gap // between the sheet's last row and the keys. - expect(code(read("src/app/globals.css"))).toMatch( + expect(code(read("src/app/globals.css"), "globals.css")).toMatch( /@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/, ); const sheet = code(read("src/components/ui/responsive-sheet.tsx")); @@ -97,7 +96,7 @@ describe("the shell owns the safe area", () => { // `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored // without a trace. In the components layer a utility wins, so the bottom // sheet must not carry one that would pin it under the keyboard. - const css = code(read("src/app/globals.css")); + const css = code(read("src/app/globals.css"), "globals.css"); const start = css.indexOf("@layer components {"); expect(start).toBeGreaterThan(-1); let depth = 0; diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts index 57befcb56..b7a296421 100644 --- a/src/__tests__/query-error-error-prop-guard.test.ts +++ b/src/__tests__/query-error-error-prop-guard.test.ts @@ -28,6 +28,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const SRC = join(__dirname, ".."); @@ -64,11 +65,6 @@ function openingTags(source: string): string[] { return tags; } -/** Comments mention the primitives by name (``); skip them. */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - const sites = walk(SRC).flatMap((file) => openingTags(stripComments(readFileSync(file, "utf8"))).map((tag) => ({ file: relative(SRC, file), diff --git a/src/__tests__/record-fence-headerless-transport-guard.test.ts b/src/__tests__/record-fence-headerless-transport-guard.test.ts index a1dc68677..a4d0b6c7e 100644 --- a/src/__tests__/record-fence-headerless-transport-guard.test.ts +++ b/src/__tests__/record-fence-headerless-transport-guard.test.ts @@ -64,6 +64,7 @@ import { describe, it, expect } from "vitest"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { stripComments } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const API_ROOT = join(SRC, "app", "api"); @@ -165,19 +166,6 @@ const API_LITERAL = /^[`"']((?:\/api\/)[^`"']*)[`"']$/; * against a one-level alias map built from the file's own * `const NAME = "/api/…"` declarations. */ -/** - * Comments are stripped before matching. - * - * A doc comment that NAMES `src="/api/…"` while explaining why the code no - * longer does that is prose, not a transport — and this guard's own - * explanations are written in exactly that shape. Commented-out code is - * dropped with them, which is correct: it issues no request. Same treatment - * and the same reason as `account-payload-consumer-guard.test.ts`. - */ -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); -} - function headerlessReferences(file: string, source: string): Reference[] { const aliases = new Map(); // `const NAME = "/api/…"`, and the ternary form diff --git a/src/__tests__/report-selection-egress-guard.test.ts b/src/__tests__/report-selection-egress-guard.test.ts index 8f516f69c..5382079c5 100644 --- a/src/__tests__/report-selection-egress-guard.test.ts +++ b/src/__tests__/report-selection-egress-guard.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { stripComments } from "./helpers/source-files"; const SRC = join(__dirname, ".."); /** @@ -52,11 +53,6 @@ function walk(dir: string, out: string[] = []): string[] { return out; } -/** Strip comments so a mention in prose is not read as a call. */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - describe("doctor-report aggregator egress", () => { // A sweep that finds nothing agrees with every allowlist, so the size of // the tree being read is asserted before anything is concluded from it. diff --git a/src/__tests__/restore-lock-consolidation-guard.test.ts b/src/__tests__/restore-lock-consolidation-guard.test.ts index b19fbbf7c..00e7600ea 100644 --- a/src/__tests__/restore-lock-consolidation-guard.test.ts +++ b/src/__tests__/restore-lock-consolidation-guard.test.ts @@ -19,6 +19,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "./helpers/source-files"; const ROOT = join(__dirname, "..", "lib"); @@ -38,9 +39,7 @@ function sourceFiles(dir: string): string[] { /** The text of a file with its comments blanked, so prose cannot match. */ function code(path: string): string { - return readFileSync(path, "utf8") - .replace(/\/\*[\s\S]*?\*\//g, " ") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return stripComments(readFileSync(path, "utf8"), path); } const passes = sourceFiles(ROOT).filter((path) => diff --git a/src/__tests__/session-user-projection-guard.test.ts b/src/__tests__/session-user-projection-guard.test.ts index c13344570..830d96af4 100644 --- a/src/__tests__/session-user-projection-guard.test.ts +++ b/src/__tests__/session-user-projection-guard.test.ts @@ -20,7 +20,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { guardModuleOf } from "./helpers/auth-guards"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; import { SESSION_USER_SELECT } from "@/lib/auth/session-user"; const SRC = join(process.cwd(), "src"); @@ -29,12 +29,6 @@ function read(rel: string): string { return readFileSync(join(SRC, rel), "utf8"); } -function stripComments(src: string): string { - return src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); -} - /** The `{ ... }` argument of every call matching `callee`, braces balanced. */ function callObjects(src: string, callee: RegExp): string[] { const code = stripComments(src); diff --git a/src/__tests__/step-up-web-caller-guard.test.ts b/src/__tests__/step-up-web-caller-guard.test.ts index cd07e2ffa..fbf260a0f 100644 --- a/src/__tests__/step-up-web-caller-guard.test.ts +++ b/src/__tests__/step-up-web-caller-guard.test.ts @@ -34,7 +34,11 @@ import { describe, expect, it } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSourceText, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -51,11 +55,7 @@ const codeCache = new Map(); function code(rel: string): string { let stripped = codeCache.get(rel); if (stripped === undefined) { - stripped = read(rel) - .replace(/\/\*[\s\S]*?\*\//g, "") - .split("\n") - .map((line) => line.replace(/(^|[^:"'`\\])\/\/.*$/, "$1")) - .join("\n"); + stripped = stripComments(read(rel), rel); codeCache.set(rel, stripped); } return stripped; diff --git a/src/__tests__/strip-comments-parity.test.ts b/src/__tests__/strip-comments-parity.test.ts new file mode 100644 index 000000000..52c94755b --- /dev/null +++ b/src/__tests__/strip-comments-parity.test.ts @@ -0,0 +1,130 @@ +/** + * The shared comment stripper reads comments the way the compiler does. + * + * Every structural guard that must not read prose as code goes through + * `stripComments` (`helpers/source-files.ts`). The private copies it replaced + * each got one case wrong (a `/*` inside a line comment, a `/*` inside a + * string, a trailing `//` after code, a URL), and a wrong strip is silent: a + * guard that loses real code reports a clean sweep. So the stripper is a + * scanner held to the TypeScript parser: every source file is stripped both + * ways, and the first file where they differ fails here with the line. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const ROOT = join(__dirname, "..", ".."); + +/** The parser's answer: remove exactly the ranges it reports as comments. */ +function parserStrip(text: string, fileName: string): string { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const file = ts.createSourceFile( + fileName, + text, + ts.ScriptTarget.Latest, + false, + kind, + ); + const ranges = new Map(); + const take = (found: ts.CommentRange[] | undefined) => { + for (const range of found ?? []) ranges.set(range.pos, range.end); + }; + const visit = (node: ts.Node) => { + take(ts.getLeadingCommentRanges(text, node.getFullStart())); + take(ts.getTrailingCommentRanges(text, node.getEnd())); + if ( + node.kind >= ts.SyntaxKind.FirstToken && + node.kind <= ts.SyntaxKind.LastToken + ) { + return; + } + for (const child of node.getChildren(file)) visit(child); + }; + visit(file); + let out = ""; + let at = 0; + for (const [pos, end] of [...ranges].sort(([a], [b]) => a - b)) { + if (pos < at) continue; + out += text.slice(at, pos) + text.slice(pos, end).replace(/[^\n]/g, ""); + at = end; + } + return out + text.slice(at); +} + +/** Trailing whitespace differs where a comment left the line; nothing else may. */ +function lines(text: string): string[] { + return text.split("\n").map((line) => line.trimEnd()); +} + +describe("stripComments", () => { + it("keeps the code a block opener inside a line comment used to hide", () => { + const source = [ + "// Every `/api/mood-entries/*` route also enforces the gate.", + "if (!enabled) return notFound();", + "/* a real block */ const x = 1;", + ].join("\n"); + expect(lines(stripComments(source))).toEqual([ + "", + "if (!enabled) return notFound();", + " const x = 1;", + ]); + }); + + it("opens nothing inside strings, templates and regular expressions", () => { + const source = [ + 'await page.route("**/api/day/**", stub); // a stub', + 'const url = "https://host/*"; const next = `/*${id /* gone */}*/`;', + "const slash = /\\/\\*/g; const ratio = a / b; // c", + ].join("\n"); + expect(lines(stripComments(source))).toEqual([ + 'await page.route("**/api/day/**", stub);', + 'const url = "https://host/*"; const next = `/*${id }*/`;', + "const slash = /\\/\\*/g; const ratio = a / b;", + ]); + }); + + it("keeps line numbers, so a finding points at the file's own line", () => { + const source = "/**\n * doc\n */\nexport const a = 1;\n"; + expect(stripComments(source).split("\n")[3]).toBe("export const a = 1;"); + }); + + it("strips only block comments from CSS", () => { + expect( + stripComments(".a { background: url(//cdn/x.png); } /* note */", "x.css"), + ).toBe(".a { background: url(//cdn/x.png); } "); + }); + + it("agrees with the TypeScript parser on every source file", () => { + const files = requireFloor( + "source files", + [ + ...walkSourceFiles(join(ROOT, "src"), { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .map((rel) => join("src", rel)), + ...walkSourceFiles(join(ROOT, "e2e"), { floor: 100 }).map((rel) => + join("e2e", rel), + ), + ].filter((rel) => /\.tsx?$/.test(rel)), + 3500, + ); + const differing: string[] = []; + for (const rel of files) { + const text = readFileSync(join(ROOT, rel), "utf8"); + const ours = lines(stripComments(text, rel)); + const parser = lines(parserStrip(text, rel)); + const at = ours.findIndex((line, index) => line !== parser[index]); + if (at !== -1 || ours.length !== parser.length) { + differing.push(`${rel}:${at + 1}`); + } + } + expect(differing).toEqual([]); + }, 180_000); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 7290edcf5..2e1ec3bed 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -48,6 +48,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, posix, relative, sep } from "node:path"; +import { stripComments } from "./helpers/source-files"; const REPO_ROOT = join(__dirname, "..", ".."); const COMPONENT_ROOT = join(REPO_ROOT, "src", "components"); @@ -528,11 +529,6 @@ const RESULT_SURFACES: ReadonlyArray<{ }, ]; -/** Code only — a comment naming a marker is prose, not an affordance. */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - function readCode(relativePath: string): string { return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8")); } diff --git a/src/__tests__/tombstone-read-guard.test.ts b/src/__tests__/tombstone-read-guard.test.ts index 5977c3c41..7ec8aac58 100644 --- a/src/__tests__/tombstone-read-guard.test.ts +++ b/src/__tests__/tombstone-read-guard.test.ts @@ -20,7 +20,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -57,10 +57,6 @@ const ALLOWED: Record = { "Write path, not analysis: resolves the row that won a raced external-id upsert, which owns the key whether or not it is deleted.", }; -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - function tombstonedModels(): string[] { const schema = readFileSync( join(process.cwd(), "prisma/schema.prisma"), diff --git a/src/__tests__/write-rate-limit-guard.test.ts b/src/__tests__/write-rate-limit-guard.test.ts index 5b6c7a2da..ca11ecad3 100644 --- a/src/__tests__/write-rate-limit-guard.test.ts +++ b/src/__tests__/write-rate-limit-guard.test.ts @@ -27,7 +27,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { guardSourceOf } from "./helpers/auth-guards"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -92,10 +92,6 @@ const OWN_BUCKET: Record = { "The ECG sweep sends one recording per request with no client-side delay, newest first, over the whole HealthKit history; the route's own 600-a-minute ingest bucket meters it.", }; -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - const WRITES = /export\s+(?:const|async\s+function)\s+(POST|PUT|PATCH|DELETE)\b/; const OWN_LIMITER = /\b(?:check\w*RateLimit|checkRateLimit)\s*\(/; diff --git a/src/app/api/__tests__/read-routes-write-nothing.test.ts b/src/app/api/__tests__/read-routes-write-nothing.test.ts index c5ce72c89..d72155e7e 100644 --- a/src/app/api/__tests__/read-routes-write-nothing.test.ts +++ b/src/app/api/__tests__/read-routes-write-nothing.test.ts @@ -20,6 +20,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "../../../__tests__/helpers/source-files"; /** Mutating Prisma model methods, as they appear at a call site. */ const PRISMA_WRITE = @@ -48,7 +49,7 @@ function sourceOf(relative: string): string { * guard that fires on its own documentation. */ function code(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + return stripComments(source); } describe("read routes issue no writes", () => { diff --git a/src/app/api/auth/native/__tests__/invariants.test.ts b/src/app/api/auth/native/__tests__/invariants.test.ts index 6ea62aab0..403cd9ca6 100644 --- a/src/app/api/auth/native/__tests__/invariants.test.ts +++ b/src/app/api/auth/native/__tests__/invariants.test.ts @@ -12,6 +12,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { guardSourceOf } from "@/__tests__/helpers/auth-guards"; +import { stripComments } from "../../../../../__tests__/helpers/source-files"; const SRC = join(process.cwd(), "src"); const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); @@ -22,9 +23,7 @@ const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); * `redirect_uri` parameter"). */ function code(rel: string): string { - return read(rel) - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return stripComments(read(rel), rel); } const ROUTE_FILES = [ @@ -43,6 +42,8 @@ describe("T6 — open redirect / redirect_uri removed by construction", () => { // Comments legitimately state "there is no redirect_uri parameter"; the // invariant is that no CODE reads or builds one. for (const rel of files) { + // A file read as nothing would pass the ban below. + expect(code(rel)).toMatch(/\bexport\b/); expect(code(rel)).not.toMatch(/redirect_uri/); } }); @@ -56,6 +57,7 @@ describe("T6 — open redirect / redirect_uri removed by construction", () => { // The routes build the Location only through the helper, never a bespoke // scheme string in code (doc comments naming the scheme are fine). for (const rel of ROUTE_FILES) { + expect(code(rel)).toMatch(/\bexport\b/); expect(code(rel)).not.toMatch(/"healthlog:\/\//); } }); diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts index 10bf7a177..d93ae6fa8 100644 --- a/src/components/day/__tests__/day-door-coverage-guard.test.ts +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; +import { stripComments } from "../../../__tests__/helpers/source-files"; /** * Every chart drawn in days is a door to its days, or it says why not. @@ -120,17 +121,6 @@ function walk(path: string): string[] { const files = SCAN.flatMap(walk).filter((f) => /\.tsx?$/.test(f)); const rel = (abs: string) => relative(ROOT, abs); -/** - * The code without its comments: a docblock that names `` is - * not a mount. Block comments (JSX ones included) go whole; a line comment - * goes from `//` to the line end unless the slashes follow a colon (a URL). - */ -export function stripComments(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); -} - const read = new Map( files.map((f) => [rel(f), stripComments(readFileSync(f, "utf8"))]), ); diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index 1236a489d..77bb7d4fc 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -199,6 +199,7 @@ import { } from "@/lib/ai/coach/tools/definitions"; import { createResultRefAllocator } from "@/lib/ai/coach/results/refs"; import type { PriorResultTurn } from "@/lib/ai/coach/results/refs"; +import { stripComments } from "../../../../__tests__/helpers/source-files"; const REACH: CoachHistoryReach = { window: "last30days", days: LIMIT_DAYS }; @@ -398,7 +399,7 @@ function walk(dir: string): string[] { /** `source` without its comments, so a doc mention is not read as a call. */ function code(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + return stripComments(source); } /** The argument text of every call to `fn(` in `source`, parentheses balanced. */ diff --git a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts index 68ab22cca..33ac0bf1e 100644 --- a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts +++ b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts @@ -17,6 +17,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { requireFloor } from "@/__tests__/helpers/source-files"; +import { stripComments } from "../../../../__tests__/helpers/source-files"; const ROOT = resolve(__dirname, "../../../.."); @@ -65,9 +66,7 @@ const DE_DEFAULT_NULLISH = /\?\?\s*"de"/; * Only executable source is checked. */ function readCode(rel: string): string { - return readFileSync(resolve(ROOT, rel), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/^\s*\/\/.*$/gm, ""); + return stripComments(readFileSync(resolve(ROOT, rel), "utf8"), rel); } describe("no German-default locale binary", () => { @@ -79,6 +78,11 @@ describe("no German-default locale binary", () => { expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH); }); + it.each(GUARDED_FILES)("%s reads as code, not as an empty file", (rel) => { + // The bans above pass on nothing; the stripped file must still export. + expect(readCode(rel)).toMatch(/\bexport\b/); + }); + it("finds the turn pipeline it guards", () => { // An empty directory read would leave the pipeline unguarded and green. expect(TURN_PIPELINE_FILES).toContain("lib/ai/coach/turn/context.ts"); diff --git a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts index e5e007f14..e39fe455c 100644 --- a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts +++ b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts @@ -44,6 +44,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { ENCRYPTED_COLUMNS, encryptedColumnKey } from "../encrypted-columns"; +import { stripComments } from "../../../__tests__/helpers/source-files"; const ROOT = join(__dirname, "../../../.."); const SRC = join(ROOT, "src"); @@ -116,13 +117,6 @@ function sourceFiles(dir: string, out: string[] = []): string[] { return out; } -/** Strip comments so prose about `encrypt()` never counts as a call. */ -function stripComments(src: string): string { - return src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); -} - const callRe = (names: string[]) => new RegExp(`(? { ); // Strip line + block comments so the guard checks executable code, // not the docstring that explicitly states the no-LLM contract. - const code = src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + const code = stripComments(src, "snapshot.ts"); + // The bans below pass on nothing; the builder must still be in the text. + expect(code).toMatch(/export async function buildDashboardSnapshot\b/); expect(code).not.toMatch(/provider-runner|provider-chain|resolveProvider/); expect(code).not.toMatch(/runRawCompletion|extractFeatures/); expect(code).not.toMatch(/fetch\(\s*["'`][^"'`]*insights\/generate/); diff --git a/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts index 28df5d5eb..28d4d11fb 100644 --- a/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts +++ b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts @@ -20,6 +20,7 @@ import { AI_CAPABILITY_KEYS, type AiCapabilityKey, } from "@/lib/ai/capabilities/types"; +import { stripComments } from "../../../__tests__/helpers/source-files"; const ROOT = join(process.cwd(), "src"); @@ -89,9 +90,7 @@ const AI_JOBS: Record = { }; function code(rel: string): string { - return readFileSync(join(ROOT, rel), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:])\/\/.*$/gm, "$1"); + return stripComments(readFileSync(join(ROOT, rel), "utf8"), rel); } describe("AI job capability inventory", () => {