diff --git a/tools/hygiene/audit-tick-shard-relative-paths.baseline.json b/tools/hygiene/audit-tick-shard-relative-paths.baseline.json
new file mode 100644
index 000000000..82f7b7147
--- /dev/null
+++ b/tools/hygiene/audit-tick-shard-relative-paths.baseline.json
@@ -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"
+ }
+]
diff --git a/tools/hygiene/audit-tick-shard-relative-paths.ts b/tools/hygiene/audit-tick-shard-relative-paths.ts
index dd772ca41..6f48eeaf2 100644
--- a/tools/hygiene/audit-tick-shard-relative-paths.ts
+++ b/tools/hygiene/audit-tick-shard-relative-paths.ts
@@ -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
# 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 # 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
@@ -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]!);
@@ -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;
+ 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
@@ -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) {
@@ -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;
}