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
52 changes: 52 additions & 0 deletions tools/hygiene/audit-tick-shard-relative-paths.baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[
{
"file": "docs/hygiene-history/ticks/2026/04/29/0852Z.md",
"line": 1,
"target": "../../../../../research/multi-ai-feedback-2026-04-29-no-directives-otto-prose-roundup.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/1436Z.md",
"line": 6,
"target": "../../../../backlog/P1/B-0442-missed-substrate-cascade-detector-background-service-2026-05-13.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/1436Z.md",
"line": 6,
"target": "../../../../backlog/P1/B-0503-b0442-slice5a-open-recovery-pr-core-function-2026-05-14.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/1436Z.md",
"line": 30,
"target": "../../../../../.claude/rules/holding-without-named-dependency-is-standing-by-failure.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/1436Z.md",
"line": 36,
"target": "../../../../backlog/P1/B-0442-missed-substrate-cascade-detector-background-service-2026-05-13.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/1436Z.md",
"line": 36,
"target": "../../../../backlog/P1/B-0503-b0442-slice5a-open-recovery-pr-core-function-2026-05-14.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/0329Z.md",
"line": 6,
"target": "../../../../backlog/P3/B-0519-multi-otto-branch-state-contamination-rca-2026-05-14.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/0329Z.md",
"line": 7,
"target": "../../../../backlog/P3/B-0528-shadow-launchd-installer-unit-tests-2026-05-15.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/15/0329Z.md",
"line": 20,
"target": "../../../../backlog/P3/B-0528-shadow-launchd-installer-unit-tests-2026-05-15.md"
},
{
"file": "docs/hygiene-history/ticks/2026/05/14/2158Z.md",
"line": 29,
"target": "docs/foo.md"
}
]
152 changes: 141 additions & 11 deletions tools/hygiene/audit-tick-shard-relative-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,23 @@
//
// Usage:
//
// bun tools/hygiene/audit-tick-shard-relative-paths.ts # detect-only
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --enforce # exit 1 on findings (CI gate)
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --files <p...> # scan specific files
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --json # JSON output
// bun tools/hygiene/audit-tick-shard-relative-paths.ts # detect-only
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --enforce # exit 1 on findings
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --enforce --baseline FILE # exit 1 only on NEW findings (grandfather mechanism)
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --files <p...> # scan specific files
// bun tools/hygiene/audit-tick-shard-relative-paths.ts --json # JSON output (includes newFindings + baselineMatched fields)
//
// The `--baseline` flag avoids the tick-shard-immutability tension at
// CI-gate scope: ship `--enforce --baseline tools/hygiene/audit-tick-shard-relative-paths.baseline.json`
// in CI, and only NEW findings (not in the baseline) fail the gate.
// Grandfathered findings stay visible in detect-only output. Same shape
// as Stryker `--reset` or ESLint suppressions.
//
// Exit codes:
//
// 0 no findings, OR detect-only mode (default)
// 1 findings present AND --enforce flag set
// 64 argument error
// 0 no NEW findings (or detect-only mode, default)
// 1 NEW findings present AND --enforce flag set
// 64 argument error / baseline file missing or malformed
//
// Composes with: audit-section-33-migration-xrefs.ts (sibling template),
// audit-memory-references.ts (relative-link resolution pattern), the
Expand Down Expand Up @@ -70,17 +77,27 @@ interface Args {
enforce: boolean;
json: boolean;
files: readonly string[] | null;
baseline: string | null;
}

function parseArgs(argv: readonly string[]): Args {
let enforce = false;
let json = false;
let files: string[] | null = null;
let baseline: string | null = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--enforce") enforce = true;
else if (a === "--json") json = true;
else if (a === "--files") {
else if (a === "--baseline") {
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
process.stderr.write("--baseline requires a path argument\n");
process.exit(64);
}
baseline = next;
i++;
} else if (a === "--files") {
files = [];
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("--")) {
files.push(argv[++i]!);
Expand All @@ -90,7 +107,84 @@ function parseArgs(argv: readonly string[]): Args {
process.exit(64);
}
}
return { enforce, json, files };
return { enforce, json, files, baseline };
}

// Baseline of known-acceptable findings — historical residue that pre-dates
// the audit's introduction. Loading the baseline lets `--enforce` reject only
// NEW findings (`new_findings - baseline`), while still surfacing the
// pre-existing 10 as detect-only signal. Same shape as Stryker's `--reset`
// or ESLint suppressions: don't edit historical artifacts; track what's
// grandfathered so new violations still fail CI.
//
// File format: JSON array of `{file, line, target}` objects. The triple
// is the match key. `reason` and `resolved` are NOT part of the match (a
// fix that changes the resolution but keeps the same file/line/target
// would NOT match — that's intentional; if the link target shape changes,
// the baseline entry no longer applies).
interface BaselineEntry {
readonly file: string;
readonly line: number;
readonly target: string;
}

