CI failed on the guard added in0c3729b, which passed locally. Both were right: docs/TODO.md sent readers to `.superpowers/sdd/2026-09-02-versioned-allocation-rules/`, a tool-managed ledger whose own .gitignore is `*`. It existed on the machine that wrote the reference and has never been in the repository, so the guard saw a real file locally and nothing at all in a fresh checkout. Two fixes, because there were two faults: - TODO.md now points at the four commit messages that actually record why those surfaces work as they do —de128e3,1cb91d3,e3023d0,f94e458— and says plainly that the fuller ledger was never committed. Three of the four brief defects it described are in those messages. - The guard now checks a path is git-TRACKED, not merely present. That is the invariant it was always meant to enforce: a path only the author can follow is exactly the reference a reader cannot. It degrades to a filesystem check when git cannot answer, and `.superpowers/` joins docs/superpowers/specs/ as a declared exception — both are named in the docs precisely because they are not in the repository. A fresh clone is now the standard the check measures against, so it means the same thing on a laptop and in CI. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
174 lines
7.5 KiB
TypeScript
Executable File
174 lines
7.5 KiB
TypeScript
Executable File
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 be in the repository.
|
|
*
|
|
* 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.
|
|
*
|
|
* TRACKED, not merely present. The first version of this test only checked the
|
|
* filesystem, passed locally, and failed in CI: TODO.md was sending readers to
|
|
* `.superpowers/sdd/...`, a tool-managed ledger that ignores its own contents
|
|
* and has never been part of the repository. It existed on the machine that
|
|
* wrote the reference and nowhere else, which is precisely the reference a
|
|
* reader cannot follow. Asking git rather than the disk is what makes a fresh
|
|
* clone the standard, so the check means the same thing everywhere.
|
|
*
|
|
* 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 are not in the repository. */
|
|
const EXPECTED_ABSENT = new Map([
|
|
[
|
|
"docs/superpowers/specs/",
|
|
"AGENTS.md §4 names it as the tooling default to override; it was removed on purpose.",
|
|
],
|
|
[
|
|
".superpowers/",
|
|
"docs/TODO.md names it to explain that the ledger it held was never committed.",
|
|
],
|
|
]);
|
|
|
|
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<string>();
|
|
|
|
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];
|
|
}
|
|
|
|
/** Null when git cannot answer — a tarball, no git binary — so the check degrades. */
|
|
async function trackedFiles(): Promise<Set<string> | null> {
|
|
try {
|
|
const listing = await $`git ls-files`.quiet().text();
|
|
const files = listing.split("\n").filter(Boolean);
|
|
return files.length > 0 ? new Set(files) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function onDisk(path: string): Promise<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function isTracked(path: string, tracked: Set<string>): boolean {
|
|
const clean = path.replace(/\/$/, "");
|
|
if (tracked.has(clean)) return true;
|
|
const prefix = `${clean}/`;
|
|
for (const file of tracked) if (file.startsWith(prefix)) return true;
|
|
return false;
|
|
}
|
|
|
|
test("every repository path named in the documentation is in the repository", async () => {
|
|
const tracked = await trackedFiles();
|
|
const resolves = tracked
|
|
? (path: string) => isTracked(path, tracked)
|
|
: (path: string) => onDisk(path);
|
|
|
|
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 resolves(fromFile)) || (await resolves(candidate))) continue;
|
|
broken.push(`${file} names ${candidate}`);
|
|
}
|
|
}
|
|
|
|
expect(broken).toEqual([]);
|
|
});
|
|
|
|
test("membership in git's index is the standard, not presence on disk", async () => {
|
|
// Asserted against a synthetic index, NOT against whatever happens to be in
|
|
// this working copy. The first version of this very test checked that
|
|
// `.superpowers/sdd` was on disk — true on the machine that wrote it, false
|
|
// in a fresh clone — and so failed in CI for the same reason the guard was
|
|
// written: a check that depends on untracked local state proves nothing.
|
|
const index = new Set(["docs/specs/a-design.md", "src/domain/fold.ts"]);
|
|
expect(isTracked("docs/specs", index)).toBe(true);
|
|
expect(isTracked("docs/specs/", index)).toBe(true);
|
|
expect(isTracked("src/domain/fold.ts", index)).toBe(true);
|
|
expect(isTracked(".superpowers/sdd", index)).toBe(false);
|
|
expect(isTracked("docs/spec", index)).toBe(false); // a prefix is not a parent
|
|
|
|
const tracked = await trackedFiles();
|
|
if (!tracked) return; // git unavailable: the filesystem fallback is used
|
|
expect(isTracked("docs/specs", tracked)).toBe(true);
|
|
expect(isTracked("src/domain/fold.ts", tracked)).toBe(true);
|
|
expect(isTracked("docs/superpowers/specs", tracked)).toBe(false);
|
|
expect(isTracked(".superpowers/sdd", tracked)).toBe(false);
|
|
});
|
|
|
|
test("the filesystem fallback answers for paths every checkout has", async () => {
|
|
expect(await onDisk("src/domain/fold.ts")).toBe(true);
|
|
expect(await onDisk("docs/specs")).toBe(true);
|
|
expect(await onDisk("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-<topic>.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);
|
|
});
|