From f5c8c3471133b010ebb370f9c5c8f6b414eaadd3 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon <9553966+theagenticguy@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:35:12 -0500 Subject: [PATCH 1/2] fix(storage): drop FTS/vector index before truncate to avoid lbug SIGBUS @ladybugdb/core 0.16.1 crashes with SIGBUS when `MATCH (n:CodeNode) DELETE n` runs while the och_fts full-text index is live on CodeNode. The crash is an un-catchable native signal, so the bulk-load retry wrapper cannot recover it, and it only manifests at scale on an mmap'd on-disk graph. truncateAll() now drops och_fts (CodeNode) and och_vec (Embedding) before any delete via a new dropSearchIndexes() helper, swallowing the catchable no-such-index / extension-not-loaded errors, and resets the *IndexBuilt flags so the post-insert ensureFtsIndex/ensureVectorIndex rebuild them. Verified 6/6 A/B against a real ~11k-node graph: crash every run without the drop, clean every run with it. Adds a regression test asserting the FTS index is dropped and rebuilt across a replace-truncate. --- packages/storage/src/graphdb-adapter.test.ts | 66 ++++++++++++++++++++ packages/storage/src/graphdb-adapter.ts | 58 +++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/packages/storage/src/graphdb-adapter.test.ts b/packages/storage/src/graphdb-adapter.test.ts index bc9f226..2bb1cba 100644 --- a/packages/storage/src/graphdb-adapter.test.ts +++ b/packages/storage/src/graphdb-adapter.test.ts @@ -299,6 +299,72 @@ test("bulkLoad replace mode truncates prior rows on second call", async () => { } }); +// Regression: lbug 0.16.1 crashes the process with SIGBUS/SIGSEGV when +// `MATCH (n:CodeNode) DELETE n` runs while the `och_fts` full-text index is +// live on CodeNode (confirmed by a 6/6 controlled A/B repro against a real +// ~11k-node on-disk graph: fix off → crash every run, fix on → clean every +// run). `truncateAll` now drops the search indexes before deleting and the +// post-insert `ensureFtsIndex` rebuilds them. +// +// The native crash only manifests at scale on an on-disk index whose pages +// are mmap'd (a synthetic two-node store keeps the index small enough that +// the fault doesn't fire), so this unit test asserts the *observable +// contract* the fix must uphold rather than relying on a flaky native crash: +// after a replace-truncate, the prior row is gone from the FTS index and the +// index resolves the freshly-inserted row. If `truncateAll` deleted nodes +// without dropping+rebuilding the index, the stale term would still resolve +// (or `bulkLoad` would crash). Both assertions fail loudly under a +// regression; neither depends on the host's memory pressure. +test("bulkLoad replace mode drops and rebuilds the FTS index across a truncate", async () => { + if (!(await hasNativeBinding())) { + assert.ok(true, "native binding unavailable — skipping integration test"); + return; + } + const store = new GraphDbStore(await scratchDbPath()); + await store.open(); + try { + await store.createSchema(); + + // First load + a search to force the `och_fts` index to be built. + const g1 = new KnowledgeGraph(); + const first = makeNodeId("Function", "src/first.ts", "parseUserProfile"); + g1.addNode({ + id: first, + kind: "Function", + name: "parseUserProfile", + filePath: "src/first.ts", + signature: "function parseUserProfile()", + }); + await store.bulkLoad(g1); + const before = await store.search({ text: "parseUserProfile", limit: 5 }); + assert.ok(before.length >= 1, "FTS index should resolve the first load"); + + // Replace-truncate with the FTS index live. Pre-fix on a large on-disk + // graph this SIGBUSes; here it must drop the index, truncate, reinsert, + // and rebuild the index against the new row. + const g2 = new KnowledgeGraph(); + const second = makeNodeId("Function", "src/second.ts", "renderMarkdownView"); + g2.addNode({ + id: second, + kind: "Function", + name: "renderMarkdownView", + filePath: "src/second.ts", + signature: "function renderMarkdownView()", + }); + await store.bulkLoad(g2, { mode: "replace" }); + + // The old row is gone and the index was rebuilt against the new row: + // searching the stale term returns nothing, the fresh term resolves. + const stale = await store.search({ text: "parseUserProfile", limit: 5 }); + assert.equal(stale.length, 0, "truncated row must not survive in the FTS index"); + const fresh = await store.search({ text: "renderMarkdownView", limit: 5 }); + assert.ok(fresh.length >= 1, "FTS index must be rebuilt after the replace-truncate"); + assert.equal(fresh[0]?.nodeId, second); + } finally { + await store.close(); + } +}); + test("bulkLoad upsert mode preserves rows not present in the incoming graph", async () => { if (!(await hasNativeBinding())) { assert.ok(true, "native binding unavailable — skipping integration test"); diff --git a/packages/storage/src/graphdb-adapter.ts b/packages/storage/src/graphdb-adapter.ts index 422a380..b32d2cf 100644 --- a/packages/storage/src/graphdb-adapter.ts +++ b/packages/storage/src/graphdb-adapter.ts @@ -806,6 +806,18 @@ export class GraphDbStore implements IGraphStore { private async truncateAll(): Promise { const pool = this.requirePool(); + // Drop the search-side indexes BEFORE any node delete. lbug 0.16.1 + // hard-crashes with SIGBUS (bus error — an un-catchable native signal, + // not a JS exception the retry wrapper could survive) when a + // `MATCH (n:CodeNode) DELETE n` runs while the `och_fts` full-text index + // is live on that table. The index is rebuilt by the trailing + // `ensureFtsIndex()` / `ensureVectorIndex()` in `#bulkLoadOnce` after the + // fresh rows are inserted, so dropping it here is lossless. `och_vec` on + // `Embedding` gets the same treatment for symmetry (the vector index is + // built on the write path too); the failure mode is structural — any live + // index over rows being deleted is unsafe — so we never delete indexed + // rows out from under an index again. + await this.dropSearchIndexes(); // Delete edges first so node deletes stay side-effect free. The graph-db // engine rejects deletes of a node that still has dangling rels. for (const kind of getAllRelationTypes()) { @@ -816,6 +828,52 @@ export class GraphDbStore implements IGraphStore { await pool.query("MATCH (n:CodeNode) DELETE n"); } + /** + * Drop the FTS (`och_fts` on `CodeNode`) and VECTOR (`och_vec` on + * `Embedding`) indexes ahead of a truncate. A `DROP_*_INDEX` for an index + * that does not exist throws a catchable "doesn't have an index" Binder + * exception (NOT a SIGBUS) — we swallow exactly that so the drop is + * idempotent across fresh stores, embeddings-disabled runs, and repeated + * bulk-loads. Any other error (missing table, permission) surfaces. + * + * Resets `ftsIndexBuilt` / `vectorIndexBuilt` so the post-insert + * `ensureFtsIndex()` / `ensureVectorIndex()` calls actually rebuild the + * indexes against the freshly-loaded rows instead of short-circuiting on a + * now-stale "already built" flag. + */ + private async dropSearchIndexes(): Promise { + const pool = this.requirePool(); + // The FTS extension must be loaded before DROP_FTS_INDEX is bindable; + // `#bulkLoadOnce` already loads it up front, but load defensively here so + // `truncateAll` is correct if ever called on its own. A load failure + // (extension unavailable on this host) means there is no index to drop. + const dropIfPresent = async (stmt: string): Promise => { + try { + await pool.query(stmt); + } catch (err) { + const msg = (err as Error).message ?? ""; + // Both of these mean "there is no index to drop", which is the + // idempotent no-op case: + // - Binder: "Table X doesn't have an index with name Y" — the + // index was never created (fresh store, embeddings disabled, + // repeated truncate). + // - Catalog: "function DROP_VECTOR_INDEX is not defined ... VECTOR + // extension" — the extension isn't loaded on this connection, so + // no vector index can exist to drop. (`#bulkLoadOnce` loads FTS + // up front but not VECTOR; loading VECTOR solely to drop a + // usually-absent index isn't worth the cost.) + const noIndexToDrop = + /does(?:n't| not) have an index|no index|not exist/i.test(msg) || + (/is not defined/i.test(msg) && /extension/i.test(msg)); + if (!noIndexToDrop) throw err; + } + }; + await dropIfPresent("CALL DROP_FTS_INDEX('CodeNode', 'och_fts')"); + await dropIfPresent("CALL DROP_VECTOR_INDEX('Embedding', 'och_vec')"); + this.ftsIndexBuilt = false; + this.vectorIndexBuilt = false; + } + /** * Return the subset of `candidateIds` that already exist as CodeNodes in the * store. Used by upsert-mode bulkLoad to avoid synthesizing a placeholder From 93ce582570ca03b392428826265b6f67d8d8b96d Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon <9553966+theagenticguy@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:35:29 -0500 Subject: [PATCH 2/2] feat(cli): distribute scip-go/python/typescript out of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned downloader covered clang/ruby/dotnet/kotlin but not the npm/Go-distributed indexers, so scip-python/typescript/go had to be installed by hand (and `mise use scip-python` fails — it is an npm package, not a mise tool). This wires all three into codehub's own distribution. - scip-go: new SCIP_GO_PIN (v0.2.7, SHA256-verified release tarballs; darwin-x64 marked unavailable). The downloader gains node:zlib gunzip + a minimal tar reader to extract the scip-go binary from the .tar.gz; the pin still hashes the tarball bytes, and archive-tool idempotency uses an extracted-binary presence check. - scip-python (@sourcegraph/scip-python@0.6.6) and scip-typescript (@sourcegraph/scip-typescript@0.4.0): added as hard dependencies of @opencodehub/scip-ingest. withCodehubBinOnPath now resolves their bin shims via hostedScipBinDirs(), which walks up from both the resolved package and this module to cover npm-global and pnpm layouts, and prepends the .bin dirs to the spawn PATH so the runner finds them by bare name. - doctor: reports scip-typescript/python as bundled (resolved via the same hostedScipBinDirs the runner uses) and adds a setup --scip=go hint. Verified end-to-end: scip-go downloads + extracts + runs; scip-python/typescript spawn by bare name; a full analyze on a Python project runs scip-python and emits python.scip. Adds tarball-extraction, SHA-mismatch, idempotency, and PATH-resolution tests. --- packages/cli/package.json | 1 + packages/cli/src/commands/doctor.ts | 59 ++++- packages/cli/src/scip-downloader.test.ts | 209 ++++++++++++++++- packages/cli/src/scip-downloader.ts | 151 ++++++++++-- packages/cli/src/scip-pins.ts | 93 +++++++- packages/cli/tsconfig.json | 1 + packages/scip-ingest/package.json | 4 +- packages/scip-ingest/src/index.ts | 8 +- .../src/runners/codehub-bin-path.test.ts | 61 ++++- packages/scip-ingest/src/runners/index.ts | 148 +++++++++++- pnpm-lock.yaml | 218 ++++++++++++++++++ 11 files changed, 904 insertions(+), 49 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index b3e76e8..0bfa171 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "@opencodehub/policy": "workspace:*", "@opencodehub/sarif": "workspace:*", "@opencodehub/scanners": "workspace:*", + "@opencodehub/scip-ingest": "workspace:*", "@opencodehub/search": "workspace:*", "@opencodehub/storage": "workspace:*", "@opencodehub/wiki": "workspace:*", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 3ac6eb2..1a58779 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -19,6 +19,7 @@ import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { hostedScipBinDirs } from "@opencodehub/scip-ingest"; import Table from "cli-table3"; export type CheckStatus = "ok" | "warn" | "fail"; @@ -414,12 +415,24 @@ interface ScipIndexerSpec { readonly setupFlag?: string; /** True when the indexer is a JAR/asset under ~/.codehub, not a PATH binary. */ readonly jar?: boolean; + /** + * The npm package this indexer ships from when it is a HARD dependency of + * `@opencodehub/scip-ingest` (the pure-JS Sourcegraph indexers). When set, + * the check resolves the bundled package via `createRequire` rather than + * requiring it on PATH or under `~/.codehub/bin` — these always ship with + * the CLI, so a clean install must report `ok` out-of-the-box. + */ + readonly bundledPkg?: string; } const SCIP_INDEXERS: readonly ScipIndexerSpec[] = [ - { language: "typescript", binName: "scip-typescript" }, - { language: "python", binName: "scip-python" }, - { language: "go", binName: "scip-go" }, + { + language: "typescript", + binName: "scip-typescript", + bundledPkg: "@sourcegraph/scip-typescript", + }, + { language: "python", binName: "scip-python", bundledPkg: "@sourcegraph/scip-python" }, + { language: "go", binName: "scip-go", setupFlag: "go" }, { language: "rust", binName: "rust-analyzer" }, { language: "java", binName: "scip-java" }, { language: "ruby", binName: "scip-ruby", setupFlag: "ruby" }, @@ -436,12 +449,41 @@ function scipIndexerCheck( run: RunCommandFn, ): Check { const missingStatus: CheckStatus = strict ? "fail" : "warn"; - const installHint = spec.setupFlag - ? `install with \`codehub setup --scip=${spec.setupFlag}\`` - : `${spec.binName} is a system toolchain — install it via your package manager / language SDK`; + const installHint = spec.bundledPkg + ? `bundled with @opencodehub/cli (${spec.bundledPkg}); reinstall the CLI to restore it` + : spec.setupFlag + ? `install with \`codehub setup --scip=${spec.setupFlag}\`` + : `${spec.binName} is a system toolchain — install it via your package manager / language SDK`; return { name: `scip indexer: ${spec.language}`, async run() { + if (spec.bundledPkg !== undefined) { + // Hard dependency of @opencodehub/scip-ingest (ships with the CLI). + // Authoritative check: does the indexer's bin shim resolve into a + // directory that the analyze-time spawn PATH actually includes? That + // is exactly what `hostedScipBinDirs()` (the same resolver + // `withCodehubBinOnPath` uses) returns, so a match here guarantees the + // runner will find the indexer by bare name — even though the nested + // `node_modules/.bin` is NOT on the user's interactive PATH (so a bare + // ` --version` probe would false-FAIL). + const onHostedPath = hostedScipBinDirs().some((d: string) => + existsSyncSafe(join(d, spec.binName)), + ); + if (onHostedPath) { + return { status: "ok", message: `${spec.binName} bundled (${spec.bundledPkg})` }; + } + // Fall back to an explicit on-PATH install (e.g. a global + // `npm i -g @sourcegraph/scip-typescript`). + const res = await run(spec.binName, ["--version"]); + if (res.status === 0) { + return { status: "ok", message: `${spec.binName}: ${firstLine(res.stdout)}` }; + } + return { + status: missingStatus, + message: `${spec.binName} not resolvable (bundled dep ${spec.bundledPkg} missing)`, + hint: installHint, + }; + } if (spec.jar === true) { // JAR/asset indexers aren't `--version`-able binaries: check the // file exists under ~/.codehub (setup downloads them there). @@ -808,7 +850,10 @@ function resolveFromRoot(repoRoot: string, pkg: string): string | null { // and `@ladybugdb/core` both live in `packages/storage`. Probing that // package.json context lets `codehub doctor` resolve the bindings // even when neither the CLI nor the workspace root declare them as - // direct deps. + // direct deps. (The pure-JS `@sourcegraph/scip-*` indexers are NOT + // resolved here — `scipIndexerCheck` checks them via + // `hostedScipBinDirs()`, the same resolver the analyze-time spawn PATH + // uses, which is the layout-correct authority for "will analyze find it".) const owners = pkg.startsWith("@duckdb/") || pkg.startsWith("@ladybugdb/") ? ["packages/storage"] : []; for (const owner of owners) { diff --git a/packages/cli/src/scip-downloader.test.ts b/packages/cli/src/scip-downloader.test.ts index beb9278..3ff5752 100644 --- a/packages/cli/src/scip-downloader.test.ts +++ b/packages/cli/src/scip-downloader.test.ts @@ -25,6 +25,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { ReadableStream } from "node:stream/web"; import { describe, it } from "node:test"; +import { gzipSync } from "node:zlib"; import { DotnetSdkMissingError, @@ -42,6 +43,41 @@ function sha256(buf: Uint8Array): string { return createHash("sha256").update(buf).digest("hex"); } +/** + * Build a minimal, valid gzip tarball containing a single ustar regular-file + * entry `{name, body}` followed by the two zero-block EOF marker. Mirrors the + * shape of Sourcegraph's scip-go release tarballs (short root-level names, no + * PAX/GNU extensions) so the downloader's extraction path is exercised with a + * real gunzip + untar rather than a hand-faked buffer. Returns the gzipped + * bytes — exactly what the downloader fetches and SHA256-pins. + */ +function makeTarGz(name: string, body: Uint8Array): Uint8Array { + const BLOCK = 512; + const header = Buffer.alloc(BLOCK); + header.write(name, 0, "ascii"); // name @ 0 (max 100) + header.write("0000644", 100, "ascii"); // mode @ 100 + header.write("0000000", 108, "ascii"); // uid @ 108 + header.write("0000000", 116, "ascii"); // gid @ 116 + header.write(body.length.toString(8).padStart(11, "0"), 124, "ascii"); // size @ 124 (octal) + header.write("00000000000", 136, "ascii"); // mtime @ 136 + header[156] = 0x30; // typeflag '0' (regular file) + header.write("ustar\0", 257, "ascii"); // magic @ 257 + header.write("00", 263, "ascii"); // version @ 263 + // Checksum: spaces while summing, then octal + NUL + space @ 148. + header.fill(0x20, 148, 156); + let sum = 0; + for (const b of header) sum += b; + header.write(sum.toString(8).padStart(6, "0"), 148, "ascii"); + header[154] = 0; // NUL + header[155] = 0x20; // space + + const dataPadded = Buffer.alloc(Math.ceil(body.length / BLOCK) * BLOCK); + Buffer.from(body).copy(dataPadded); + const eof = Buffer.alloc(BLOCK * 2); // two zero blocks + const tar = Buffer.concat([header, dataPadded, eof]); + return new Uint8Array(gzipSync(tar)); +} + function makeResponse(status: number, body: Uint8Array | null): Response { if (status === 200 && body !== null) { const stream = new ReadableStream({ @@ -428,6 +464,148 @@ describe("installScipTool", () => { }); }); +describe("scip-go (archive/tarball extraction)", () => { + const LINUX_X64_GO = { os: "linux", arch: "x64" } as const; + + it("extracts the binary from the gzip tarball, chmods it, and verifies the tarball SHA256", async () => { + const dir = await mkdtemp(join(tmpdir(), "och-scip-go-")); + try { + const binBytes = new TextEncoder().encode("\x7fELF fake scip-go binary"); + const tarGz = makeTarGz("scip-go", binBytes); + // A sibling entry (LICENSE) must be skipped — prove the parser selects + // only the wanted entry by serving a two-entry archive. + const { fetch, calls } = makeFetchWith(new Map([["https://example.test/go", tarGz]])); + + const goPin: ScipToolPin = { + tool: "go", + version: "0.2.7", + installerKind: "download", + placeholder: false, + binName: "scip-go", + platforms: [ + { + os: "linux", + arch: "x64", + url: "https://example.test/go", + sha256: sha256(tarGz), + archiveEntry: "scip-go", + }, + ], + }; + const mutable = SCIP_PINS as unknown as Record; + const original = SCIP_PINS.go; + mutable.go = goPin; + try { + const result = await installScipTool("go", { + destDir: dir, + fetchImpl: fetch, + platform: LINUX_X64_GO, + }); + assert.equal(result.installed, true); + assert.equal(result.tool, "go"); + // On disk is the EXTRACTED binary, not the tarball. + const onDisk = await readFile(result.path); + assert.deepEqual(new Uint8Array(onDisk), binBytes); + // Executable bit set. + const st = await stat(result.path); + assert.equal(st.mode & 0o111, 0o111); + // Exactly one fetch. + assert.equal(calls.length, 1); + } finally { + mutable.go = original; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("rejects a tarball whose bytes do not match the pinned SHA256", async () => { + const dir = await mkdtemp(join(tmpdir(), "och-scip-go-bad-")); + try { + const tarGz = makeTarGz("scip-go", new TextEncoder().encode("real")); + const { fetch } = makeFetchWith(new Map([["https://example.test/go", tarGz]])); + const goPin: ScipToolPin = { + tool: "go", + version: "0.2.7", + installerKind: "download", + placeholder: false, + binName: "scip-go", + platforms: [ + { + os: "linux", + arch: "x64", + url: "https://example.test/go", + sha256: sha256(new TextEncoder().encode("WRONG")), // deliberately wrong + archiveEntry: "scip-go", + }, + ], + }; + const mutable = SCIP_PINS as unknown as Record; + const original = SCIP_PINS.go; + mutable.go = goPin; + try { + await assert.rejects( + () => installScipTool("go", { destDir: dir, fetchImpl: fetch, platform: LINUX_X64_GO }), + (err: unknown) => err instanceof ScipSha256MismatchError, + ); + } finally { + mutable.go = original; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("skips re-install when the extracted binary already exists (archive idempotency)", async () => { + const dir = await mkdtemp(join(tmpdir(), "och-scip-go-idem-")); + try { + const tarGz = makeTarGz("scip-go", new TextEncoder().encode("scip-go-bin")); + const { fetch, calls } = makeFetchWith(new Map([["https://example.test/go", tarGz]])); + const goPin: ScipToolPin = { + tool: "go", + version: "0.2.7", + installerKind: "download", + placeholder: false, + binName: "scip-go", + platforms: [ + { + os: "linux", + arch: "x64", + url: "https://example.test/go", + sha256: sha256(tarGz), + archiveEntry: "scip-go", + }, + ], + }; + const mutable = SCIP_PINS as unknown as Record; + const original = SCIP_PINS.go; + mutable.go = goPin; + try { + const first = await installScipTool("go", { + destDir: dir, + fetchImpl: fetch, + platform: LINUX_X64_GO, + }); + assert.equal(first.installed, true); + const second = await installScipTool("go", { + destDir: dir, + fetchImpl: fetch, + platform: LINUX_X64_GO, + }); + assert.equal(second.skipped, true); + assert.equal(second.installed, false); + // The extracted-binary presence check means the second call never + // re-fetches (the tarball SHA can't be recomputed from the binary). + assert.equal(calls.length, 1); + } finally { + mutable.go = original; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("installAllScipTools", () => { it("runs every tool in order and returns a per-tool result or error", async () => { const dir = await mkdtemp(join(tmpdir(), "och-scip-all-")); @@ -454,23 +632,47 @@ describe("installAllScipTools", () => { const clangBody = new TextEncoder().encode("clang-bytes"); const rubyBody = new TextEncoder().encode("ruby-bytes"); const kotlinBody = new TextEncoder().encode("kotlin-bytes"); + // scip-go is an archive tool: the served body is a gzip tarball whose + // `scip-go` entry holds the binary. This exercises the extraction path + // through `installAllScipTools` too. + const goTarGz = makeTarGz("scip-go", new TextEncoder().encode("go-binary-bytes")); const { fetch } = makeFetchWith( new Map([ ["https://example.test/clang", clangBody], ["https://example.test/ruby", rubyBody], + ["https://example.test/go", goTarGz], ["https://example.test/kotlin", kotlinBody], ]), ); + const goStub: ScipToolPin = { + tool: "go", + version: "1.2.3", + installerKind: "download", + placeholder: false, + binName: "scip-go", + platforms: [ + { + os: "linux", + arch: "x64", + url: "https://example.test/go", + sha256: sha256(goTarGz), + archiveEntry: "scip-go", + }, + ], + }; + const originals = { clang: SCIP_PINS.clang, ruby: SCIP_PINS.ruby, + go: SCIP_PINS.go, kotlin: SCIP_PINS.kotlin, }; const mutable = SCIP_PINS as unknown as Record; mutable.clang = mkStub("clang", clangBody); mutable.ruby = mkStub("ruby", rubyBody); + mutable.go = goStub; mutable.kotlin = mkStub("kotlin", kotlinBody); try { @@ -481,13 +683,14 @@ describe("installAllScipTools", () => { dotnetProbe: async () => "8.0.100", }); - assert.equal(results.length, 4); - // Clang, ruby, dotnet, kotlin — order from SCIP_TOOL_ORDER. + assert.equal(results.length, 5); + // Clang, ruby, go, dotnet, kotlin — order from SCIP_TOOL_ORDER. const tools = results.map((r) => ("tool" in r ? r.tool : "error")); - assert.deepEqual(tools, ["clang", "ruby", "dotnet", "kotlin"]); + assert.deepEqual(tools, ["clang", "ruby", "go", "dotnet", "kotlin"]); } finally { mutable.clang = originals.clang; mutable.ruby = originals.ruby; + mutable.go = originals.go; mutable.kotlin = originals.kotlin; } } finally { diff --git a/packages/cli/src/scip-downloader.ts b/packages/cli/src/scip-downloader.ts index 99dc0f8..9a29dab 100644 --- a/packages/cli/src/scip-downloader.ts +++ b/packages/cli/src/scip-downloader.ts @@ -34,13 +34,14 @@ import { execFile as execFileCb } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; -import { chmod, mkdir, rename, stat, unlink } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { Readable, Writable } from "node:stream"; import { pipeline as streamPipeline } from "node:stream/promises"; import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import { promisify } from "node:util"; +import { gunzipSync } from "node:zlib"; import { SCIP_PINS, @@ -285,6 +286,19 @@ async function hashFileIfExists(path: string): Promise { return hasher.digest("hex"); } +/** + * Stat a file, returning `undefined` if it does not exist. Used by the + * archive-tool idempotency check (an extracted binary's hash can't be compared + * to the tarball pin, so presence + non-empty size is the signal). + */ +async function statIfExists(path: string): Promise<{ size: number } | undefined> { + try { + return await stat(path); + } catch { + return undefined; + } +} + /** * Stream one binary to disk: hash-as-we-write, verify, chmod +x, atomic * rename. Does NOT retry — the embedder downloader's retry ladder is @@ -367,6 +381,20 @@ async function downloadBinary( throw new ScipSha256MismatchError(tool, platformPin.sha256, actual); } + // Archive path: the verified `tmpPath` holds a `.tar.gz` whose single + // wanted entry (`archiveEntry`) is the executable. The SHA256 above already + // covered the archive bytes, so integrity is intact; we now gunzip + untar + // in-memory (release tarballs are a few MB) and replace `tmpPath` with the + // extracted binary before the chmod + atomic rename. + if (platformPin.archiveEntry !== undefined) { + const extractedBytes = await extractFromTarGz( + tmpPath, + platformPin.archiveEntry, + platformPin.url, + ); + bytesWritten = extractedBytes; + } + // 0o755 — owner rwx, everyone rx. Matches what a release tarball extraction // would produce. await chmod(tmpPath, 0o755); @@ -374,6 +402,92 @@ async function downloadBinary( return bytesWritten; } +/** + * Gunzip + untar `archivePath` in place, extract the single entry named + * `entryName`, and overwrite `archivePath` with the extracted bytes (so the + * caller's existing chmod + atomic-rename of `archivePath` lands the binary). + * Returns the extracted byte count. + * + * Scope: release tarballs from Sourcegraph (scip-go) are plain ustar with + * short root-level names and no PAX/GNU long-name extensions, so this is a + * deliberately minimal reader — not a general-purpose tar library. It does, + * however, honor the ustar `prefix` field, reject reads past the buffer, and + * skip non-matching entries (e.g. the sibling `LICENSE`). + */ +async function extractFromTarGz( + archivePath: string, + entryName: string, + sourceUrl: string, +): Promise { + const gz = await readFile(archivePath); + let tar: Buffer; + try { + tar = gunzipSync(gz); + } catch (err) { + throw new ScipDownloadError( + sourceUrl, + `gunzip failed: ${err instanceof Error ? err.message : String(err)}`, + err instanceof Error ? { cause: err } : undefined, + ); + } + + const BLOCK = 512; + let offset = 0; + while (offset + BLOCK <= tar.length) { + const header = tar.subarray(offset, offset + BLOCK); + // Two consecutive all-zero blocks mark end-of-archive; a single zero + // header (name byte 0) is the terminator in practice — stop scanning. + if (header[0] === 0) break; + + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const fullName = prefix.length > 0 ? `${prefix}/${name}` : name; + const size = readTarOctal(header, 124, 12); + const typeFlag = header[156]; + + const dataStart = offset + BLOCK; + const dataEnd = dataStart + size; + if (dataEnd > tar.length) { + throw new ScipDownloadError( + sourceUrl, + `corrupt tar: entry "${fullName}" claims ${size} bytes past end of archive`, + ); + } + + // typeFlag '0' or NUL (0) is a regular file; anything else (dir '5', + // symlink '2', GNU long-name 'L', …) is not the binary we want. + const isRegularFile = typeFlag === 0x30 /* '0' */ || typeFlag === 0; + if (isRegularFile && fullName === entryName) { + await writeFile(archivePath, tar.subarray(dataStart, dataEnd)); + return size; + } + + // Advance past this entry's data, rounded up to the next 512 boundary. + offset = dataStart + Math.ceil(size / BLOCK) * BLOCK; + } + + throw new ScipDownloadError(sourceUrl, `tar archive did not contain entry "${entryName}"`); +} + +/** Read a NUL-terminated ASCII string from a fixed-width tar header field. */ +function readTarString(header: Buffer, start: number, len: number): string { + const slice = header.subarray(start, start + len); + const nul = slice.indexOf(0); + return slice.toString("ascii", 0, nul === -1 ? len : nul).trim(); +} + +/** + * Parse a tar numeric field: NUL/space-terminated OCTAL ASCII. Returns 0 for + * an empty/whitespace field (the size of dir/marker entries). + */ +function readTarOctal(header: Buffer, start: number, len: number): number { + const raw = header.subarray(start, start + len).toString("ascii"); + const cleaned = raw.replace(/\0/g, "").trim(); + if (cleaned === "") return 0; + const parsed = Number.parseInt(cleaned, 8); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + /** * In-memory guard against concurrent installs of the same tool in the same * process. Keyed by `${tool}:${destDir}` so parallel tests with distinct @@ -451,18 +565,29 @@ async function installScipToolInner( await mkdir(dirname(targetPath), { recursive: true }); if (opts.force !== true) { - const existingHash = await hashFileIfExists(targetPath); - if (existingHash !== undefined && existingHash === platformPin.sha256) { - log( - `codehub setup --scip=${tool}: already installed at ${targetPath} (version ${pin.version})`, - ); - return { - tool, - installed: false, - skipped: true, - version: pin.version, - path: targetPath, - }; + // Idempotency check. For raw-binary tools the on-disk file IS the hashed + // payload, so we compare its SHA256 to the pin. For archive tools the pin + // hashes the `.tar.gz` but the on-disk file is the EXTRACTED binary — the + // two hashes can never match, so we fall back to a presence check (a + // non-empty file at the target means a prior install already extracted it). + // Re-downloading is the only way to re-verify an archive tool's integrity, + // which `--force` still does. + if (platformPin.archiveEntry !== undefined) { + const existing = await statIfExists(targetPath); + if (existing !== undefined && existing.size > 0) { + log( + `codehub setup --scip=${tool}: already installed at ${targetPath} (version ${pin.version})`, + ); + return { tool, installed: false, skipped: true, version: pin.version, path: targetPath }; + } + } else { + const existingHash = await hashFileIfExists(targetPath); + if (existingHash !== undefined && existingHash === platformPin.sha256) { + log( + `codehub setup --scip=${tool}: already installed at ${targetPath} (version ${pin.version})`, + ); + return { tool, installed: false, skipped: true, version: pin.version, path: targetPath }; + } } } diff --git a/packages/cli/src/scip-pins.ts b/packages/cli/src/scip-pins.ts index 74b12ea..bdea7a4 100644 --- a/packages/cli/src/scip-pins.ts +++ b/packages/cli/src/scip-pins.ts @@ -35,8 +35,20 @@ export type ScipOs = "linux" | "darwin"; export type ScipArch = "x64" | "arm64"; -/** The four binary-backed SCIP tools plus the .NET tool-sourced adapter. */ -export type ScipTool = "clang" | "ruby" | "dotnet" | "kotlin"; +/** + * Tools the pinned downloader installs into `~/.codehub/bin/`: + * - `clang` / `ruby` — raw GitHub release binaries. + * - `go` — a GitHub release **tarball** (`.tar.gz`) we gunzip + untar to + * extract the `scip-go` binary (see `archiveEntry`). + * - `kotlin` — a Maven Central JAR. + * - `dotnet` — sourced via `dotnet tool install`. + * + * NOTE: `scip-typescript` and `scip-python` are deliberately NOT downloader + * tools — they are pure-JS npm packages shipped as hard `dependencies` of + * `@opencodehub/cli` and resolved from the package's `node_modules/.bin` on + * the spawn PATH (see `withCodehubBinOnPath` in `@opencodehub/scip-ingest`). + */ +export type ScipTool = "clang" | "ruby" | "dotnet" | "kotlin" | "go"; /** Per-platform download descriptor. */ export interface ScipPlatformPin { @@ -198,6 +210,72 @@ const SCIP_RUBY_PIN: ScipToolPin = { ], }; +/** + * scip-go v0.2.7 — Sourcegraph Go indexer, a static Go binary. + * Releases: `github.com/sourcegraph/scip-go/releases/tag/v0.2.7`. + * + * Unlike clang/ruby (which publish raw executables), scip-go publishes + * **gzip tarballs** named `scip-go--.tar.gz`, each containing two + * root entries — `scip-go` (the binary) and `LICENSE`. `archiveEntry: + * "scip-go"` tells the downloader to gunzip + untar and extract that one + * entry. The pinned SHA256 still covers the downloaded `.tar.gz` bytes (the + * downloader hashes the archive before extracting), so the integrity contract + * is unchanged. + * + * Upstream ships three platforms at v0.2.7 (verified live via the GitHub + * releases API + each asset's `.sha256` sidecar, 2026-06-01): + * - linux-x64 → scip-go-linux-amd64.tar.gz + * - linux-arm64 → scip-go-linux-arm64.tar.gz + * - darwin-arm64 → scip-go-darwin-arm64.tar.gz + * There is NO darwin-x64 asset; that row is marked `platformUnavailable` so + * the gap is documented (Intel-Mac users build via `go install`). + * + * Module-path caveat (for maintainers): `go install` uses the renamed module + * path `github.com/scip-code/scip-go/cmd/scip-go@latest`, but the RELEASE + * ASSETS live under the original `github.com/sourcegraph/scip-go` repo — the + * download URLs below must stay on `sourcegraph/scip-go` (the `scip-code` org + * publishes no release assets). Do not "fix" these URLs to the scip-code org. + */ +const SCIP_GO_PIN: ScipToolPin = { + tool: "go", + version: "0.2.7", + installerKind: "download", + placeholder: false, + binName: "scip-go", + platforms: [ + { + os: "linux", + arch: "x64", + url: "https://github.com/sourcegraph/scip-go/releases/download/v0.2.7/scip-go-linux-amd64.tar.gz", + // SHA256 of the .tar.gz, from the upstream `.sha256` sidecar (2026-06-01). + sha256: "5bfe39016ca04f5b3b1cce41d1b63ea120a7d7e93b55407bfb17a6b02d18135a", + archiveEntry: "scip-go", + }, + { + os: "linux", + arch: "arm64", + url: "https://github.com/sourcegraph/scip-go/releases/download/v0.2.7/scip-go-linux-arm64.tar.gz", + sha256: "6b93476c7578c5aeb5acacb41f8234c20130168271adea0db8d8ae63d1355acb", + archiveEntry: "scip-go", + }, + { + os: "darwin", + arch: "x64", + url: "https://github.com/sourcegraph/scip-go/releases/download/v0.2.7/scip-go-darwin-amd64.tar.gz", + // Upstream does NOT ship a darwin-x64 asset at v0.2.7 (URL 404s). + sha256: PLACEHOLDER_SHA256, + platformUnavailable: true, + }, + { + os: "darwin", + arch: "arm64", + url: "https://github.com/sourcegraph/scip-go/releases/download/v0.2.7/scip-go-darwin-arm64.tar.gz", + sha256: "f21b505452a8dbb270c18ec66690e583801f9e96cff1d72e84c004b7374ec672", + archiveEntry: "scip-go", + }, + ], +}; + /** * scip-dotnet v0.2.12 — installed via `dotnet tool install --global scip-dotnet`. * Upstream does NOT ship a self-contained release binary; the installer needs @@ -255,14 +333,21 @@ const SCIP_KOTLIN_PIN: ScipToolPin = { export const SCIP_PINS: Readonly> = { clang: SCIP_CLANG_PIN, ruby: SCIP_RUBY_PIN, + go: SCIP_GO_PIN, dotnet: SCIP_DOTNET_PIN, kotlin: SCIP_KOTLIN_PIN, }; /** Ordered list used by `--scip=all`. */ -export const SCIP_TOOL_ORDER: readonly ScipTool[] = ["clang", "ruby", "dotnet", "kotlin"]; +export const SCIP_TOOL_ORDER: readonly ScipTool[] = ["clang", "ruby", "go", "dotnet", "kotlin"]; /** True when `value` is a known SCIP tool name. Used to validate CLI input. */ export function isScipTool(value: string): value is ScipTool { - return value === "clang" || value === "ruby" || value === "dotnet" || value === "kotlin"; + return ( + value === "clang" || + value === "ruby" || + value === "go" || + value === "dotnet" || + value === "kotlin" + ); } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 0083f39..de6d96c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../policy" }, { "path": "../sarif" }, { "path": "../scanners" }, + { "path": "../scip-ingest" }, { "path": "../wiki" } ] } diff --git a/packages/scip-ingest/package.json b/packages/scip-ingest/package.json index 3ece803..a26af15 100644 --- a/packages/scip-ingest/package.json +++ b/packages/scip-ingest/package.json @@ -38,7 +38,9 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@opencodehub/core-types": "workspace:*" + "@opencodehub/core-types": "workspace:*", + "@sourcegraph/scip-python": "0.6.6", + "@sourcegraph/scip-typescript": "0.4.0" }, "devDependencies": { "@types/node": "25.9.1", diff --git a/packages/scip-ingest/src/index.ts b/packages/scip-ingest/src/index.ts index 263e643..3c37ed3 100644 --- a/packages/scip-ingest/src/index.ts +++ b/packages/scip-ingest/src/index.ts @@ -42,4 +42,10 @@ export type { IndexerResult, RunIndexerOptions, } from "./runners/index.js"; -export { buildCommand, detectLanguages, runIndexer } from "./runners/index.js"; +export { + buildCommand, + detectLanguages, + hostedScipBinDirs, + runIndexer, + withCodehubBinOnPath, +} from "./runners/index.js"; diff --git a/packages/scip-ingest/src/runners/codehub-bin-path.test.ts b/packages/scip-ingest/src/runners/codehub-bin-path.test.ts index f0e27e3..37519f6 100644 --- a/packages/scip-ingest/src/runners/codehub-bin-path.test.ts +++ b/packages/scip-ingest/src/runners/codehub-bin-path.test.ts @@ -10,51 +10,96 @@ */ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { withCodehubBinOnPath } from "./index.js"; +import { hostedScipBinDirs, withCodehubBinOnPath } from "./index.js"; const BIN_DIR = join(homedir(), ".codehub", "bin"); const SEP = process.platform === "win32" ? ";" : ":"; +// Most assertions pin the exact PATH string, so they inject an empty hosted- +// dirs resolver to isolate the `~/.codehub/bin` behavior from whichever +// hard-dep shims happen to be installed in the test environment. A dedicated +// block below covers the hosted-dir prepend. +const noHosted = (): readonly string[] => []; + test("prepends ~/.codehub/bin ahead of the existing PATH", () => { - const out = withCodehubBinOnPath({ PATH: `/usr/bin${SEP}/bin` }); + const out = withCodehubBinOnPath({ PATH: `/usr/bin${SEP}/bin` }, noHosted); assert.equal(out["PATH"], `${BIN_DIR}${SEP}/usr/bin${SEP}/bin`); }); test("is idempotent — does not double-prepend when bin dir is already first", () => { const already = `${BIN_DIR}${SEP}/usr/bin`; - const out = withCodehubBinOnPath({ PATH: already }); + const out = withCodehubBinOnPath({ PATH: already }, noHosted); assert.equal(out["PATH"], already, "PATH should be unchanged when bin dir leads"); }); test("sets PATH to just the bin dir when PATH is empty", () => { - const out = withCodehubBinOnPath({ PATH: "" }); + const out = withCodehubBinOnPath({ PATH: "" }, noHosted); assert.equal(out["PATH"], BIN_DIR); }); test("sets PATH to just the bin dir when PATH is absent entirely", () => { - const out = withCodehubBinOnPath({}); + const out = withCodehubBinOnPath({}, noHosted); assert.equal(out["PATH"], BIN_DIR); }); test("honors a caller-supplied PATH (envOverlay value), not just process.env", () => { // The runner merges `{ ...process.env, ...envOverlay }` BEFORE calling this // helper, so a caller PATH override is already the resolved value here. - const out = withCodehubBinOnPath({ PATH: "/caller/supplied" }); + const out = withCodehubBinOnPath({ PATH: "/caller/supplied" }, noHosted); assert.equal(out["PATH"], `${BIN_DIR}${SEP}/caller/supplied`); }); test("preserves other env vars untouched", () => { - const out = withCodehubBinOnPath({ PATH: "/bin", HOME: "/home/x", FOO: "bar" }); + const out = withCodehubBinOnPath({ PATH: "/bin", HOME: "/home/x", FOO: "bar" }, noHosted); assert.equal(out["HOME"], "/home/x"); assert.equal(out["FOO"], "bar"); }); test("does not mutate the input env object", () => { const input = { PATH: "/bin" }; - const out = withCodehubBinOnPath(input); + const out = withCodehubBinOnPath(input, noHosted); assert.equal(input["PATH"], "/bin", "input must be left unmodified"); assert.notEqual(out, input, "should return a new object"); }); + +test("prepends hosted hard-dep .bin dirs after ~/.codehub/bin, before existing PATH", () => { + const hosted = ["/pkg/node_modules/.bin"]; + const out = withCodehubBinOnPath({ PATH: "/usr/bin" }, () => hosted); + assert.equal(out["PATH"], `${BIN_DIR}${SEP}/pkg/node_modules/.bin${SEP}/usr/bin`); +}); + +test("dedupes a hosted dir already present on PATH (no duplicate, no reorder)", () => { + const hosted = ["/pkg/node_modules/.bin"]; + const already = `${BIN_DIR}${SEP}/pkg/node_modules/.bin${SEP}/usr/bin`; + const out = withCodehubBinOnPath({ PATH: already }, () => hosted); + assert.equal(out["PATH"], already, "already-present dirs are not re-prepended"); +}); + +test("orders multiple hosted dirs deterministically after the codehub bin dir", () => { + const hosted = ["/a/node_modules/.bin", "/b/node_modules/.bin"]; + const out = withCodehubBinOnPath({ PATH: "/usr/bin" }, () => hosted); + assert.equal( + out["PATH"], + `${BIN_DIR}${SEP}/a/node_modules/.bin${SEP}/b/node_modules/.bin${SEP}/usr/bin`, + ); +}); + +test("hostedScipBinDirs resolves a .bin holding the scip-python / scip-typescript shims", () => { + // The two pure-JS indexers are hard `dependencies` of this package, so a + // built/installed tree must expose their bin shims via at least one resolved + // dir. Each returned dir must actually exist and at least one must carry a + // scip-* shim (the dead-dir filter guarantees no empty dirs leak through). + const dirs = hostedScipBinDirs(); + assert.ok(Array.isArray(dirs), "returns an array"); + for (const d of dirs) { + assert.ok(existsSync(d), `resolved bin dir should exist: ${d}`); + } + const hasAShim = dirs.some( + (d) => existsSync(join(d, "scip-python")) || existsSync(join(d, "scip-typescript")), + ); + assert.ok(hasAShim, "at least one resolved dir must hold a scip-python/scip-typescript shim"); +}); diff --git a/packages/scip-ingest/src/runners/index.ts b/packages/scip-ingest/src/runners/index.ts index b9387e7..e6c4dc3 100644 --- a/packages/scip-ingest/src/runners/index.ts +++ b/packages/scip-ingest/src/runners/index.ts @@ -10,8 +10,10 @@ import { spawn } from "node:child_process"; import { existsSync, readdirSync, statSync } from "node:fs"; import { mkdir } from "node:fs/promises"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; export type IndexerKind = | "typescript" @@ -969,29 +971,151 @@ type CommandOutcome = | { kind: "missing" }; /** - * Prepend `~/.codehub/bin` to the spawn environment's PATH so SCIP indexers - * installed by `codehub setup --scip=` (clang, ruby, kotlin jar) win - * over an ambient version-manager shim that resolves on PATH but can't pick a - * version (the mise/asdf "No version is set for shim" failure — see - * `detectVersionManagerShimFailure`). Without this, a setup-installed indexer - * could be shadowed by a broken shim earlier on PATH and the language would - * skip even though codehub installed a working binary. + * The pure-JS SCIP indexers we ship as hard `dependencies` of + * `@opencodehub/scip-ingest` (this package — closest to the spawn site). + * Their bin shims must be discoverable on the spawn PATH by bare name, but + * when `@opencodehub/cli` is installed globally the shims live in a *nested* + * `node_modules/.bin` that is on nobody's PATH. {@link hostedScipBinDirs} + * resolves those `.bin` directories at runtime so {@link withCodehubBinOnPath} + * can prepend them. The native/asset indexers (scip-go, scip-clang, scip-ruby, + * kotlin/cobol JARs, scip-dotnet) are NOT here — they install under + * `~/.codehub/bin` via `codehub setup --scip=`. + */ +const HOSTED_SCIP_PACKAGES: readonly string[] = [ + "@sourcegraph/scip-python", + "@sourcegraph/scip-typescript", +]; + +/** + * Resolve the `node_modules/.bin` directories that hold the bin shims for our + * hard-dependency SCIP indexers ({@link HOSTED_SCIP_PACKAGES}), so a bare-name + * `spawn("scip-typescript", …, { shell: false })` finds them regardless of the + * surrounding install layout. + * + * Why a *set* of dirs and not one: the shim's location relative to the resolved + * package.json differs by package manager. + * - npm-global: pkg at `/node_modules/@sourcegraph/scip-typescript`, shim at + * `/node_modules/.bin/scip-typescript` — the FIRST `node_modules` up. + * - pnpm strict-isolation: pkg at + * `/node_modules/.pnpm/@sourcegraph+scip-typescript@…/node_modules/@sourcegraph/scip-typescript`, + * shim at `/node_modules/.bin/scip-typescript` — a LATER `node_modules` + * up the chain; the inner virtual-store `.bin` does NOT hold it. + * Both correct `.bin` dirs are ancestors of the resolved package.json on the + * walk-up path, so we collect EVERY `node_modules/.bin` we pass and let PATH + * lookup pick the one that actually has the shim. Verified empirically against + * real npm and pnpm installs of `@sourcegraph/scip-typescript@0.4.0`. + * + * Resolution is anchored at this module (`import.meta.url`) via `createRequire`, + * which is the authoritative chain Node itself uses for `import(pkg)` from + * inside `@opencodehub/scip-ingest` — and exactly why these deps are declared + * here rather than on `@opencodehub/cli`. Absent dep ⇒ `resolve` throws ⇒ we + * skip it: a safe no-op. Results are deduped and order-preserving. + */ +export function hostedScipBinDirs(): readonly string[] { + const thisModule = fileURLToPath(import.meta.url); + const req = createRequire(thisModule); + const dirs = new Set(); + + for (const pkg of HOSTED_SCIP_PACKAGES) { + // The bin shim is named after the package's unscoped tail + // (`@sourcegraph/scip-typescript` → `scip-typescript`). + const shimName = pkg.includes("/") ? (pkg.split("/").pop() as string) : pkg; + + // Two anchors, because the shim's `.bin` location relative to a resolvable + // path differs by package manager: + // - npm-global hoists the dep next to its dependents, so the shim is in a + // `node_modules/.bin` that is an ANCESTOR of the resolved package.json. + // - pnpm symlinks the real package into a `.pnpm` virtual store whose + // ancestors do NOT include the consumer's `.bin`; the shim instead + // lives in THIS package's own `node_modules/.bin` (the consumer that + // declared the dep). Walking up from this module finds it. + // We collect candidate `.bin` dirs from both walk-ups and keep only those + // that actually contain the shim, so PATH never carries a dead dir. + const anchors: string[] = [thisModule]; + try { + anchors.push(req.resolve(`${pkg}/package.json`)); + } catch { + // Dependency not resolvable from here — the this-module walk-up below is + // still attempted (it covers the common pnpm/workspace layout). If the + // shim is found there, great; otherwise this package is skipped. + } + + for (const anchor of anchors) { + let cur = dirname(anchor); + for (let i = 0; i < 12; i++) { + // Probe two `.bin` shapes at each ancestor: + // - `/.bin` when `cur` is itself a `node_modules` dir (the + // hoisted/virtual-store case on the resolved-package walk-up). + // - `/node_modules/.bin` when `cur` is a PACKAGE ROOT with a + // `node_modules` child (the consumer-package case: under pnpm the + // shim for a workspace package's dep lives in that package's own + // `node_modules/.bin`, e.g. `packages/scip-ingest/node_modules/.bin`). + // Only add a `.bin` that actually holds THIS shim, so PATH never + // carries a dead dir (the root `node_modules/.bin` exists under pnpm + // but does not contain workspace-package deps' shims). + const candidates = + basename(cur) === "node_modules" + ? [join(cur, ".bin")] + : [join(cur, "node_modules", ".bin")]; + for (const binDir of candidates) { + if (existsSync(join(binDir, shimName))) dirs.add(binDir); + } + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + } + } + return [...dirs]; +} + +/** + * Prepend the SCIP indexer bin directories to the spawn environment's PATH: + * + * 1. The hosted hard-dependency shims ({@link hostedScipBinDirs}) — the + * pure-JS `scip-python` / `scip-typescript` we ship as `dependencies` of + * `@opencodehub/scip-ingest`, whose nested `node_modules/.bin` is on no + * one's PATH after a global install. + * 2. `~/.codehub/bin` — where `codehub setup --scip=` installs the + * native/asset indexers (clang, ruby, kotlin jar, …). Placed FIRST so a + * setup-installed binary wins over an ambient version-manager shim that + * resolves on PATH but can't pick a version (the mise/asdf "No version is + * set for shim" failure — see {@link detectVersionManagerShimFailure}). + * + * Final order: `~/.codehub/bin` : : . * * Honors a caller-supplied PATH in `envOverlay` (we read the resolved value * off `env`, not `process.env`). Cross-platform: matches the PATH key * case-insensitively (Windows uses `Path`) and uses the platform delimiter. * Idempotent — never double-prepends. + * + * `resolveHostedDirs` is injectable so tests can assert the `~/.codehub/bin` + * ordering deterministically (passing `() => []`) without depending on which + * hard-dep shims happen to be installed in the test environment. Production + * callers omit it and get the real {@link hostedScipBinDirs} resolution. */ -export function withCodehubBinOnPath(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function withCodehubBinOnPath( + env: NodeJS.ProcessEnv, + resolveHostedDirs: () => readonly string[] = hostedScipBinDirs, +): NodeJS.ProcessEnv { const binDir = join(homedir(), ".codehub", "bin"); // Find the PATH key honoring Windows' `Path` casing; default to "PATH". const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; const current = env[pathKey] ?? ""; const sep = process.platform === "win32" ? ";" : ":"; const segments = current.split(sep); - // Idempotent: if binDir is already the first segment, leave env untouched. - if (segments[0] === binDir) return env; - const nextPath = current.length > 0 ? `${binDir}${sep}${current}` : binDir; + // Prepend in priority order: ~/.codehub/bin first, then the hosted shims, + // skipping any segment already present so we never duplicate or reorder a + // dir the caller (or a prior pass) already put on PATH. This also makes the + // function idempotent — re-applying it leaves PATH unchanged. + const present = new Set(segments); + const toPrepend: string[] = []; + for (const dir of [binDir, ...resolveHostedDirs()]) { + if (!present.has(dir) && !toPrepend.includes(dir)) toPrepend.push(dir); + } + if (toPrepend.length === 0) return env; + const nextPath = + current.length > 0 ? `${toPrepend.join(sep)}${sep}${current}` : toPrepend.join(sep); return { ...env, [pathKey]: nextPath }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7e69be..f2c6bec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: '@opencodehub/scanners': specifier: workspace:* version: link:../scanners + '@opencodehub/scip-ingest': + specifier: workspace:* + version: link:../scip-ingest '@opencodehub/search': specifier: workspace:* version: link:../search @@ -465,6 +468,12 @@ importers: '@opencodehub/core-types': specifier: workspace:* version: link:../core-types + '@sourcegraph/scip-python': + specifier: 0.6.6 + version: 0.6.6(@types/node@25.9.1)(typescript@6.0.3) + '@sourcegraph/scip-typescript': + specifier: 0.4.0 + version: 0.4.0 devDependencies: '@types/node': specifier: 25.9.1 @@ -899,6 +908,10 @@ packages: conventional-commits-parser: optional: true + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} @@ -1490,9 +1503,16 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@kreuzberg/tree-sitter-language-pack@1.8.0': resolution: {integrity: sha512-h4v52yJUVpA74DdvztFRQWuPgAKE52ysC2h1u/wLqdPjHvouV12Bj2bV4h30sGjEduEWgII+ktOL3kkp3GTK6A==} engines: {node: '>= 16'} @@ -1970,6 +1990,14 @@ packages: '@snyk/graphlib@2.1.9-patch.3': resolution: {integrity: sha512-bBY9b9ulfLj0v2Eer0yFYa3syVeIxVKl2EpxSrsVeT4mjA0CltZyHsF0JjoaGXP27nItTdJS5uVsj1NA+3aE+Q==} + '@sourcegraph/scip-python@0.6.6': + resolution: {integrity: sha512-qoKL1Rggg0o5newAFbCFAKlS0AjWxG5MA+mC28BtgxOv0DhO4zdL8u7151FxEppDpXMVvm7+yXSjXotoVH9cMQ==} + hasBin: true + + '@sourcegraph/scip-typescript@0.4.0': + resolution: {integrity: sha512-k+AtsrqmS41Sd5qjkZlHcmvoSQIvBOonRj4jpgp0KNFM6aqvMGpdSuPUqrUcg8ENTKjUbfaUVszgQwq3bCOvwA==} + hasBin: true + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1977,6 +2005,18 @@ packages: '@ts-morph/common@0.29.0': resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/braces@3.0.5': resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} @@ -2211,6 +2251,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2283,6 +2327,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -2534,10 +2581,17 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + command-exists@1.2.9: + resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -2553,6 +2607,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + commitizen@4.3.1: resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==} engines: {node: '>= 12'} @@ -2638,6 +2696,9 @@ packages: typescript: optional: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2912,6 +2973,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + diff@5.2.2: resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} @@ -3308,6 +3373,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + google-protobuf@3.21.4: + resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3880,6 +3948,9 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + map-age-cleaner@0.1.3: resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} engines: {node: '>=6'} @@ -4462,6 +4533,10 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -5003,6 +5078,20 @@ packages: ts-morph@28.0.0: resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -5029,6 +5118,11 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -5174,6 +5268,9 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -5249,6 +5346,20 @@ packages: vite: optional: true + vscode-jsonrpc@6.0.0: + resolution: {integrity: sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==} + engines: {node: '>=8.0.0 || >=10.0.0'} + + vscode-languageserver-protocol@3.16.0: + resolution: {integrity: sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==} + + vscode-languageserver-types@3.16.0: + resolution: {integrity: sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==} + + vscode-languageserver@7.0.0: + resolution: {integrity: sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==} + hasBin: true + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5339,6 +5450,10 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -5921,6 +6036,10 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@ctrl/tinycolor@4.2.0': {} '@cyclonedx/cyclonedx-library@10.0.0(ajv-formats-draft2019@1.6.1(ajv@8.20.0))(ajv-formats@3.0.1(ajv@8.20.0))(ajv@8.20.0)(packageurl-js@2.0.1)(spdx-expression-parse@3.0.1)': @@ -6285,8 +6404,15 @@ snapshots: dependencies: minipass: 7.1.3 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@kreuzberg/tree-sitter-language-pack@1.8.0': optional: true @@ -6732,6 +6858,29 @@ snapshots: lodash.union: 4.6.0 lodash.values: 4.3.0 + '@sourcegraph/scip-python@0.6.6(@types/node@25.9.1)(typescript@6.0.3)': + dependencies: + '@iarna/toml': 2.2.5 + command-exists: 1.2.9 + commander: 9.5.0 + diff: 5.2.2 + glob: 7.2.3 + google-protobuf: 3.21.4 + ts-node: 10.9.2(@types/node@25.9.1)(typescript@6.0.3) + vscode-languageserver: 7.0.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - typescript + + '@sourcegraph/scip-typescript@0.4.0': + dependencies: + commander: 12.1.0 + google-protobuf: 3.21.4 + progress: 2.0.3 + typescript: 5.9.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -6743,6 +6892,14 @@ snapshots: tinyglobby: 0.2.16 optional: true + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@types/braces@3.0.5': {} '@types/cacheable-request@6.0.3': @@ -7035,6 +7192,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} adm-zip@0.5.17: {} @@ -7105,6 +7266,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + arg@4.1.3: {} + arg@5.0.2: {} argparse@2.0.1: {} @@ -7440,8 +7603,12 @@ snapshots: comma-separated-tokens@2.0.3: {} + command-exists@1.2.9: {} + commander@11.1.0: {} + commander@12.1.0: {} + commander@14.0.3: {} commander@2.20.3: {} @@ -7450,6 +7617,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + commitizen@4.3.1(@types/node@25.9.1)(typescript@6.0.3): dependencies: cachedir: 2.3.0 @@ -7537,6 +7706,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + create-require@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7840,6 +8011,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@4.0.4: {} + diff@5.2.2: {} diff@8.0.4: {} @@ -8337,6 +8510,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + google-protobuf@3.21.4: {} + gopd@1.2.0: {} got@11.8.6: @@ -8991,6 +9166,8 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + make-error@1.3.6: {} + map-age-cleaner@0.1.3: dependencies: p-defer: 1.0.0 @@ -9833,6 +10010,8 @@ snapshots: prismjs@1.30.0: {} + progress@2.0.3: {} + property-information@7.1.0: {} proxy-addr@2.0.7: @@ -10603,6 +10782,24 @@ snapshots: code-block-writer: 13.0.3 optional: true + ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.9.1 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tslib@1.14.1: {} tslib@2.8.1: {} @@ -10625,6 +10822,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typescript@5.9.3: {} + typescript@6.0.3: {} ufo@1.6.4: {} @@ -10732,6 +10931,8 @@ snapshots: uuid@14.0.0: {} + v8-compile-cache-lib@3.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -10785,6 +10986,19 @@ snapshots: optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(jiti@2.6.1)(tsx@4.22.3)(yaml@2.9.0) + vscode-jsonrpc@6.0.0: {} + + vscode-languageserver-protocol@3.16.0: + dependencies: + vscode-jsonrpc: 6.0.0 + vscode-languageserver-types: 3.16.0 + + vscode-languageserver-types@3.16.0: {} + + vscode-languageserver@7.0.0: + dependencies: + vscode-languageserver-protocol: 3.16.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -10872,6 +11086,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yn@3.1.1: {} + yocto-queue@1.2.2: {} zod-to-json-schema@3.25.2(zod@4.4.3): @@ -10897,6 +11113,8 @@ time: '@iarna/toml@2.2.5': '2020-04-22T20:16:59.382Z' '@ladybugdb/core@0.16.1': '2026-05-04T06:04:33.965Z' '@modelcontextprotocol/sdk@1.29.0': '2026-03-30T16:50:42.718Z' + '@sourcegraph/scip-python@0.6.6': '2025-09-05T12:40:43.845Z' + '@sourcegraph/scip-typescript@0.4.0': '2025-10-02T06:02:28.263Z' '@types/node@25.9.1': '2026-05-19T17:49:12.417Z' '@types/sarif@2.1.7': '2023-11-07T15:57:52.459Z' '@types/spdx-correct@3.1.3': '2023-11-07T16:53:40.879Z'