From 0c3729b99fc42c5e1a1bb4fe6ce190611a72f85c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 21:58:21 +0200 Subject: [PATCH] test: guard every repository path named in a navigable document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the docs rewrote about thirty cross-references in one go. A reference left pointing at nothing fails no build, fails no test, and does not look wrong in review — it is found by the next person who follows it. That is the same silence eventCapture.test.ts and cssTokens.test.ts exist for. Checks markdown links and backticked paths in AGENTS.md, CLAUDE.md, GEMINI.md, README.md and docs/*.md. A path counts as resolved from either the file naming it or the repository root, since both conventions are in use and both are unambiguous to a reader. Scoped to the docs a reader NAVIGATES. docs/specs/ and docs/plans/ are excluded on purpose: they are dated records of a moment, and the plan that names src/App.tsx is describing scaffolding it went on to delete. Holding history to today's tree would force rewriting the past or deleting the guard. One declared exception, with its reason in the source: AGENTS.md §4 names docs/superpowers/specs/ precisely because it must NOT exist — it is the tooling default that has to be overridden, or the next spec silently recreates the old tree. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 7 +++ src/docsLinks.test.ts | 118 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100755 src/docsLinks.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4bb49b4..b70aa67 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,13 @@ ship twice — re-introducing one is a regression, not a style choice. worker's precache list (`src/swPrecache.test.ts`) — which must contain no duplicate, because `cache.addAll` rejects on one, which is why the service worker had never once installed in any environment. +- **Every repository path named in a navigable document must exist** — this + file, `README.md`, and `docs/*.md`. → `src/docsLinks.test.ts`, added when the + docs moved into `docs/`: a restructure that quietly leaves thirty references + pointing at nothing is the failure mode, and nothing else would catch it. It + excludes `docs/specs/` and `docs/plans/` on purpose — those are dated records + of a moment, and a plan naming scaffolding it later deleted is accurate + history, not a broken link. Two rules with no test behind them. Only review catches these: diff --git a/src/docsLinks.test.ts b/src/docsLinks.test.ts new file mode 100755 index 0000000..3f239f4 --- /dev/null +++ b/src/docsLinks.test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; +import { dirname, join, normalize } from "node:path"; + +/** + * Every repository path named in the documentation must exist. + * + * This exists because moving the docs into `docs/` rewrote about thirty + * cross-references at once. A reference left pointing at nothing does not fail + * a build, does not fail a test, and does not look wrong in review — it is only + * discovered by the next person who follows it and finds nothing there. The + * same silence is why `eventCapture.test.ts` and `cssTokens.test.ts` exist. + * + * Two kinds of reference are checked: markdown links, and backticked strings + * that look like paths. A candidate passes if it resolves either relative to + * the file naming it or from the repository root, because both conventions are + * in use and both are unambiguous to a reader. + * + * Scope is the docs a reader NAVIGATES — the entry points and `docs/*.md`. It + * deliberately excludes `docs/specs/` and `docs/plans/`, which are dated + * records of what was decided and built at a moment in time: a plan naming + * `src/App.tsx` is describing scaffolding it went on to delete, and holding + * history to today's tree would force either rewriting the past or deleting the + * guard. + * + * Also skipped: fenced code blocks (shell recipes, `/tmp` paths, URLs), + * absolute paths, and backticked strings starting with `./` or `../`, which are + * import specifiers relative to some source file rather than repository paths. + */ + +/** Paths named deliberately BECAUSE they do not exist. */ +const EXPECTED_ABSENT = new Map([ + [ + "docs/superpowers/specs/", + "AGENTS.md §4 names it as the tooling default to override; it was removed on purpose.", + ], +]); + +function stripCodeBlocks(markdown: string): string { + return markdown.replace(/^```[\s\S]*?^```/gm, ""); +} + +/** A candidate is a repo path only if it could not be prose or an identifier. */ +function looksLikeRepoPath(candidate: string): boolean { + if (!candidate.includes("/")) return false; // `sw.js`, `Post.rules` + if (/[*<>{}|\s]/.test(candidate)) return false; // globs and placeholders + if (/^[a-z]+:/.test(candidate)) return false; // http:, mailto: + if (candidate.startsWith("#")) return false; // in-page anchor + if (candidate.startsWith("/")) return false; // /tmp/shot.png, a URL base path + return candidate.endsWith("/") || /\.[a-z0-9]+$/i.test(candidate); +} + +function candidatesIn(source: string): string[] { + const body = stripCodeBlocks(source); + const found = new Set(); + + for (const match of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { + const target = match[1]!.split("#")[0]!.trim(); + if (target && looksLikeRepoPath(target)) found.add(target); + } + for (const match of body.matchAll(/`([^`\n]+)`/g)) { + const inner = match[1]!.trim(); + // `../domain/money.ts` in prose is an import specifier, not a repo path. + if (inner.startsWith("./") || inner.startsWith("../")) continue; + if (looksLikeRepoPath(inner)) found.add(inner); + } + return [...found]; +} + +async function exists(path: string): Promise { + const clean = path.replace(/\/$/, ""); + if (await Bun.file(clean).exists()) return true; + // Bun.file().exists() is false for a directory, so probe it as one. + try { + return [...new Glob("*").scanSync({ cwd: clean, onlyFiles: false })].length >= 0; + } catch { + return false; + } +} + +test("every repository path named in the documentation exists", async () => { + const files = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md"]; + for await (const file of new Glob("docs/*.md").scan(".")) files.push(file); + + const broken: string[] = []; + + for (const file of files) { + const source = await Bun.file(file).text(); + for (const candidate of candidatesIn(source)) { + if (EXPECTED_ABSENT.has(candidate)) continue; + const fromFile = normalize(join(dirname(file), candidate)); + if ((await exists(fromFile)) || (await exists(candidate))) continue; + broken.push(`${file} names ${candidate}`); + } + } + + expect(broken).toEqual([]); +}); + +test("the guard resolves both path conventions and rejects a dead one", async () => { + // Root-relative and file-relative both count, so the check cannot be passed + // by accident and cannot be tripped by a convention it does not like. + expect(await exists("docs/specs")).toBe(true); + expect(await exists("src/domain/fold.ts")).toBe(true); + expect(await exists("docs/superpowers/specs")).toBe(false); +}); + +test("prose, identifiers and non-repository paths are not mistaken for paths", () => { + expect(looksLikeRepoPath("/tmp/shot.png")).toBe(false); + expect(looksLikeRepoPath("/budget2.0/")).toBe(false); + expect(looksLikeRepoPath("Post.rules")).toBe(false); + expect(looksLikeRepoPath("sw.js")).toBe(false); + expect(looksLikeRepoPath("src/**/*.{ts,tsx}")).toBe(false); + expect(looksLikeRepoPath("docs/specs/YYYY-MM-DD-.md")).toBe(false); + expect(looksLikeRepoPath("http://localhost:3000/month/2026-09")).toBe(false); + expect(looksLikeRepoPath("src/domain/money.ts")).toBe(true); + expect(looksLikeRepoPath("docs/specs/")).toBe(true); +});