Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<name>.ts`.** There are two shapes:
- **Claim-based** — operates on extracted claims, e.g. `checkPaths(claims, projectRoot, scaffoldRoot)` in `path.ts`.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---------|----------------|
Expand All @@ -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.

Expand Down
99 changes: 99 additions & 0 deletions src/drift/checkers/broken-link.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +62 to +67

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the #fragment false positive originates. For [install](./target.md#install), target stays ./target.md#install, which fails existsSync, so an existing file is reported broken (at error severity). Reproduced locally.

Suggested fix — drop the fragment/query before returning:

function normalizeLinkTarget(raw: string): string {
  let target = raw.replace(/^<|>$/g, "").trim();
  const titleSplit = target.match(/^([^\s]+)(?:\s+["'].+["'])?$/);
  if (titleSplit) target = titleSplit[1];
  // Drop in-page fragment / query so links to a heading in another file resolve
  target = target.replace(/[#?].*$/, "");
  return target;
}

Bonus: a pure #section link then normalizes to "" and is already skipped by the !target guard up in the loop, so same-page anchors stay ignored without needing the startsWith("#") branch. Worth a test for [x](./target.md#heading) (target exists) -> expect no issue.

}

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;
}
5 changes: 5 additions & 0 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions test/checkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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");
});
});
Loading