diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index e1e78dd7..6dcfc16b 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { HOME, ensureDir, log, pkgRoot, symlinkForce, warn, writeJson } from "./util.js"; import { pidPathFor, socketPathFor } from "../embeddings/protocol.js"; import { getEmbeddingsEnabled, setEmbeddingsEnabled } from "../user-config.js"; +import { ensureGraphDeps } from "./graph-deps.js"; /** * Shared-deps location for the embedding daemon's runtime dependencies. @@ -107,11 +108,17 @@ function ensureSharedDeps(): void { log(` Embeddings installing ${TRANSFORMERS_PKG}@${TRANSFORMERS_RANGE} into ${SHARED_DIR}`); log(` (~600 MB; first install only — every agent will share this)`); ensureDir(SHARED_DIR); - writeJson(join(SHARED_DIR, "package.json"), { + // Merge, don't clobber: ensureGraphDeps may have already declared the + // tree-sitter parsers here. Overwriting with transformers-only would drop + // that declaration and let a subsequent reconcile prune the parsers. + const pkgPath = join(SHARED_DIR, "package.json"); + let existing: { dependencies?: Record } = {}; + try { existing = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { /* none yet */ } + writeJson(pkgPath, { name: "hivemind-embed-deps", version: "1.0.0", private: true, - dependencies: { [TRANSFORMERS_PKG]: TRANSFORMERS_RANGE }, + dependencies: { ...(existing.dependencies ?? {}), [TRANSFORMERS_PKG]: TRANSFORMERS_RANGE }, }); execFileSync("npm", ["install", "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund"], { cwd: SHARED_DIR, @@ -170,6 +177,10 @@ function linkAgent(install: AgentInstall): void { */ export function installEmbeddings(): void { ensureSharedDeps(); + // Provision the code-graph parsers into the same shared dir so the + // graph-on-stop hook (which symlinks here) can auto-build the graph. + // Best-effort — a native-build failure never aborts the embeddings install. + ensureGraphDeps(); const installs = findHivemindInstalls(); if (installs.length === 0) { warn(" Embeddings no hivemind installs detected — run `hivemind install` first"); diff --git a/src/cli/graph-deps.ts b/src/cli/graph-deps.ts new file mode 100644 index 00000000..c5d065ca --- /dev/null +++ b/src/cli/graph-deps.ts @@ -0,0 +1,152 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ensureDir, log, pkgRoot, warn, writeJson } from "./util.js"; +import { SHARED_DIR, SHARED_NODE_MODULES } from "./embeddings.js"; + +/** + * The code-graph feature resolves `tree-sitter` (+ language grammars) at + * runtime from the SAME shared `embed-deps/node_modules` that the graph-on-stop + * hook symlinks to. `ensureSharedDeps` only installs @huggingface/transformers + * there, so on a real agent install tree-sitter is absent and the graph never + * auto-builds (the hook degrades to a graceful skip). This module provisions + * the parsers where the hook looks. + * + * tree-sitter is a NATIVE module: symlinking the package dir alone is not + * enough (it needs its own node-gyp-build / node-addon-api siblings resolved), + * so we run a scoped `npm install` and let npm lay down the full tree. + */ +export function isGraphDepsInstalled( + sharedNodeModules: string = SHARED_NODE_MODULES, + specs: string[] = treeSitterSpecs(), +): boolean { + // Require EVERY requested parser to be present — not just `tree-sitter` — so + // an interrupted or partial install counts as "needs (re)install" rather + // than being skipped as done. `@` → dir name is the part before + // the (unscoped) version separator. + if (specs.length === 0) return false; + return specs.every((s) => existsSync(join(sharedNodeModules, s.slice(0, s.lastIndexOf("@"))))); +} + +/** + * Build `@` specs for every tree-sitter* entry in the package's + * own optionalDependencies. Reading them from package.json (rather than a + * hardcoded list) keeps the embed-deps versions in lockstep with what the + * bundles were built against — no drift, no ABI mismatch. + */ +export function treeSitterSpecs(pkgJsonPath: string = join(pkgRoot(), "package.json")): string[] { + let opt: Record = {}; + try { + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + opt = pkg.optionalDependencies ?? {}; + } catch { return []; } + return Object.keys(opt) + .filter((n) => n === "tree-sitter" || n.startsWith("tree-sitter-")) + .sort() + .map((n) => `${n}@${opt[n]}`); +} + +/** + * Injection seam for ensureGraphDeps. Tests replace the two external boundaries + * (`runNpm` = the npm install, `runHeal` = the native-build heal) and point + * `sharedDir` / `sharedNodeModules` / `specs` at a tmp fixture, so the branch + * logic runs against real fs without spawning npm or compiling anything. + */ +export interface GraphDepsDeps { + sharedDir?: string; + sharedNodeModules?: string; + specs?: string[]; + runNpm?: (specs: string[], cwd: string) => void; + runHeal?: (cwd: string) => void; + logFn?: (msg: string) => void; + warnFn?: (msg: string) => void; +} + +/** + * Install the tree-sitter parsers into the shared embed-deps dir. Idempotent + * (a specs-hash marker + a per-package presence check skip the re-download, + * and catch version bumps / partial installs) and best-effort: any failure is + * logged and swallowed so it never aborts the caller — the graph stays + * disabled but nothing else breaks, and the graph-on-stop hook degrades + * gracefully rather than crashing. + * + * Two-phase native provisioning: `npm install --ignore-scripts` first, so a + * platform without a prebuild (arm64 / Node 24) doesn't fail the download, + * THEN scripts/ensure-tree-sitter.mjs always runs — it validates the bindings + * load and compiles from source where no prebuild exists (a fast no-op on + * healthy prebuilt installs). Running the heal unconditionally repairs a + * previously interrupted or ABI-mismatched install. + * + * Decoupled from the ~600 MB embeddings download: this installs ONLY the + * parsers (tens of MB), so the code graph can work without semantic search. + */ +export function ensureGraphDeps(deps: GraphDepsDeps = {}): void { + const { + sharedDir = SHARED_DIR, + sharedNodeModules = SHARED_NODE_MODULES, + specs = treeSitterSpecs(), + runNpm = defaultRunNpm, + runHeal = defaultRunHeal, + logFn = log, + warnFn = warn, + } = deps; + try { + if (specs.length === 0) { + warnFn(` Graph no tree-sitter optionalDependencies found in package.json — skipping`); + return; + } + ensureDir(sharedDir); + // Create a package.json if none exists yet (user never ran embeddings + // install). Don't clobber an existing one — it may already declare + // transformers; the parsers are installed alongside. + const pkgPath = join(sharedDir, "package.json"); + if (!existsSync(pkgPath)) { + writeJson(pkgPath, { name: "hivemind-embed-deps", version: "1.0.0", private: true, dependencies: {} }); + } + // Skip the (re)download only when the exact spec set was installed before + // AND every package is still present. The marker also catches version + // bumps: a changed spec set forces a reinstall. + const marker = join(sharedDir, ".graph-deps"); + const wantKey = specs.join("\n"); + const haveKey = existsSync(marker) ? readFileSync(marker, "utf8") : ""; + if (haveKey !== wantKey || !isGraphDepsInstalled(sharedNodeModules, specs)) { + logFn(` Graph installing tree-sitter parsers into ${sharedDir} (code-graph; ~tens of MB)`); + runNpm(specs, sharedDir); + writeFileSync(marker, wantKey); + } else { + logFn(` Graph tree-sitter parsers already present at ${sharedDir}`); + } + // ALWAYS heal: validates bindings load + compiles from source where no + // prebuild exists (arm64 / Node 24). Fast no-op on healthy prebuilt + // installs; repairs an interrupted or ABI-mismatched one. + runHeal(sharedDir); + } catch (err) { + warnFn(` Graph tree-sitter provisioning failed (${err instanceof Error ? err.message : String(err)}); code graph stays disabled — everything else works`); + } +} + +/** + * Default npm boundary. `--ignore-scripts` fetches the packages without the + * native build that fails on platforms lacking a prebuild (the heal does the + * build). The parsers ARE saved to the shared package.json (default): a plain + * `npm install` PRUNES unsaved packages (verified on npm 11), so an unsaved + * install would be wiped by ensureSharedDeps' transformers reconcile. Being + * declared, they're neither pruned nor (once present and version-satisfied) + * rebuilt by that reconcile — so ensureSharedDeps stays free of + * --ignore-scripts, which would break onnxruntime-node / sharp (both have real + * install/postinstall native steps). + */ +function defaultRunNpm(specs: string[], cwd: string): void { + execFileSync("npm", ["install", ...specs, "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund", "--ignore-scripts"], { + cwd, + stdio: "inherit", + }); +} + +/** Default heal boundary: run scripts/ensure-tree-sitter.mjs if present. */ +function defaultRunHeal(cwd: string): void { + const heal = join(pkgRoot(), "scripts", "ensure-tree-sitter.mjs"); + if (existsSync(heal)) { + execFileSync(process.execPath, [heal], { cwd, stdio: "inherit" }); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index a4b42cf5..21161096 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { statusEmbeddings, uninstallEmbeddings, } from "./embeddings.js"; +import { ensureGraphDeps } from "./graph-deps.js"; import { ensureLoggedIn, isLoggedIn, loginWithProvidedToken, maybeShowOrgChoice } from "./auth.js"; import { runAuthCommand } from "../commands/auth-login.js"; // NOTE: ../commands/graph.js is intentionally NOT imported statically. It pulls @@ -547,6 +548,14 @@ async function main(): Promise { } if (cmd === "graph") { + // `graph init` sets up the auto-build. Provision the tree-sitter parsers + // into the shared embed-deps dir the graph-on-stop hook resolves from, so + // the automatic rebuild works — not just this CLI run (which resolves + // tree-sitter from its own package). Idempotent + best-effort; decoupled + // from the ~600 MB embeddings download. + if (args[1] === "init") { + try { ensureGraphDeps(); } catch { /* best-effort — never block graph init */ } + } let runGraphCommand: (a: string[]) => Promise | void; try { ({ runGraphCommand } = await import("../commands/graph.js")); diff --git a/tests/cli/cli-embeddings-install.test.ts b/tests/cli/cli-embeddings-install.test.ts new file mode 100644 index 00000000..b608a743 --- /dev/null +++ b/tests/cli/cli-embeddings-install.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest"; +import { mkdtempSync, mkdirSync, existsSync, rmSync, lstatSync, readlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setFakeHome, clearFakeHome } from "../shared/fake-home.js"; + +// Mock ONLY the process boundary: ensureSharedDeps / ensureGraphDeps shell out +// to `npm install`, which we must not actually run. spawnSync + everything else +// stay real (importActual). +vi.mock("node:child_process", async (orig) => ({ + ...(await orig()), + execFileSync: vi.fn(), +})); + +// embeddings.ts binds `HOME`/`SHARED_DIR` at module-evaluation time, and the +// orchestration functions (installEmbeddings/statusEmbeddings/uninstall) read +// them (and findHivemindInstalls) with NO injectable seam. Point HOME at a tmp +// dir BEFORE importing the module so every path resolves under the sandbox and +// nothing touches the real ~/.hivemind or the real agent installs. +const HOME_DIR = mkdtempSync(join(tmpdir(), "emb-install-home-")); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let mod: typeof import("../../src/cli/embeddings.js"); +let cfg: typeof import("../../src/user-config.js"); + +const PREV_TABLE = process.env.HIVEMIND_TABLE; + +beforeAll(async () => { + // Sandbox the home dir on POSIX AND Windows (os.homedir reads USERPROFILE on + // Windows) BEFORE importing embeddings.ts, which binds HOME/SHARED_DIR at load. + setFakeHome(HOME_DIR); + delete process.env.HIVEMIND_TABLE; + mod = await import("../../src/cli/embeddings.js"); + cfg = await import("../../src/user-config.js"); +}); + +afterAll(() => { + clearFakeHome(); + if (PREV_TABLE === undefined) delete process.env.HIVEMIND_TABLE; + else process.env.HIVEMIND_TABLE = PREV_TABLE; + rmSync(HOME_DIR, { recursive: true, force: true }); +}); + +function freshHome(): void { + for (const sub of [".hivemind", ".deeplake", ".codex", ".cursor", ".hermes", ".claude"]) { + rmSync(join(HOME_DIR, sub), { recursive: true, force: true }); + } + cfg._resetUserConfigForTesting(); + cfg._setConfigPathForTesting(() => join(HOME_DIR, ".deeplake", "config.json")); + mkdirSync(join(HOME_DIR, ".deeplake"), { recursive: true }); +} + +describe("installEmbeddings (sandboxed HOME, mocked npm)", () => { + beforeEach(() => { freshHome(); }); + afterEach(() => { cfg._resetUserConfigForTesting(); }); + + it("no agent installs → still provisions shared deps and flips the config flag on", async () => { + const cp = await import("node:child_process"); + vi.mocked(cp.execFileSync).mockClear(); + mod.installEmbeddings(); + // ensureSharedDeps ran its npm install (mocked) into the sandbox shared dir. + expect(vi.mocked(cp.execFileSync).mock.calls.some((c) => c[0] === "npm")).toBe(true); + // A shared package.json was laid down under the tmp HOME. + expect(existsSync(join(HOME_DIR, ".hivemind", "embed-deps", "package.json"))).toBe(true); + // Config flag flipped on regardless of whether any agent was detected. + expect(cfg.getEmbeddingsEnabled()).toBe(true); + }); + + it("shared deps already present → skips the npm install (isSharedDepsInstalled true branch)", async () => { + // Pre-create /node_modules/@huggingface/transformers so + // ensureSharedDeps takes its "already present" skip path. + mkdirSync(join(mod.SHARED_NODE_MODULES, mod.TRANSFORMERS_PKG), { recursive: true }); + const cp = await import("node:child_process"); + vi.mocked(cp.execFileSync).mockClear(); + mod.installEmbeddings(); + // No transformers `npm install` — the only npm call (if any) is the graph + // parsers, never the transformers one. + const transformersInstall = vi.mocked(cp.execFileSync).mock.calls.some( + (c) => c[0] === "npm" && Array.isArray(c[1]) && c[1].includes(mod.TRANSFORMERS_PKG), + ); + expect(transformersInstall).toBe(false); + expect(cfg.getEmbeddingsEnabled()).toBe(true); + }); + + it("with a detected agent install → symlinks its node_modules to the shared deps", async () => { + // Lay down a codex plugin bundle so findHivemindInstalls() sees it. + mkdirSync(join(HOME_DIR, ".codex", "hivemind", "bundle"), { recursive: true }); + mod.installEmbeddings(); + const link = join(HOME_DIR, ".codex", "hivemind", "node_modules"); + // npm is mocked so the shared node_modules target is never created; assert + // the symlink itself was laid down and points at the canonical shared dir + // (existsSync would follow the link to the absent target and report false). + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(readlinkSync(link)).toBe(mod.SHARED_NODE_MODULES); + expect(cfg.getEmbeddingsEnabled()).toBe(true); + }); +}); + +describe("uninstallEmbeddings --prune (sandboxed HOME)", () => { + beforeEach(() => { freshHome(); }); + afterEach(() => { cfg._resetUserConfigForTesting(); }); + + it("prune removes the shared-deps dir and flips the flag off", () => { + mkdirSync(mod.SHARED_DIR, { recursive: true }); + expect(existsSync(mod.SHARED_DIR)).toBe(true); + mod.uninstallEmbeddings({ prune: true }); + expect(existsSync(mod.SHARED_DIR)).toBe(false); + expect(cfg.getEmbeddingsEnabled()).toBe(false); + }); + + it("without prune, leaves the shared-deps dir in place but still disables", () => { + mkdirSync(mod.SHARED_DIR, { recursive: true }); + mod.uninstallEmbeddings(); + expect(existsSync(mod.SHARED_DIR)).toBe(true); + expect(cfg.getEmbeddingsEnabled()).toBe(false); + }); +}); + +describe("statusEmbeddings (sandboxed HOME)", () => { + beforeEach(() => { freshHome(); }); + afterEach(() => { cfg._resetUserConfigForTesting(); vi.restoreAllMocks(); }); + + it("prints config + shared-deps + per-agent link state without throwing", () => { + mkdirSync(join(HOME_DIR, ".codex", "hivemind", "bundle"), { recursive: true }); + // log()/warn() write to process.stdout/stderr (not console.*), so capture + // the raw streams. + const lines: string[] = []; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((c: unknown) => { lines.push(String(c)); return true; }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((c: unknown) => { lines.push(String(c)); return true; }); + expect(() => mod.statusEmbeddings()).not.toThrow(); + outSpy.mockRestore(); + errSpy.mockRestore(); + const out = lines.join("\n"); + // Shared deps absent + codex has no node_modules symlink → specific lines. + expect(out).toContain(`Shared deps: ${mod.SHARED_DIR}`); + expect(out).toContain("Installed: no"); + expect(out).toMatch(/codex\s+✗ not linked/); + }); + + it("reports a linked agent + present shared deps (linked/present status arms)", () => { + // Shared deps present + codex bundle symlinked into them → exercises the + // isSharedDepsInstalled=true and linked-to-shared status branches. + mkdirSync(join(HOME_DIR, ".hivemind", "embed-deps", "node_modules", "@huggingface", "transformers"), { recursive: true }); + mkdirSync(join(HOME_DIR, ".codex", "hivemind", "bundle"), { recursive: true }); + mod._linkAgentForTesting({ id: "codex", pluginDir: join(HOME_DIR, ".codex", "hivemind") }); + const lines: string[] = []; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((c: unknown) => { lines.push(String(c)); return true; }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((c: unknown) => { lines.push(String(c)); return true; }); + expect(() => mod.statusEmbeddings()).not.toThrow(); + outSpy.mockRestore(); + errSpy.mockRestore(); + const out = lines.join("\n"); + // Shared deps present + codex symlinked → the "installed" + linked arms. + expect(out).toContain("Installed: yes"); + expect(out).toMatch(/codex\s+✓ linked → shared/); + }); +}); diff --git a/tests/shared/graph-deps.test.ts b/tests/shared/graph-deps.test.ts new file mode 100644 index 00000000..cf42f3c6 --- /dev/null +++ b/tests/shared/graph-deps.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock ONLY the process boundary (execFileSync) — the network/exec seam the +// repo's testing philosophy says to mock — so the default runNpm/runHeal +// wrappers can be exercised without spawning real npm or the heal compile. +// spawnSync (used by transitively-imported embeddings.ts) stays real. +vi.mock("node:child_process", async (orig) => ({ + ...(await orig()), + execFileSync: vi.fn(), +})); + +import { execFileSync } from "node:child_process"; +import { treeSitterSpecs, isGraphDepsInstalled, ensureGraphDeps } from "../../src/cli/graph-deps.js"; + +/** + * The code-graph auto-build hook resolves tree-sitter from the shared + * embed-deps dir. `ensureGraphDeps` provisions it there; these tests cover its + * two pure helpers — the version derivation (which must track package.json so + * embed-deps never drifts from what the bundles were built against) and the + * presence check. The full install flow is covered by the e2e in the PR. + */ +describe("graph-deps helpers", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "graph-deps-")); + }); + afterEach(() => { + try { rmSync(dir, { recursive: true, force: true }); } catch {} + }); + + describe("treeSitterSpecs", () => { + it("returns only tree-sitter* optionalDependencies as name@range, sorted, skipping non-parsers", () => { + const pkg = join(dir, "package.json"); + writeFileSync(pkg, JSON.stringify({ + optionalDependencies: { + // deliberately unsorted + mixed with a non-parser native dep + "tree-sitter-typescript": "^0.23.2", + "@huggingface/transformers": "^3.0.0", + "tree-sitter": "^0.21.1", + "tree-sitter-python": "0.23.4", + }, + })); + expect(treeSitterSpecs(pkg)).toEqual([ + "tree-sitter@^0.21.1", + "tree-sitter-python@0.23.4", + "tree-sitter-typescript@^0.23.2", + ]); + // The 600MB transformers dep must NOT be dragged into the graph install. + expect(treeSitterSpecs(pkg).join(" ")).not.toContain("transformers"); + }); + + it("returns [] when the file is missing or has no optionalDependencies", () => { + expect(treeSitterSpecs(join(dir, "does-not-exist.json"))).toEqual([]); + const empty = join(dir, "empty.json"); + writeFileSync(empty, JSON.stringify({ dependencies: { foo: "1.0.0" } })); + expect(treeSitterSpecs(empty)).toEqual([]); + }); + + it("does not match a package merely containing 'tree-sitter' mid-name", () => { + const pkg = join(dir, "package.json"); + writeFileSync(pkg, JSON.stringify({ + optionalDependencies: { "not-tree-sitter-thing": "1.0.0", "tree-sitter": "^0.21.1" }, + })); + // Only the real prefix/exact match — `tree-sitter-` or `tree-sitter`. + expect(treeSitterSpecs(pkg)).toEqual(["tree-sitter@^0.21.1"]); + }); + }); + + describe("isGraphDepsInstalled", () => { + const specs = ["tree-sitter@^0.21.1", "tree-sitter-python@0.23.4"]; + + it("is true only when EVERY requested parser dir is present (partial install → false)", () => { + const nm = join(dir, "node_modules"); + mkdirSync(nm, { recursive: true }); + expect(isGraphDepsInstalled(nm, specs)).toBe(false); + // Partial install: one of the two parsers present → still not "installed". + mkdirSync(join(nm, "tree-sitter"), { recursive: true }); + expect(isGraphDepsInstalled(nm, specs)).toBe(false); + mkdirSync(join(nm, "tree-sitter-python"), { recursive: true }); + expect(isGraphDepsInstalled(nm, specs)).toBe(true); + }); + + it("is false when the spec set is empty (nothing to require)", () => { + const nm = join(dir, "node_modules"); + mkdirSync(nm, { recursive: true }); + expect(isGraphDepsInstalled(nm, [])).toBe(false); + }); + }); + + describe("ensureGraphDeps", () => { + const SPECS = ["tree-sitter@^0.21.1", "tree-sitter-python@0.23.4"]; + let nm: string; + // A fake npm boundary that "installs" by creating the parser dirs, so the + // presence check flips to true afterward (mirrors a successful install). + const fakeInstall = (specs: string[]) => { + for (const s of specs) mkdirSync(join(nm, s.slice(0, s.lastIndexOf("@"))), { recursive: true }); + }; + + beforeEach(() => { nm = join(dir, "node_modules"); }); + + function baseDeps(over: Record = {}) { + return { + sharedDir: dir, + sharedNodeModules: nm, + specs: SPECS, + logFn: () => {}, + warnFn: () => {}, + ...over, + }; + } + + it("empty specs → warns and skips (no npm, no heal)", () => { + const runNpm = vi.fn(); + const runHeal = vi.fn(); + const warnFn = vi.fn(); + ensureGraphDeps(baseDeps({ specs: [], runNpm, runHeal, warnFn })); + expect(runNpm).not.toHaveBeenCalled(); + expect(runHeal).not.toHaveBeenCalled(); + expect(warnFn).toHaveBeenCalledWith(expect.stringContaining("no tree-sitter optionalDependencies")); + }); + + it("fresh → creates package.json, installs, writes the marker, then heals", () => { + const runNpm = vi.fn((s: string[]) => fakeInstall(s)); + const runHeal = vi.fn(); + ensureGraphDeps(baseDeps({ runNpm, runHeal })); + expect(runNpm).toHaveBeenCalledTimes(1); + expect(runNpm).toHaveBeenCalledWith(SPECS, dir); + expect(existsSync(join(dir, "package.json"))).toBe(true); + expect(readFileSync(join(dir, ".graph-deps"), "utf8")).toBe(SPECS.join("\n")); + // Heal ALWAYS runs, even right after a fresh install. + expect(runHeal).toHaveBeenCalledTimes(1); + }); + + it("already present + marker matches → skips the install but STILL heals", () => { + fakeInstall(SPECS); + writeFileSync(join(dir, ".graph-deps"), SPECS.join("\n")); + const runNpm = vi.fn(); + const runHeal = vi.fn(); + ensureGraphDeps(baseDeps({ runNpm, runHeal })); + expect(runNpm).not.toHaveBeenCalled(); + expect(runHeal).toHaveBeenCalledTimes(1); + }); + + it("stale marker (spec set changed) → reinstalls even if the dirs exist", () => { + fakeInstall(SPECS); + writeFileSync(join(dir, ".graph-deps"), "tree-sitter@0.0.1"); // old key + const runNpm = vi.fn((s: string[]) => fakeInstall(s)); + ensureGraphDeps(baseDeps({ runNpm, runHeal: () => {} })); + expect(runNpm).toHaveBeenCalledTimes(1); + expect(readFileSync(join(dir, ".graph-deps"), "utf8")).toBe(SPECS.join("\n")); + }); + + it("partial install (marker matches but a parser dir is missing) → reinstalls", () => { + mkdirSync(join(nm, "tree-sitter"), { recursive: true }); // only one of two + writeFileSync(join(dir, ".graph-deps"), SPECS.join("\n")); + const runNpm = vi.fn(); + ensureGraphDeps(baseDeps({ runNpm, runHeal: () => {} })); + expect(runNpm).toHaveBeenCalledTimes(1); + }); + + it("npm failure is swallowed (best-effort) — never throws, no marker written", () => { + const runNpm = vi.fn(() => { throw new Error("npm exploded"); }); + const warnFn = vi.fn(); + expect(() => ensureGraphDeps(baseDeps({ runNpm, runHeal: () => {}, warnFn }))).not.toThrow(); + expect(existsSync(join(dir, ".graph-deps"))).toBe(false); + expect(warnFn).toHaveBeenCalledWith(expect.stringContaining("tree-sitter provisioning failed")); + }); + + it("heal failure is swallowed too — never throws", () => { + const runHeal = vi.fn(() => { throw new Error("heal exploded"); }); + const warnFn = vi.fn(); + expect(() => ensureGraphDeps(baseDeps({ runNpm: (s: string[]) => fakeInstall(s), runHeal, warnFn }))).not.toThrow(); + expect(warnFn).toHaveBeenCalledWith(expect.stringContaining("tree-sitter provisioning failed")); + }); + + it("default boundaries: runNpm/runHeal go through execFileSync (npm install + heal)", () => { + vi.mocked(execFileSync).mockClear(); + // No runNpm/runHeal injected → the real default wrappers run, but + // execFileSync is mocked so nothing spawns. + ensureGraphDeps({ sharedDir: dir, sharedNodeModules: nm, specs: SPECS, logFn: () => {}, warnFn: () => {} }); + const calls = vi.mocked(execFileSync).mock.calls; + // defaultRunNpm issued `npm install ...`. + expect(calls.some((c) => c[0] === "npm" && Array.isArray(c[1]) && c[1][0] === "install")).toBe(true); + // defaultRunHeal spawned the ensure-tree-sitter heal via node (the repo + // ships scripts/ensure-tree-sitter.mjs, so the existsSync gate passes). + expect(calls.some((c) => c[0] === process.execPath + && Array.isArray(c[1]) && String(c[1][0]).endsWith("ensure-tree-sitter.mjs"))).toBe(true); + }); + + it("does not clobber an existing package.json", () => { + // Exercises the `if (!existsSync(pkgPath))` false arm: a prior embeddings + // install may already have written transformers there. + const pkgPath = join(dir, "package.json"); + writeFileSync(pkgPath, JSON.stringify({ dependencies: { "@huggingface/transformers": "^3.0.0" } })); + fakeInstall(SPECS); + writeFileSync(join(dir, ".graph-deps"), SPECS.join("\n")); + ensureGraphDeps(baseDeps({ runNpm: vi.fn(), runHeal: () => {} })); + expect(JSON.parse(readFileSync(pkgPath, "utf8")).dependencies["@huggingface/transformers"]).toBe("^3.0.0"); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3f851e72..82000c60 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -153,6 +153,17 @@ export default defineConfig({ functions: 90, lines: 90, }, + // PR #323 — feat: provision tree-sitter parsers into the shared + // embed-deps dir so the graph-on-stop auto-build hook resolves them. + // Pure logic + an injectable npm/heal boundary — fully unit-tested. + // Branches held at 85: a few defensive arms (malformed package.json, + // non-Error throw coerced via String(err)) aren't worth a fixture each. + "src/cli/graph-deps.ts": { + statements: 90, + branches: 85, + functions: 90, + lines: 90, + }, // embedding_generation — nomic daemon + IPC client + SQL helper. // Lines/statements held at 90; branches + functions are allowed to // dip on the daemon because a few paths (SIGINT/SIGTERM handlers,