Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ All notable changes to this project will be documented in this file.

### Added
- **Scaffold identity** — `.mex/config.json` now carries a stable `scaffold_id` (UUID v4), `scaffold_name`, and nullable `origin`/`upstream`. Generated at `mex setup` and silently backfilled for existing scaffolds on the next CLI invocation. New `getScaffoldIdentity()` export on the public API.
- **stale-pattern drift checker** — flags pattern files not linked from `ROUTER.md` or `context/*.md`.
- **broken-link drift checker** — flags Markdown links in scaffold files whose local target file does not exist.

### Changed
- README and CONTRIBUTING now list all 11 drift checkers (including `tool-config-sync`, `todo-fixme`, and `broken-link`).
- README and CONTRIBUTING now list all 12 drift checkers (including `tool-config-sync`, `todo-fixme`, `broken-link`, and `stale-pattern`).

## [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 11 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 12 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 11 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 12 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 @@ -106,7 +106,7 @@ Editable source: [docs/diagrams/context-routing.excalidraw](docs/diagrams/contex

## Drift Detection

Eleven checkers validate your scaffold against the real codebase. Zero tokens, zero AI.
Twelve checkers validate your scaffold against the real codebase. Zero tokens, zero AI.

| Checker | What it catches |
|---------|----------------|
Expand All @@ -121,6 +121,7 @@ Eleven checkers validate your scaffold against the real codebase. Zero tokens, z
| **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 |
| **stale-pattern** | Pattern files not linked from `ROUTER.md` or `context/*.md` |

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

Expand Down
87 changes: 87 additions & 0 deletions src/drift/checkers/stale-pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve, relative } from "node:path";
import { globSync } from "glob";
import type { DriftIssue } from "../../types.js";

