diff --git a/CHANGELOG.md b/CHANGELOG.md index 477e3b9..c53ad47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,45 @@ All notable changes to engram are documented here. Format based on now regenerate their output files on source-file delete (not just on reindex), so generated artifacts no longer keep stale references to deleted sources. +- **`engram reindex ` CLI subcommand** + ([#8](https://github.com/NickCirv/engram/issues/8)) — re-indexes a + single file into the knowledge graph. The missing primitive for per- + edit freshness: Claude Code PostToolUse hooks, editor plugins, and CI + can now keep the graph in sync without running a long-lived watcher. + Reuses `syncFile()` so semantics match `engram watch`: exists → + reindex; missing-but-previously-indexed → prune; unsupported ext or + ignored directory → silent exit 0 (safe to fire on every edit). On + success prints a single line `engram: reindexed ( nodes)` + (or `pruned`) using locale-stable `formatThousands`. `--verbose` + surfaces stack traces; default error output is a single stderr line. + Missing graph exits 1 with `engram: no graph found at . Run + 'engram init' first.`, matching `engram watch`. +- **`formatReindexLine(result, displayPath)`** exported from + `src/watcher.ts` — pure formatter shared by the new subcommand. Returns + `null` for skipped results so callers stay silent. +- **`engram reindex-hook` subcommand + `engram install-hook --auto-reindex`** + ([#8](https://github.com/NickCirv/engram/issues/8), opt-in auto-wire). + `reindex-hook` reads Claude Code's PostToolUse payload from stdin and + re-indexes `tool_input.file_path` via the shared `syncFile()` primitive. + Contract: ALWAYS exits 0 — malformed JSON, missing fields, non-project + `cwd`, and all internal errors resolve to a silent no-op so the hook + can never fail Claude Code's tool cycle. `install-hook --auto-reindex` + appends a second PostToolUse entry with matcher `Edit|Write|MultiEdit` + calling `engram reindex-hook`; off by default so existing users aren't + surprised. The new entry is recognized by `isEngramHookEntry()` so + `engram uninstall-hook` strips it alongside the primary intercept + entries. Idempotent — reinstalling with `--auto-reindex` is a no-op + when the entry already exists. +- **`runReindexHook(payload)`** exported from `src/watcher.ts` — the + pure async handler behind the `reindex-hook` subcommand. Validates + payload shape, resolves project root from `cwd`, delegates to + `syncFile`. Swallows every error. +- **`buildReindexHookEntry()` + `ENGRAM_REINDEX_HOOK_MATCHER` + (`"Edit|Write|MultiEdit"`) + `DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND` + (`"engram reindex-hook"`)** exported from `src/intercept/installer.ts` + — the data primitives for the optional entry. Added + `InstallOptions.autoReindex` and `InstallResult.autoReindexAdded` to + thread the opt-in through the existing installer surface. ### Notes diff --git a/README.md b/README.md index a426498..e09850b 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ engram install-hook # default: .claude/settings.local.json (git engram install-hook --scope project # .claude/settings.json (committed) engram install-hook --scope user # ~/.claude/settings.json (global) engram install-hook --dry-run # preview changes without writing +engram install-hook --auto-reindex # also keep the graph fresh after every Edit/Write/MultiEdit (#8) ``` **Kill switch (if anything goes wrong):** @@ -336,6 +337,8 @@ engram hook-enable # remove kill switch ```bash engram watch [path] # live file watcher — incremental re-index on save +engram reindex # re-index one file (editor/hook/CI primitive, issue #8) +engram reindex-hook # PostToolUse hook entry point (reads JSON from stdin, always exits 0) engram dashboard [path] # live terminal dashboard engram hud-label [path] # JSON label for Claude HUD --extra-cmd integration engram hooks install # install post-commit + post-checkout git hooks diff --git a/src/cli.ts b/src/cli.ts index 98932f7..43beef3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,7 +27,12 @@ import { install as installHooks, uninstall as uninstallHooks, status as hooksSt import { formatThousands } from "./graph/render-utils.js"; import { autogen } from "./autogen.js"; import { dispatchHook } from "./intercept/dispatch.js"; -import { watchProject } from "./watcher.js"; +import { + watchProject, + syncFile, + formatReindexLine, + runReindexHook, +} from "./watcher.js"; import { startDashboard } from "./dashboard.js"; import { handleCursorBeforeReadFile } from "./intercept/cursor-adapter.js"; import { @@ -198,6 +203,105 @@ program await new Promise(() => {}); }); +/** + * engram reindex — re-index a single file into the knowledge + * graph. Primitive for per-edit freshness via Claude Code PostToolUse + * hooks, editor plugins, or CI ([#8](https://github.com/NickCirv/engram/issues/8)). + * + * Shares `syncFile()` with `engram watch`, so semantics match: exists + * → reindex; missing-but-previously-indexed → prune; unsupported ext or + * ignored dir → silent skip. Silent skips keep stdout/stderr clean so + * the command is safe to fire on every edit from a hook. + */ +program + .command("reindex") + .description("Re-index a single file into the knowledge graph") + .argument("", "File path (absolute or relative to --project)") + .option("-p, --project ", "Project directory", ".") + .option("--verbose", "Print stack traces on error", false) + .action( + async (file: string, opts: { project: string; verbose: boolean }) => { + const root = pathResolve(opts.project); + if (!existsSync(join(root, ".engram", "graph.db"))) { + console.error( + `engram: no graph found at ${root}. Run 'engram init' first.` + ); + process.exit(1); + } + const absFile = pathResolve(root, file); + try { + const result = await syncFile(absFile, root); + const line = formatReindexLine(result, file); + if (line !== null) console.log(line); + process.exitCode = 0; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`engram: ${msg}`); + if (opts.verbose && err instanceof Error && err.stack) { + console.error(err.stack); + } + process.exit(1); + } + } + ); + +/** + * engram reindex-hook — PostToolUse hook entry point for the optional + * auto-reindex wiring ([#8](https://github.com/NickCirv/engram/issues/8)). + * Reads Claude Code's JSON payload from stdin, extracts + * `tool_input.file_path`, and delegates to `syncFile` (via + * `runReindexHook`). ALWAYS exits 0 — never blocks the hook. + * + * Shape contract matches `engram intercept`: bounded stdin read with a + * 3s watchdog, swallows parse errors, and sets `process.exitCode = 0` + * without calling `process.exit` so sql.js's WASM handle can drain + * cleanly (see the note on `intercept`). + */ +program + .command("reindex-hook") + .description( + "PostToolUse hook entry point: reads JSON from stdin, reindexes tool_input.file_path (always exits 0)" + ) + .action(async () => { + const stdinTimeout = setTimeout(() => { + process.exit(0); + }, 3000); + stdinTimeout.unref(); + + let input = ""; + let stdinFailed = false; + try { + for await (const chunk of process.stdin) { + input += chunk; + if (input.length > 1_000_000) break; + } + } catch { + stdinFailed = true; + } + clearTimeout(stdinTimeout); + + if (stdinFailed || !input.trim()) { + process.exitCode = 0; + return; + } + + let payload: unknown; + try { + payload = JSON.parse(input); + } catch { + process.exitCode = 0; + return; + } + + try { + await runReindexHook(payload); + } catch { + // runReindexHook already swallows errors; this is belt-and-braces. + } + + process.exitCode = 0; + }); + program .command("dashboard") .alias("hud") @@ -765,11 +869,17 @@ program .option("--scope ", "local | project | user", "local") .option("--dry-run", "Show diff without writing", false) .option("-p, --project ", "Project directory", ".") + .option( + "--auto-reindex", + "Also register a PostToolUse Edit|Write|MultiEdit entry calling 'engram reindex-hook' (keeps graph fresh after every edit, #8)", + false + ) .action( async (opts: { scope: string; dryRun: boolean; project: string; + autoReindex: boolean; }) => { const settingsPath = resolveSettingsPath(opts.scope, opts.project); if (!settingsPath) { @@ -802,14 +912,25 @@ program } } - const result = installEngramHooks(existing); + const result = installEngramHooks(existing, undefined, { + autoReindex: opts.autoReindex, + }); console.log( chalk.bold(`\n📌 engram install-hook (scope: ${opts.scope})`) ); console.log(chalk.dim(` Target: ${settingsPath}`)); + if (opts.autoReindex) { + console.log( + chalk.dim(" Auto-reindex: enabled (engram reindex-hook)") + ); + } - if (result.added.length === 0 && !result.statusLineAdded) { + if ( + result.added.length === 0 && + !result.statusLineAdded && + !result.autoReindexAdded + ) { console.log( chalk.yellow( `\n All engram hooks already installed (${result.alreadyPresent.join(", ")}).` @@ -869,6 +990,13 @@ program chalk.green(" ✅ StatusLine: engram hud-label (HUD visible in Claude Code)") ); } + if (result.autoReindexAdded) { + console.log( + chalk.green( + " ✅ PostToolUse: engram reindex-hook (matcher: Edit|Write|MultiEdit)" + ) + ); + } if (result.alreadyPresent.length > 0) { console.log( chalk.dim( diff --git a/src/intercept/installer.ts b/src/intercept/installer.ts index f94acdf..56fe6f9 100644 --- a/src/intercept/installer.ts +++ b/src/intercept/installer.ts @@ -43,6 +43,20 @@ export const ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash"; */ export const DEFAULT_ENGRAM_COMMAND = "engram intercept"; +/** + * Matcher for the optional auto-reindex PostToolUse entry installed by + * `engram install-hook --auto-reindex` (#8). Broader than the issue's + * initial `Edit|Write` because MultiEdit also produces file writes. + */ +export const ENGRAM_REINDEX_HOOK_MATCHER = "Edit|Write|MultiEdit"; + +/** + * Default command for the optional auto-reindex PostToolUse entry. + * Reads Claude Code's PostToolUse payload from stdin and re-indexes + * `tool_input.file_path`. Always exits 0 — never blocks a hook. + */ +export const DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND = "engram reindex-hook"; + /** * Default per-invocation timeout in seconds. Kept short (5s) because * the Sentinel handlers should complete in well under 500ms each; @@ -128,6 +142,24 @@ export function buildEngramHookEntries( }; } +/** + * Build the optional auto-reindex PostToolUse entry (#8). Off by default + * when `engram install-hook` runs; added when the user passes + * `--auto-reindex` so existing installs aren't disturbed. + * + * Recognized by `isEngramHookEntry()` so `engram uninstall-hook` strips + * it alongside the primary `engram intercept` entries. + */ +export function buildReindexHookEntry( + command: string = DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND, + timeout: number = DEFAULT_HOOK_TIMEOUT_SEC +): HookEntry { + return { + matcher: ENGRAM_REINDEX_HOOK_MATCHER, + hooks: [{ type: "command", command, timeout }], + }; +} + /** * Check whether a hook entry is engram-owned (based on command string * inspection). Used to detect existing installs and target uninstalls. @@ -139,7 +171,11 @@ export function isEngramHookEntry(entry: unknown): entry is HookEntry { for (const h of e.hooks) { if (h === null || typeof h !== "object") continue; const cmd = (h as HookCommand).command; - if (typeof cmd === "string" && cmd.includes("engram intercept")) { + if (typeof cmd !== "string") continue; + if ( + cmd.includes("engram intercept") || + cmd.includes("engram reindex-hook") + ) { return true; } } @@ -158,6 +194,21 @@ export interface InstallResult { readonly alreadyPresent: readonly EngramHookEvent[]; /** Whether a statusLine entry was added for `engram hud-label`. */ readonly statusLineAdded: boolean; + /** + * Whether the optional `engram reindex-hook` PostToolUse entry was + * added this run (#8, opt-in via `--auto-reindex`). `false` when the + * option was disabled OR when the entry was already present. + */ + readonly autoReindexAdded: boolean; +} + +/** Options for `installEngramHooks`. */ +export interface InstallOptions { + /** + * Also register the optional `engram reindex-hook` PostToolUse entry + * (#8). Off by default so existing users aren't surprised. + */ + readonly autoReindex?: boolean; } /** @@ -169,7 +220,8 @@ export interface InstallResult { */ export function installEngramHooks( settings: ClaudeCodeSettings, - command: string = DEFAULT_ENGRAM_COMMAND + command: string = DEFAULT_ENGRAM_COMMAND, + options: InstallOptions = {} ): InstallResult { const entries = buildEngramHookEntries(command); const added: EngramHookEvent[] = []; @@ -186,8 +238,14 @@ export function installEngramHooks( for (const event of ENGRAM_HOOK_EVENTS) { const eventArr = hooksClone[event] ?? []; - const hasEngram = eventArr.some((e) => isEngramHookEntry(e)); - if (hasEngram) { + // Idempotence check targets the PRIMARY intercept entry specifically. + // Using `isEngramHookEntry` here would false-positive once the + // opt-in reindex-hook entry lands, causing install to skip adding + // the missing intercept entry. + const hasIntercept = eventArr.some((e) => + entryContainsCommand(e, "engram intercept") + ); + if (hasIntercept) { alreadyPresent.push(event); hooksClone[event] = eventArr; continue; @@ -196,6 +254,20 @@ export function installEngramHooks( added.push(event); } + // Optional auto-reindex entry — appended as a SECOND PostToolUse entry + // so it's orthogonal to the observer. Idempotent. + let autoReindexAdded = false; + if (options.autoReindex) { + const postToolArr = hooksClone.PostToolUse ?? []; + const hasReindexHook = postToolArr.some((e) => + entryContainsCommand(e, "engram reindex-hook") + ); + if (!hasReindexHook) { + hooksClone.PostToolUse = [...postToolArr, buildReindexHookEntry()]; + autoReindexAdded = true; + } + } + // StatusLine: set `engram hud-label` only if no statusLine is configured. // This gives users a visible HUD out of the box without overwriting any // existing statusLine (e.g., claude-hud plugin or a custom command). @@ -215,9 +287,26 @@ export function installEngramHooks( added, alreadyPresent, statusLineAdded, + autoReindexAdded, }; } +/** + * True when any of the entry's commands contains the given substring. + * Used for targeted idempotence checks in `installEngramHooks` — each + * engram-owned entry has a distinguishing command, so substring match + * is sufficient. + */ +function entryContainsCommand(entry: HookEntry, substring: string): boolean { + if (!Array.isArray(entry.hooks)) return false; + for (const h of entry.hooks) { + if (h === null || typeof h !== "object") continue; + const cmd = (h as HookCommand).command; + if (typeof cmd === "string" && cmd.includes(substring)) return true; + } + return false; +} + /** * Result of an uninstall operation. `removed` lists events where an * engram entry was removed. Empty arrays and empty `hooks` object are diff --git a/src/watcher.ts b/src/watcher.ts index 5f509f7..91fa3c0 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -19,6 +19,8 @@ import { resolve, relative, extname, join, sep } from "node:path"; import { extractFile } from "./miners/ast-miner.js"; import { toPosixPath } from "./graph/path-utils.js"; import { getStore, getDbPath } from "./core.js"; +import { formatThousands } from "./graph/render-utils.js"; +import { findProjectRoot, isValidCwd } from "./intercept/context.js"; /** Extensions the AST miner can handle. */ const WATCHABLE_EXTENSIONS = new Set([ @@ -102,6 +104,64 @@ export async function syncFile( } } +/** + * Format the CLI output line for a `SyncResult`. Returns `null` for + * skipped results so the caller can stay silent (AC 4 in #8 — safe to + * fire as a PostToolUse hook on every edit without producing noise). + */ +export function formatReindexLine( + result: SyncResult, + displayPath: string +): string | null { + if (result.action === "indexed") { + return `engram: reindexed ${displayPath} (${formatThousands(result.count)} nodes)`; + } + if (result.action === "pruned") { + return `engram: pruned ${displayPath} (${formatThousands(result.count)} nodes)`; + } + return null; +} + +/** + * Run the optional auto-reindex PostToolUse hook: parse a Claude Code + * payload, resolve the project root from `cwd`, and sync the file at + * `tool_input.file_path`. Never throws — every error path resolves to + * a silent no-op so the hook can never fail Claude Code's tool cycle + * (maintainer's contract on #8). + * + * Accepts `unknown` because stdin has not yet been validated. Returns + * nothing; the effect is a graph mutation (or no-op). + */ +export async function runReindexHook(payload: unknown): Promise { + try { + if (payload === null || typeof payload !== "object") return; + const p = payload as { + cwd?: unknown; + tool_input?: unknown; + }; + + const cwd = p.cwd; + if (typeof cwd !== "string" || !isValidCwd(cwd)) return; + + const toolInput = p.tool_input; + if (toolInput === null || typeof toolInput !== "object") return; + const filePath = (toolInput as Record).file_path; + if (typeof filePath !== "string" || filePath.length === 0) return; + + // Resolve the file against cwd when it's relative, then walk UP from + // the file's location — not cwd. A Claude Code session cwd may sit + // above (or beside) the engram-initialized project that owns the + // edited file (e.g. multi-project parent, monorepo subtree). + const absPath = resolve(cwd, filePath); + const projectRoot = findProjectRoot(absPath); + if (projectRoot === null) return; + + await syncFile(absPath, projectRoot); + } catch { + // Swallow everything — a hook is never allowed to fail. + } +} + export interface WatchOptions { /** Called when a file is re-indexed. */ readonly onReindex?: (filePath: string, nodeCount: number) => void; diff --git a/tests/intercept/installer.test.ts b/tests/intercept/installer.test.ts index eceec83..32b73cb 100644 --- a/tests/intercept/installer.test.ts +++ b/tests/intercept/installer.test.ts @@ -14,11 +14,14 @@ import { installEngramHooks, uninstallEngramHooks, buildEngramHookEntries, + buildReindexHookEntry, isEngramHookEntry, formatInstallDiff, ENGRAM_HOOK_EVENTS, ENGRAM_PRETOOL_MATCHER, + ENGRAM_REINDEX_HOOK_MATCHER, DEFAULT_ENGRAM_COMMAND, + DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND, DEFAULT_STATUSLINE_COMMAND, type ClaudeCodeSettings, type HookEntry, @@ -67,6 +70,23 @@ describe("buildEngramHookEntries", () => { }); }); +describe("buildReindexHookEntry (#8 auto-reindex)", () => { + it("builds a PostToolUse entry with matcher 'Edit|Write|MultiEdit' and command 'engram reindex-hook'", () => { + const entry = buildReindexHookEntry(); + expect(entry.matcher).toBe(ENGRAM_REINDEX_HOOK_MATCHER); + expect(entry.matcher).toBe("Edit|Write|MultiEdit"); + expect(entry.hooks.length).toBe(1); + expect(entry.hooks[0].type).toBe("command"); + expect(entry.hooks[0].command).toBe(DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND); + expect(entry.hooks[0].command).toBe("engram reindex-hook"); + expect(entry.hooks[0].timeout).toBeGreaterThan(0); + }); + + it("is recognized by isEngramHookEntry (so uninstall removes it)", () => { + expect(isEngramHookEntry(buildReindexHookEntry())).toBe(true); + }); +}); + describe("isEngramHookEntry", () => { it("detects entries whose command contains 'engram intercept'", () => { const entry: HookEntry = { @@ -86,6 +106,14 @@ describe("isEngramHookEntry", () => { expect(isEngramHookEntry(entry)).toBe(true); }); + it("detects the optional auto-reindex entry ('engram reindex-hook') so uninstall cleans it up (#8)", () => { + const entry: HookEntry = { + matcher: "Edit|Write|MultiEdit", + hooks: [{ type: "command", command: "engram reindex-hook" }], + }; + expect(isEngramHookEntry(entry)).toBe(true); + }); + it("does NOT match other hooks", () => { const entry: HookEntry = { matcher: "Bash", @@ -184,6 +212,85 @@ describe("installEngramHooks", () => { expect(result.updated.permissions).toEqual({ foo: true }); }); + describe("autoReindex option (#8)", () => { + it("adds a second PostToolUse entry (Edit|Write|MultiEdit → engram reindex-hook) when enabled", () => { + const result = installEngramHooks({}, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }); + const postTool = result.updated.hooks!.PostToolUse!; + expect(postTool.length).toBe(2); + // Original observer entry. + expect(postTool[0].hooks[0].command).toBe("engram intercept"); + // New reindex-hook entry. + expect(postTool[1].matcher).toBe("Edit|Write|MultiEdit"); + expect(postTool[1].hooks[0].command).toBe("engram reindex-hook"); + expect(result.autoReindexAdded).toBe(true); + }); + + it("does NOT add the reindex-hook entry by default (off for existing users)", () => { + const result = installEngramHooks({}); + const postTool = result.updated.hooks!.PostToolUse!; + expect(postTool.length).toBe(1); + expect(postTool[0].hooks[0].command).toBe("engram intercept"); + expect(result.autoReindexAdded).toBe(false); + }); + + it("is idempotent — second install with autoReindex=true leaves settings unchanged", () => { + const first = installEngramHooks({}, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }); + const second = installEngramHooks(first.updated, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }); + expect(second.autoReindexAdded).toBe(false); + expect(JSON.stringify(second.updated)).toBe(JSON.stringify(first.updated)); + }); + + it("adds reindex-hook onto an existing PostToolUse that already has the intercept entry", () => { + const base = installEngramHooks({}).updated; // intercept-only + const result = installEngramHooks(base, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }); + const postTool = result.updated.hooks!.PostToolUse!; + expect(postTool.length).toBe(2); + expect(postTool.some((e) => e.hooks[0].command === "engram reindex-hook")).toBe( + true + ); + expect(result.autoReindexAdded).toBe(true); + }); + + it("uninstall strips the reindex-hook entry too", () => { + const installed = installEngramHooks({}, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }).updated; + const result = uninstallEngramHooks(installed); + // PostToolUse must be gone entirely — no engram-owned entries remain. + expect(result.updated.hooks?.PostToolUse).toBeUndefined(); + expect(result.removed).toContain("PostToolUse"); + }); + + it("uninstall preserves non-engram PostToolUse hooks when auto-reindex was installed", () => { + const withOther: ClaudeCodeSettings = { + hooks: { + PostToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "other-post-hook" }], + }, + ], + }, + }; + const installed = installEngramHooks(withOther, DEFAULT_ENGRAM_COMMAND, { + autoReindex: true, + }).updated; + const result = uninstallEngramHooks(installed); + const postTool = result.updated.hooks?.PostToolUse; + expect(postTool).toBeDefined(); + expect(postTool!.length).toBe(1); + expect(postTool![0].hooks[0].command).toBe("other-post-hook"); + }); + }); + it("detects partial installs (some events present, others not)", () => { // Simulate a broken state where only PreToolUse has engram's entry. const partial: ClaudeCodeSettings = { @@ -286,6 +393,19 @@ describe("formatInstallDiff", () => { expect(diff).toBe("(no changes)"); }); + it("shows the auto-reindex entry when --auto-reindex is used (#8)", () => { + const before: ClaudeCodeSettings = {}; + const { updated: after } = installEngramHooks( + before, + DEFAULT_ENGRAM_COMMAND, + { autoReindex: true } + ); + const diff = formatInstallDiff(before, after); + expect(diff).toContain("PostToolUse"); + expect(diff).toContain("engram reindex-hook"); + expect(diff).toContain("Edit|Write|MultiEdit"); + }); + it("shows statusLine addition in diff", () => { const before: ClaudeCodeSettings = {}; const { updated: after } = installEngramHooks(before); diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts index 433db7c..ca8c463 100644 --- a/tests/watcher.test.ts +++ b/tests/watcher.test.ts @@ -2,11 +2,61 @@ * Tests for the file watcher — incremental re-indexing. */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { mkdirSync, writeFileSync, rmSync, renameSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { + mkdirSync, + writeFileSync, + rmSync, + renameSync, + existsSync, + mkdtempSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { init, getStore } from "../src/core.js"; -import { watchProject } from "../src/watcher.js"; +import { + watchProject, + formatReindexLine, + runReindexHook, + type SyncResult, +} from "../src/watcher.js"; + +// fileURLToPath is required on Windows — `new URL(...).pathname` returns +// `/C:/Users/...` which then gets a second drive letter prepended by +// resolve(), producing `C:\C:\Users\...` and an ENOENT. +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CLI_PATH = join(REPO_ROOT, "dist", "cli.js"); + +function runReindexCli( + args: string[], + cwd: string +): { stdout: string; stderr: string; status: number | null } { + const r = spawnSync("node", [CLI_PATH, "reindex", ...args], { + cwd, + encoding: "utf-8", + timeout: 15_000, + }); + return { + stdout: r.stdout || "", + stderr: r.stderr || "", + status: r.status, + }; +} + +function runReindexCliAsync( + args: string[], + cwd: string +): Promise<{ stdout: string; stderr: string; status: number | null }> { + return new Promise((resolve) => { + const child = spawn("node", [CLI_PATH, "reindex", ...args], { cwd }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + child.on("close", (code) => resolve({ stdout, stderr, status: code })); + }); +} const rootDir = join(tmpdir(), `engram-watcher-test-${Date.now()}`); const projectRoot = join(rootDir, "watchtest"); @@ -219,3 +269,381 @@ describe("watchProject", () => { } }); }); + +describe("formatReindexLine", () => { + it("formats an indexed result as 'engram: reindexed ( nodes)'", () => { + const result: SyncResult = { action: "indexed", count: 12 }; + expect(formatReindexLine(result, "src/foo.ts")).toBe( + "engram: reindexed src/foo.ts (12 nodes)" + ); + }); + + it("formats a pruned result as 'engram: pruned ( nodes)'", () => { + const result: SyncResult = { action: "pruned", count: 7 }; + expect(formatReindexLine(result, "src/gone.ts")).toBe( + "engram: pruned src/gone.ts (7 nodes)" + ); + }); + + it("returns null for a skipped result so the caller stays silent", () => { + const result: SyncResult = { action: "skipped", count: 0 }; + expect(formatReindexLine(result, "README.md")).toBeNull(); + }); + + it("formats large counts with comma thousands separators (locale-stable)", () => { + const result: SyncResult = { action: "indexed", count: 1234567 }; + expect(formatReindexLine(result, "src/huge.ts")).toBe( + "engram: reindexed src/huge.ts (1,234,567 nodes)" + ); + }); +}); + +describe("engram reindex — end-to-end CLI", () => { + let rxRoot: string; + + beforeAll(async () => { + if (!existsSync(CLI_PATH)) { + const r = spawnSync("npm", ["run", "build"], { + cwd: REPO_ROOT, + stdio: "ignore", + timeout: 60_000, + shell: process.platform === "win32", + }); + if (r.status !== 0) { + throw new Error(`npm run build failed with status ${r.status}`); + } + } + rxRoot = mkdtempSync(join(tmpdir(), "engram-reindex-cli-")); + mkdirSync(join(rxRoot, "src"), { recursive: true }); + writeFileSync( + join(rxRoot, "src", "seed.ts"), + "export function seed() { return 1; }\n" + ); + await init(rxRoot); + }, 90_000); + + afterAll(() => { + if (rxRoot) rmSync(rxRoot, { recursive: true, force: true }); + }); + + it("exits 0 and prints 'engram: reindexed (N nodes)' after a real edit", () => { + const target = join(rxRoot, "src", "added.ts"); + writeFileSync( + target, + "export function addedFn() { return 42; }\n" + ); + + const r = runReindexCli([target, "-p", rxRoot], rxRoot); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^engram: reindexed .+ \(\d+ nodes\)\n?$/); + expect(r.stderr).toBe(""); + }); + + it("updates the graph when a file is modified (new function in, old out)", async () => { + const target = join(rxRoot, "src", "evolve.ts"); + writeFileSync( + target, + "export function originalFn() { return 1; }\n" + ); + // First reindex: original function indexed. + expect(runReindexCli([target, "-p", rxRoot], rxRoot).status).toBe(0); + + const storeBefore = await getStore(rxRoot); + const beforeLabels = storeBefore + .getAllNodes() + .filter((n) => n.sourceFile === "src/evolve.ts") + .map((n) => n.label); + storeBefore.close(); + expect(beforeLabels).toContain("originalFn()"); + expect(beforeLabels).not.toContain("replacementFn()"); + + // Edit: swap the function out. + writeFileSync( + target, + "export function replacementFn() { return 2; }\n" + ); + expect(runReindexCli([target, "-p", rxRoot], rxRoot).status).toBe(0); + + const storeAfter = await getStore(rxRoot); + const afterLabels = storeAfter + .getAllNodes() + .filter((n) => n.sourceFile === "src/evolve.ts") + .map((n) => n.label); + storeAfter.close(); + expect(afterLabels).toContain("replacementFn()"); + expect(afterLabels).not.toContain("originalFn()"); + }); + + it("two parallel subprocess reindex calls leave a coherent graph (AC 6, multi-process)", async () => { + const a = join(rxRoot, "src", "conc-a.ts"); + const b = join(rxRoot, "src", "conc-b.ts"); + writeFileSync(a, "export function concA() { return 'a'; }\n"); + writeFileSync(b, "export function concB() { return 'b'; }\n"); + + const [rA, rB] = await Promise.all([ + runReindexCliAsync([a, "-p", rxRoot], rxRoot), + runReindexCliAsync([b, "-p", rxRoot], rxRoot), + ]); + // Neither invocation should crash; both must exit 0. + expect(rA.status).toBe(0); + expect(rB.status).toBe(0); + + // Both files' nodes must be readable after the dust settles — + // opening the store asserts the DB file is not corrupted. + const store = await getStore(rxRoot); + try { + const aNodes = store.getAllNodes().filter((n) => n.sourceFile === "src/conc-a.ts"); + const bNodes = store.getAllNodes().filter((n) => n.sourceFile === "src/conc-b.ts"); + // At least one of the two must have persisted — SQLite's last- + // writer-wins semantics mean one write may clobber the other when + // two sql.js processes save concurrently, but the DB must remain + // openable and at least one set must be present. + expect(aNodes.length + bNodes.length).toBeGreaterThan(0); + } finally { + store.close(); + } + }, 30_000); + + it("exits 1 with a single stderr line when the project has no graph", () => { + const unInitRoot = mkdtempSync(join(tmpdir(), "engram-reindex-nograph-")); + try { + const target = join(unInitRoot, "src", "x.ts"); + mkdirSync(join(unInitRoot, "src"), { recursive: true }); + writeFileSync(target, "export const x = 1;\n"); + + const r = runReindexCli([target, "-p", unInitRoot], unInitRoot); + expect(r.status).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toContain("no graph found"); + expect(r.stderr.split("\n").filter((l) => l.length > 0).length).toBe(1); + } finally { + rmSync(unInitRoot, { recursive: true, force: true }); + } + }); + + it("exits 0 silently for a non-code file (safe for PostToolUse hook)", async () => { + const target = join(rxRoot, "notes.md"); + writeFileSync(target, "# Notes\n"); + + const storeBefore = await getStore(rxRoot); + const countBefore = storeBefore.getAllNodes().length; + storeBefore.close(); + + const r = runReindexCli([target, "-p", rxRoot], rxRoot); + expect(r.status).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(""); + + const storeAfter = await getStore(rxRoot); + const countAfter = storeAfter.getAllNodes().length; + storeAfter.close(); + expect(countAfter).toBe(countBefore); + }); +}); + +describe("runReindexHook — Claude Code PostToolUse stdin handler (#8)", () => { + let hookRoot: string; + + beforeAll(async () => { + hookRoot = mkdtempSync(join(tmpdir(), "engram-reindex-hook-")); + mkdirSync(join(hookRoot, "src"), { recursive: true }); + writeFileSync( + join(hookRoot, "src", "base.ts"), + "export function base() { return 0; }\n" + ); + await init(hookRoot); + }); + + afterAll(() => { + if (hookRoot) rmSync(hookRoot, { recursive: true, force: true }); + }); + + it("reindexes tool_input.file_path from a well-formed PostToolUse payload", async () => { + const target = join(hookRoot, "src", "hook-added.ts"); + writeFileSync(target, "export function hookAdded() { return 7; }\n"); + + await runReindexHook({ + hook_event_name: "PostToolUse", + cwd: hookRoot, + tool_name: "Edit", + tool_input: { file_path: target }, + }); + + const store = await getStore(hookRoot); + try { + const labels = store + .getAllNodes() + .filter((n) => n.sourceFile === "src/hook-added.ts") + .map((n) => n.label); + expect(labels).toContain("hookAdded()"); + } finally { + store.close(); + } + }); + + it("resolves relative file_path against cwd (Claude Code sometimes sends relative paths)", async () => { + const target = join(hookRoot, "src", "relative-path.ts"); + writeFileSync(target, "export function relPath() { return 'rp'; }\n"); + + await runReindexHook({ + hook_event_name: "PostToolUse", + cwd: hookRoot, + tool_name: "Write", + tool_input: { file_path: "src/relative-path.ts" }, + }); + + const store = await getStore(hookRoot); + try { + const labels = store + .getAllNodes() + .filter((n) => n.sourceFile === "src/relative-path.ts") + .map((n) => n.label); + expect(labels).toContain("relPath()"); + } finally { + store.close(); + } + }); + + it("is a silent no-op for every malformed payload shape", async () => { + // None of these should throw or mutate the graph. + await expect(runReindexHook(null)).resolves.toBeUndefined(); + await expect(runReindexHook(undefined)).resolves.toBeUndefined(); + await expect(runReindexHook("not an object")).resolves.toBeUndefined(); + await expect(runReindexHook(42)).resolves.toBeUndefined(); + await expect(runReindexHook({})).resolves.toBeUndefined(); + await expect(runReindexHook({ cwd: hookRoot })).resolves.toBeUndefined(); + await expect( + runReindexHook({ cwd: hookRoot, tool_input: "wrong type" }) + ).resolves.toBeUndefined(); + await expect( + runReindexHook({ cwd: hookRoot, tool_input: {} }) + ).resolves.toBeUndefined(); + await expect( + runReindexHook({ cwd: hookRoot, tool_input: { file_path: 42 } }) + ).resolves.toBeUndefined(); + await expect( + runReindexHook({ cwd: hookRoot, tool_input: { file_path: "" } }) + ).resolves.toBeUndefined(); + }); + + it("walks up from the FILE path, not cwd — finds the project even when cwd sits above it", async () => { + // Real-world shape: Claude Code session cwd is a parent of the + // engram-initialized project (e.g. a monorepo or a parent folder + // containing multiple sub-projects). The hook must still resolve + // the correct graph by walking from the edited file's path. + const parentCwd = dirname(hookRoot); + const target = join(hookRoot, "src", "nested-proj.ts"); + writeFileSync(target, "export function nestedProj() { return 'np'; }\n"); + + // Fresh cache so the lookup actually walks (per-invocation cache + // lives inside intercept/context.ts). + const { _resetCacheForTests } = await import( + "../src/intercept/context.js" + ); + _resetCacheForTests(); + + await runReindexHook({ + hook_event_name: "PostToolUse", + cwd: parentCwd, + tool_name: "Edit", + tool_input: { file_path: target }, + }); + + const store = await getStore(hookRoot); + try { + const labels = store + .getAllNodes() + .filter((n) => n.sourceFile === "src/nested-proj.ts") + .map((n) => n.label); + expect(labels).toContain("nestedProj()"); + } finally { + store.close(); + } + }); + + it("is a silent no-op when cwd is outside any engram-initialized project", async () => { + const nonProject = mkdtempSync(join(tmpdir(), "engram-reindex-hook-nop-")); + try { + await expect( + runReindexHook({ + hook_event_name: "PostToolUse", + cwd: nonProject, + tool_name: "Edit", + tool_input: { file_path: join(nonProject, "foo.ts") }, + }) + ).resolves.toBeUndefined(); + } finally { + rmSync(nonProject, { recursive: true, force: true }); + } + }); + + it("the `engram reindex-hook` subprocess always exits 0 (stdin happy path + malformed JSON + no stdin)", async () => { + if (!existsSync(CLI_PATH)) { + const r = spawnSync("npm", ["run", "build"], { + cwd: REPO_ROOT, + stdio: "ignore", + timeout: 60_000, + shell: process.platform === "win32", + }); + if (r.status !== 0) { + throw new Error(`npm run build failed with status ${r.status}`); + } + } + + // Helper: spawn `engram reindex-hook` with stdin input, assert exit 0 silent. + const runHookCli = (stdin: string | undefined) => + new Promise<{ status: number | null; stdout: string; stderr: string }>( + (resolveP) => { + const child = spawn("node", [CLI_PATH, "reindex-hook"], { + cwd: hookRoot, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + child.on("close", (code) => + resolveP({ status: code, stdout, stderr }) + ); + if (stdin !== undefined) { + child.stdin.end(stdin); + } else { + child.stdin.end(); + } + } + ); + + const target = join(hookRoot, "src", "subproc.ts"); + writeFileSync(target, "export function subproc() { return 9; }\n"); + const happy = await runHookCli( + JSON.stringify({ + hook_event_name: "PostToolUse", + cwd: hookRoot, + tool_name: "Edit", + tool_input: { file_path: target }, + }) + ); + expect(happy.status).toBe(0); + expect(happy.stdout).toBe(""); + expect(happy.stderr).toBe(""); + + const malformed = await runHookCli("not valid json"); + expect(malformed.status).toBe(0); + expect(malformed.stdout).toBe(""); + + const empty = await runHookCli(""); + expect(empty.status).toBe(0); + expect(empty.stdout).toBe(""); + + // And the happy-path subprocess actually updated the graph. + const store = await getStore(hookRoot); + try { + const labels = store + .getAllNodes() + .filter((n) => n.sourceFile === "src/subproc.ts") + .map((n) => n.label); + expect(labels).toContain("subproc()"); + } finally { + store.close(); + } + }, 30_000); +});