function isBaselineEntry(v: unknown): v is BaselineEntry {
if (v === null || typeof v !== "object") return false;
const o = v as Record<string, unknown>;
return (
typeof o["file"] === "string" &&
typeof o["line"] === "number" &&
Number.isInteger(o["line"]) &&
(o["line"] as number) >= 1 &&
typeof o["target"] === "string"
);
}

function loadBaseline(path: string): readonly BaselineEntry[] {
const absolutePath = resolve(path);
if (!existsSync(absolutePath)) {
process.stderr.write(`baseline file not found: ${path}\n`);
process.exit(64);
}
let data: unknown;
try {
const text = readFileSync(absolutePath, "utf8");
data = JSON.parse(text);
} catch (e) {
process.stderr.write(`baseline parse failed: ${path}: ${(e as Error).message}\n`);
process.exit(64);
}
if (!Array.isArray(data)) {
process.stderr.write(`baseline file is not a JSON array: ${path}\n`);
process.exit(64);
}
// Validate each entry: malformed entries must surface as exit 64 (the
// documented "argument error / baseline file missing or malformed" code),
// not as silent mismatches that would turn grandfathered findings into
// "new" ones under --enforce.
const bad: { index: number; reason: string }[] = [];
for (let i = 0; i < data.length; i++) {
if (!isBaselineEntry(data[i])) {
bad.push({
index: i,
reason: "missing or wrong-typed file/line/target (expect {file: string, line: integer >= 1, target: string})",
});
}
}
if (bad.length > 0) {
for (const b of bad) {
process.stderr.write(`baseline entry [${b.index}] invalid: ${b.reason}\n`);
}
process.exit(64);
}
return data as readonly BaselineEntry[];
}

function isInBaseline(f: Finding, baseline: readonly BaselineEntry[]): boolean {
for (const b of baseline) {
if (b.file === f.file && b.line === f.line && b.target === f.target) return true;
}
return false;
}

// shard discovery
Expand Down Expand Up @@ -288,10 +382,36 @@ export function main(argv: readonly string[]): 0 | 1 | 64 {
}
}

// Partition findings against baseline (if loaded). New findings are
// unmatched; baseline findings are grandfathered.
const baseline = args.baseline ? loadBaseline(args.baseline) : [];
const newFindings: Finding[] = [];
const baselineMatched: Finding[] = [];
for (const f of findings) {
if (isInBaseline(f, baseline)) baselineMatched.push(f);
else newFindings.push(f);
}

if (args.json) {
process.stdout.write(JSON.stringify({ shardsScanned: shards.length, findings }, null, 2) + "\n");
process.stdout.write(JSON.stringify({
shardsScanned: shards.length,
findings,
newFindings,
baselineMatched,
baselineLoaded: baseline.length,
}, null, 2) + "\n");
} else if (findings.length === 0) {
process.stdout.write(`ok: scanned ${shards.length} tick shards; 0 broken relative-path links\n`);
} else if (args.baseline) {
process.stdout.write(`scanned ${shards.length} tick shards; ${findings.length} broken relative-path links (${baselineMatched.length} grandfathered by baseline, ${newFindings.length} new):\n\n`);
if (newFindings.length > 0) {
process.stdout.write("NEW findings (not in baseline):\n");
for (const f of newFindings) {
process.stdout.write(` ${f.file}:${f.line} ${f.reason}\n`);
process.stdout.write(` target: ${f.target}\n`);
process.stdout.write(` resolved: ${f.resolved}\n\n`);
}
}
} else {
process.stdout.write(`scanned ${shards.length} tick shards; ${findings.length} broken relative-path links:\n\n`);
for (const f of findings) {
Expand All @@ -301,7 +421,17 @@ export function main(argv: readonly string[]): 0 | 1 | 64 {
}
}

if (args.enforce && findings.length > 0) return 1;
// Exit logic:
// --enforce + no baseline → exit 1 on any finding
// --enforce + baseline → exit 1 on NEW findings only (grandfathered ones pass)
// default → exit 0 (detect-only)
if (args.enforce) {
if (args.baseline) {
if (newFindings.length > 0) return 1;
} else {
if (findings.length > 0) return 1;
}
}
return 0;
}

Expand Down
Loading