Skip to content

Commit 44dfd0b

Browse files
bketelsenclaude
andauthored
fix(docs-check): validate local Markdown section anchors (#127)
* fix(docs-check): validate local Markdown section anchors The docs-integrity gate stripped fragments before checking a link, so a link to an existing file with a stale or misspelled #anchor counted as healthy link integrity and same-document anchors were skipped entirely. AGENTS.md requires touched docs to keep both targets and section anchors valid; nothing enforced the second half. Resolve fragments against GitHub-style heading slugs in the resolved target (same-document links included), honoring duplicate-heading suffixes, explicit HTML id/name anchors, and fenced-code stripping. Path resolution, skill containment, index coverage, and symlink checks are unchanged, and an unresolvable path is still reported as such before its fragment is considered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UigLsmd17TVDuaaSN4yfdU * fix(docs-check): exclude tilde-fenced headings from anchor extraction Review round 1 on #127 blocked defect:scripts/check-docs.mjs:tilde-fence- heading-counted: anchorsOf() removed fenced code with a backtick-only regular expression, so a heading inside a CommonMark tilde fence became a real anchor and a link to it passed even though GitHub renders no such section. The same regular expression also mismatched across a stray ``` line inside a tilde fence, silently swallowing the real headings between it and the next backtick fence. Replace the regular expression with a line-oriented stripFences() that follows CommonMark: up to three leading spaces, three or more backticks or tildes, closed only by at least as many of the same character (or by the end of the document), with a backtick fence's info string barred from containing a backtick. Fenced lines are blanked rather than removed so the surviving heading regular expression keeps its line anchoring. Two fixture tests cover it: a link to a heading that exists only inside a ~~~ block now fails with the missing-anchor diagnostic, and real headings before, between, and after mixed backtick and tilde fences still resolve. Both fail against the previous script. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UigLsmd17TVDuaaSN4yfdU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 12beba6 commit 44dfd0b

2 files changed

Lines changed: 209 additions & 6 deletions

File tree

scripts/check-docs.mjs

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,73 @@ for (const cat of categories) {
4141
}
4242
}
4343

