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", () => {