diff --git a/src/config.ts b/src/config.ts index d80eae39..4db466d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -294,15 +294,22 @@ function loadHeartbeatConfig(raw: MexPersistedConfig | null): HeartbeatConfig | } const h = raw.heartbeat as Record; const out: HeartbeatConfig = {}; - const staleDays = readPositiveNumber(h.staleDays); - const memoryCleanupDays = readPositiveNumber(h.memoryCleanupDays); - const dailyMemoryRetentionDays = readPositiveNumber(h.dailyMemoryRetentionDays); + // Day-based heartbeat thresholds accept 0 ("stale as soon as older than + // today", #42) while still rejecting negatives and garbage. + const staleDays = readDayThreshold(h.staleDays); + const memoryCleanupDays = readDayThreshold(h.memoryCleanupDays); + const dailyMemoryRetentionDays = readDayThreshold(h.dailyMemoryRetentionDays); if (staleDays !== undefined) out.staleDays = staleDays; if (memoryCleanupDays !== undefined) out.memoryCleanupDays = memoryCleanupDays; if (dailyMemoryRetentionDays !== undefined) out.dailyMemoryRetentionDays = dailyMemoryRetentionDays; return Object.keys(out).length ? out : undefined; } +function readDayThreshold(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v; + return undefined; +} + function readPositiveNumber(v: unknown): number | undefined { if (typeof v === "number" && Number.isFinite(v) && v > 0) return v; return undefined; diff --git a/src/doctor.ts b/src/doctor.ts index c7b14555..61d0e2c0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -28,7 +28,11 @@ export async function runDoctor(config: MexConfig): Promise { printLine("Drift", report.score >= 80 && errors === 0, `${report.score}/100 (${errors} errors, ${warnings} warnings)`); printLine("Graph", graph.status === "fresh", graphStatusDetail(graph)); - printLine("Heartbeat", heartbeat.ok, heartbeat.ok ? "HEARTBEAT_OK" : `${heartbeat.staleFiles.length} stale files, ${heartbeat.oldDailyMemoryFiles.length} old memory files`); + printLine("Heartbeat", heartbeat.ok, heartbeat.ok + ? (heartbeat.filesWithoutLastUpdated + ? "HEARTBEAT_OK (staleness checks inactive: no last_updated fields)" + : "HEARTBEAT_OK") + : `${heartbeat.staleFiles.length} stale files, ${heartbeat.oldDailyMemoryFiles.length} old memory files`); printLine("Events", true, `${events.length} logged event${events.length === 1 ? "" : "s"}`); const hasConfig = existsSync(resolve(config.scaffoldRoot, "config.json")); printLine("Config", true, hasConfig ? ".mex/config.json loaded with defaults for missing values" : "using defaults; no .mex/config.json found"); diff --git a/src/drift/index.ts b/src/drift/index.ts index 7ba072b6..19ae4b88 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { resolve, relative } from "node:path"; import { globSync } from "glob"; import type { MexConfig, DriftReport, DriftIssue, Claim } from "../types.js"; @@ -419,15 +419,28 @@ export function findScaffoldFiles( ): string[] { const files: string[] = []; - // Search inside scaffold root (handles both .mex/ and root layouts) + // Search inside scaffold root (handles both .mex/ and root layouts). + // follow: true supports symlinked scaffold content; deduplicating by real + // path keeps one file reached through two links a single scan entry, and + // glob bounds symlink loops so runaway scans stay off the table (#40). + const seenReal = new Set(); for (const pattern of patterns) { - const matches = globSync(pattern, { + for (const match of globSync(pattern, { cwd: scaffoldRoot, absolute: true, follow: true, ignore: ["node_modules/**"], - }); - files.push(...matches); + })) { + let real: string; + try { + real = realpathSync(match); + } catch { + real = match; + } + if (seenReal.has(real)) continue; + seenReal.add(real); + files.push(match); + } } // Also check project root for tool config files (CLAUDE.md, etc.) diff --git a/src/heartbeat.ts b/src/heartbeat.ts index 26b5feb4..a04d5c69 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { resolve, relative } from "node:path"; import { globSync } from "glob"; import chalk from "chalk"; @@ -12,6 +12,15 @@ export interface HeartbeatResult { staleFiles: Array<{ file: string; days: number }>; memoryCleanupDue: boolean; oldDailyMemoryFiles: string[]; + /** + * Additive: number of scaffold files the heartbeat scanned that carry no + * parseable `last_updated` at all. When every scanned file lacks one, + * staleness checks are silently inert — the CLI surfaces a hint instead of + * reporting a healthy-looking sweep that checked nothing. Absent (0) for + * scaffolds where at least one file opts in, so existing JSON consumers + * see no change. + */ + filesWithoutLastUpdated?: number; } export interface HeartbeatOpts { @@ -63,9 +72,13 @@ export function checkHeartbeat( const memoryCleanupDays = config.heartbeat?.memoryCleanupDays ?? DEFAULT_MEMORY_CLEANUP_DAYS; const dailyRetentionDays = config.heartbeat?.dailyMemoryRetentionDays ?? DEFAULT_DAILY_MEMORY_RETENTION_DAYS; + let scanned = 0; + let withLastUpdated = 0; const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot, opts.scaffoldPatterns) .map((file) => { const fm = parseFrontmatter(file); + scanned++; + if (typeof fm?.last_updated === "string") withLastUpdated++; const days = daysSinceFrontmatterDate( typeof fm?.last_updated === "string" ? fm.last_updated : undefined, now, @@ -84,6 +97,7 @@ export function checkHeartbeat( staleFiles, memoryCleanupDue, oldDailyMemoryFiles, + ...(scanned > 0 && withLastUpdated === 0 ? { filesWithoutLastUpdated: scanned } : {}), }; } @@ -91,14 +105,31 @@ function scaffoldHeartbeatFiles( scaffoldRoot: string, patterns: readonly string[] = DEFAULT_HEARTBEAT_PATTERNS, ): string[] { - return patterns.flatMap((pattern) => + // follow: true supports symlinked scaffold content, and deduplicating by + // real path keeps a file reached through two links a single heartbeat + // entry; glob itself bounds symlink loops, so runaway scans stay off the + // table (#40). + const seen = new Set(); + const files: string[] = []; + for (const file of patterns.flatMap((pattern) => globSync(pattern, { cwd: scaffoldRoot, absolute: true, follow: true, nodir: true, }), - ); + )) { + let real: string; + try { + real = realpathSync(file); + } catch { + real = file; + } + if (seen.has(real)) continue; + seen.add(real); + files.push(file); + } + return files; } function isMemoryCleanupDue(projectRoot: string, thresholdDays: number, now: Date): boolean { @@ -137,11 +168,27 @@ function daysSinceIsoDate(value: string, now: Date): number | null { function printHeartbeat(result: HeartbeatResult, config: MexConfig): void { if (result.ok) { + if (result.filesWithoutLastUpdated) { + console.log("HEARTBEAT_OK"); + console.log(); + console.log( + chalk.dim(`No scaffold files include last_updated; staleness checks are currently skipped. ` + + `Add last_updated: YYYY-MM-DD to frontmatter to opt files in.`), + ); + return; + } console.log("HEARTBEAT_OK"); return; } console.log(chalk.bold("Heartbeat needs attention")); + if (result.filesWithoutLastUpdated) { + console.log(); + console.log( + chalk.dim(`No scaffold files include last_updated; staleness checks are currently skipped. ` + + `Add last_updated: YYYY-MM-DD to frontmatter to opt files in.`), + ); + } if (result.staleFiles.length) { console.log(); console.log(chalk.yellow("Stale scaffold files:")); diff --git a/src/types.ts b/src/types.ts index e9ee055d..f6b60c74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,11 +39,21 @@ export interface WatchConfig { } export interface HeartbeatConfig { - /** Days since `last_updated` before heartbeat reports stale context */ + /** + * Days since `last_updated` before heartbeat reports stale context. `0` + * means stale as soon as a file is older than today (#42); negatives and + * other garbage fall back to the default. + */ staleDays?: number; - /** Days since memory cleanup before heartbeat reports cleanup due */ + /** + * Days since memory cleanup before heartbeat reports cleanup due. `0` + * means due every day; negatives fall back to the default. + */ memoryCleanupDays?: number; - /** Daily memory files older than this are considered cleanup candidates */ + /** + * Daily memory files older than this are considered cleanup candidates. + * `0` flags yesterday's file; negatives fall back to the default. + */ dailyMemoryRetentionDays?: number; } diff --git a/test/config.test.ts b/test/config.test.ts index 15ecddfd..f766dc53 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -193,7 +193,13 @@ describe("findConfig — watch and heartbeat config", () => { }); }); - it("ignores non-positive watch and heartbeat values", () => { + it("accepts zero for day-based heartbeat thresholds but rejects negatives (#42)", () => { + setupConfig({ heartbeat: { staleDays: 0, memoryCleanupDays: 0, dailyMemoryRetentionDays: -3 } }); + const config = findConfig(tmpDir); + expect(config.heartbeat).toEqual({ staleDays: 0, memoryCleanupDays: 0 }); + }); + + it("ignores negative watch and heartbeat values", () => { setupConfig({ watch: { intervalMinutes: 0 }, heartbeat: { staleDays: -1 } }); const config = findConfig(tmpDir); expect(config.watch).toBeUndefined(); diff --git a/test/heartbeat.test.ts b/test/heartbeat.test.ts index 8b449053..69f6a706 100644 --- a/test/heartbeat.test.ts +++ b/test/heartbeat.test.ts @@ -46,8 +46,89 @@ describe("heartbeat", () => { expect(result.memoryCleanupDue).toBe(true); expect(result.oldDailyMemoryFiles).toEqual(["memory/2026-04-20.md"]); }); + + it("reports zero participating files when no scaffold file opts into staleness (#41)", () => { + rmSync(join(tmpDir, ".mex/ROUTER.md")); + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "---\nname: router\n---\n\n# Router\n"); + const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z")); + expect(result.ok).toBe(true); + expect(result.filesWithoutLastUpdated).toBe(1); + }); + + it("omits filesWithoutLastUpdated once any file opts in", () => { + writeFileSync(join(tmpDir, ".mex/context/architecture.md"), "---\nname: architecture\n---\n\nno date here\n"); + const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z")); + expect(result.filesWithoutLastUpdated).toBeUndefined(); + }); + + it("keeps staleness active and the field absent when files carry dates", () => { + const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z")); + expect(result.filesWithoutLastUpdated).toBeUndefined(); + expect(result.staleFiles).toEqual([]); + }); }); function frontmatter(name: string, lastUpdated: string): string { return `---\nname: ${name}\nlast_updated: ${lastUpdated}\n---\n\n# ${name}\n`; } + +describe("zero-day heartbeat thresholds (#42)", () => { + let zeroTmp: string; + let zeroConfig: MexConfig; + + beforeEach(() => { + zeroTmp = mkdtempSync(join(tmpdir(), "mex-heartbeat-zero-")); + mkdirSync(join(zeroTmp, ".mex/context"), { recursive: true }); + zeroConfig = { + projectRoot: zeroTmp, + scaffoldRoot: join(zeroTmp, ".mex"), + aiTools: [], + heartbeat: { staleDays: 0, memoryCleanupDays: 0, dailyMemoryRetentionDays: 0 }, + }; + }); + + afterEach(() => { + rmSync(zeroTmp, { recursive: true, force: true }); + }); + + it("flags a file dated yesterday as stale when staleDays is 0", () => { + writeFileSync(join(zeroTmp, ".mex/ROUTER.md"), frontmatter("router", "2026-05-13")); + const result = checkHeartbeat(zeroConfig, new Date("2026-05-14T00:00:00Z")); + expect(result.staleFiles.map((f) => f.file)).toContain("ROUTER.md"); + }); + + it("does not flag a file dated today when staleDays is 0", () => { + writeFileSync(join(zeroTmp, ".mex/ROUTER.md"), frontmatter("router", "2026-05-14")); + const result = checkHeartbeat(zeroConfig, new Date("2026-05-14T00:00:00Z")); + expect(result.staleFiles).toEqual([]); + expect(result.ok).toBe(true); + }); +}); + +describe("symlinked scaffold files (#40)", () => { + it("counts a file reached through two glob paths exactly once", () => { + const root = mkdtempSync(join(tmpdir(), "mex-heartbeat-symlink-")); + try { + mkdirSync(join(root, ".mex/context"), { recursive: true }); + mkdirSync(join(root, ".mex/extra"), { recursive: true }); + const real = join(root, ".mex/context/architecture.md"); + writeFileSync(real, frontmatter("architecture", "2026-05-01")); + // Same content reachable through a second pattern's directory. + let linked = true; + try { + symlinkSync(real, join(root, ".mex/extra/architecture.md")); + } catch { + linked = false; // Windows without symlink privilege: skip assert + } + const result = checkHeartbeat({ + projectRoot: root, + scaffoldRoot: join(root, ".mex"), + aiTools: [], + }, new Date("2026-05-14T00:00:00Z")); + if (!linked) return; + expect(result.staleFiles.filter((f) => f.file.endsWith("architecture.md"))).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});