From 52ec8ebd3d29e3d4f6656144d4432f6027d7d770 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Sat, 18 Jul 2026 07:45:36 +0000 Subject: [PATCH 1/5] feat(graph): provision tree-sitter into embed-deps so the auto-build hook works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code-graph auto-build hook (graph-on-stop, on codex/cursor/hermes) resolves the native tree-sitter module at runtime from the shared ~/.hivemind/embed-deps/node_modules that each agent's plugin symlinks to. But `ensureSharedDeps` only installs @huggingface/transformers there, so on a real install tree-sitter is absent and the graph never auto-builds — the hook just degrades to a graceful skip (the CLI `hivemind graph build` works because it resolves tree-sitter from its own package). This provisions the parsers where the hook actually 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. - src/cli/embeddings.ts: new `ensureGraphDeps()` — idempotent (specs-hash marker + per-package presence check catch partial installs and version bumps) and best-effort (any failure is logged and swallowed, never aborts the caller). Two-phase native provisioning: `npm install --ignore-scripts` so a platform without a prebuild (arm64 / Node 24) doesn't fail the download, then scripts/ensure-tree-sitter.mjs ALWAYS runs to validate bindings + compile from source where needed (fast no-op on healthy prebuilds; also repairs an interrupted / ABI-mismatched install). Versions are read from the package's own optionalDependencies (`treeSitterSpecs`) so embed-deps never drifts from what the bundles were built against. Decoupled from the ~600 MB embeddings download — installs only the parsers (tens of MB). - src/cli/embeddings.ts: `ensureSharedDeps` now MERGES the shared package.json instead of overwriting it, so a prior graph-deps declaration survives a first-time embeddings install. - Wired into `installEmbeddings()` and `hivemind graph init` (best-effort). - tests: treeSitterSpecs (version derivation, no drift, excludes transformers) + isGraphDepsInstalled (all-parsers-present / partial-install). Verified e2e: `graph init` provisions tree-sitter into a sandbox embed-deps (12 pkgs), a second run skips the reinstall (idempotent), and the codex graph-on-stop hook then builds the graph (Nodes: 5, Edges: 2). Full suite 5546 passed. Codex review: both prior HIGH findings resolved. Known limitation (pre-existing, unchanged): npm is invoked as bare `npm`, which does not resolve on Windows — same as the existing ensureSharedDeps invocation; a proper cross-platform npm exec is out of scope here. --- src/cli/embeddings.ts | 121 +++++++++++++++++++++++++++++++- src/cli/index.ts | 9 +++ tests/shared/graph-deps.test.ts | 83 ++++++++++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 tests/shared/graph-deps.test.ts diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index e1e78dd75..327a645d7 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -1,4 +1,4 @@ -import { copyFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, unlinkSync } from "node:fs"; +import { copyFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { execFileSync, spawnSync } from "node:child_process"; import { userInfo } from "node:os"; import { join } from "node:path"; @@ -68,6 +68,111 @@ export function isSharedDepsInstalled(sharedNodeModules: string = SHARED_NODE_MO return existsSync(join(sharedNodeModules, TRANSFORMERS_PKG)); } +/** + * 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 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. Exported for tests. + */ +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]}`); +} + +/** + * 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(): void { + try { + const specs = treeSitterSpecs(); + if (specs.length === 0) { + warn(` Graph no tree-sitter optionalDependencies found in package.json — skipping`); + return; + } + ensureDir(SHARED_DIR); + // Create a package.json if none exists yet (user never ran embeddings + // install). Don't clobber an existing one — it may already declare + // transformers; `npm install ` merges the parsers alongside. + const pkgPath = join(SHARED_DIR, "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(SHARED_DIR, ".graph-deps"); + const wantKey = specs.join("\n"); + const haveKey = existsSync(marker) ? readFileSync(marker, "utf8") : ""; + if (haveKey !== wantKey || !isGraphDepsInstalled(SHARED_NODE_MODULES, specs)) { + log(` Graph installing tree-sitter parsers into ${SHARED_DIR} (code-graph; ~tens of MB)`); + // --ignore-scripts: fetch the packages without the native build that + // fails on platforms lacking a prebuild; the heal below does the build. + execFileSync("npm", ["install", ...specs, "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund", "--ignore-scripts"], { + cwd: SHARED_DIR, + stdio: "inherit", + }); + writeFileSync(marker, wantKey); + } else { + log(` Graph tree-sitter parsers already present at ${SHARED_DIR}`); + } + // 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. + const heal = join(pkgRoot(), "scripts", "ensure-tree-sitter.mjs"); + if (existsSync(heal)) { + execFileSync(process.execPath, [heal], { cwd: SHARED_DIR, stdio: "inherit" }); + } + } catch (err) { + warn(` Graph tree-sitter provisioning failed (${err instanceof Error ? err.message : String(err)}); code graph stays disabled — everything else works`); + } +} + function isSymlinkToSharedDeps(linkPath: string, sharedNodeModules: string): boolean { if (!existsSync(linkPath)) return false; try { @@ -107,11 +212,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 +281,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/index.ts b/src/cli/index.ts index a4b42cf5f..e01e7e877 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { installPi, uninstallPi } from "./install-pi.js"; import { disableEmbeddings, enableEmbeddings, + ensureGraphDeps, installEmbeddings, statusEmbeddings, uninstallEmbeddings, @@ -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/shared/graph-deps.test.ts b/tests/shared/graph-deps.test.ts new file mode 100644 index 000000000..1021c73f6 --- /dev/null +++ b/tests/shared/graph-deps.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { treeSitterSpecs, isGraphDepsInstalled } from "../../src/cli/embeddings.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); + }); + }); +}); From 3711727db5292e9a4beccfd5cfca556f5bebe576 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Mon, 20 Jul 2026 23:39:32 +0000 Subject: [PATCH 2/5] docs(embeddings): explain why graph parsers are saved (not --no-save) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respond to CodeRabbit's Major on the shared-deps install: document that the tree-sitter parsers are intentionally recorded in the shared package.json. 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 are neither pruned nor (once present and satisfied) rebuilt by that reconcile — so ensureSharedDeps stays free of --ignore-scripts, which would break onnxruntime-node / sharp (both run real native install steps). --- src/cli/embeddings.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index 327a645d7..030b108d2 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -152,7 +152,14 @@ export function ensureGraphDeps(): void { if (haveKey !== wantKey || !isGraphDepsInstalled(SHARED_NODE_MODULES, specs)) { log(` Graph installing tree-sitter parsers into ${SHARED_DIR} (code-graph; ~tens of MB)`); // --ignore-scripts: fetch the packages without the native build that - // fails on platforms lacking a prebuild; the heal below does the build. + // fails on platforms lacking a prebuild; the heal below 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). execFileSync("npm", ["install", ...specs, "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund", "--ignore-scripts"], { cwd: SHARED_DIR, stdio: "inherit", From 1a3677086429d78457de3edaba566e4f54763369 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Tue, 21 Jul 2026 00:40:51 +0000 Subject: [PATCH 3/5] test(graph-deps): extract to its own file + unit-test above the coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph-deps provisioning logic lived in src/cli/embeddings.ts (~50% covered, ungated), so its new code wasn't held to the repo's per-file bar. Extract it to a dedicated, fully unit-tested module and gate it. - src/cli/graph-deps.ts (new): treeSitterSpecs, isGraphDepsInstalled, and ensureGraphDeps, moved verbatim. ensureGraphDeps now takes an injectable deps object (sharedDir/sharedNodeModules/specs/runNpm/runHeal/logFn/warnFn) whose defaults reproduce the prior inline behavior exactly (same `npm install --ignore-scripts`, same ensure-tree-sitter.mjs heal spawn), so the branch logic can be unit-tested without spawning npm or compiling. - embeddings.ts / index.ts import ensureGraphDeps from the new module. - tests/shared/graph-deps.test.ts: cover every ensureGraphDeps branch — empty specs, fresh install + marker + heal, already-present skip, stale-marker and partial-install reinstall, existing-package.json preservation, npm/heal failure swallowed, and a default-boundaries test that mocks node:child_process.execFileSync to exercise the real npm + heal wrappers. - vitest.config.ts: gate src/cli/graph-deps.ts at 90/85/90/90. Coverage: graph-deps.ts 100% stmts / 93.5% branch / 100% funcs / 100% lines. Full suite + coverage exit 0. Codex review: refactor sound, no blocker. --- src/cli/embeddings.ts | 115 +----------------------- src/cli/graph-deps.ts | 152 ++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 +- tests/shared/graph-deps.test.ts | 128 ++++++++++++++++++++++++++- vitest.config.ts | 11 +++ 5 files changed, 291 insertions(+), 117 deletions(-) create mode 100644 src/cli/graph-deps.ts diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index 030b108d2..6dcfc16be 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -1,10 +1,11 @@ -import { copyFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, chmodSync, existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, unlinkSync } from "node:fs"; import { execFileSync, spawnSync } from "node:child_process"; import { userInfo } from "node:os"; 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. @@ -68,118 +69,6 @@ export function isSharedDepsInstalled(sharedNodeModules: string = SHARED_NODE_MO return existsSync(join(sharedNodeModules, TRANSFORMERS_PKG)); } -/** - * 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 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. Exported for tests. - */ -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]}`); -} - -/** - * 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(): void { - try { - const specs = treeSitterSpecs(); - if (specs.length === 0) { - warn(` Graph no tree-sitter optionalDependencies found in package.json — skipping`); - return; - } - ensureDir(SHARED_DIR); - // Create a package.json if none exists yet (user never ran embeddings - // install). Don't clobber an existing one — it may already declare - // transformers; `npm install ` merges the parsers alongside. - const pkgPath = join(SHARED_DIR, "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(SHARED_DIR, ".graph-deps"); - const wantKey = specs.join("\n"); - const haveKey = existsSync(marker) ? readFileSync(marker, "utf8") : ""; - if (haveKey !== wantKey || !isGraphDepsInstalled(SHARED_NODE_MODULES, specs)) { - log(` Graph installing tree-sitter parsers into ${SHARED_DIR} (code-graph; ~tens of MB)`); - // --ignore-scripts: fetch the packages without the native build that - // fails on platforms lacking a prebuild; the heal below 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). - execFileSync("npm", ["install", ...specs, "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund", "--ignore-scripts"], { - cwd: SHARED_DIR, - stdio: "inherit", - }); - writeFileSync(marker, wantKey); - } else { - log(` Graph tree-sitter parsers already present at ${SHARED_DIR}`); - } - // 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. - const heal = join(pkgRoot(), "scripts", "ensure-tree-sitter.mjs"); - if (existsSync(heal)) { - execFileSync(process.execPath, [heal], { cwd: SHARED_DIR, stdio: "inherit" }); - } - } catch (err) { - warn(` Graph tree-sitter provisioning failed (${err instanceof Error ? err.message : String(err)}); code graph stays disabled — everything else works`); - } -} - function isSymlinkToSharedDeps(linkPath: string, sharedNodeModules: string): boolean { if (!existsSync(linkPath)) return false; try { diff --git a/src/cli/graph-deps.ts b/src/cli/graph-deps.ts new file mode 100644 index 000000000..c5d065ca7 --- /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 e01e7e877..21161096d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,11 +8,11 @@ import { installPi, uninstallPi } from "./install-pi.js"; import { disableEmbeddings, enableEmbeddings, - ensureGraphDeps, installEmbeddings, 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 diff --git a/tests/shared/graph-deps.test.ts b/tests/shared/graph-deps.test.ts index 1021c73f6..cf42f3c60 100644 --- a/tests/shared/graph-deps.test.ts +++ b/tests/shared/graph-deps.test.ts @@ -1,9 +1,19 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +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"; -import { treeSitterSpecs, isGraphDepsInstalled } from "../../src/cli/embeddings.js"; +// 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 @@ -80,4 +90,116 @@ describe("graph-deps helpers", () => { 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 3f851e729..82000c60b 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, From 3dca0617d493326a4ae90cafe9105a7fd90c5d72 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Tue, 21 Jul 2026 01:25:28 +0000 Subject: [PATCH 4/5] test(embeddings): cover install/status/uninstall orchestration (52% -> 87%) installEmbeddings, ensureSharedDeps, statusEmbeddings, and uninstall --prune used module-level HOME/SHARED_DIR with no injectable seam, so they were unreachable from the existing per-function tests. Add a sandboxed suite: sandbox the home dir via tests/shared/fake-home (POSIX + Windows) BEFORE importing embeddings.ts, and mock only node:child_process.execFileSync so the npm installs never spawn. Restores env + removes the tmp home in afterAll. Covers: fresh install (provisions shared deps + flips the config flag), already-present skip path, agent symlink wiring, prune vs non-prune uninstall, and statusEmbeddings output. embeddings.ts: 52% -> 87% lines. Codex review: sound (env-restore + Windows-home fixes applied). --- tests/cli/cli-embeddings-install.test.ts | 153 +++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/cli/cli-embeddings-install.test.ts diff --git a/tests/cli/cli-embeddings-install.test.ts b/tests/cli/cli-embeddings-install.test.ts new file mode 100644 index 000000000..16221411c --- /dev/null +++ b/tests/cli/cli-embeddings-install.test.ts @@ -0,0 +1,153 @@ +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"); + // Surfaces the shared-deps location and the detected codex agent. + expect(out).toMatch(/embed-deps|shared/i); + expect(out.toLowerCase()).toContain("codex"); + }); + + 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(); + expect(lines.join("\n").toLowerCase()).toContain("codex"); + }); +}); From 02e1cadc9231f0c5e1c74508a29588fb0e632dc1 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Tue, 21 Jul 2026 01:35:43 +0000 Subject: [PATCH 5/5] test(embeddings): assert specific status lines, not loose substrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit (tests/** path instruction): the statusEmbeddings tests matched the loose substring "codex", which could pass on an incidental match. Assert the exact rendered lines instead — `Installed: no/yes` and the per-agent `codex ✗ not linked` / `codex ✓ linked → shared` label. --- tests/cli/cli-embeddings-install.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/cli/cli-embeddings-install.test.ts b/tests/cli/cli-embeddings-install.test.ts index 16221411c..b608a7437 100644 --- a/tests/cli/cli-embeddings-install.test.ts +++ b/tests/cli/cli-embeddings-install.test.ts @@ -131,9 +131,10 @@ describe("statusEmbeddings (sandboxed HOME)", () => { outSpy.mockRestore(); errSpy.mockRestore(); const out = lines.join("\n"); - // Surfaces the shared-deps location and the detected codex agent. - expect(out).toMatch(/embed-deps|shared/i); - expect(out.toLowerCase()).toContain("codex"); + // 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)", () => { @@ -148,6 +149,9 @@ describe("statusEmbeddings (sandboxed HOME)", () => { expect(() => mod.statusEmbeddings()).not.toThrow(); outSpy.mockRestore(); errSpy.mockRestore(); - expect(lines.join("\n").toLowerCase()).toContain("codex"); + 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/); }); });