const LINK_RE = /\[.*?\]\((.+?\.md(?:#[\w-]+)?)\)/g;
const BACKTICK_MD_RE = /`([\w-]+\.md)`/g;

/** Pattern files not linked from ROUTER.md or context/*.md (orphans in nav graph). */
export function checkStalePatterns(
projectRoot: string,
scaffoldRoot: string,
): DriftIssue[] {
// Try scaffold root first (deployed as .mex/), then project root
let patternsDir = resolve(scaffoldRoot, "patterns");
if (!existsSync(patternsDir)) {
patternsDir = resolve(projectRoot, "patterns");
}
if (!existsSync(patternsDir)) return [];

const patternFiles = globSync("*.md", {
cwd: patternsDir,
ignore: ["node_modules/**"],
}).filter((f) => f !== "INDEX.md" && f !== "README.md");
if (patternFiles.length === 0) return [];

const referenced = new Set<string>();
const sources = navSources(projectRoot, scaffoldRoot);

for (const filePath of sources) {
if (!existsSync(filePath)) continue;
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch {
continue;
}
collectPatternRefs(content.replace(/<!--[\s\S]*?-->/g, ""), referenced);
}

const issues: DriftIssue[] = [];
for (const file of patternFiles) {
const ref = `patterns/${file}`;
if (!referenced.has(file) && !referenced.has(ref)) {
issues.push({
code: "STALE_PATTERN",
severity: "warning",
file: relative(projectRoot, resolve(patternsDir, file)),
line: null,
message: `Pattern ${ref} is not linked from ROUTER.md or context/*.md`,
});
}
}
return issues;
}

function navSources(projectRoot: string, scaffoldRoot: string): string[] {
let router = resolve(scaffoldRoot, "ROUTER.md");
if (!existsSync(router) && scaffoldRoot !== projectRoot) {
router = resolve(projectRoot, "ROUTER.md");
}

let contextDir = resolve(scaffoldRoot, "context");
if (!existsSync(contextDir) && scaffoldRoot !== projectRoot) {
contextDir = resolve(projectRoot, "context");
}
const contextSources = existsSync(contextDir)
? globSync("*.md", { cwd: contextDir }).map((f) => resolve(contextDir, f))
: [];

return [router, ...contextSources];
}

function collectPatternRefs(content: string, out: Set<string>): void {
let match: RegExpExecArray | null;
LINK_RE.lastIndex = 0;
while ((match = LINK_RE.exec(content)) !== null) {
const target = match[1].replace(/#.*$/, "").replace(/^\.\//, "");
out.add(target);
out.add(target.split("/").pop()!);
}
BACKTICK_MD_RE.lastIndex = 0;
while ((match = BACKTICK_MD_RE.exec(content)) !== null) {
out.add(match[1]);
out.add(`patterns/${match[1]}`);
}
}
5 changes: 5 additions & 0 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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";
import { checkStalePatterns } from "./checkers/stale-pattern.js";

/**
* Default glob patterns used to locate scaffold markdown files, relative to
Expand Down Expand Up @@ -112,6 +113,10 @@ export async function runDriftCheck(
allIssues.push(...indexSyncIssues);
checkerIssueCounts.push(["index-sync", indexSyncIssues.length]);

const stalePatternIssues = checkStalePatterns(projectRoot, scaffoldRoot);
allIssues.push(...stalePatternIssues);
checkerIssueCounts.push(["stale-pattern", stalePatternIssues.length]);

// Run coverage checkers (reality → scaffold direction)
const scriptCoverageIssues = checkScriptCoverage(scaffoldFiles, projectRoot);
allIssues.push(...scriptCoverageIssues);
Expand Down
2 changes: 2 additions & 0 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ function remediationFor(code: DriftIssue["code"]): string | null {
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.";
case "STALE_PATTERN":
return "Link the pattern from ROUTER.md or a context/*.md file so agents can discover it.";
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 @@ -114,7 +114,8 @@ export type IssueCode =
| "UNDOCUMENTED_SCRIPT"
| "TOOL_CONFIG_DRIFT"
| "TODO_FIXME"
| "BROKEN_LINK";
| "BROKEN_LINK"
| "STALE_PATTERN";

export interface DriftIssue {
code: IssueCode;
Expand Down
99 changes: 99 additions & 0 deletions test/checkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 { checkStalePatterns } from "../src/drift/checkers/stale-pattern.js";
import type { Claim, ScaffoldFrontmatter } from "../src/types.js";

vi.mock("../src/git.js", () => ({
Expand Down Expand Up @@ -596,3 +597,101 @@ describe("checkBrokenLinks", () => {
expect(issues[0].severity).toBe("warning");
});
});

// ── Stale Pattern Checker ──

describe("checkStalePatterns", () => {
it("flags pattern files not linked from ROUTER or context", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
mkdirSync(join(tmpDir, "context"), { recursive: true });
writeFileSync(join(tmpDir, "ROUTER.md"), "# Router\n\nSee [linked](patterns/linked.md)\n");
writeFileSync(join(tmpDir, "patterns/linked.md"), "# Linked\n");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
code: "STALE_PATTERN",
severity: "warning",
file: "patterns/orphan.md",
});
});

it("accepts backtick references in context files", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
mkdirSync(join(tmpDir, "context"), { recursive: true });
writeFileSync(join(tmpDir, "ROUTER.md"), "# Router\n");
writeFileSync(join(tmpDir, "context/guide.md"), "Use `cited.md` for citations.\n");
writeFileSync(join(tmpDir, "patterns/cited.md"), "# Cited\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("returns empty when patterns directory is missing", () => {
writeFileSync(join(tmpDir, "ROUTER.md"), "# Router\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("still flags patterns only listed in INDEX.md (index-sync covers INDEX ↔ files)", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(join(tmpDir, "ROUTER.md"), "# Router\n");
writeFileSync(
join(tmpDir, "patterns/INDEX.md"),
"| [indexed-only.md](indexed-only.md) | Indexed |\n",
);
writeFileSync(join(tmpDir, "patterns/indexed-only.md"), "# Indexed only\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(1);
expect(issues[0].file).toBe("patterns/indexed-only.md");
});

it("ignores pattern references inside HTML comments in ROUTER", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(
join(tmpDir, "ROUTER.md"),
"# Router\n\n<!-- See [commented.md](patterns/commented.md) -->\n",
);
writeFileSync(join(tmpDir, "patterns/commented.md"), "# Commented\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(1);
expect(issues[0].file).toBe("patterns/commented.md");
});

it("accepts markdown links with anchor fragments and relative paths", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(
join(tmpDir, "ROUTER.md"),
"# Router\n\nSee [anchored](./patterns/anchored.md#section)\n",
);
writeFileSync(join(tmpDir, "patterns/anchored.md"), "# Anchored\n");
const issues = checkStalePatterns(tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("resolves patterns from project root when scaffold has no patterns/", () => {
const scaffold = join(tmpDir, ".mex");
mkdirSync(scaffold, { recursive: true });
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(join(scaffold, "ROUTER.md"), "# Router\n\nSee [linked](patterns/linked.md)\n");
writeFileSync(join(tmpDir, "patterns/linked.md"), "# Linked\n");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan\n");
const issues = checkStalePatterns(tmpDir, scaffold);
expect(issues).toHaveLength(1);
expect(issues[0].file).toBe("patterns/orphan.md");
});

it("falls back to project-root ROUTER and context when scaffold lacks them", () => {
const scaffold = join(tmpDir, ".mex");
mkdirSync(scaffold, { recursive: true });
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
mkdirSync(join(tmpDir, "context"), { recursive: true });
writeFileSync(join(tmpDir, "ROUTER.md"), "# Router\n\nSee [linked](patterns/linked.md)\n");
writeFileSync(join(tmpDir, "context/guide.md"), "Also cites `cited.md`.\n");
writeFileSync(join(tmpDir, "patterns/linked.md"), "# Linked\n");
writeFileSync(join(tmpDir, "patterns/cited.md"), "# Cited\n");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan\n");
const issues = checkStalePatterns(tmpDir, scaffold);
expect(issues).toHaveLength(1);
expect(issues[0].file).toBe("patterns/orphan.md");
});
});
31 changes: 31 additions & 0 deletions test/public-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,37 @@ describe("public API — runDriftCheck", () => {
const report = await runDriftCheck(config, opts);
expect(report).toBeDefined();
});

it("flags stale patterns via the drift pipeline", async () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(
join(tmpDir, ".mex/ROUTER.md"),
"# Router\n\nSee [linked](patterns/linked.md)\n",
);
writeFileSync(join(tmpDir, "patterns/linked.md"), "# Linked\n");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan\n");
const report = await runDriftCheck(config);
const stale = report.issues.filter((i) => i.code === "STALE_PATTERN");
expect(stale).toHaveLength(1);
expect(stale[0].file).toBe("patterns/orphan.md");
});

it("uses project-root ROUTER when scaffold has no nav files", async () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
mkdirSync(join(tmpDir, "context"), { recursive: true });
writeFileSync(
join(tmpDir, "ROUTER.md"),
"# Router\n\nSee [linked](patterns/linked.md)\n",
);
writeFileSync(join(tmpDir, "context/guide.md"), "Also cites `cited.md`.\n");
writeFileSync(join(tmpDir, "patterns/linked.md"), "# Linked\n");
writeFileSync(join(tmpDir, "patterns/cited.md"), "# Cited\n");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan\n");
const report = await runDriftCheck(config);
const stale = report.issues.filter((i) => i.code === "STALE_PATTERN");
expect(stale).toHaveLength(1);
expect(stale[0].file).toBe("patterns/orphan.md");
});
});

describe("public API — heartbeat", () => {
Expand Down