From 0a4858df05135d518da92efd303bb468dceee547 Mon Sep 17 00:00:00 2001 From: ot0m1 <6190966+ot0m1@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:16:16 +0900 Subject: [PATCH 1/2] Add frontmatter completeness checker --- src/drift/checkers/broken-link.ts | 3 +- .../checkers/frontmatter-completeness.ts | 41 ++++++++++ src/drift/checkers/todo-fixme.ts | 3 +- src/drift/index.ts | 13 +++ src/heartbeat.ts | 3 +- src/path-utils.ts | 3 + src/reporter.ts | 2 + src/scanner/entry-points.ts | 4 +- src/types.ts | 3 +- test/checkers.test.ts | 82 +++++++++++++++++++ 10 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/drift/checkers/frontmatter-completeness.ts create mode 100644 src/path-utils.ts diff --git a/src/drift/checkers/broken-link.ts b/src/drift/checkers/broken-link.ts index ed85394..f73a56b 100644 --- a/src/drift/checkers/broken-link.ts +++ b/src/drift/checkers/broken-link.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve, relative } from "node:path"; import type { DriftIssue } from "../../types.js"; +import { toPosixPath } from "../../path-utils.js"; const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g; @@ -13,7 +14,7 @@ export function checkBrokenLinks( const issues: DriftIssue[] = []; for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosixPath(relative(projectRoot, filePath)); let content: string; try { content = readFileSync(filePath, "utf-8"); diff --git a/src/drift/checkers/frontmatter-completeness.ts b/src/drift/checkers/frontmatter-completeness.ts new file mode 100644 index 0000000..f8de816 --- /dev/null +++ b/src/drift/checkers/frontmatter-completeness.ts @@ -0,0 +1,41 @@ +import { relative } from "node:path"; +import type { DriftIssue, ScaffoldFrontmatter } from "../../types.js"; +import { toPosixPath } from "../../path-utils.js"; +import { parseFrontmatter } from "../frontmatter.js"; + +const REQUIRED_FIELDS = ["name", "description", "last_updated"] as const; + +/** Warn when context/pattern scaffold files are missing required frontmatter. */ +export function checkFrontmatterCompleteness( + filePath: string, + projectRoot: string, + scaffoldRoot: string +): DriftIssue[] { + const logicalPath = toPosixPath(relative(scaffoldRoot, filePath)); + if (!isRequiredFrontmatterFile(logicalPath)) return []; + + const source = toPosixPath(relative(projectRoot, filePath)); + const frontmatter = parseFrontmatter(filePath); + const missingFields = missingRequiredFields(frontmatter); + + return missingFields.map((field) => ({ + code: "FRONTMATTER_MISSING_FIELD", + severity: "warning", + file: source, + line: 1, + message: `Required frontmatter field is missing or blank: ${field}`, + })); +} + +function missingRequiredFields( + frontmatter: ScaffoldFrontmatter | null +): string[] { + return REQUIRED_FIELDS.filter((field) => { + const value = frontmatter?.[field]; + return typeof value !== "string" || value.trim().length === 0; + }); +} + +function isRequiredFrontmatterFile(path: string): boolean { + return /^(context|patterns)\/[^/]+\.md$/i.test(path); +} diff --git a/src/drift/checkers/todo-fixme.ts b/src/drift/checkers/todo-fixme.ts index bba15bf..a840e75 100644 --- a/src/drift/checkers/todo-fixme.ts +++ b/src/drift/checkers/todo-fixme.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { relative } from "node:path"; import type { DriftIssue } from "../../types.js"; +import { toPosixPath } from "../../path-utils.js"; const MARKER_RE = /\b(TODO|FIXME)\b/g; @@ -12,7 +13,7 @@ export function checkTodoFixme( const issues: DriftIssue[] = []; for (const filePath of scaffoldFiles) { - const source = relative(projectRoot, filePath); + const source = toPosixPath(relative(projectRoot, filePath)); let content: string; try { content = readFileSync(filePath, "utf-8"); diff --git a/src/drift/index.ts b/src/drift/index.ts index e47bf4e..cd97db2 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 @@ -86,8 +87,20 @@ export async function runDriftCheck( ); allIssues.push(...stalenessIssues); + // Frontmatter completeness check + const frontmatterCompletenessIssues = checkFrontmatterCompleteness( + filePath, + projectRoot, + scaffoldRoot + ); + allIssues.push(...frontmatterCompletenessIssues); + checkerIssueCounts.push([`edges:${source}`, edgeIssues.length]); checkerIssueCounts.push([`staleness:${source}`, stalenessIssues.length]); + checkerIssueCounts.push([ + `frontmatter-completeness:${source}`, + frontmatterCompletenessIssues.length, + ]); } // Run checkers that work on claims diff --git a/src/heartbeat.ts b/src/heartbeat.ts index e89640c..79b5cef 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -4,6 +4,7 @@ import { globSync } from "glob"; import chalk from "chalk"; import { parseFrontmatter } from "./drift/frontmatter.js"; import { daysSinceFrontmatterDate } from "./drift/checkers/staleness.js"; +import { toPosixPath } from "./path-utils.js"; import type { MexConfig } from "./types.js"; export interface HeartbeatResult { @@ -70,7 +71,7 @@ export function checkHeartbeat( now, ); return days !== null && days > staleDays - ? { file: relative(config.scaffoldRoot, file), days } + ? { file: toPosixPath(relative(config.scaffoldRoot, file)), days } : null; }) .filter((v): v is { file: string; days: number } => Boolean(v)); diff --git a/src/path-utils.ts b/src/path-utils.ts new file mode 100644 index 0000000..8f69285 --- /dev/null +++ b/src/path-utils.ts @@ -0,0 +1,3 @@ +export function toPosixPath(path: string): string { + return path.replace(/\\/g, "/"); +} diff --git a/src/reporter.ts b/src/reporter.ts index cbc64ef..714c454 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 "FRONTMATTER_MISSING_FIELD": + return "Add name, description, and last_updated to the scaffold file frontmatter."; default: return null; } diff --git a/src/scanner/entry-points.ts b/src/scanner/entry-points.ts index 6d68a1b..960d565 100644 --- a/src/scanner/entry-points.ts +++ b/src/scanner/entry-points.ts @@ -1,5 +1,6 @@ import { globSync } from "glob"; import type { EntryPoint } from "../types.js"; +import { toPosixPath } from "../path-utils.js"; const MAIN_PATTERNS = [ "src/index.{ts,js,tsx,jsx}", @@ -44,7 +45,8 @@ export function scanEntryPoints(projectRoot: string): EntryPoint[] { cwd: projectRoot, ignore: ["node_modules/**", "dist/**", "build/**", ".git/**"], }); - for (const path of matches) { + for (const match of matches) { + const path = toPosixPath(match); if (seen.has(path)) continue; seen.add(path); entries.push({ path, type }); diff --git a/src/types.ts b/src/types.ts index 88c6933..6f14e29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,7 +96,8 @@ export type IssueCode = | "UNDOCUMENTED_SCRIPT" | "TOOL_CONFIG_DRIFT" | "TODO_FIXME" - | "BROKEN_LINK"; + | "BROKEN_LINK" + | "FRONTMATTER_MISSING_FIELD"; export interface DriftIssue { code: IssueCode; diff --git a/test/checkers.test.ts b/test/checkers.test.ts index 720cdd5..e52c609 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", () => ({ @@ -230,6 +231,87 @@ describe("checkEdges", () => { }); }); +// ── Frontmatter Completeness Checker ── + +describe("checkFrontmatterCompleteness", () => { + it("warns for missing required frontmatter fields in context files", () => { + const file = join(tmpDir, "context/architecture.md"); + mkdirSync(join(tmpDir, "context"), { recursive: true }); + writeFileSync(file, "# Architecture\n"); + + const issues = checkFrontmatterCompleteness(file, tmpDir, tmpDir); + + expect(issues).toHaveLength(3); + expect(issues.map((i) => i.code)).toEqual([ + "FRONTMATTER_MISSING_FIELD", + "FRONTMATTER_MISSING_FIELD", + "FRONTMATTER_MISSING_FIELD", + ]); + expect(issues.map((i) => i.message)).toEqual([ + "Required frontmatter field is missing or blank: name", + "Required frontmatter field is missing or blank: description", + "Required frontmatter field is missing or blank: last_updated", + ]); + expect(issues.every((i) => i.severity === "warning")).toBe(true); + }); + + it("passes when context frontmatter has all required fields", () => { + const file = join(tmpDir, "context/architecture.md"); + mkdirSync(join(tmpDir, "context"), { recursive: true }); + writeFileSync( + file, + "---\nname: Architecture\ndescription: System map\nlast_updated: 2026-06-06\n---\n# Architecture\n" + ); + + const issues = checkFrontmatterCompleteness(file, tmpDir, tmpDir); + + expect(issues).toHaveLength(0); + }); + + it("warns for blank required frontmatter fields in pattern files", () => { + const file = join(tmpDir, "patterns/auth.md"); + mkdirSync(join(tmpDir, "patterns"), { recursive: true }); + writeFileSync( + file, + "---\nname: Auth\ndescription: \"\"\nlast_updated: \" \"\n---\n# Auth\n" + ); + + const issues = checkFrontmatterCompleteness(file, tmpDir, tmpDir); + + expect(issues.map((i) => i.message)).toEqual([ + "Required frontmatter field is missing or blank: description", + "Required frontmatter field is missing or blank: last_updated", + ]); + }); + + it("ignores non-context and non-pattern scaffold files", () => { + const file = join(tmpDir, "AGENTS.md"); + writeFileSync(file, "# Agents\n"); + + const issues = checkFrontmatterCompleteness(file, tmpDir, tmpDir); + + expect(issues).toHaveLength(0); + }); + + it("checks files relative to scaffoldRoot when scaffold lives under .mex", () => { + const mexDir = join(tmpDir, ".mex"); + const file = join(mexDir, "context/architecture.md"); + mkdirSync(join(mexDir, "context"), { recursive: true }); + writeFileSync(file, "---\nname: Architecture\n---\n# Architecture\n"); + + const issues = checkFrontmatterCompleteness(file, tmpDir, mexDir); + + expect(issues.map((i) => i.file)).toEqual([ + ".mex/context/architecture.md", + ".mex/context/architecture.md", + ]); + expect(issues.map((i) => i.message)).toEqual([ + "Required frontmatter field is missing or blank: description", + "Required frontmatter field is missing or blank: last_updated", + ]); + }); +}); + // ── Command Checker ── describe("checkCommands", () => { From a12f49b6fb695a507dbee66a41b6508b6a401efe Mon Sep 17 00:00:00 2001 From: ot0m1 <6190966+ot0m1@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:39:06 +0900 Subject: [PATCH 2/2] test: handle Windows symlink permission limits --- test/cli.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/cli.test.ts b/test/cli.test.ts index f240f2b..5cd2011 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -219,11 +219,20 @@ describe("built CLI main-module guard", () => { execSync("npm run build", { cwd: repoRoot, stdio: "pipe" }); }); - it("parses argv when invoked through a symlinked bin (npm/npx layout)", () => { + it("parses argv when invoked through a symlinked bin (npm/npx layout)", (ctx) => { const binDir = mkdtempSync(join(tmpdir(), "mex-bin-")); const symlinkedCli = join(binDir, "mex"); try { - symlinkSync(cliPath, symlinkedCli); + try { + symlinkSync(cliPath, symlinkedCli); + } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") { + // Windows can deny symlink creation without Developer Mode or elevated rights. + ctx.skip(); + return; + } + throw error; + } const result = spawnSync(process.execPath, [symlinkedCli, "--version"], { encoding: "utf8", env: { ...process.env, NO_COLOR: "1" },