From 2085dba6711071fb262abae75f96c8bf04248cc0 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:23:21 -0400 Subject: [PATCH] test: add branch-coverage floor over scripts/*.mjs helpers Every quality fix in this repository since early October has been the same change: a test asserting a helper branch that already ran under the suite but was never checked (#232, #241, #247). Nothing failed when a new branch was added to a helper without an assertion, so the class recurred. scripts/coverage-floor.test.mjs runs the rest of the suite as a child with NODE_V8_COVERAGE, merges the raw V8 block-coverage ranges for scripts/*.mjs (not *.test.mjs) across processes, and fails naming each range no process executed: file, line and the quoted source slice. A tool's main-module guard is excluded because its self-test runs it via spawnSync against a copy. Fixture self-tests cover the cross-process merge, the exclusions, the message shape and a real child run over a tmp helper with one dead branch. The ranges the gate found at HEAD are asserted in the same change: page-csp.mjs parseCsp without , a contentless CSP meta (null content offset/quote), empty directive segments, script-src with no default-src, neither img-src nor default-src, without src; and update-csp-hashes.mjs refusing a contentless policy meta. Closes #251 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- README.md | 11 ++ scripts/coverage-floor.test.mjs | 230 +++++++++++++++++++++++++++++ scripts/page-csp.test.mjs | 51 ++++++- scripts/update-csp-hashes.test.mjs | 5 + 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 scripts/coverage-floor.test.mjs diff --git a/README.md b/README.md index 0505662..468bcdf 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,17 @@ red PR job against. Then pins the live tree: every committed content page is already up to date, so the tool and `page-csp.test.mjs` agree on what correct means, and a failure names the regenerate command. Zero dependencies. +- `node --test scripts/coverage-floor.test.mjs` — branch-coverage floor over the + `scripts/*.mjs` helpers the other gates import (`page-csp.mjs`, `html-pages.mjs`, + `actions-if.mjs`, `update-csp-hashes.mjs`): runs the rest of the suite once more as a + child with `NODE_V8_COVERAGE`, reads the raw V8 block-coverage ranges for every helper + (`*.test.mjs` excluded) and fails naming each range no process executed — file, line + and the quoted source slice — so a helper branch that runs under the suite without a + test asserting what it decides cannot merge green. A range counts as executed if any + child process ran it; a tool's main-module guard (`if (process.argv[1] … + import.meta.url)`) is excluded because its self-test runs it via `spawnSync` against a + copy. Each rule has a fixture self-test, including a real child run over a tmp helper + with one dead branch. Zero dependencies. - `node --test scripts/llms-txt.test.mjs` — static gate keeping `llms.txt` in step with `index.html`: the agent CLIs it names must equal the Agent CLIs chips, the inference engines/gateways it names must equal the engine and gateway chips (classifier chips diff --git a/scripts/coverage-floor.test.mjs b/scripts/coverage-floor.test.mjs new file mode 100644 index 0000000..f69c44c --- /dev/null +++ b/scripts/coverage-floor.test.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +// Branch-coverage floor over the scripts/*.mjs helpers. Every other gate here +// is a *.test.mjs that imports a helper (page-csp.mjs, html-pages.mjs, ...), +// so a branch added to a helper runs under the suite the moment any page hits +// it — and nothing fails when no test asserts what that branch decides. This +// gate runs the rest of the suite once more as a child with NODE_V8_COVERAGE, +// reads the raw V8 block-coverage ranges for scripts/*.mjs (not *.test.mjs), +// and fails naming each range the suite never executed: file, line and the +// quoted source slice, so the fix is a test rather than arithmetic. The +// main-module guard of a tool (`if (process.argv[1] ... import.meta.url) {`) +// is excluded: its self-test runs it via spawnSync against a copy, which V8 +// attributes to the copy. Each rule has a fixture self-test. Zero dependencies. +// Usage: node --test scripts/coverage-floor.test.mjs +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, ".."); +const SELF = basename(fileURLToPath(import.meta.url)); +// Set in the child so a stray `coverage-floor.test.mjs` in its file list can +// never recurse; the file list below already leaves it out. +const CHILD_ENV = "COVERAGE_FLOOR_CHILD"; + +// --- analysis --------------------------------------------------------------- + +// [start, end) offsets of every main-module guard block in a tool's source: +// `if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { ... }`. +export function cliEntrySpans(src) { + const spans = []; + for (const m of src.matchAll(/^if \(process\.argv\[1\][^\n]*import\.meta\.url\)\) \{/gm)) { + let depth = 0; + for (let i = m.index + m[0].length - 1; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) { spans.push([m.index, i + 1]); break; } + } + } + return spans; +} + +// Execution count of the whole span [start, end) in one process: the count of +// the innermost V8 range that encloses it entirely. V8 only emits a range for +// a block whose count differs from its parent, so a block the process ran as +// often as its parent has no range of its own and inherits the parent's count. +export function countOf(functions, start, end) { + let best; + for (const fn of functions) { + for (const r of fn.ranges) { + if (r.startOffset <= start && r.endOffset >= end && (!best || r.endOffset - r.startOffset < best.endOffset - best.startOffset)) best = r; + } + } + return best?.count; +} + +// Ranges no process executed: zero-count in one run and still zero in every +// other run that loaded the script. `runs` is one `functions` array per process. +export function deadRanges(src, runs) { + const seen = new Map(); + for (const functions of runs) { + for (const fn of functions) { + for (const r of fn.ranges) { + if (r.count === 0) seen.set(`${r.startOffset}:${r.endOffset}`, [r.startOffset, r.endOffset]); + } + } + } + const skip = cliEntrySpans(src); + const out = []; + for (const [start, end] of seen.values()) { + if (!src.slice(start, end).trim()) continue; + if (skip.some(([s, e]) => start >= s && start < e)) continue; + if (runs.some((functions) => (countOf(functions, start, end) ?? 0) > 0)) continue; + out.push([start, end]); + } + return out.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +export function lineOf(src, offset) { + return src.slice(0, offset).split("\n").length; +} + +// One problem per dead range, naming file, line and the source it starts with. +export function problemsOf(sources, runsByFile) { + const problems = []; + for (const [file, src] of [...sources].sort(([a], [b]) => a.localeCompare(b))) { + const runs = runsByFile.get(file); + if (!runs) { problems.push(`${file}: never loaded by the suite`); continue; } + for (const [start, end] of deadRanges(src, runs)) { + const slice = src.slice(start, end).trim().split("\n")[0]; + const quoted = slice.length > 60 ? `${slice.slice(0, 57)}...` : slice; + problems.push(`${file}:${lineOf(src, start)} \`${quoted}\` never executed by the suite (add a test that asserts what this branch decides)`); + } + } + return problems; +} + +// --- collection ------------------------------------------------------------- + +// Helper sources under /scripts: *.mjs that are not *.test.mjs, keyed by +// repository-relative path. +export function helperSources(root) { + const dir = join(root, "scripts"); + return new Map(readdirSync(dir).filter((n) => n.endsWith(".mjs") && !n.endsWith(".test.mjs")).sort() + .map((n) => [`scripts/${n}`, readFileSync(join(dir, n), "utf8")])); +} + +// Parsed NODE_V8_COVERAGE reports reduced to Map. +export function runsFromReports(reports, root, sources) { + const prefix = pathToFileURL(join(resolve(root), "scripts")).href + "/"; + const out = new Map(); + for (const report of reports) { + for (const script of report.result ?? []) { + if (!script.url.startsWith(prefix)) continue; + const file = relative(resolve(root), fileURLToPath(script.url)).split("\\").join("/"); + if (!sources.has(file)) continue; + if (!out.has(file)) out.set(file, []); + out.get(file).push(script.functions); + } + } + return out; +} + +// Runs `node --test ` from with coverage into a temp dir; returns +// the parsed reports and the child's status. +export function collect(root, files) { + const dir = mkdtempSync(join(tmpdir(), "coverage-floor-")); + try { + // The parent runner marks its own children with NODE_TEST_CONTEXT; a child + // `node --test` that inherits it reports over IPC instead of running files. + const { NODE_TEST_CONTEXT: _ctx, ...env } = process.env; + const child = spawnSync(process.execPath, ["--test", ...files], { + cwd: root, encoding: "utf8", env: { ...env, NODE_V8_COVERAGE: dir, [CHILD_ENV]: "1" }, maxBuffer: 64 * 1024 * 1024, + }); + const reports = readdirSync(dir).filter((n) => n.endsWith(".json")).map((n) => JSON.parse(readFileSync(join(dir, n), "utf8"))); + return { reports, status: child.status, stdout: child.stdout, stderr: child.stderr }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +export function suiteFiles(root) { + return readdirSync(join(root, "scripts")).filter((n) => n.endsWith(".test.mjs") && n !== SELF).sort().map((n) => `scripts/${n}`); +} + +// --- fixture self-tests ----------------------------------------------------- + +// Synthetic V8 `functions` arrays: a 40-char module whose block [20, 30) is +// the branch under test. +const SRC = "const a = 1;\nif (a) { b(); } else { c(); }"; +const FULL = [{ functionName: "", ranges: [{ startOffset: 0, endOffset: SRC.length, count: 1 }], isBlockCoverage: true }]; +const DEAD_ELSE = [{ functionName: "", ranges: [{ startOffset: 0, endOffset: SRC.length, count: 1 }, { startOffset: 27, endOffset: 42, count: 0 }], isBlockCoverage: true }]; +const DEAD_THEN = [{ functionName: "", ranges: [{ startOffset: 0, endOffset: SRC.length, count: 1 }, { startOffset: 20, endOffset: 27, count: 0 }], isBlockCoverage: true }]; + +test("fixture: a range zero in one run but inherited as executed in another is not dead", () => { + assert.deepEqual(deadRanges(SRC, [DEAD_ELSE, FULL]), []); + assert.deepEqual(deadRanges(SRC, [DEAD_ELSE, DEAD_THEN]), []); + assert.deepEqual(deadRanges(SRC, [DEAD_ELSE, DEAD_ELSE]), [[27, 42]]); + assert.deepEqual(deadRanges(SRC, [DEAD_ELSE]), [[27, 42]]); + assert.equal(countOf(DEAD_ELSE, 27, 42), 0); + assert.equal(countOf(FULL, 27, 42), 1); + assert.equal(countOf([], 0, 1), undefined); +}); + +test("fixture: problems name file, line and the first line of the dead source, sorted by file then offset", () => { + const src = "export function f(x) {\n return x\n ? 1\n : 2;\n}\n"; + const runs = [[{ functionName: "f", ranges: [{ startOffset: 0, endOffset: src.length, count: 1 }, { startOffset: src.indexOf(": 2"), endOffset: src.indexOf(";"), count: 0 }, { startOffset: src.indexOf("? 1"), endOffset: src.indexOf("\n :"), count: 0 }], isBlockCoverage: true }]]; + const sources = new Map([["scripts/z.mjs", src], ["scripts/a.mjs", src]]); + assert.deepEqual(problemsOf(sources, new Map([["scripts/z.mjs", runs], ["scripts/a.mjs", runs]])), [ + "scripts/a.mjs:3 `? 1` never executed by the suite (add a test that asserts what this branch decides)", + "scripts/a.mjs:4 `: 2` never executed by the suite (add a test that asserts what this branch decides)", + "scripts/z.mjs:3 `? 1` never executed by the suite (add a test that asserts what this branch decides)", + "scripts/z.mjs:4 `: 2` never executed by the suite (add a test that asserts what this branch decides)", + ]); + assert.deepEqual(problemsOf(new Map([["scripts/q.mjs", src]]), new Map()), ["scripts/q.mjs: never loaded by the suite"]); + const long = `x(${"a".repeat(80)})`; + const longRuns = [[{ functionName: "", ranges: [{ startOffset: 0, endOffset: long.length, count: 1 }, { startOffset: 0, endOffset: long.length - 1, count: 0 }], isBlockCoverage: true }]]; + const [problem] = problemsOf(new Map([["scripts/l.mjs", long]]), new Map([["scripts/l.mjs", longRuns]])); + assert.match(problem, /`x\(a{55}\.\.\.` never executed/); +}); + +test("fixture: whitespace-only ranges and the main-module guard block are excluded", () => { + const src = 'import { resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nexport function f() { return 1; }\nif (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {\n if (f()) { g(); }\n}\n'; + const guard = src.indexOf("if (process.argv[1]"); + assert.deepEqual(cliEntrySpans(src), [[guard, src.length - 1]]); + assert.deepEqual(cliEntrySpans("export const x = 1;\n"), []); + const runs = [[ + { functionName: "", ranges: [{ startOffset: 0, endOffset: src.length, count: 1 }, { startOffset: guard + src.slice(guard).indexOf("{"), endOffset: src.length - 1, count: 0 }, { startOffset: src.indexOf("\n", guard), endOffset: src.indexOf("\n", guard) + 2, count: 0 }], isBlockCoverage: true }, + { functionName: "f", ranges: [{ startOffset: src.indexOf("function f"), endOffset: src.indexOf("}\nif") + 1, count: 0 }], isBlockCoverage: true }, + ]]; + assert.deepEqual(deadRanges(src, runs).map(([s]) => lineOf(src, s)), [3]); +}); + +test("fixture: a real child run reports exactly the dead branch of a tmp helper and nothing for a fully asserted one", () => { + const dir = mkdtempSync(join(tmpdir(), "coverage-floor-fixture-")); + try { + mkdirSync(join(dir, "scripts")); + writeFileSync(join(dir, "scripts", "dead.mjs"), "export function pick(x) {\n return x ? 'yes' : 'no';\n}\n"); + writeFileSync(join(dir, "scripts", "dead.test.mjs"), 'import { test } from "node:test";\nimport assert from "node:assert/strict";\nimport { pick } from "./dead.mjs";\ntest("yes", () => assert.equal(pick(1), "yes"));\n'); + writeFileSync(join(dir, "scripts", "live.mjs"), "export function pick(x) {\n return x ? 'yes' : 'no';\n}\n"); + writeFileSync(join(dir, "scripts", "live.test.mjs"), 'import { test } from "node:test";\nimport assert from "node:assert/strict";\nimport { pick } from "./live.mjs";\ntest("both", () => { assert.equal(pick(1), "yes"); assert.equal(pick(0), "no"); });\n'); + writeFileSync(join(dir, "scripts", "coverage-floor.test.mjs"), 'import { test } from "node:test";\ntest("self", () => {});\n'); + const sources = helperSources(dir); + assert.deepEqual([...sources.keys()], ["scripts/dead.mjs", "scripts/live.mjs"]); + assert.deepEqual(suiteFiles(dir), ["scripts/dead.test.mjs", "scripts/live.test.mjs"]); + const { reports, status } = collect(dir, suiteFiles(dir)); + assert.equal(status, 0); + const runs = runsFromReports(reports, dir, sources); + assert.deepEqual([...runs.keys()].sort(), ["scripts/dead.mjs", "scripts/live.mjs"]); + assert.deepEqual(problemsOf(sources, runs), [ + "scripts/dead.mjs:2 `: 'no'` never executed by the suite (add a test that asserts what this branch decides)", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- live gate -------------------------------------------------------------- + +test("every branch of every scripts/*.mjs helper is executed by the suite", { skip: process.env[CHILD_ENV] ? "running inside the coverage-floor child" : false }, () => { + const sources = helperSources(ROOT); + assert.ok(sources.size > 0, "no helpers under scripts/"); + const files = suiteFiles(ROOT); + assert.ok(!files.includes(`scripts/${SELF}`)); + const { reports, status, stdout, stderr } = collect(ROOT, files); + assert.equal(status, 0, `child suite failed; fix it first\n${stdout}\n${stderr}`); + assert.deepEqual(problemsOf(sources, runsFromReports(reports, ROOT, sources)), []); +}); diff --git a/scripts/page-csp.test.mjs b/scripts/page-csp.test.mjs index 66f298b..3364755 100644 --- a/scripts/page-csp.test.mjs +++ b/scripts/page-csp.test.mjs @@ -14,7 +14,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { relative } from "node:path"; import { ROOT, htmlFiles, isRedirectPage } from "./html-pages.mjs"; -import { REGEN_HINT, inlineScriptSources, problemsOf, sha256Source } from "./page-csp.mjs"; +import { REGEN_HINT, externalImageOrigins, inlineScriptSources, parseCsp, problemsOf, sha256Source } from "./page-csp.mjs"; // --- per-page gates --------------------------------------------------------- @@ -119,6 +119,55 @@ test("fixture: 'unsafe-inline' and 'unsafe-eval' in script-src are rejected", () assert.deepEqual(problems, ["script-src must not contain 'unsafe-inline'", "script-src must not contain 'unsafe-eval'"]); }); +test("fixture: a page without has no policy", () => { + assert.equal(parseCsp("

x

"), null); + assert.deepEqual(problemsOf("

x

"), ['missing ']); +}); + +test("fixture: a CSP meta without a content attribute parses to no directives and a null content offset", () => { + const html = fixture({ csp: null }).replace("", '<meta http-equiv="Content-Security-Policy"><title>'); + const csp = parseCsp(html); + assert.deepEqual(csp.directives, {}); + assert.equal(csp.count, 1); + assert.equal(csp.content, null); + assert.equal(csp.contentAt, -1); + assert.equal(csp.contentQuote, null); + assert.deepEqual(problemsOf(html), [ + "script-src directive missing", + "base-uri must be 'none' or 'self'", + "form-action directive missing", + ]); +}); + +test("fixture: empty directive segments (`;;`, trailing `;`) are ignored", () => { + const h = sha256Source("console.log(1)"); + const html = fixture({ csp: `${BASE};; script-src ${h};` }); + assert.deepEqual(Object.keys(parseCsp(html).directives), ["default-src", "img-src", "base-uri", "form-action", "script-src"]); + assert.deepEqual(problemsOf(html), []); +}); + +test("fixture: script-src present but default-src missing is reported", () => { + const h = sha256Source("console.log(1)"); + assert.deepEqual(problemsOf(fixture({ csp: `img-src 'self'; base-uri 'none'; form-action 'none'; script-src ${h}` })), [ + "default-src must be 'none' so every fetch type is allow-listed explicitly", + ]); +}); + +test("fixture: with neither img-src nor default-src, an external <img> origin is not allowed", () => { + const h = sha256Source("console.log(1)"); + const img = '<img src="https://github.com/octocat.png" alt="">'; + assert.deepEqual(problemsOf(fixture({ csp: `base-uri 'none'; form-action 'none'; script-src ${h}`, img })), [ + "default-src must be 'none' so every fetch type is allow-listed explicitly", + "img-src does not allow external <img> origin https://github.com", + ]); +}); + +test("fixture: <img> without src and relative src contribute no external origin", () => { + assert.deepEqual(externalImageOrigins('<img alt=""><img src="/og.png" alt=""><IMG SRC="HTTPS://Example.org/a.png">'), ["https://example.org"]); + const h = sha256Source("console.log(1)"); + assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; script-src ${h}`, img: '<img alt="">' })), []); +}); + test("fixture: inline event handler requires 'unsafe-hashes' plus its hash", () => { const h = sha256Source("console.log(1)"); const hh = sha256Source("this.hidden=true"); diff --git a/scripts/update-csp-hashes.test.mjs b/scripts/update-csp-hashes.test.mjs index c8e0141..b70863d 100644 --- a/scripts/update-csp-hashes.test.mjs +++ b/scripts/update-csp-hashes.test.mjs @@ -80,6 +80,11 @@ test("does not relax unsafe policy tokens or invent missing policies", () => { assert.throws(() => updateCspHashes(fixture({ policy: BASE })), /exactly one script-src/); assert.throws(() => updateCspHashes(fixture({ policy: `${BASE}; script-src 'self'; script-src 'none'` })), /exactly one script-src/); assert.throws(() => updateCspHashes(fixture().replace("<title>", '<meta http-equiv="Content-Security-Policy" content=""><title>')), /exactly one CSP/); + // A single policy meta with no content attribute at all: parseCsp reports + // content === null and the tool must refuse rather than invent a policy. + const contentless = fixture().replace(/<META [^>]*>/, '<meta http-equiv="Content-Security-Policy">'); + assert.equal(parseCsp(contentless).content, null); + assert.throws(() => updateCspHashes(contentless), /exactly one CSP meta with a content attribute/); }); test("live tree: every committed content page is already up to date", () => {