Skip to content

Commit 738511b

Browse files
fix(ingestion): parse npm/pnpm lockfiles natively, drop snyk-nodejs-lockfile-parser (#259)
## What `npm install -g @opencodehub/cli` emitted **5 deprecation warnings**. Three (`lodash.clone`, `lodash.isequal`, `uuid@8`) came from `snyk-nodejs-lockfile-parser` — a ~126-package transitive tree the CLI bundled *solely* to list resolved packages from a lockfile. This replaces it with a native parser and removes the dependency from both `@opencodehub/ingestion` and `@opencodehub/cli`. Result: **warnings drop 5 → 2.** (The remaining `glob`/`inflight` come from `@sourcegraph/scip-python` and need an upstream fix — tracked separately.) ## Why native parsing is the right call All the dependency-ingestion phase needs from a lockfile is the flat set of resolved `name@version` pairs, and every lockfile shape already carries that verbatim: | Lockfile | Source of pairs | |---|---| | npm v2/v3 | `packages` map — keyed by `node_modules/<name>` path + `version` | | npm v1 | nested `dependencies` tree — `version` per node | | pnpm 5/6/9 | `packages:`/`snapshots:` keys — `name@version` (or legacy `/name/version`), peer suffixes stripped | The license harvester in this same file **already string-scanned the raw lockfile**, so the package list and its licenses now come from one pass over one source of truth. No YAML dependency added — pnpm keys are scanned exactly the way licenses already were. This also sidesteps a real bundling failure: snyk's CJS graph does dynamic `require()` of `fs` and `@snyk/error-catalog-nodejs-public`, which broke when inlined via tsup (the alternative fix). Native parsing has no such hazard. ## Verification (end-to-end, from the packed tarball) Built → `pnpm pack` → installed the tarball into a clean prefix → ran `codehub analyze`: - **Warnings:** 5 → 2; `snyk-nodejs-lockfile-parser` absent from the consumer tree; snyk symbols gone from `dist`. - **npm v3 `package-lock.json`:** `analyze` exit 0 — indexed `left-pad` + `@scope/util` (scoped, with licenses). - **pnpm v9 `pnpm-lock.yaml`:** `analyze` exit 0 — indexed `left-pad`, `@scope/util`, `react-dom` (peer-suffix `(react@18.2.0)` correctly stripped). Gates: `typecheck` ✓ (CI-equivalent, excludes docs per ci.yml), **638 ingestion tests** ✓ (incl. +5 new dep-parser fixtures: npm v1/v2/v3, pnpm v9/v6, missing-manifest), `biome` ✓ (686 files), docs build ✓ (64 pages). ## Also in this PR - **Troubleshooting docs:** new section explaining the residual cosmetic `glob`/`inflight` warnings are not security issues (CI runs osv/grype/semgrep/npm-audit; overrides pin patched versions), and noting the lockfile-parser warnings are now gone. - **Stale-doc fix:** corrected `@duckdb/node-api` references in the troubleshooting guide — DuckDB was removed in ADR 0019; `onnxruntime-node` is the only remaining native binding. ## Blast radius Internal lockfile-parsing implementation only; no public API change. The dependency-ingestion phase output is unchanged (verified by the analyze runs + existing tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1ca07d commit 738511b

7 files changed

Lines changed: 520 additions & 918 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ storage bindings:** `@ladybugdb/core` AND `@duckdb/node-api` are both
9292
removed (ADR 0019 supersedes ADR 0016). The write-only Parquet embeddings
9393
sidecar (BOM item #7) was dropped with DuckDB — nothing ever read it back;
9494
embeddings live in the `embeddings` table in `store.sqlite`. The code-pack
95-
is now an 8-item BOM. (`onnxruntime-node`, the embedder, is the only
96-
remaining native dep — optional, lazy under `--embeddings`.)
95+
is now an 8-item BOM. **Zero native bindings, full stop:** the embedder is
96+
`onnxruntime-web` (prebuilt WASM, in `optionalDependencies`, lazy under
97+
`--embeddings`) — there is no `onnxruntime-node` and nothing compiles at
98+
install. Parsing is WASM (`web-tree-sitter`) and the store is the built-in
99+
`node:sqlite`, so the entire install is pure JS + WASM.
97100

98101
Schema: one generic `nodes` table (typed base columns +
99102
`payload` JSON overflow for the 37 kind-specific shapes), one polymorphic

packages/cli/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"listr2": "10.2.1",
5959
"lru-cache": "11.5.1",
6060
"piscina": "5.2.0",
61-
"snyk-nodejs-lockfile-parser": "2.8.1",
6261
"web-tree-sitter": "0.26.9",
6362
"write-file-atomic": "8.0.0",
6463
"yaml": "2.9.0",

packages/docs/src/content/docs/guides/troubleshooting.md

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,32 @@ sidebar:
55
order: 90
66
---
77

8-
## Native build failures
8+
## Install / `node-gyp` build failures
99

10-
Symptoms: `pnpm install` fails while building `@duckdb/node-api`. Error
11-
mentions `node-gyp`, `python`, a C/C++ compiler, or `Visual Studio
12-
Build Tools`.
10+
Symptoms: `npm install -g @opencodehub/cli` (or `pnpm install` from a
11+
checkout) fails with `node-gyp`, `python`, a C/C++ compiler, or `Visual
12+
Studio Build Tools` in the error.
1313

14-
Fix:
14+
OpenCodeHub installs with **zero native bindings**, so it never compiles
15+
anything at install time. Every runtime component is pure JS or WASM:
16+
17+
- Parsing is `web-tree-sitter` (WASM), with grammars vendored as `.wasm`
18+
blobs — no native tree-sitter build.
19+
- The store is a single-file SQLite index via the built-in `node:sqlite`
20+
(Node ≥ 24.15) — no native database binding.
21+
- The optional embedder is `onnxruntime-web` (prebuilt WASM), loaded lazily
22+
only under `--embeddings` — no `onnxruntime-node`, no native ONNX build.
23+
24+
So a `node-gyp` error almost always comes from an unrelated package in your
25+
own project's tree, not from OpenCodeHub. Confirm with:
1526

16-
```bash title="probe the native toolchain"
27+
```bash title="probe the environment"
1728
codehub doctor
1829
```
1930

20-
`doctor` checks Node version, the platform's C/C++ toolchain, and
21-
whether each native module can load. Follow the remediation hints it
22-
prints.
23-
24-
The parse runtime is `web-tree-sitter` (WASM) on every supported Node
25-
version, so a missing C/C++ toolchain does not break parsing. The only
26-
native bindings OpenCodeHub loads are `@duckdb/node-api` (temporal store)
27-
and `onnxruntime-node` (the local embedder) — both ship platform
28-
prebuilds, so a normal install does not compile anything. If a prebuild
29-
is missing for your platform, `codehub doctor` reports which module
30-
failed to load and prints the remediation steps.
31+
`doctor` checks the Node version and that each WASM/JS component loads. If
32+
it reports green but your install still fails, the failing module belongs to
33+
something else you are installing alongside the CLI.
3134

3235
## Stale index
3336

@@ -62,23 +65,14 @@ for the exact shape.
6265

6366
## Windows quirks
6467

65-
Parsing is WASM, so the parser needs no native toolchain on Windows. The
66-
native bindings (`@duckdb/node-api`, `onnxruntime-node`) ship `win32-x64`
67-
prebuilds, so a standard install pulls a binary rather than compiling.
68-
If a prebuild is unavailable and a module has to build from source, you
69-
need the Microsoft C++ Build Tools plus a matching Python for
70-
`node-gyp`. In practice the fastest fix is to run everything under WSL2 —
71-
WSL2 ships with a working toolchain out of the box and avoids path
72-
separator issues.
73-
74-
If you must stay on native Windows and a source build is forced:
68+
OpenCodeHub has no native bindings, so a standard install never compiles
69+
on Windows — parsing is WASM (`web-tree-sitter`), the store is the built-in
70+
`node:sqlite`, and the optional embedder is `onnxruntime-web` (prebuilt
71+
WASM). There is no C/C++ toolchain requirement.
7572

76-
1. Install Visual Studio Build Tools with the "Desktop development
77-
with C++" workload.
78-
2. Install Python from the Microsoft Store (Python 3.12).
79-
3. `npm config set msvs_version 2022` and `npm config set python
80-
python3.12`.
81-
4. Re-run `pnpm install --frozen-lockfile`.
73+
The remaining Windows friction is path-separator and shell quirks rather
74+
than builds. If you hit those, the smoothest environment is WSL2, which
75+
matches the POSIX paths the rest of the toolchain assumes.
8276

8377
## The index is missing a language I expected
8478

@@ -88,6 +82,26 @@ language without a native toolchain. If the language is not listed,
8882
it is not yet registered — see
8983
[adding a language provider](/opencodehub/contributing/adding-a-language-provider/).
9084

85+
## Deprecation warnings during `npm install -g @opencodehub/cli`
86+
87+
Symptoms: `npm install -g @opencodehub/cli` prints `npm warn deprecated`
88+
lines for transitive packages such as `glob@7.2.3` and `inflight@1.0.6`.
89+
90+
These are cosmetic. They are deprecation notices npm emits for indirect
91+
dependencies pulled in by a SCIP indexer the CLI ships
92+
(`@sourcegraph/scip-python``glob``inflight`). They are not security
93+
advisories: every published OpenCodeHub release passes osv-scanner, grype,
94+
semgrep, and npm-audit in CI, and pinned `overrides` hold transitive
95+
packages at patched versions. Nothing about the warnings affects install
96+
correctness or runtime behaviour, and there is no action for you to take.
97+
98+
The lockfile-parser warnings (`lodash.clone`, `lodash.isequal`, `uuid@8`)
99+
that earlier releases also emitted are gone as of the native lockfile
100+
parser — the CLI no longer bundles a third-party resolver for dependency
101+
ingestion. The remaining `glob`/`inflight` pair originates inside the
102+
upstream indexer and is tracked for removal once that package updates its
103+
own dependencies.
104+
91105
## More help
92106

93107
- `codehub doctor --verbose` dumps every probe the doctor runs.

packages/ingestion/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
"graphology": "0.26.0",
5656
"graphology-dag": "0.4.1",
5757
"piscina": "5.2.0",
58-
"snyk-nodejs-lockfile-parser": "2.8.1",
5958
"spdx-correct": "^3.2.0",
6059
"web-tree-sitter": "0.26.9",
6160
"write-file-atomic": "8.0.0"

packages/ingestion/src/pipeline/dep-parsers/npm.test.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,242 @@ describe("parseNpmDeps — package-lock.json (lockfileVersion 2)", () => {
7676
});
7777
});
7878

79+
describe("parseNpmDeps — package-lock.json (lockfileVersion 3 + scoped + license)", () => {
80+
let dir: string;
81+
82+
before(async () => {
83+
dir = await mkdtemp(path.join(tmpdir(), "och-npm-lock3-"));
84+
await writeFile(
85+
path.join(dir, "package.json"),
86+
JSON.stringify({ name: "fixture", version: "2.0.0" }),
87+
);
88+
// v3 lockfiles drop the legacy top-level `dependencies` mirror entirely
89+
// and key everything under `packages`. Includes a scoped package and a
90+
// `license` field to exercise the license join.
91+
await writeFile(
92+
path.join(dir, "package-lock.json"),
93+
JSON.stringify({
94+
name: "fixture",
95+
version: "2.0.0",
96+
lockfileVersion: 3,
97+
requires: true,
98+
packages: {
99+
"": { name: "fixture", version: "2.0.0" },
100+
"node_modules/left-pad": { version: "1.3.0", license: "WTFPL" },
101+
"node_modules/@scope/util": { version: "4.5.6", license: "MIT" },
102+
// Nested transitive (deduped npm layout) — still a resolved pkg.
103+
"node_modules/left-pad/node_modules/semver": { version: "7.6.0" },
104+
},
105+
}),
106+
);
107+
});
108+
109+
after(async () => {
110+
await rm(dir, { recursive: true, force: true });
111+
});
112+
113+
it("emits every resolved package incl. scoped, nested, and license", async () => {
114+
const warnings: string[] = [];
115+
const out = await parseNpmDeps({
116+
absPath: path.join(dir, "package-lock.json"),
117+
relPath: "package-lock.json",
118+
repoRoot: dir,
119+
onWarn: (m) => warnings.push(m),
120+
});
121+
assert.equal(warnings.length, 0, `unexpected warnings: ${warnings.join("\n")}`);
122+
const byName = new Map(out.map((d) => [d.name, d]));
123+
assert.equal(byName.get("left-pad")?.version, "1.3.0");
124+
assert.equal(byName.get("left-pad")?.license, "WTFPL");
125+
assert.equal(byName.get("@scope/util")?.version, "4.5.6");
126+
assert.equal(byName.get("@scope/util")?.license, "MIT");
127+
// nested transitive captured by node_modules path tail
128+
assert.equal(byName.get("semver")?.version, "7.6.0");
129+
// root project itself must NOT appear as a dependency
130+
assert.equal(byName.has("fixture"), false);
131+
});
132+
});
133+
134+
describe("parseNpmDeps — package-lock.json (legacy lockfileVersion 1)", () => {
135+
let dir: string;
136+
137+
before(async () => {
138+
dir = await mkdtemp(path.join(tmpdir(), "och-npm-lock1-"));
139+
await writeFile(
140+
path.join(dir, "package.json"),
141+
JSON.stringify({ name: "fixture", version: "1.0.0" }),
142+
);
143+
// v1 has no `packages` map — only the nested `dependencies` tree.
144+
await writeFile(
145+
path.join(dir, "package-lock.json"),
146+
JSON.stringify({
147+
name: "fixture",
148+
version: "1.0.0",
149+
lockfileVersion: 1,
150+
requires: true,
151+
dependencies: {
152+
"left-pad": { version: "1.3.0" },
153+
minimist: {
154+
version: "1.2.8",
155+
dependencies: { "nested-dep": { version: "0.0.1" } },
156+
},
157+
},
158+
}),
159+
);
160+
});
161+
162+
after(async () => {
163+
await rm(dir, { recursive: true, force: true });
164+
});
165+
166+
it("walks the nested dependencies tree", async () => {
167+
const out = await parseNpmDeps({
168+
absPath: path.join(dir, "package-lock.json"),
169+
relPath: "package-lock.json",
170+
repoRoot: dir,
171+
onWarn: () => {},
172+
});
173+
const byName = new Map(out.map((d) => [d.name, d]));
174+
assert.equal(byName.get("left-pad")?.version, "1.3.0");
175+
assert.equal(byName.get("minimist")?.version, "1.2.8");
176+
assert.equal(byName.get("nested-dep")?.version, "0.0.1");
177+
});
178+
});
179+
180+
describe("parseNpmDeps — pnpm-lock.yaml (v9 modern keys)", () => {
181+
let dir: string;
182+
183+
before(async () => {
184+
dir = await mkdtemp(path.join(tmpdir(), "och-pnpm9-"));
185+
await writeFile(
186+
path.join(dir, "package.json"),
187+
JSON.stringify({ name: "fixture", version: "1.0.0" }),
188+
);
189+
// v9 keys are `name@version` / `@scope/name@version`, optionally with a
190+
// `(peerHash)` suffix under both `packages:` and `snapshots:`.
191+
await writeFile(
192+
path.join(dir, "pnpm-lock.yaml"),
193+
[
194+
"lockfileVersion: '9.0'",
195+
"",
196+
"packages:",
197+
"",
198+
" left-pad@1.3.0:",
199+
" resolution: {integrity: sha512-fake==}",
200+
"",
201+
" '@scope/util@4.5.6':",
202+
" resolution: {integrity: sha512-fake==}",
203+
"",
204+
" react-dom@18.2.0(react@18.2.0):",
205+
" resolution: {integrity: sha512-fake==}",
206+
"",
207+
"snapshots:",
208+
"",
209+
" left-pad@1.3.0: {}",
210+
"",
211+
" '@scope/util@4.5.6': {}",
212+
"",
213+
].join("\n"),
214+
);
215+
});
216+
217+
after(async () => {
218+
await rm(dir, { recursive: true, force: true });
219+
});
220+
221+
it("parses modern keys incl. scoped and peer-suffixed versions", async () => {
222+
const warnings: string[] = [];
223+
const out = await parseNpmDeps({
224+
absPath: path.join(dir, "pnpm-lock.yaml"),
225+
relPath: "pnpm-lock.yaml",
226+
repoRoot: dir,
227+
onWarn: (m) => warnings.push(m),
228+
});
229+
assert.equal(warnings.length, 0, `unexpected warnings: ${warnings.join("\n")}`);
230+
const byName = new Map(out.map((d) => [d.name, d]));
231+
assert.equal(byName.get("left-pad")?.version, "1.3.0");
232+
assert.equal(byName.get("@scope/util")?.version, "4.5.6");
233+
// peer suffix stripped to the bare version
234+
assert.equal(byName.get("react-dom")?.version, "18.2.0");
235+
});
236+
});
237+
238+
describe("parseNpmDeps — pnpm-lock.yaml (legacy v6 slash keys)", () => {
239+
let dir: string;
240+
241+
before(async () => {
242+
dir = await mkdtemp(path.join(tmpdir(), "och-pnpm6-"));
243+
await writeFile(
244+
path.join(dir, "package.json"),
245+
JSON.stringify({ name: "fixture", version: "1.0.0" }),
246+
);
247+
// v5/v6 keys are `/name/version` / `/@scope/name/version`, with optional
248+
// `_peer` or `(peer)` suffix.
249+
await writeFile(
250+
path.join(dir, "pnpm-lock.yaml"),
251+
[
252+
"lockfileVersion: '6.0'",
253+
"",
254+
"packages:",
255+
"",
256+
" /left-pad/1.3.0:",
257+
" resolution: {integrity: sha512-fake==}",
258+
"",
259+
" /@scope/util/4.5.6:",
260+
" resolution: {integrity: sha512-fake==}",
261+
"",
262+
" /react-dom/18.2.0_react@18.2.0:",
263+
" resolution: {integrity: sha512-fake==}",
264+
"",
265+
].join("\n"),
266+
);
267+
});
268+
269+
after(async () => {
270+
await rm(dir, { recursive: true, force: true });
271+
});
272+
273+
it("parses legacy slash keys incl. scoped and peer suffix", async () => {
274+
const out = await parseNpmDeps({
275+
absPath: path.join(dir, "pnpm-lock.yaml"),
276+
relPath: "pnpm-lock.yaml",
277+
repoRoot: dir,
278+
onWarn: () => {},
279+
});
280+
const byName = new Map(out.map((d) => [d.name, d]));
281+
assert.equal(byName.get("left-pad")?.version, "1.3.0");
282+
assert.equal(byName.get("@scope/util")?.version, "4.5.6");
283+
assert.equal(byName.get("react-dom")?.version, "18.2.0");
284+
});
285+
});
286+
287+
describe("parseNpmDeps — lockfile without sibling package.json", () => {
288+
let dir: string;
289+
before(async () => {
290+
dir = await mkdtemp(path.join(tmpdir(), "och-nolock-"));
291+
await writeFile(
292+
path.join(dir, "package-lock.json"),
293+
JSON.stringify({ name: "x", version: "1.0.0", lockfileVersion: 3, packages: {} }),
294+
);
295+
});
296+
after(async () => {
297+
await rm(dir, { recursive: true, force: true });
298+
});
299+
it("warns about the missing manifest and returns []", async () => {
300+
const warnings: string[] = [];
301+
const out = await parseNpmDeps({
302+
absPath: path.join(dir, "package-lock.json"),
303+
relPath: "package-lock.json",
304+
repoRoot: dir,
305+
onWarn: (m) => warnings.push(m),
306+
});
307+
assert.deepEqual([...out], []);
308+
assert.ok(
309+
warnings.some((w) => w.includes("lacks sibling package.json")),
310+
`expected sibling-manifest warning, got: ${warnings.join("\n")}`,
311+
);
312+
});
313+
});
314+
79315
describe("parseNpmDeps — bare package.json fallback", () => {
80316
let dir: string;
81317

0 commit comments

Comments
 (0)