diff --git a/.gitignore b/.gitignore index b288b54e..6441e711 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ result # OS Thumbs.db + +# Per-user Claude Code config +.claude/ diff --git a/package.json b/package.json index cb6b0c60..ffbae7db 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,15 @@ "version": "0.3.5", "description": "CLI engine for mex scaffold — drift detection, pre-analysis, and targeted sync", "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "mex": "./dist/cli.js" }, diff --git a/src/config.ts b/src/config.ts index c294283f..f5cb9b2c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,51 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { resolve, dirname, isAbsolute } from "node:path"; import type { MexConfig, AiTool, StalenessThresholds, WatchConfig, HeartbeatConfig } from "./types.js"; import { DEFAULT_STALENESS_THRESHOLDS } from "./drift/checkers/staleness.js"; +/** + * Inputs accepted by {@link createConfig}. Only the two roots are required — + * everything else mirrors the optional fields on {@link MexConfig} so callers + * can opt in field by field. + */ +export interface CreateConfigInput { + /** Absolute path to the project root (e.g. where .git lives). */ + projectRoot: string; + /** Absolute path to the scaffold root (the directory holding ROUTER.md, etc.). */ + scaffoldRoot: string; + aiTools?: AiTool[]; + stalenessThresholds?: StalenessThresholds; + watch?: WatchConfig; + heartbeat?: HeartbeatConfig; +} + +/** + * Build a {@link MexConfig} from explicit inputs, bypassing the on-disk + * discovery that {@link findConfig} performs. Intended for embedders that + * already know where the project and scaffold live — for example, tools that + * use a non-default scaffold directory name and therefore can't rely on + * findConfig's `.mex/` lookup. + * + * Defaults `aiTools` to an empty array. Other optional fields are passed + * through untouched. + */ +export function createConfig(input: CreateConfigInput): MexConfig { + if (!isAbsolute(input.projectRoot)) { + throw new Error(`createConfig: projectRoot must be an absolute path, got "${input.projectRoot}"`); + } + if (!isAbsolute(input.scaffoldRoot)) { + throw new Error(`createConfig: scaffoldRoot must be an absolute path, got "${input.scaffoldRoot}"`); + } + return { + projectRoot: input.projectRoot, + scaffoldRoot: input.scaffoldRoot, + aiTools: input.aiTools ?? [], + stalenessThresholds: input.stalenessThresholds, + watch: input.watch, + heartbeat: input.heartbeat, + }; +} + /** * Walk up from startDir looking for .git to find project root, * then look for scaffold root (.mex/ or context/ directory). diff --git a/src/drift/index.ts b/src/drift/index.ts index 62201baa..c6d2dbbf 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -15,15 +15,45 @@ import { checkCrossFile } from "./checkers/cross-file.js"; import { checkScriptCoverage } from "./checkers/script-coverage.js"; import { checkToolConfigSync } from "./checkers/tool-config-sync.js"; +/** + * Default glob patterns used to locate scaffold markdown files, relative to + * `MexConfig.scaffoldRoot`. Exported so consumers can extend rather than + * replace the list, e.g. + * + * ```ts + * runDriftCheck(config, { + * scaffoldPatterns: [...DEFAULT_SCAFFOLD_PATTERNS, "traces/**\/*.md"], + * }); + * ``` + * + * NOT a stable contract — mex may add to this list between minor versions. + * If exact behavior matters, pass `scaffoldPatterns` explicitly. + */ +export const DEFAULT_SCAFFOLD_PATTERNS = [ + "context/*.md", + "patterns/*.md", + "ROUTER.md", + "AGENTS.md", + "SETUP.md", + "SYNC.md", +] as const; + +export interface RunDriftCheckOpts { + verbose?: boolean; + /** Override the glob patterns used to discover scaffold files (relative to + * `config.scaffoldRoot`). Defaults to {@link DEFAULT_SCAFFOLD_PATTERNS}. */ + scaffoldPatterns?: readonly string[]; +} + /** Run full drift detection across all scaffold files */ export async function runDriftCheck( config: MexConfig, - opts: { verbose?: boolean } = {} + opts: RunDriftCheckOpts = {} ): Promise { const { projectRoot, scaffoldRoot } = config; // Find all markdown files in scaffold - const scaffoldFiles = findScaffoldFiles(projectRoot, scaffoldRoot); + const scaffoldFiles = findScaffoldFiles(projectRoot, scaffoldRoot, opts.scaffoldPatterns); const allClaims: Claim[] = []; const allIssues: DriftIssue[] = []; const checkerIssueCounts: Array<[string, number]> = []; @@ -106,21 +136,13 @@ export async function runDriftCheck( /** Find all markdown files that are part of the scaffold */ function findScaffoldFiles( projectRoot: string, - scaffoldRoot: string + scaffoldRoot: string, + patterns: readonly string[] = DEFAULT_SCAFFOLD_PATTERNS ): string[] { - const scaffoldPatterns = [ - "context/*.md", - "patterns/*.md", - "ROUTER.md", - "AGENTS.md", - "SETUP.md", - "SYNC.md", - ]; - const files: string[] = []; // Search inside scaffold root (handles both .mex/ and root layouts) - for (const pattern of scaffoldPatterns) { + for (const pattern of patterns) { const matches = globSync(pattern, { cwd: scaffoldRoot, absolute: true, diff --git a/src/events.ts b/src/events.ts index 2bd98f42..836dc7b5 100644 --- a/src/events.ts +++ b/src/events.ts @@ -3,7 +3,11 @@ import { dirname, resolve, relative } from "node:path"; import chalk from "chalk"; import type { MexConfig } from "./types.js"; -export type EventKind = "decision" | "note" | "risk" | "todo"; +/** Runtime list of valid event kinds. Re-exported as part of the public API so + * consumers can validate user-supplied kinds against the same source of truth. */ +export const EVENT_KINDS = ["decision", "note", "risk", "todo"] as const; + +export type EventKind = (typeof EVENT_KINDS)[number]; export interface EventEntry { timestamp: string; @@ -25,7 +29,7 @@ export interface TimelineOpts { limit?: number; } -const VALID_KINDS = new Set(["decision", "note", "risk", "todo"]); +const VALID_KINDS = new Set(EVENT_KINDS); const EVENT_FILE = "events/decisions.jsonl"; export function eventLogPath(config: MexConfig): string { diff --git a/src/heartbeat.ts b/src/heartbeat.ts index be8c4dd9..e89640c8 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -15,14 +15,30 @@ export interface HeartbeatResult { export interface HeartbeatOpts { json?: boolean; + /** Override the glob patterns used to discover heartbeat files (relative to + * `config.scaffoldRoot`). Defaults to {@link DEFAULT_HEARTBEAT_PATTERNS}. */ + scaffoldPatterns?: readonly string[]; } +/** + * Default glob patterns used by the heartbeat checker, relative to + * `MexConfig.scaffoldRoot`. NOT a stable contract — mex may add to this list + * between minor versions. Pass `scaffoldPatterns` explicitly when exact + * behavior matters. + */ +export const DEFAULT_HEARTBEAT_PATTERNS = [ + "ROUTER.md", + "AGENTS.md", + "context/*.md", + "patterns/*.md", +] as const; + const DEFAULT_STALE_DAYS = 7; const DEFAULT_MEMORY_CLEANUP_DAYS = 7; const DEFAULT_DAILY_MEMORY_RETENTION_DAYS = 14; export async function runHeartbeat(config: MexConfig, opts: HeartbeatOpts = {}): Promise { - const result = checkHeartbeat(config); + const result = checkHeartbeat(config, new Date(), { scaffoldPatterns: opts.scaffoldPatterns }); if (opts.json) { console.log(JSON.stringify(result, null, 2)); return result; @@ -31,12 +47,22 @@ export async function runHeartbeat(config: MexConfig, opts: HeartbeatOpts = {}): return result; } -export function checkHeartbeat(config: MexConfig, now = new Date()): HeartbeatResult { +export interface CheckHeartbeatOpts { + /** Override the glob patterns used to discover heartbeat files (relative to + * `config.scaffoldRoot`). Defaults to {@link DEFAULT_HEARTBEAT_PATTERNS}. */ + scaffoldPatterns?: readonly string[]; +} + +export function checkHeartbeat( + config: MexConfig, + now = new Date(), + opts: CheckHeartbeatOpts = {} +): HeartbeatResult { const staleDays = config.heartbeat?.staleDays ?? DEFAULT_STALE_DAYS; const memoryCleanupDays = config.heartbeat?.memoryCleanupDays ?? DEFAULT_MEMORY_CLEANUP_DAYS; const dailyRetentionDays = config.heartbeat?.dailyMemoryRetentionDays ?? DEFAULT_DAILY_MEMORY_RETENTION_DAYS; - const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot) + const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot, opts.scaffoldPatterns) .map((file) => { const fm = parseFrontmatter(file); const days = daysSinceFrontmatterDate( @@ -60,8 +86,10 @@ export function checkHeartbeat(config: MexConfig, now = new Date()): HeartbeatRe }; } -function scaffoldHeartbeatFiles(scaffoldRoot: string): string[] { - const patterns = ["ROUTER.md", "AGENTS.md", "context/*.md", "patterns/*.md"]; +function scaffoldHeartbeatFiles( + scaffoldRoot: string, + patterns: readonly string[] = DEFAULT_HEARTBEAT_PATTERNS, +): string[] { return patterns.flatMap((pattern) => globSync(pattern, { cwd: scaffoldRoot, diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..9a4a59bd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,61 @@ +/** + * Public API for `mex-agent`. + * + * Everything re-exported from this file is part of the package's compatibility + * contract — removing or reshaping any of these is a breaking change. + * + * Internal modules (`src/cli.ts`, `src/sync/`, `src/scanner/`, `src/setup/`, + * `src/tui.ts`, `src/watch.ts`, `src/doctor.ts`, etc.) are NOT part of the + * contract and may change without notice. Import only from `"mex-agent"`. + */ + +// ── Config ─────────────────────────────────────────────────────────────────── +export { findConfig, createConfig } from "./config.js"; +export type { CreateConfigInput } from "./config.js"; + +// ── Events (append-only JSONL log) ─────────────────────────────────────────── +export { + appendEvent, + readEvents, + eventLogPath, + EVENT_KINDS, +} from "./events.js"; +export type { EventEntry, EventKind } from "./events.js"; + +// ── Drift detection ────────────────────────────────────────────────────────── +export { + runDriftCheck, + DEFAULT_SCAFFOLD_PATTERNS, +} from "./drift/index.js"; +export type { RunDriftCheckOpts } from "./drift/index.js"; +export { parseFrontmatter } from "./drift/frontmatter.js"; +export { DEFAULT_STALENESS_THRESHOLDS } from "./drift/checkers/staleness.js"; + +// ── Heartbeat (scaffold staleness + memory cleanup) ────────────────────────── +export { + checkHeartbeat, + runHeartbeat, + DEFAULT_HEARTBEAT_PATTERNS, +} from "./heartbeat.js"; +export type { + HeartbeatResult, + HeartbeatOpts, + CheckHeartbeatOpts, +} from "./heartbeat.js"; + +// ── Shared types ───────────────────────────────────────────────────────────── +export type { + AiTool, + MexConfig, + StalenessThresholds, + WatchConfig, + HeartbeatConfig, + ScaffoldFrontmatter, + FrontmatterEdge, + DriftReport, + DriftIssue, + IssueCode, + Severity, + Claim, + ClaimKind, +} from "./types.js"; diff --git a/test/public-api.test.ts b/test/public-api.test.ts new file mode 100644 index 00000000..8118d7f8 --- /dev/null +++ b/test/public-api.test.ts @@ -0,0 +1,209 @@ +/** + * Public API smoke test. + * + * This file imports ONLY from src/index.ts — the same surface that + * package.json's `exports` field publishes. Its job is to fail when someone + * accidentally renames, removes, or reshapes a public-facing export. If you + * need to change something this test asserts, that's a breaking change. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + // functions + appendEvent, + readEvents, + eventLogPath, + runDriftCheck, + checkHeartbeat, + runHeartbeat, + parseFrontmatter, + findConfig, + createConfig, + + // runtime constants + EVENT_KINDS, + DEFAULT_STALENESS_THRESHOLDS, + DEFAULT_SCAFFOLD_PATTERNS, + DEFAULT_HEARTBEAT_PATTERNS, + + // types (compile-time only — verified by usage below) + type MexConfig, + type EventEntry, + type EventKind, + type DriftReport, + type HeartbeatResult, + type CreateConfigInput, + type RunDriftCheckOpts, + type StalenessThresholds, + type ScaffoldFrontmatter, +} from "../src/index.js"; + +let tmpDir: string; +let config: MexConfig; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mex-public-api-")); + mkdirSync(join(tmpDir, ".mex"), { recursive: true }); + config = createConfig({ + projectRoot: tmpDir, + scaffoldRoot: join(tmpDir, ".mex"), + }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("public API — function exports", () => { + it("exports the functions T-Rex (and other embedders) depend on", () => { + expect(typeof appendEvent).toBe("function"); + expect(typeof readEvents).toBe("function"); + expect(typeof eventLogPath).toBe("function"); + expect(typeof runDriftCheck).toBe("function"); + expect(typeof checkHeartbeat).toBe("function"); + expect(typeof runHeartbeat).toBe("function"); + expect(typeof parseFrontmatter).toBe("function"); + expect(typeof findConfig).toBe("function"); + expect(typeof createConfig).toBe("function"); + }); +}); + +describe("public API — runtime constants", () => { + it("exports EVENT_KINDS as an array of valid kinds", () => { + expect(Array.isArray(EVENT_KINDS)).toBe(true); + expect(EVENT_KINDS).toContain("decision"); + expect(EVENT_KINDS).toContain("note"); + expect(EVENT_KINDS).toContain("risk"); + expect(EVENT_KINDS).toContain("todo"); + }); + + it("exports DEFAULT_STALENESS_THRESHOLDS with the documented shape", () => { + const t: StalenessThresholds = DEFAULT_STALENESS_THRESHOLDS; + expect(typeof t.warnDays).toBe("number"); + expect(typeof t.errorDays).toBe("number"); + expect(typeof t.warnCommits).toBe("number"); + expect(typeof t.errorCommits).toBe("number"); + }); + + it("exports DEFAULT_SCAFFOLD_PATTERNS as a non-empty list", () => { + expect(Array.isArray(DEFAULT_SCAFFOLD_PATTERNS)).toBe(true); + expect(DEFAULT_SCAFFOLD_PATTERNS.length).toBeGreaterThan(0); + }); + + it("exports DEFAULT_HEARTBEAT_PATTERNS as a non-empty list", () => { + expect(Array.isArray(DEFAULT_HEARTBEAT_PATTERNS)).toBe(true); + expect(DEFAULT_HEARTBEAT_PATTERNS.length).toBeGreaterThan(0); + }); +}); + +describe("public API — createConfig", () => { + it("builds a usable MexConfig from minimal input", () => { + const input: CreateConfigInput = { + projectRoot: tmpDir, + scaffoldRoot: join(tmpDir, ".mex"), + }; + const c = createConfig(input); + expect(c.projectRoot).toBe(tmpDir); + expect(c.scaffoldRoot).toBe(join(tmpDir, ".mex")); + expect(c.aiTools).toEqual([]); + }); + + it("rejects relative paths to prevent silent breakage", () => { + expect(() => + createConfig({ projectRoot: "relative/path", scaffoldRoot: join(tmpDir, ".mex") }), + ).toThrow(/projectRoot must be an absolute path/); + expect(() => + createConfig({ projectRoot: tmpDir, scaffoldRoot: "relative/path" }), + ).toThrow(/scaffoldRoot must be an absolute path/); + }); +}); + +describe("public API — appendEvent / readEvents round-trip", () => { + it("persists a decision event and reads it back with the documented shape", () => { + const written: EventEntry = appendEvent(config, "JWT over sessions", { + kind: "decision", + files: ["src/auth.ts"], + }); + expect(written.kind).toBe("decision"); + expect(written.message).toBe("JWT over sessions"); + // Normalize separators so the test is path-agnostic across OSes — + // appendEvent uses `path.relative` internally, which emits backslashes on Windows. + expect(written.files.map((f) => f.replace(/\\/g, "/"))).toEqual(["src/auth.ts"]); + expect(typeof written.timestamp).toBe("string"); + + const events = readEvents(config); + expect(events).toHaveLength(1); + expect(events[0].message).toBe("JWT over sessions"); + expect(events[0].kind).toBe("decision"); + + // eventLogPath should point at a real file under scaffoldRoot + expect(eventLogPath(config)).toContain(".mex"); + }); + + it("accepts every kind in EVENT_KINDS", () => { + for (const kind of EVENT_KINDS) { + const k: EventKind = kind; + appendEvent(config, `event for ${k}`, { kind: k }); + } + expect(readEvents(config)).toHaveLength(EVENT_KINDS.length); + }); +}); + +describe("public API — parseFrontmatter", () => { + it("reads YAML frontmatter from a markdown file", () => { + const file = join(tmpDir, "page.md"); + writeFileSync( + file, + "---\nname: example\ndescription: a doc\nlast_updated: 2026-05-14\n---\n\nbody\n", + ); + const fm: ScaffoldFrontmatter | null = parseFrontmatter(file); + expect(fm).not.toBeNull(); + expect(fm?.name).toBe("example"); + expect(fm?.description).toBe("a doc"); + expect(fm?.last_updated).toBe("2026-05-14"); + }); + + it("returns null for files that don't exist", () => { + expect(parseFrontmatter(join(tmpDir, "missing.md"))).toBeNull(); + }); +}); + +describe("public API — runDriftCheck", () => { + it("runs on an empty scaffold and returns a DriftReport", async () => { + // Minimum scaffold so runDriftCheck has something to scan + writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "# Router\n"); + const report: DriftReport = await runDriftCheck(config); + expect(typeof report.score).toBe("number"); + expect(Array.isArray(report.issues)).toBe(true); + expect(typeof report.filesChecked).toBe("number"); + expect(typeof report.timestamp).toBe("string"); + }); + + it("accepts scaffoldPatterns override without throwing", async () => { + const opts: RunDriftCheckOpts = { + scaffoldPatterns: [...DEFAULT_SCAFFOLD_PATTERNS, "traces/**/*.md"], + }; + const report = await runDriftCheck(config, opts); + expect(report).toBeDefined(); + }); +}); + +describe("public API — heartbeat", () => { + it("checkHeartbeat returns the documented HeartbeatResult shape", () => { + const result: HeartbeatResult = checkHeartbeat(config); + expect(typeof result.ok).toBe("boolean"); + expect(Array.isArray(result.staleFiles)).toBe(true); + expect(typeof result.memoryCleanupDue).toBe("boolean"); + expect(Array.isArray(result.oldDailyMemoryFiles)).toBe(true); + }); + + it("checkHeartbeat accepts a scaffoldPatterns override", () => { + const result = checkHeartbeat(config, new Date(), { + scaffoldPatterns: [...DEFAULT_HEARTBEAT_PATTERNS, "traces/**/*.md"], + }); + expect(result).toBeDefined(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 19199967..059c7031 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,15 +1,34 @@ import { defineConfig } from "tsup"; -export default defineConfig({ - entry: ["src/cli.ts"], - format: ["esm"], - target: "node20", - outDir: "dist", - clean: true, - splitting: false, - sourcemap: true, - dts: false, - banner: { - js: "#!/usr/bin/env node", +/** + * Two-config build: + * - cli → dist/cli.js (shebang banner, no .d.ts; consumed by `bin`) + * - index → dist/index.js + dist/index.d.ts (library entry consumed via `exports`) + */ +export default defineConfig([ + { + entry: { cli: "src/cli.ts" }, + format: ["esm"], + target: "node20", + outDir: "dist", + clean: true, + splitting: false, + sourcemap: true, + dts: false, + banner: { + js: "#!/usr/bin/env node", + }, }, -}); + { + entry: { index: "src/index.ts" }, + format: ["esm"], + target: "node20", + outDir: "dist", + // clean: false here — the CLI build above already cleans dist on each run, + // and we don't want the library build to wipe the CLI artifacts. + clean: false, + splitting: false, + sourcemap: true, + dts: true, + }, +]);