Skip to content

Commit 4029239

Browse files
alexsoyesblafourcade
authored andcommitted
feat(framework): add markdown link checker
1 parent dc14a73 commit 4029239

4 files changed

Lines changed: 677 additions & 0 deletions

File tree

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ make setup
4343

4444
`make` lists every target; `make doctor` / `make check` verify the environment and run the pre-commit checks.
4545

46+
### Markdown links
47+
48+
Lefthook runs the Markdown link checker during pre-commit. `make setup` installs the hooks; if dependencies are already installed and you only need the hook wiring, run:
49+
50+
```bash
51+
pnpm exec lefthook install --force
52+
```
53+
54+
Run `node scripts/check-markdown-links.js` to scan the repository. Detailed usage, supported forms, exclusions, and fix guidance live in `node scripts/check-markdown-links.js --help`.
55+
4656
### Test your changes locally
4757

4858
Exercise the skills you touched before opening a PR. Neither tool hot-reloads the checkout (both serve a copied cache), so after editing:

lefthook.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ pre-commit:
4242
fi
4343
node scripts/sync-skill-argument-hints.mjs >/dev/null
4444
git add plugins/*/skills/*/SKILL.md 2>/dev/null || true
45+
markdown-links:
46+
glob: "**/*.md"
47+
run: |
48+
if ! command -v node >/dev/null 2>&1; then
49+
echo "ℹ️ node not available; skipping markdown-links"
50+
exit 0
51+
fi
52+
node scripts/check-markdown-links.js
4553
summarize-plugin-catalogs:
4654
run: |
4755
[ -d plugins ] || exit 0
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
const assert = require("node:assert/strict");
2+
const { spawnSync } = require("node:child_process");
3+
const fs = require("node:fs");
4+
const path = require("node:path");
5+
const test = require("node:test");
6+
7+
const {
8+
formatIssue,
9+
reportProblems,
10+
} = require("../check-markdown-links.js");
11+
12+
const root = path.resolve(__dirname, "../..");
13+
const script = path.join(root, "scripts/check-markdown-links.js");
14+
15+
test("formatIssue includes correction details", () => {
16+
assert.equal(
17+
formatIssue({
18+
raw: "./courses/intro.md",
19+
reason: "cross-repo-relative-link",
20+
suggestion: "https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md",
21+
}),
22+
"./courses/intro.md (cross-repo relative link; suggestion: https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md)",
23+
);
24+
});
25+
26+
test("reportProblems writes a markdown source issue table", () => {
27+
const lines = [];
28+
const originalConsoleError = console.error;
29+
console.error = (line = "") => lines.push(line);
30+
31+
try {
32+
reportProblems(
33+
[
34+
{
35+
file: path.join(root, "CLAUDE.md"),
36+
line: 34,
37+
raw: "@aidd_docs/memory/architecture.md",
38+
reason: "local-path-not-found",
39+
},
40+
],
41+
339,
42+
);
43+
} finally {
44+
console.error = originalConsoleError;
45+
}
46+
47+
assert.deepEqual(lines, [
48+
"| source | issue |",
49+
"| - | - |",
50+
"| CLAUDE.md:34 | @aidd_docs/memory/architecture.md (local path not found) |",
51+
"",
52+
"❌ Links: 1 broken in 339 files",
53+
]);
54+
});
55+
56+
test("reportProblems writes a success summary when there are no problems", () => {
57+
const lines = [];
58+
59+
const status = reportProblems([], 42, () => {
60+
throw new Error("unexpected error logger call");
61+
}, (line) => lines.push(line));
62+
63+
assert.equal(status, 0);
64+
assert.deepEqual(lines, ["✅ Links: 0 broken in 42 files"]);
65+
});
66+
67+
test("--help documents supported links, exclusions, fixes, and examples", () => {
68+
const result = spawnSync(process.execPath, [script, "--help"], {
69+
cwd: root,
70+
encoding: "utf8",
71+
});
72+
73+
assert.equal(result.status, 0);
74+
assert.match(result.stdout, /node scripts\/check-markdown-links\.js \[--ignore path\]/u);
75+
assert.match(result.stdout, /Markdown links and images/u);
76+
assert.match(result.stdout, /@path/u);
77+
assert.match(result.stdout, /src: path/u);
78+
assert.match(result.stdout, /Anchor-only links/u);
79+
assert.match(result.stdout, /mailto:/u);
80+
assert.match(result.stdout, /tel:/u);
81+
assert.match(result.stdout, /HTML angle-bracket/u);
82+
assert.match(result.stdout, /\.git and node_modules/u);
83+
assert.match(result.stdout, /Runtime variables, glob patterns, and bare words/u);
84+
assert.match(result.stdout, /\| Need \| Use \|/u);
85+
assert.match(result.stdout, /\| Include\/import a file in agent context \| @path\/to\/file\.md \|/u);
86+
assert.match(result.stdout, /\| Reference a file for the reader \| \[label\]\(path\/to\/file\.md\) \|/u);
87+
assert.match(result.stdout, /\| Case \| Example \|/u);
88+
assert.match(result.stdout, /@plugins\/aidd-context\/skills\/11-explore\/SKILL\.md/u);
89+
assert.match(result.stdout, /\[explore skill\]\(plugins\/aidd-context\/skills\/11-explore\/SKILL\.md\)/u);
90+
});
91+
92+
test("CLI checks one markdown file covering supported, ignored, and broken forms", () => {
93+
const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-"));
94+
const fixture = path.join(tempDir, "all-cases.md");
95+
const readmePath = path.relative(tempDir, path.join(root, "README.md"));
96+
const logoPath = path.relative(tempDir, path.join(root, "docs/assets/logo.png"));
97+
const claudePath = path.relative(tempDir, path.join(root, "CLAUDE.md"));
98+
const contributingPath = path.relative(tempDir, path.join(root, "CONTRIBUTING.md"));
99+
const outsidePath = path.relative(tempDir, path.resolve(root, "..", "outside.md"));
100+
const outsideSuggestion = "https://github.com/ai-driven-dev/framework/blob/main/outside.md";
101+
102+
fs.writeFileSync(
103+
fixture,
104+
[
105+
"# Link checker fixture",
106+
"",
107+
"## Supported existing forms",
108+
`See [README](${readmePath}).`,
109+
`![Logo](${logoPath})`,
110+
`@${claudePath}`,
111+
`src: ${contributingPath}`,
112+
"",
113+
"## Ignored forms",
114+
"[Anchor only](#local-heading)",
115+
"[Mail](mailto:security@example.com)",
116+
"[Phone](tel:+33123456789)",
117+
"<https://example.com>",
118+
`<img src="${logoPath}" alt="Logo">`,
119+
"src: $ARGUMENTS",
120+
"src: plugins/*/README.md",
121+
"src: README",
122+
"[External URL](https://example.com/not-checked.md)",
123+
"",
124+
"## Broken forms",
125+
"[Missing markdown](./missing.md)",
126+
"@./missing-agent.md",
127+
"src: ./missing-source.md",
128+
`[Cross repo](${outsidePath})`,
129+
"",
130+
].join("\n"),
131+
"utf8",
132+
);
133+
134+
try {
135+
const result = spawnSync(process.execPath, [script, fixture], {
136+
cwd: root,
137+
encoding: "utf8",
138+
});
139+
140+
assert.equal(result.status, 1);
141+
assert.equal(result.stdout, "");
142+
assert.match(result.stderr, /\| source \| issue \|/u);
143+
assert.match(result.stderr, /\| - \| - \|/u);
144+
assert.match(result.stderr, /missing\.md \(local path not found\)/u);
145+
assert.match(result.stderr, /@\.\/missing-agent\.md \(local path not found\)/u);
146+
assert.match(result.stderr, /\.\/missing-source\.md \(local path not found\)/u);
147+
assert.ok(
148+
result.stderr.includes(`${outsidePath} (cross-repo relative link; suggestion: ${outsideSuggestion})`),
149+
result.stderr,
150+
);
151+
assert.match(result.stderr, / Links: 4 broken in 1 files/u);
152+
153+
assert.doesNotMatch(result.stderr, /local-heading/u);
154+
assert.doesNotMatch(result.stderr, /mailto:security@example\.com/u);
155+
assert.doesNotMatch(result.stderr, /tel:\+33123456789/u);
156+
assert.doesNotMatch(result.stderr, /(?:^|\s)https?:\/\/example\.com\/not-checked(?:\s|$)/u);
157+
assert.doesNotMatch(result.stderr, /\$ARGUMENTS/u);
158+
assert.doesNotMatch(result.stderr, /plugins\/\*\/README\.md/u);
159+
} finally {
160+
fs.rmSync(tempDir, { recursive: true, force: true });
161+
}
162+
});
163+
164+
test("CLI without paths scans the repository and prints a summary", () => {
165+
const result = spawnSync(process.execPath, [script], {
166+
cwd: root,
167+
encoding: "utf8",
168+
});
169+
170+
assert.equal(result.status, 0);
171+
assert.match(result.stdout, / Links: 0 broken in \d+ files/u);
172+
});
173+
174+
test("CLI fails when an explicit input path does not exist", () => {
175+
const result = spawnSync(process.execPath, [script, "DOES_NOT_EXIST.md"], {
176+
cwd: root,
177+
encoding: "utf8",
178+
});
179+
180+
assert.equal(result.status, 1);
181+
assert.match(result.stderr, / Path not found: DOES_NOT_EXIST\.md/u);
182+
});
183+
184+
test("repository scan ignores interrupted test temp directories", () => {
185+
const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-"));
186+
187+
try {
188+
fs.writeFileSync(path.join(tempDir, "leftover.md"), "[Missing](./missing.md)\n", "utf8");
189+
190+
const result = spawnSync(process.execPath, [script], {
191+
cwd: root,
192+
encoding: "utf8",
193+
});
194+
195+
assert.equal(result.status, 0);
196+
assert.match(result.stdout, / Links: 0 broken in \d+ files/u);
197+
} finally {
198+
fs.rmSync(tempDir, { recursive: true, force: true });
199+
}
200+
});

0 commit comments

Comments
 (0)