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 @@ -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

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 |
| **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.

Expand Down
37 changes: 37 additions & 0 deletions src/drift/checkers/frontmatter-completeness.ts
Original file line number Diff line number Diff line change
@@ -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);
}
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 { checkFrontmatterCompleteness } from "./checkers/frontmatter-completeness.js";

/**
* Default glob patterns used to locate scaffold markdown files, relative to
Expand Down Expand Up @@ -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,
Expand All @@ -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]);
}

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 "INCOMPLETE_FRONTMATTER":
return "Add the missing name, description, or last_updated field to the YAML frontmatter.";
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"
| "INCOMPLETE_FRONTMATTER";

export interface DriftIssue {
code: IssueCode;
Expand Down
102 changes: 102 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 { checkFrontmatterCompleteness } from "../src/drift/checkers/frontmatter-completeness.js";
import type { Claim, ScaffoldFrontmatter } from "../src/types.js";

vi.mock("../src/git.js", () => ({
Expand Down Expand Up @@ -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",
])
);
});
});
36 changes: 36 additions & 0 deletions test/public-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading