diff --git a/CHANGELOG.md b/CHANGELOG.md index 0478277c..90edc094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added -- **todo-fixme drift checker** — flags unresolved `TODO` / `FIXME` markers in scaffold markdown. +- **broken-link drift checker** — flags Markdown links in scaffold files whose local target file does not exist. ### Changed -- README and CONTRIBUTING now list all 10 drift checkers (including `tool-config-sync` and `todo-fixme`). +- README and CONTRIBUTING now list all 11 drift checkers (including `tool-config-sync`, `todo-fixme`, and `broken-link`). ## [0.3.5] - 2026-05-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71621fbc..300c3737 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thanks for your interest in contributing! Here's how to get started. -**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. +**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. ## Setup @@ -50,7 +50,7 @@ test/ # Vitest tests ## Adding a drift checker -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. +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. 1. **Create `src/drift/checkers/.ts`.** There are two shapes: - **Claim-based** — operates on extracted claims, e.g. `checkPaths(claims, projectRoot, scaffoldRoot)` in `path.ts`. diff --git a/README.md b/README.md index 54065335..84f6f85c 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Editable source: [docs/diagrams/context-routing.excalidraw](docs/diagrams/contex ## Drift Detection -Ten checkers validate your scaffold against the real codebase. Zero tokens, zero AI. +Eleven checkers validate your scaffold against the real codebase. Zero tokens, zero AI. | Checker | What it catches | |---------|----------------| @@ -110,6 +110,7 @@ Ten checkers validate your scaffold against the real codebase. Zero tokens, zero | **script-coverage** | `package.json` scripts not mentioned in any scaffold file | | **tool-config-sync** | Installed AI tool config files (e.g. `CLAUDE.md`, `.cursorrules`) out of sync with each other | | **todo-fixme** | Unresolved `TODO` / `FIXME` markers left in scaffold markdown | +| **broken-link** | Markdown links to local files that do not exist on disk | Scoring starts at 100. mex deducts 10 per error, 3 per warning, and 1 per info. diff --git a/src/drift/checkers/broken-link.ts b/src/drift/checkers/broken-link.ts new file mode 100644 index 00000000..ed853946 --- /dev/null +++ b/src/drift/checkers/broken-link.ts @@ -0,0 +1,99 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve, relative } from "node:path"; +import type { DriftIssue } from "../../types.js"; + +const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g; + +/** Scan scaffold markdown for local links whose target file does not exist. */ +export function checkBrokenLinks( + scaffoldFiles: string[], + projectRoot: string, + scaffoldRoot: string +): DriftIssue[] { + const issues: DriftIssue[] = []; + + for (const filePath of scaffoldFiles) { + const source = relative(projectRoot, filePath); + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const fileDir = dirname(filePath); + const lines = content.split("\n"); + let inFence = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (trimmed.startsWith("```")) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const scanLine = line.replace(/`[^`]+`/g, ""); + LINK_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = LINK_RE.exec(scanLine)) !== null) { + const rawTarget = match[2].trim(); + const target = normalizeLinkTarget(rawTarget); + if (!target || isExternalOrAnchor(target)) continue; + + if (!linkTargetExists(target, fileDir, projectRoot, scaffoldRoot)) { + const isPattern = source.includes("patterns/"); + issues.push({ + code: "BROKEN_LINK", + severity: isPattern ? "warning" : "error", + file: source, + line: i + 1, + message: `Markdown link target does not exist: ${target}`, + }); + } + } + } + } + + return issues; +} + +function normalizeLinkTarget(raw: string): string { + let target = raw.replace(/^<|>$/g, "").trim(); + const titleSplit = target.match(/^([^\s]+)(?:\s+["'].+["'])?$/); + if (titleSplit) target = titleSplit[1]; + target = target.replace(/[#?].*$/, ""); + return target; +} + +function isExternalOrAnchor(target: string): boolean { + return ( + /^https?:\/\//i.test(target) || + /^mailto:/i.test(target) || + target.startsWith("#") + ); +} + +function linkTargetExists( + target: string, + fileDir: string, + projectRoot: string, + scaffoldRoot: string +): boolean { + const fromFile = resolve(fileDir, target); + if (existsSync(fromFile)) return true; + + if (existsSync(resolve(projectRoot, target))) return true; + + if (scaffoldRoot !== projectRoot && existsSync(resolve(scaffoldRoot, target))) { + return true; + } + + if (target.startsWith(".mex/")) { + const withoutPrefix = target.slice(".mex/".length); + if (existsSync(resolve(projectRoot, withoutPrefix))) return true; + } + + return false; +} diff --git a/src/drift/index.ts b/src/drift/index.ts index bd97d32a..e47bf4ef 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -15,6 +15,7 @@ import { checkCrossFile } from "./checkers/cross-file.js"; import { checkScriptCoverage } from "./checkers/script-coverage.js"; import { checkToolConfigSync } from "./checkers/tool-config-sync.js"; import { checkTodoFixme } from "./checkers/todo-fixme.js"; +import { checkBrokenLinks } from "./checkers/broken-link.js"; /** * Default glob patterns used to locate scaffold markdown files, relative to @@ -124,6 +125,10 @@ export async function runDriftCheck( allIssues.push(...todoFixmeIssues); checkerIssueCounts.push(["todo-fixme", todoFixmeIssues.length]); + const brokenLinkIssues = checkBrokenLinks(scaffoldFiles, projectRoot, scaffoldRoot); + allIssues.push(...brokenLinkIssues); + checkerIssueCounts.push(["broken-link", brokenLinkIssues.length]); + const score = computeScore(allIssues); const verboseLog = opts.verbose ? buildVerboseLog(scaffoldFiles.length, allClaims, checkerIssueCounts) diff --git a/src/reporter.ts b/src/reporter.ts index 37cc68f3..cbc64efe 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -135,6 +135,8 @@ function remediationFor(code: DriftIssue["code"]): string | null { return "Copy the intended tool config text across installed agent config files."; case "TODO_FIXME": return "Resolve the TODO/FIXME or remove the marker from the scaffold."; + case "BROKEN_LINK": + return "Fix the link target path or remove the broken Markdown link."; default: return null; } diff --git a/src/types.ts b/src/types.ts index ceb37349..88c6933f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,8 @@ export type IssueCode = | "INDEX_ORPHAN_ENTRY" | "UNDOCUMENTED_SCRIPT" | "TOOL_CONFIG_DRIFT" - | "TODO_FIXME"; + | "TODO_FIXME" + | "BROKEN_LINK"; export interface DriftIssue { code: IssueCode; diff --git a/test/checkers.test.ts b/test/checkers.test.ts index 879b882c..07b20f78 100644 --- a/test/checkers.test.ts +++ b/test/checkers.test.ts @@ -15,6 +15,7 @@ import { checkCrossFile } from "../src/drift/checkers/cross-file.js"; import { checkIndexSync } from "../src/drift/checkers/index-sync.js"; import { checkToolConfigSync } from "../src/drift/checkers/tool-config-sync.js"; import { checkTodoFixme } from "../src/drift/checkers/todo-fixme.js"; +import { checkBrokenLinks } from "../src/drift/checkers/broken-link.js"; import type { Claim, ScaffoldFrontmatter } from "../src/types.js"; vi.mock("../src/git.js", () => ({ @@ -468,3 +469,69 @@ describe("checkTodoFixme", () => { expect(issues.map((i) => i.line)).toEqual([1, 1]); }); }); + +// ── Broken Link Checker ── + +describe("checkBrokenLinks", () => { + it("flags a broken relative Markdown link", () => { + mkdirSync(join(tmpDir, "context"), { recursive: true }); + const file = join(tmpDir, "context/guide.md"); + writeFileSync(file, "# Guide\n\nSee [setup](./missing.md).\n"); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + code: "BROKEN_LINK", + severity: "error", + file: "context/guide.md", + line: 3, + message: "Markdown link target does not exist: ./missing.md", + }); + }); + + it("passes when the linked file exists", () => { + mkdirSync(join(tmpDir, "context"), { recursive: true }); + writeFileSync(join(tmpDir, "context/target.md"), "# Target\n"); + const file = join(tmpDir, "context/guide.md"); + writeFileSync(file, "Link [here](./target.md).\n"); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(0); + }); + + it("ignores external links and anchors", () => { + const file = join(tmpDir, "ROUTER.md"); + writeFileSync( + file, + "[web](https://example.com) [mail](mailto:a@b.com) [section](#intro)\n" + ); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(0); + }); + + it("does not scan links inside fenced or inline code", () => { + const file = join(tmpDir, "SETUP.md"); + writeFileSync( + file, + "```md\n[fake](./nowhere.md)\n```\n\nInline `[x](./also-missing.md)` ok.\n" + ); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(0); + }); + + it("resolves links with fragment or query to the base file", () => { + mkdirSync(join(tmpDir, "context"), { recursive: true }); + writeFileSync(join(tmpDir, "context/target.md"), "# Target\n"); + const file = join(tmpDir, "context/guide.md"); + writeFileSync(file, "See [install](./target.md#install).\n"); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(0); + }); + + it("downgrades broken links in patterns/ to warning", () => { + mkdirSync(join(tmpDir, "patterns"), { recursive: true }); + const file = join(tmpDir, "patterns/example.md"); + writeFileSync(file, "[x](./missing.md)\n"); + const issues = checkBrokenLinks([file], tmpDir, tmpDir); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("warning"); + }); +});