Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
- name: Report head reachability
run: node scripts/check-test-reachability.mjs --report
- run: node --test scripts/check-test-reachability.test.mjs
- run: node scripts/check-test-reachability.mjs --base origin/main
- run: node scripts/check-test-reachability.mjs

changes:
name: Detect changes
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-no-main-deletions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const STORYBOARD_VIEW_REASON =
"owner-directed removal of the Studio storyboard view; its only readers were deleted with it";

export const ALLOWED_DELETIONS = new Map([
[
"scripts/test-reachability-baseline.json",
"Reachability now requires zero orphans and rejects baseline files.",
],
[
"packages/studio/src/player/hooks/useTimelineRowElements.ts",
"D-834 removes the duplicate row-source hook; manifest elements are now the single timeline row owner",
Expand Down
53 changes: 8 additions & 45 deletions scripts/check-test-reachability.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";
import { posix, resolve } from "node:path";
Expand Down Expand Up @@ -399,46 +398,13 @@ export function audit(files, read, manifest) {
return issues;
}

function baselineIssues(file, count, current, previous) {
const errors = [];
if (![Number.isInteger(count), count > 0, count <= previous].every(Boolean))
errors.push(`${file}: baseline may only shrink`);
if (current < count) errors.push(`${file}: lower baseline to ${current}`);
return errors;
}

export function ratchet(issues, baseline, previous = baseline) {
const errors = Object.entries(issues).flatMap(([file, failures]) => {
return failures.length > (baseline.files[file] ?? 0) ? [`${file}: ${failures.join("; ")}`] : [];
});
const budgetErrors = Object.entries(baseline.files).flatMap(([file, count]) =>
baselineIssues(file, count, issues[file]?.length ?? 0, previous.files[file] ?? 0),
export function verdict(issues, baselinePresent = false) {
const errors = Object.entries(issues).map(
([file, failures]) => `${file}: ${failures.join("; ")}`,
);
if (baseline.total !== Object.values(baseline.files).reduce((sum, n) => sum + n, 0))
errors.push("Incorrect baseline total");
return [...errors, ...budgetErrors];
}

function baseArgument(index) {
const base = process.argv[index + 1];
if (!base || base.startsWith("-")) throw new Error("--base needs a Git ref");
return base;
}

function previousBaseline(baseline) {
const index = process.argv.indexOf("--base");
let previous = baseline;
if (index !== -1) {
const base = baseArgument(index);
const paths = execFileSync("git", ["ls-tree", "--name-only", base, "--", BASELINE], {
encoding: "utf8",
});
if (paths.trim())
previous = JSON.parse(
execFileSync("git", ["show", `${base}:${BASELINE}`], { encoding: "utf8" }),
);
}
return previous;
if (baselinePresent)
errors.push("Test reachability baseline is forbidden; every test must be reachable.");
return errors;
}

function main() {
Expand All @@ -451,13 +417,10 @@ function main() {
console.log(JSON.stringify(issues, null, 2));
return;
}
const baseline = JSON.parse(read(BASELINE));
const previous = previousBaseline(baseline);
const errors = ratchet(issues, baseline, previous);
const errors = verdict(issues, existsSync(resolve(root, BASELINE)));
if (errors.length) {
console.error(errors.join("\n"));
process.exitCode = 1;
} else
console.log(`Test reachability verified: ${Object.keys(issues).length} baselined test files.`);
} else console.log("Test reachability verified: zero orphan tests.");
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
52 changes: 42 additions & 10 deletions scripts/check-test-reachability.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { audit, digest, pinnedSource, ratchet } from "./check-test-reachability.mjs";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { audit, digest, pinnedSource, verdict } from "./check-test-reachability.mjs";

function fixture(command = "bun run test:scripts", filter = '"scripts/**"') {
return {
Expand Down Expand Up @@ -28,11 +33,10 @@ jobs:
}
const check = (tree, manifest = { guards: {}, runners: [] }) =>
audit(Object.keys(tree), (path) => tree[path], manifest);
const empty = { total: 0, files: {} };

test("planted guard-filter hole fails and adding its directory passes", () => {
const tree = fixture();
assert.match(ratchet(check(tree), empty)[0], /CI filters exclude skills\/lib/);
assert.match(verdict(check(tree))[0], /CI filters exclude skills\/lib/);
tree[".github/workflows/ci.yml"] = tree[".github/workflows/ci.yml"].replace(
'"scripts/**"',
'"scripts/**"\n - "skills/**"',
Expand Down Expand Up @@ -115,13 +119,13 @@ test("custom runner mappings require matching commands and unchanged producer so
assert.throws(() => check(tree, manifest), /mapping needs review/);
});

test("baselines accept existing debt, reject new debt and cannot be raised", () => {
const issues = { "a.test.ts": ["orphan"] };
const baseline = { total: 1, files: { "a.test.ts": 1 } };
assert.deepEqual(ratchet(issues, baseline), []);
assert.match(ratchet({ ...issues, "new.test.ts": ["orphan"] }, baseline)[0], /new.test.ts/);
assert.match(ratchet(issues, baseline, empty)[0], /only shrink/);
assert.match(ratchet({}, baseline)[0], /lower baseline/);
test("zero orphans passes and any orphan fails", () => {
assert.deepEqual(verdict({}), []);
assert.deepEqual(verdict({ "a.test.ts": ["orphan"] }), ["a.test.ts: orphan"]);
});

test("a baseline file is forbidden even with zero orphans", () => {
assert.match(verdict({}, true)[0], /baseline is forbidden/);
});

test("conditions in the first step key are never credited", () => {
Expand Down Expand Up @@ -226,3 +230,31 @@ test("a missing pinned package gives the runner mapping diagnostic", () => {
/Runner mapping needs review: missing\/package.json#script#test/,
);
});

test("report CLI prints diagnostics without applying the gate verdict", (t) => {
const cwd = mkdtempSync(join(tmpdir(), "reachability-"));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
const tree = fixture("node --test scripts/parity.test.mjs", '"**"');
tree["scripts/test-reachability.json"] = JSON.stringify({ guards: {}, runners: [] });
for (const [path, text] of Object.entries(tree)) {
mkdirSync(dirname(join(cwd, path)), { recursive: true });
writeFileSync(join(cwd, path), text);
}
execFileSync("git", ["init", "--quiet"], { cwd });
execFileSync("git", ["add", "."], { cwd });
const script = fileURLToPath(new URL("./check-test-reachability.mjs", import.meta.url));
const run = () => spawnSync(process.execPath, [script, "--report"], { cwd, encoding: "utf8" });
const clean = run();
assert.equal(clean.status, 0, clean.stderr);
const baseline = join(cwd, "scripts/test-reachability-baseline.json");
writeFileSync(baseline, '{"total":0,"files":{}}');
const restored = run();
assert.equal(restored.status, 0, restored.stderr);
assert.deepEqual(JSON.parse(restored.stdout), {});
rmSync(baseline);
writeFileSync(join(cwd, "scripts/orphan.test.mjs"), "");
execFileSync("git", ["add", "."], { cwd });
const orphan = run();
assert.equal(orphan.status, 0, orphan.stderr);
assert.match(orphan.stdout, /orphan.test.mjs/);
});
1 change: 0 additions & 1 deletion scripts/test-reachability-baseline.json

This file was deleted.

Loading