diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7704d..fa07f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,10 @@ 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. - **broken-link drift checker** — flags Markdown links in scaffold files whose local target file does not exist. +- **frontmatter-completeness drift checker** — warns when `context/` or `patterns/` files lack recommended `name`, `description`, or `last_updated` frontmatter. ### 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 `broken-link` and `frontmatter-completeness`). ## [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..c3668ac 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 | +| **frontmatter-completeness** | Missing `name`, `description`, or `last_updated` in `context/` and `patterns/` files | Scoring starts at 100. mex deducts 10 per error, 3 per warning, and 1 per info. diff --git a/src/drift/checkers/frontmatter-completeness.ts b/src/drift/checkers/frontmatter-completeness.ts new file mode 100644 index 0000000..684ed53 --- /dev/null +++ b/src/drift/checkers/frontmatter-completeness.ts @@ -0,0 +1,37 @@ +import { basename } from "node:path"; +import type { DriftIssue, ScaffoldFrontmatter } from "../../types.js"; + +const RECOMMENDED_FIELDS = ["name", "description", "last_updated"] as const; +const EXCLUDED_PATTERN_FILES = new Set(["INDEX.md", "README.md"]); + +/** Warn when context/ or patterns/ files lack recommended frontmatter fields. */ +export function checkFrontmatterCompleteness( + frontmatter: ScaffoldFrontmatter | null, + source: string +): DriftIssue[] { + if (!isContextOrPatternFile(source)) return []; + + const issues: DriftIssue[] = []; + const fm = frontmatter ?? {}; + + for (const field of RECOMMENDED_FIELDS) { + const value = fm[field]; + if (typeof value !== "string" || value.trim() === "") { + issues.push({ + code: "INCOMPLETE_FRONTMATTER", + severity: "warning", + file: source, + line: null, + message: `Missing recommended frontmatter field: ${field}`, + }); + } + } + + return issues; +} + +function isContextOrPatternFile(source: string): boolean { + if (EXCLUDED_PATTERN_FILES.has(basename(source))) return false; + // Match both root-layout (context/foo.md) and deployed (.mex/context/foo.md) paths. + return /(^|\/)context\//.test(source) || /(^|\/)patterns\//.test(source); +} diff --git a/src/drift/index.ts b/src/drift/index.ts index e47bf4e..8080c84 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 { checkFrontmatterCompleteness } from "./checkers/frontmatter-completeness.js"; /** * Default glob patterns used to locate scaffold markdown files, relative to @@ -76,6 +77,9 @@ export async function runDriftCheck( const edgeIssues = checkEdges(frontmatter, filePath, source, projectRoot, scaffoldRoot); allIssues.push(...edgeIssues); + const frontmatterIssues = checkFrontmatterCompleteness(frontmatter, source); + allIssues.push(...frontmatterIssues); + // Staleness check const stalenessIssues = await checkStaleness( source, @@ -87,6 +91,7 @@ export async function runDriftCheck( allIssues.push(...stalenessIssues); checkerIssueCounts.push([`edges:${source}`, edgeIssues.length]); + checkerIssueCounts.push([`frontmatter-completeness:${source}`, frontmatterIssues.length]); checkerIssueCounts.push([`staleness:${source}`, stalenessIssues.length]); } diff --git a/src/reporter.ts b/src/reporter.ts index cbc64ef..2765dd3 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 "INCOMPLETE_FRONTMATTER": + return "Add the missing name, description, or last_updated field to the YAML frontmatter."; default: return null; } diff --git a/src/types.ts b/src/types.ts index ebf5067..d9e5617 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" + | "INCOMPLETE_FRONTMATTER"; export interface DriftIssue { code: IssueCode; diff --git a/test/checkers.test.ts b/test/checkers.test.ts index 720cdd5..e450723 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 { checkFrontmatterCompleteness } from "../src/drift/checkers/frontmatter-completeness.js"; import type { Claim, ScaffoldFrontmatter } from "../src/types.js"; vi.mock("../src/git.js", () => ({ @@ -596,3 +597,104 @@ describe("checkBrokenLinks", () => { expect(issues[0].severity).toBe("warning"); }); }); + +// ── Frontmatter completeness ── + +describe("checkFrontmatterCompleteness", () => { + it("warns on missing recommended fields in context/", () => { + const issues = checkFrontmatterCompleteness( + { name: "Auth" }, + "context/auth.md" + ); + expect(issues).toHaveLength(2); + expect(issues.every((i) => i.code === "INCOMPLETE_FRONTMATTER")).toBe(true); + expect(issues.every((i) => i.severity === "warning")).toBe(true); + expect(issues.map((i) => i.message)).toEqual( + expect.arrayContaining([ + "Missing recommended frontmatter field: description", + "Missing recommended frontmatter field: last_updated", + ]) + ); + }); + + it("warns on all fields when frontmatter is missing", () => { + const issues = checkFrontmatterCompleteness(null, "context/auth.md"); + expect(issues).toHaveLength(3); + expect(issues.map((i) => i.message)).toEqual( + expect.arrayContaining([ + "Missing recommended frontmatter field: name", + "Missing recommended frontmatter field: description", + "Missing recommended frontmatter field: last_updated", + ]) + ); + }); + + it("treats whitespace-only values as missing", () => { + const issues = checkFrontmatterCompleteness( + { name: " ", description: "ok", last_updated: "\t" }, + "patterns/auth.md" + ); + expect(issues).toHaveLength(2); + expect(issues.map((i) => i.message)).toEqual( + expect.arrayContaining([ + "Missing recommended frontmatter field: name", + "Missing recommended frontmatter field: last_updated", + ]) + ); + }); + + it("passes when all recommended fields are present", () => { + const issues = checkFrontmatterCompleteness( + { + name: "Auth", + description: "Authentication overview", + last_updated: "2026-06-01", + }, + "patterns/auth.md" + ); + expect(issues).toHaveLength(0); + }); + + it("warns on .mex/context/ paths (deployed scaffold layout)", () => { + const issues = checkFrontmatterCompleteness(null, ".mex/context/auth.md"); + expect(issues).toHaveLength(3); + expect(issues.every((i) => i.code === "INCOMPLETE_FRONTMATTER")).toBe(true); + }); + + it("warns on .mex/patterns/ paths (deployed scaffold layout)", () => { + const issues = checkFrontmatterCompleteness(null, ".mex/patterns/auth.md"); + expect(issues).toHaveLength(3); + expect(issues.every((i) => i.code === "INCOMPLETE_FRONTMATTER")).toBe(true); + }); + + it("ignores ROUTER.md and other scaffold files", () => { + const issues = checkFrontmatterCompleteness(null, "ROUTER.md"); + expect(issues).toHaveLength(0); + }); + + it("ignores structural pattern meta-files (INDEX.md, README.md)", () => { + for (const source of [ + "patterns/INDEX.md", + "patterns/README.md", + ".mex/patterns/INDEX.md", + ".mex/patterns/README.md", + ]) { + const issues = checkFrontmatterCompleteness(null, source); + expect(issues).toHaveLength(0); + } + }); + + it("treats non-string YAML values as missing", () => { + const issues = checkFrontmatterCompleteness( + { name: 123, description: "ok", last_updated: ["YYYY-MM-DD"] }, + "context/auth.md" + ); + expect(issues).toHaveLength(2); + expect(issues.map((i) => i.message)).toEqual( + expect.arrayContaining([ + "Missing recommended frontmatter field: name", + "Missing recommended frontmatter field: last_updated", + ]) + ); + }); +}); diff --git a/test/public-api.test.ts b/test/public-api.test.ts index 63f2e15..9a6bd9c 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -230,6 +230,42 @@ describe("public API — runDriftCheck", () => { const report = await runDriftCheck(config, opts); expect(report).toBeDefined(); }); + + it("surfaces INCOMPLETE_FRONTMATTER for context files missing recommended fields", async () => { + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "# Router\n"); + mkdirSync(join(tmpDir, ".mex/context"), { recursive: true }); + writeFileSync( + join(tmpDir, ".mex/context/auth.md"), + "---\nname: Auth\n---\n\n# Auth\n", + ); + const report = await runDriftCheck(config); + const frontmatterIssues = report.issues.filter( + (issue) => issue.code === "INCOMPLETE_FRONTMATTER", + ); + expect(frontmatterIssues.length).toBeGreaterThanOrEqual(2); + expect( + frontmatterIssues.every((issue) => issue.severity === "warning"), + ).toBe(true); + expect( + frontmatterIssues.some((issue) => issue.message.includes("description")), + ).toBe(true); + expect( + frontmatterIssues.some((issue) => issue.message.includes("last_updated")), + ).toBe(true); + }); + + it("skips structural pattern meta-files in frontmatter-completeness checks", async () => { + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "# Router\n"); + mkdirSync(join(tmpDir, ".mex/patterns"), { recursive: true }); + writeFileSync(join(tmpDir, ".mex/patterns/INDEX.md"), "# Patterns\n"); + const report = await runDriftCheck(config); + const indexIssues = report.issues.filter( + (issue) => + issue.code === "INCOMPLETE_FRONTMATTER" && + issue.file.endsWith("patterns/INDEX.md"), + ); + expect(indexIssues).toHaveLength(0); + }); }); describe("public API — heartbeat", () => {