Skip to content
Merged
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
28 changes: 26 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import chalk from "chalk";
import { Command } from "commander";
import { Command, InvalidArgumentError } from "commander";
import { findConfig } from "./config.js";
import { reportConsole, reportQuiet, reportJSON, reportVerbose } from "./reporter.js";

function parseIntArg(raw: string): number {
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) {
throw new InvalidArgumentError(`Expected a non-negative integer, got "${raw}".`);
}
return n;
}

const program = new Command();

program
Expand Down Expand Up @@ -33,11 +41,27 @@ program
.option("--quiet", "Single-line summary only")
.option("--fix", "Run sync to fix any issues found")
.option("--verbose", "Show detailed diagnostic output")
.option("--stale-warn-days <n>", "Warn when a file hasn't changed in N days (default 30)", parseIntArg)
.option("--stale-error-days <n>", "Error when a file hasn't changed in N days (default 90)", parseIntArg)
.option("--stale-warn-commits <n>", "Warn when a file has N commits since its last change (default 50)", parseIntArg)
.option("--stale-error-commits <n>", "Error when a file has N commits since its last change (default 200)", parseIntArg)
.action(async (opts) => {
try {
const config = findConfig();
const { runDriftCheck } = await import("./drift/index.js");
const report = await runDriftCheck(config, { verbose: opts.verbose });
const { DEFAULT_STALENESS_THRESHOLDS } = await import("./drift/checkers/staleness.js");

const stalenessThresholds = {
warnDays: opts.staleWarnDays ?? config.stalenessThresholds?.warnDays ?? DEFAULT_STALENESS_THRESHOLDS.warnDays,
errorDays: opts.staleErrorDays ?? config.stalenessThresholds?.errorDays ?? DEFAULT_STALENESS_THRESHOLDS.errorDays,
warnCommits: opts.staleWarnCommits ?? config.stalenessThresholds?.warnCommits ?? DEFAULT_STALENESS_THRESHOLDS.warnCommits,
errorCommits: opts.staleErrorCommits ?? config.stalenessThresholds?.errorCommits ?? DEFAULT_STALENESS_THRESHOLDS.errorCommits,
};

const report = await runDriftCheck(
{ ...config, stalenessThresholds },
{ verbose: opts.verbose },
);

if (opts.json) {
reportJSON(report, { verbose: opts.verbose });
Expand Down
59 changes: 57 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import type { MexConfig, AiTool } from "./types.js";
import type { MexConfig, AiTool, StalenessThresholds } from "./types.js";
import { DEFAULT_STALENESS_THRESHOLDS } from "./drift/checkers/staleness.js";

/**
* Walk up from startDir looking for .git to find project root,
Expand Down Expand Up @@ -36,7 +37,8 @@ export function findConfig(startDir?: string): MexConfig {
}

const aiTools = loadAiTools(scaffoldRoot);
return { projectRoot, scaffoldRoot, aiTools };
const stalenessThresholds = loadStalenessThresholds(scaffoldRoot);
return { projectRoot, scaffoldRoot, aiTools, stalenessThresholds };
}

function findProjectRoot(dir: string): string | null {
Expand All @@ -57,6 +59,7 @@ const CONFIG_FILE = "config.json";

interface MexPersistedConfig {
aiTools?: unknown;
staleness?: unknown;
[key: string]: unknown;
}

Expand All @@ -76,6 +79,58 @@ function loadAiTools(scaffoldRoot: string): AiTool[] {
}
}

function loadStalenessThresholds(scaffoldRoot: string): StalenessThresholds | undefined {
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
if (!existsSync(configPath)) return undefined;
try {
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
const staleness = (raw as MexPersistedConfig).staleness;
if (typeof staleness !== "object" || staleness === null || Array.isArray(staleness)) return undefined;
const s = staleness as Record<string, unknown>;

const readInt = (key: string): number | undefined => {
const v = s[key];
if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v;
return undefined;
};

const warnDays = readInt("warnDays");
const errorDays = readInt("errorDays");
const warnCommits = readInt("warnCommits");
const errorCommits = readInt("errorCommits");

// Any field missing falls back to defaults, so partial overrides still work.
if (warnDays === undefined && errorDays === undefined && warnCommits === undefined && errorCommits === undefined) {
return undefined;
}
const resolved: StalenessThresholds = {
warnDays: warnDays ?? DEFAULT_STALENESS_THRESHOLDS.warnDays,
errorDays: errorDays ?? DEFAULT_STALENESS_THRESHOLDS.errorDays,
warnCommits: warnCommits ?? DEFAULT_STALENESS_THRESHOLDS.warnCommits,
errorCommits: errorCommits ?? DEFAULT_STALENESS_THRESHOLDS.errorCommits,
};

// Reject inverted warn/error pairs. A misconfigured
// warnDays: 90, errorDays: 30 silently makes the warn path unreachable,
// so surface it and fall back to defaults rather than honoring a
// config that disables half of the checker.
if (resolved.errorDays < resolved.warnDays || resolved.errorCommits < resolved.warnCommits) {
console.warn(
`[mex] staleness thresholds in ${configPath} invert warn/error ` +
`(warnDays=${resolved.warnDays}, errorDays=${resolved.errorDays}, ` +
`warnCommits=${resolved.warnCommits}, errorCommits=${resolved.errorCommits}); ` +
`falling back to defaults.`
);
return { ...DEFAULT_STALENESS_THRESHOLDS };
}

return resolved;
} catch {
return undefined;
}
}

export function saveAiTools(scaffoldRoot: string, tools: AiTool[]): void {
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
let existing: Record<string, unknown> = {};
Expand Down
50 changes: 32 additions & 18 deletions src/drift/checkers/staleness.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,51 @@
import { daysSinceLastChange, commitsSinceLastChange } from "../../git.js";
import type { DriftIssue, Severity } from "../../types.js";
import type { DriftIssue, Severity, StalenessThresholds } from "../../types.js";

const WARN_DAYS = 30;
const ERROR_DAYS = 90;
const WARN_COMMITS = 50;
const ERROR_COMMITS = 200;
/** Default thresholds. Overridden via MexConfig.stalenessThresholds / CLI flags. */
export const DEFAULT_STALENESS_THRESHOLDS: StalenessThresholds = {
warnDays: 30,
errorDays: 90,
warnCommits: 50,
errorCommits: 200,
};

type StaleSignal = { severity: Severity; message: string };

function daysSignal(days: number): StaleSignal | null {
if (days >= ERROR_DAYS) {
function daysSignal(
days: number,
warnDays: number,
errorDays: number
): StaleSignal | null {
if (days >= errorDays) {
return {
severity: "error",
message: `File hasn't been updated in ${days} days (threshold: ${ERROR_DAYS}d)`,
message: `File hasn't been updated in ${days} days (threshold: ${errorDays}d)`,
};
}
if (days >= WARN_DAYS) {
if (days >= warnDays) {
return {
severity: "warning",
message: `File hasn't been updated in ${days} days (threshold: ${WARN_DAYS}d)`,
message: `File hasn't been updated in ${days} days (threshold: ${warnDays}d)`,
};
}
return null;
}

function commitsSignal(commits: number): StaleSignal | null {
if (commits >= ERROR_COMMITS) {
function commitsSignal(
commits: number,
warnCommits: number,
errorCommits: number
): StaleSignal | null {
if (commits >= errorCommits) {
return {
severity: "error",
message: `${commits} commits since file was last updated (threshold: ${ERROR_COMMITS})`,
message: `${commits} commits since file was last updated (threshold: ${errorCommits})`,
};
}
if (commits >= WARN_COMMITS) {
if (commits >= warnCommits) {
return {
severity: "warning",
message: `${commits} commits since file was last updated (threshold: ${WARN_COMMITS})`,
message: `${commits} commits since file was last updated (threshold: ${warnCommits})`,
};
}
return null;
Expand All @@ -57,18 +68,21 @@ const SEVERITY_RANK: Record<Severity, number> = {
export async function checkStaleness(
filePath: string,
source: string,
cwd: string
cwd: string,
thresholds: StalenessThresholds = DEFAULT_STALENESS_THRESHOLDS
): Promise<DriftIssue[]> {
const { warnDays, errorDays, warnCommits, errorCommits } = thresholds;

const days = await daysSinceLastChange(filePath, cwd);
const commits = await commitsSinceLastChange(filePath, cwd);

const signals: StaleSignal[] = [];
if (days !== null) {
const s = daysSignal(days);
const s = daysSignal(days, warnDays, errorDays);
if (s) signals.push(s);
}
if (commits !== null) {
const s = commitsSignal(commits);
const s = commitsSignal(commits, warnCommits, errorCommits);
if (s) signals.push(s);
}

Expand Down
7 changes: 6 additions & 1 deletion src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ export async function runDriftCheck(
allIssues.push(...edgeIssues);

// Staleness check
const stalenessIssues = await checkStaleness(source, source, projectRoot);
const stalenessIssues = await checkStaleness(
source,
source,
projectRoot,
config.stalenessThresholds,
);
allIssues.push(...stalenessIssues);

checkerIssueCounts.push([`edges:${source}`, edgeIssues.length]);
Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,26 @@ export const AI_TOOLS: Record<AiTool, AiToolMeta> = {

// ── Config ──

export interface StalenessThresholds {
/** Days since last change that trigger a warning */
warnDays: number;
/** Days since last change that trigger an error */
errorDays: number;
/** Commits since last change that trigger a warning */
warnCommits: number;
/** Commits since last change that trigger an error */
errorCommits: number;
}

export interface MexConfig {
/** Absolute path to project root (where .git lives) */
projectRoot: string;
/** Absolute path to scaffold root (.mex/ directory) */
scaffoldRoot: string;
/** Which AI tool(s) the user selected during setup */
aiTools: AiTool[];
/** Staleness thresholds (warn/error for days and commits). Optional. */
stalenessThresholds?: StalenessThresholds;
}

// ── Claims (extracted from markdown) ──
Expand Down
74 changes: 74 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,80 @@ describe("findConfig", () => {
});
});

describe("findConfig — stalenessThresholds", () => {
function setupScaffold(staleness: unknown): void {
mkdirSync(join(tmpDir, ".git"));
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath);
writeFileSync(join(mexPath, "ROUTER.md"), "");
writeFileSync(join(mexPath, "config.json"), JSON.stringify({ staleness }));
}

it("loads full thresholds from config.json", () => {
setupScaffold({ warnDays: 14, errorDays: 60, warnCommits: 25, errorCommits: 100 });
const config = findConfig(tmpDir);
expect(config.stalenessThresholds).toEqual({
warnDays: 14,
errorDays: 60,
warnCommits: 25,
errorCommits: 100,
});
});

it("fills missing fields from the checker defaults", () => {
setupScaffold({ warnDays: 14 });
const config = findConfig(tmpDir);
expect(config.stalenessThresholds).toEqual({
warnDays: 14,
errorDays: 90,
warnCommits: 50,
errorCommits: 200,
});
});

it("warns and falls back to defaults when warn exceeds error", () => {
setupScaffold({ warnDays: 90, errorDays: 30 });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (msg: string) => {
warnings.push(msg);
};
try {
const config = findConfig(tmpDir);
expect(config.stalenessThresholds).toEqual({
warnDays: 30,
errorDays: 90,
warnCommits: 50,
errorCommits: 200,
});
expect(warnings.some((w) => w.includes("invert warn/error"))).toBe(true);
} finally {
console.warn = originalWarn;
}
});

it("warns when commit invariant is violated too", () => {
setupScaffold({ warnCommits: 500, errorCommits: 100 });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (msg: string) => {
warnings.push(msg);
};
try {
const config = findConfig(tmpDir);
expect(config.stalenessThresholds).toEqual({
warnDays: 30,
errorDays: 90,
warnCommits: 50,
errorCommits: 200,
});
expect(warnings).toHaveLength(1);
} finally {
console.warn = originalWarn;
}
});
});

describe("saveAiTools", () => {
it("creates config.json with aiTools", () => {
const mexPath = join(tmpDir, ".mex");
Expand Down
Loading
Loading