44-
// ---- 2. Link integrity: relative md links resolve. ----
44+
// ---- 2. Link integrity: relative md links resolve, fragments included. ----
45+
// GitHub-style heading slugs, so a link may only name a section that exists.
46+
const slugify = (heading) =>
47+
heading
48+
.replace(/`([^`]*)`/g, "$1")
49+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
50+
.replace(/<[^>]*>/g, "")
51+
.replace(/[*_~]/g, "")
52+
.trim()
53+
.toLowerCase()
54+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
55+
.replace(/\s/g, "-");
56+
const normalizeFragment = (fragment) => {
57+
try {
58+
return decodeURIComponent(fragment).toLowerCase();
59+
} catch {
60+
return fragment.toLowerCase();
61+
}
62+
};
63+
// CommonMark fenced code blocks, backtick and tilde alike: up to three leading
64+
// spaces, three or more fence characters, closed by at least as many of the
65+
// same character (or by the end of the document). Fenced lines are blanked
66+
// rather than removed so surviving line anchors stay intact.
67+
function stripFences(text) {
68+
const lines = text.split("\n");
69+
let open = null;
70+
return lines
71+
.map((line) => {
72+
if (open === null) {
73+
const start = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
74+
// A backtick fence's info string may not itself contain a backtick.
75+
if (!start || (start[1][0] === "`" && start[2].includes("`"))) return line;
76+
open = { char: start[1][0], length: start[1].length };
77+
return "";
78+
}
79+
const end = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
80+
if (end && end[1][0] === open.char && end[1].length >= open.length) open = null;
81+
return "";
82+
})
83+
.join("\n");
84+
}
85+
const anchorCache = new Map();
86+
function anchorsOf(path) {
87+
const cached = anchorCache.get(path);
88+
if (cached) return cached;
89+
const anchors = new Set();
90+
anchorCache.set(path, anchors);
91+
let body;
92+
try {
93+
body = stripFences(readFileSync(path, "utf8"));
94+
} catch {
95+
return anchors; // unreadable target; the path check already reported it
96+
}
97+
const used = new Map();
98+
for (const m of body.matchAll(/^ {0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm)) {
99+
const slug = slugify(m[1]);
100+
if (!slug) continue;
101+
const seen = used.get(slug) ?? 0;
102+
used.set(slug, seen + 1);
103+
anchors.add(seen === 0 ? slug : `${slug}-${seen}`);
104+
}
105+
// Explicit HTML anchors: <a id="x">, <a name="x">, or id="x" on any tag.
106+
for (const m of body.matchAll(/<[^>]*\s(?:id|name)=["']([^"']+)["']/g)) {
107+
anchors.add(m[1].toLowerCase());
108+
}
109+
return anchors;
110+
}
45111
const mdFiles = [join(root, "AGENTS.md"), join(root, "README.md"), join(root, "docs/README.md")];
46112
for (const cat of categories) {
47113
for (const name of readdirSync(join(root, "docs", cat))) {
@@ -67,19 +133,31 @@ for (const file of mdFiles) {
67133
const target = m[1];
68134
if (
69135
/^[a-z][a-z+.-]*:/i.test(target) ||
70-
target.startsWith("#") ||
71136
(isSkillDoc && target.startsWith("/"))
72-
) continue; // external, anchor, or site-root path
137+
) continue; // external or site-root path
138+
const hash = target.indexOf("#");
139+
const targetPath = hash === -1 ? target : target.slice(0, hash);
140+
const fragment = hash === -1 ? "" : normalizeFragment(target.slice(hash + 1));
141+
if (targetPath === "") {
142+
if (fragment === "") continue; // a bare "#" links nowhere in particular
143+
linksTotal++;
144+
if (anchorsOf(file).has(fragment)) linksOk++;
145+
else failures.push(`link: ${relative(root, file)} -> ${target} has no matching section anchor in ${relative(root, file)}`);
146+
continue;
147+
}
73148
linksTotal++;
74-
const path = resolve(dirname(file), target.split("#")[0]);
149+
const path = resolve(dirname(file), targetPath);
75150
if (
76151
isSkillDoc &&
77152
path !== skillsRoot &&
78153
!path.startsWith(skillsRoot + sep)
79154
) {
80155
failures.push(`link: ${relative(root, file)} -> ${target} escapes .agents/skills`);
81-
} else if (existsSync(path)) linksOk++;
82-
else failures.push(`link: ${relative(root, file)} -> ${target} does not resolve`);
156+
} else if (!existsSync(path)) {
157+
failures.push(`link: ${relative(root, file)} -> ${target} does not resolve`);
158+
} else if (fragment !== "" && path.endsWith(".md") && !anchorsOf(path).has(fragment)) {
159+
failures.push(`link: ${relative(root, file)} -> ${target} has no matching section anchor in ${relative(root, path)}`);
160+
} else linksOk++;
83161
}
84162
}
85163

test/docs-integrity.test.mjs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,131 @@ test("docs gate accepts index coverage from an actual relative markdown link", a
101101
assert.match(stdout, /ok docs_index_coverage: 1\.000/);
102102
});
103103

104+
test("docs gate accepts valid same-document and cross-document section anchors", async () => {
105+
const root = await createFixture();
106+
await writeFile(
107+
path.join(root, "docs/adr/example.md"),
108+
"# Example\n\n## Phase 4 — Supply-chain cleanup\n\n[back to the top](#example)\n",
109+
);
110+
await writeFile(
111+
path.join(root, "docs/README.md"),
112+
"[Metric](specs/pr-acceptance-metric.md)\n" +
113+
"[Example](adr/example.md)\n" +
114+
"[Phase 4](adr/example.md#phase-4--supply-chain-cleanup)\n",
115+
);
116+
117+
const { stdout } = await runDocsGate(root);
118+
assert.match(stdout, /ok link_integrity: 1\.000/);
119+
assert.match(stdout, /ok docs_index_coverage: 1\.000/);
120+
});
121+
122+
test("docs gate rejects a same-document link to a nonexistent section anchor", async () => {
123+
const root = await createFixture();
124+
await writeFile(
125+
path.join(root, "docs/adr/example.md"),
126+
"# Example\n\n[missing section](#no-such-heading)\n",
127+
);
128+
await writeFile(
129+
path.join(root, "docs/README.md"),
130+
"[Metric](specs/pr-acceptance-metric.md)\n[Example](adr/example.md)\n",
131+
);
132+
133+
await assert.rejects(
134+
runDocsGate(root),
135+
(error) =>
136+
error.stderr.includes(
137+
"link: docs/adr/example.md -> #no-such-heading" +
138+
" has no matching section anchor in docs/adr/example.md",
139+
),
140+
);
141+
});
142+
143+
test("docs gate rejects a cross-document link to a nonexistent section anchor", async () => {
144+
const root = await createFixture();
145+
await writeFile(path.join(root, "docs/adr/example.md"), "# Example\n");
146+
await writeFile(
147+
path.join(root, "docs/design/uses-example.md"),
148+
"# Uses example\n\n[phase](../adr/example.md#no-such-heading)\n",
149+
);
150+
await writeFile(
151+
path.join(root, "docs/README.md"),
152+
"[Metric](specs/pr-acceptance-metric.md)\n" +
153+
"[Example](adr/example.md)\n" +
154+
"[Uses](design/uses-example.md)\n",
155+
);
156+
157+
await assert.rejects(
158+
runDocsGate(root),
159+
(error) =>
160+
error.stderr.includes(
161+
"link: docs/design/uses-example.md -> ../adr/example.md#no-such-heading" +
162+
" has no matching section anchor in docs/adr/example.md",
163+
),
164+
);
165+
});
166+
167+
test("docs gate still reports an unresolvable path before looking at its fragment", async () => {
168+
const root = await createFixture();
169+
await writeFile(
170+
path.join(root, "docs/adr/example.md"),
171+
"# Example\n\n[gone](../design/missing.md#anything)\n",
172+
);
173+
await writeFile(
174+
path.join(root, "docs/README.md"),
175+
"[Metric](specs/pr-acceptance-metric.md)\n[Example](adr/example.md)\n",
176+
);
177+
178+
await assert.rejects(
179+
runDocsGate(root),
180+
(error) =>
181+
error.stderr.includes(
182+
"link: docs/adr/example.md -> ../design/missing.md#anything does not resolve",
183+
),
184+
);
185+
});
186+
187+
test("docs gate rejects a link to a heading that only appears inside a tilde fence", async () => {
188+
const root = await createFixture();
189+
await writeFile(
190+
path.join(root, "docs/adr/example.md"),
191+
"# Example\n\n~~~markdown\n## Fenced only heading\n~~~\n",
192+
);
193+
await writeFile(
194+
path.join(root, "docs/README.md"),
195+
"[Metric](specs/pr-acceptance-metric.md)\n" +
196+
"[Example](adr/example.md)\n" +
197+
"[Fenced](adr/example.md#fenced-only-heading)\n",
198+
);
199+
200+
await assert.rejects(
201+
runDocsGate(root),
202+
(error) =>
203+
error.stderr.includes(
204+
"link: docs/README.md -> adr/example.md#fenced-only-heading" +
205+
" has no matching section anchor in docs/adr/example.md",
206+
),
207+
);
208+
});
209+
210+
test("docs gate still counts real headings around backtick and tilde fences", async () => {
211+
const root = await createFixture();
212+
await writeFile(
213+
path.join(root, "docs/adr/example.md"),
214+
"# Example\n\n~~~markdown\n## Fenced only heading\n```\n~~~\n\n" +
215+
"## Real heading\n\n```markdown\n## Also fenced\n```\n\n## Later heading\n",
216+
);
217+
await writeFile(
218+
path.join(root, "docs/README.md"),
219+
"[Metric](specs/pr-acceptance-metric.md)\n" +
220+
"[Example](adr/example.md)\n" +
221+
"[Real](adr/example.md#real-heading)\n" +
222+
"[Later](adr/example.md#later-heading)\n",
223+
);
224+
225+
const { stdout } = await runDocsGate(root);
226+
assert.match(stdout, /ok link_integrity: 1\.000/);
227+
});
228+
104229
async function createFixture() {
105230
const root = await mkdtemp(path.join(os.tmpdir(), "core-docs-gate-"));
106231
for (const dir of [

0 commit comments

Comments
 (0)