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 src/drift/checkers/broken-link.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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");
Expand Down
41 changes: 41 additions & 0 deletions src/drift/checkers/frontmatter-completeness.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/drift/checkers/todo-fixme.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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");
Expand Down
13 changes: 13 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 @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions src/path-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function toPosixPath(path: string): string {
return path.replace(/\\/g, "/");
}
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 "FRONTMATTER_MISSING_FIELD":
return "Add name, description, and last_updated to the scaffold file frontmatter.";
default:
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion src/scanner/entry-points.ts
Original file line number Diff line number Diff line change
@@ -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}",
Expand Down Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 82 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 @@ -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", () => {
Expand Down
13 changes: 11 additions & 2 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading