Skip to content

Commit 2c27529

Browse files
feat: add broken-link drift checker (#61)
Adds a broken-link drift checker that flags Markdown links to local files that do not exist. Closes #52.
1 parent 63f76e4 commit 2c27529

8 files changed

Lines changed: 181 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ All notable changes to this project will be documented in this file.
55
## [Unreleased]
66

77
### Added
8-
- **todo-fixme drift checker** — flags unresolved `TODO` / `FIXME` markers in scaffold markdown.
8+
- **broken-link drift checker** — flags Markdown links in scaffold files whose local target file does not exist.
99

1010
### Changed
11-
- README and CONTRIBUTING now list all 10 drift checkers (including `tool-config-sync` and `todo-fixme`).
11+
- README and CONTRIBUTING now list all 11 drift checkers (including `tool-config-sync`, `todo-fixme`, and `broken-link`).
1212

1313
## [0.3.5] - 2026-05-14
1414

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Thanks for your interest in contributing! Here's how to get started.
44

5-
**New here?** The best starting point is an issue labeled [`good first issue`](https://github.com/theDakshJaitly/mex/labels/good%20first%20issue) — most are self-contained drift checkers, and there are 10 existing checkers to copy from. See [Adding a drift checker](#adding-a-drift-checker) below.
5+
**New here?** The best starting point is an issue labeled [`good first issue`](https://github.com/theDakshJaitly/mex/labels/good%20first%20issue) — most are self-contained drift checkers, and there are 11 existing checkers to copy from. See [Adding a drift checker](#adding-a-drift-checker) below.
66

77
## Setup
88

@@ -50,7 +50,7 @@ test/ # Vitest tests
5050

5151
## Adding a drift checker
5252

53-
New checkers are the most newcomer-friendly contribution. A checker is a small function that inspects scaffold files (or extracted claims) and returns `DriftIssue[]`. There are 10 existing checkers in `src/drift/checkers/` — pick the closest as a template.
53+
New checkers are the most newcomer-friendly contribution. A checker is a small function that inspects scaffold files (or extracted claims) and returns `DriftIssue[]`. There are 11 existing checkers in `src/drift/checkers/` — pick the closest as a template.
5454

5555
1. **Create `src/drift/checkers/<name>.ts`.** There are two shapes:
5656
- **Claim-based** — operates on extracted claims, e.g. `checkPaths(claims, projectRoot, scaffoldRoot)` in `path.ts`.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Editable source: [docs/diagrams/context-routing.excalidraw](docs/diagrams/contex
9696

9797
## Drift Detection
9898

99-
Ten checkers validate your scaffold against the real codebase. Zero tokens, zero AI.
99+
Eleven checkers validate your scaffold against the real codebase. Zero tokens, zero AI.
100100

101101
| Checker | What it catches |
102102
|---------|----------------|
@@ -110,6 +110,7 @@ Ten checkers validate your scaffold against the real codebase. Zero tokens, zero
110110
| **script-coverage** | `package.json` scripts not mentioned in any scaffold file |
111111
| **tool-config-sync** | Installed AI tool config files (e.g. `CLAUDE.md`, `.cursorrules`) out of sync with each other |
112112
| **todo-fixme** | Unresolved `TODO` / `FIXME` markers left in scaffold markdown |
113+
| **broken-link** | Markdown links to local files that do not exist on disk |
113114

114115
Scoring starts at 100. mex deducts 10 per error, 3 per warning, and 1 per info.
115116

src/drift/checkers/broken-link.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, resolve, relative } from "node:path";
3+
import type { DriftIssue } from "../../types.js";
4+
5+
const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
6+
7+
/** Scan scaffold markdown for local links whose target file does not exist. */
8+
export function checkBrokenLinks(
9+
scaffoldFiles: string[],
10+
projectRoot: string,
11+
scaffoldRoot: string
12+
): DriftIssue[] {
13+
const issues: DriftIssue[] = [];
14+
15+
for (const filePath of scaffoldFiles) {
16+
const source = relative(projectRoot, filePath);
17+
let content: string;
18+
try {
19+
content = readFileSync(filePath, "utf-8");
20+
} catch {
21+
continue;
22+
}
23+
24+
const fileDir = dirname(filePath);
25+
const lines = content.split("\n");
26+
let inFence = false;
27+
28+
for (let i = 0; i < lines.length; i++) {
29+
const line = lines[i];
30+
const trimmed = line.trim();
31+
if (trimmed.startsWith("```")) {
32+
inFence = !inFence;
33+
continue;
34+
}
35+
if (inFence) continue;
36+
37+
const scanLine = line.replace(/`[^`]+`/g, "");
38+
LINK_RE.lastIndex = 0;
39+
let match: RegExpExecArray | null;
40+
while ((match = LINK_RE.exec(scanLine)) !== null) {
41+
const rawTarget = match[2].trim();
42+
const target = normalizeLinkTarget(rawTarget);
43+
if (!target || isExternalOrAnchor(target)) continue;
44+
45+
if (!linkTargetExists(target, fileDir, projectRoot, scaffoldRoot)) {
46+
const isPattern = source.includes("patterns/");
47+
issues.push({
48+
code: "BROKEN_LINK",
49+
severity: isPattern ? "warning" : "error",
50+
file: source,
51+
line: i + 1,
52+
message: `Markdown link target does not exist: ${target}`,
53+
});
54+
}
55+
}
56+
}
57+
}
58+
59+
return issues;
60+
}
61+
62+
function normalizeLinkTarget(raw: string): string {
63+
let target = raw.replace(/^<|>$/g, "").trim();
64+
const titleSplit = target.match(/^([^\s]+)(?:\s+["'].+["'])?$/);
65+
if (titleSplit) target = titleSplit[1];
66+
target = target.replace(/[#?].*$/, "");
67+
return target;
68+
}
69+
70+
function isExternalOrAnchor(target: string): boolean {
71+
return (
72+
/^https?:\/\//i.test(target) ||
73+
/^mailto:/i.test(target) ||
74+
target.startsWith("#")
75+
);
76+
}
77+
78+
function linkTargetExists(
79+
target: string,
80+
fileDir: string,
81+
projectRoot: string,
82+
scaffoldRoot: string
83+
): boolean {
84+
const fromFile = resolve(fileDir, target);
85+
if (existsSync(fromFile)) return true;
86+
87+
if (existsSync(resolve(projectRoot, target))) return true;
88+
89+
if (scaffoldRoot !== projectRoot && existsSync(resolve(scaffoldRoot, target))) {
90+
return true;
91+
}
92+
93+
if (target.startsWith(".mex/")) {
94+
const withoutPrefix = target.slice(".mex/".length);
95+
if (existsSync(resolve(projectRoot, withoutPrefix))) return true;
96+
}
97+
98+
return false;
99+
}

src/drift/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { checkCrossFile } from "./checkers/cross-file.js";
1515
import { checkScriptCoverage } from "./checkers/script-coverage.js";
1616
import { checkToolConfigSync } from "./checkers/tool-config-sync.js";
1717
import { checkTodoFixme } from "./checkers/todo-fixme.js";
18+
import { checkBrokenLinks } from "./checkers/broken-link.js";
1819

1920
/**
2021
* Default glob patterns used to locate scaffold markdown files, relative to
@@ -124,6 +125,10 @@ export async function runDriftCheck(
124125
allIssues.push(...todoFixmeIssues);
125126
checkerIssueCounts.push(["todo-fixme", todoFixmeIssues.length]);
126127

128+
const brokenLinkIssues = checkBrokenLinks(scaffoldFiles, projectRoot, scaffoldRoot);
129+
allIssues.push(...brokenLinkIssues);
130+
checkerIssueCounts.push(["broken-link", brokenLinkIssues.length]);
131+
127132
const score = computeScore(allIssues);
128133
const verboseLog = opts.verbose
129134
? buildVerboseLog(scaffoldFiles.length, allClaims, checkerIssueCounts)

src/reporter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ function remediationFor(code: DriftIssue["code"]): string | null {
135135
return "Copy the intended tool config text across installed agent config files.";
136136
case "TODO_FIXME":
137137
return "Resolve the TODO/FIXME or remove the marker from the scaffold.";
138+
case "BROKEN_LINK":
139+
return "Fix the link target path or remove the broken Markdown link.";
138140
default:
139141
return null;
140142
}

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export type IssueCode =
9595
| "INDEX_ORPHAN_ENTRY"
9696
| "UNDOCUMENTED_SCRIPT"
9797
| "TOOL_CONFIG_DRIFT"
98-
| "TODO_FIXME";
98+
| "TODO_FIXME"
99+
| "BROKEN_LINK";
99100

100101
export interface DriftIssue {
101102
code: IssueCode;

test/checkers.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { checkCrossFile } from "../src/drift/checkers/cross-file.js";
1515
import { checkIndexSync } from "../src/drift/checkers/index-sync.js";
1616
import { checkToolConfigSync } from "../src/drift/checkers/tool-config-sync.js";
1717
import { checkTodoFixme } from "../src/drift/checkers/todo-fixme.js";
18+
import { checkBrokenLinks } from "../src/drift/checkers/broken-link.js";
1819
import type { Claim, ScaffoldFrontmatter } from "../src/types.js";
1920

2021
vi.mock("../src/git.js", () => ({
@@ -468,3 +469,69 @@ describe("checkTodoFixme", () => {
468469
expect(issues.map((i) => i.line)).toEqual([1, 1]);
469470
});
470471
});
472+
473+
// ── Broken Link Checker ──
474+
475+
describe("checkBrokenLinks", () => {
476+
it("flags a broken relative Markdown link", () => {
477+
mkdirSync(join(tmpDir, "context"), { recursive: true });
478+
const file = join(tmpDir, "context/guide.md");
479+
writeFileSync(file, "# Guide\n\nSee [setup](./missing.md).\n");
480+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
481+
expect(issues).toHaveLength(1);
482+
expect(issues[0]).toMatchObject({
483+
code: "BROKEN_LINK",
484+
severity: "error",
485+
file: "context/guide.md",
486+
line: 3,
487+
message: "Markdown link target does not exist: ./missing.md",
488+
});
489+
});
490+
491+
it("passes when the linked file exists", () => {
492+
mkdirSync(join(tmpDir, "context"), { recursive: true });
493+
writeFileSync(join(tmpDir, "context/target.md"), "# Target\n");
494+
const file = join(tmpDir, "context/guide.md");
495+
writeFileSync(file, "Link [here](./target.md).\n");
496+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
497+
expect(issues).toHaveLength(0);
498+
});
499+
500+
it("ignores external links and anchors", () => {
501+
const file = join(tmpDir, "ROUTER.md");
502+
writeFileSync(
503+
file,
504+
"[web](https://example.com) [mail](mailto:a@b.com) [section](#intro)\n"
505+
);
506+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
507+
expect(issues).toHaveLength(0);
508+
});
509+
510+
it("does not scan links inside fenced or inline code", () => {
511+
const file = join(tmpDir, "SETUP.md");
512+
writeFileSync(
513+
file,
514+
"```md\n[fake](./nowhere.md)\n```\n\nInline `[x](./also-missing.md)` ok.\n"
515+
);
516+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
517+
expect(issues).toHaveLength(0);
518+
});
519+
520+
it("resolves links with fragment or query to the base file", () => {
521+
mkdirSync(join(tmpDir, "context"), { recursive: true });
522+
writeFileSync(join(tmpDir, "context/target.md"), "# Target\n");
523+
const file = join(tmpDir, "context/guide.md");
524+
writeFileSync(file, "See [install](./target.md#install).\n");
525+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
526+
expect(issues).toHaveLength(0);
527+
});
528+
529+
it("downgrades broken links in patterns/ to warning", () => {
530+
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
531+
const file = join(tmpDir, "patterns/example.md");
532+
writeFileSync(file, "[x](./missing.md)\n");
533+
const issues = checkBrokenLinks([file], tmpDir, tmpDir);
534+
expect(issues).toHaveLength(1);
535+
expect(issues[0].severity).toBe("warning");
536+
});
537+
});

0 commit comments

Comments
 (0)