diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7704d..d0f3821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 300c373..7a6de65 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 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 @@ -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/.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 1306002..50ccee3 100644 --- a/README.md +++ b/README.md @@ -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 | |---------|----------------| @@ -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. diff --git a/src/drift/checkers/stale-pattern.ts b/src/drift/checkers/stale-pattern.ts new file mode 100644 index 0000000..df02b47 --- /dev/null +++ b/src/drift/checkers/stale-pattern.ts @@ -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(); + 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(//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): 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]}`); + } +} diff --git a/src/drift/index.ts b/src/drift/index.ts index e47bf4e..1f1784b 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -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 @@ -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); diff --git a/src/reporter.ts b/src/reporter.ts index cbc64ef..9ddaa2c 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -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; } diff --git a/src/types.ts b/src/types.ts index ebf5067..cf3c3b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/test/checkers.test.ts b/test/checkers.test.ts index 720cdd5..80b7673 100644 --- a/test/checkers.test.ts +++ b/test/checkers.test.ts @@ -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", () => ({ @@ -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\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"); + }); +}); diff --git a/test/public-api.test.ts b/test/public-api.test.ts index 63f2e15..65c7c3c 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -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", () => {