fix(deps): make tree-sitter peer dependencies install cleanly (#3555)

* fix(deps): make tree-sitter peer installs clean

Keep the eight audited lockfile peer ranges consistent with the bundled tree-sitter 0.25.1 runtime and apply the existing manifest preparation during postinstall. Ordinary and strict npm installs resolve without peer warnings.

* docs: explain audited tree-sitter peer maintenance

* fix(deps): pin audited tree-sitter peers to 0.25.1

* fix(deps): normalize audited peers to the exact runtime

* docs: clarify exact tree-sitter runtime peer pins

* test(deps): cover exact peer pins and existing-install migration

Verify exact 0.25.1 peers, repeatable preparation, and migration from the previously widened peer metadata.

* fix(deps): support pnpm symlinks in bundle preparation (#3555)

Follow package symlinks and pnpm sibling dependency containers. Deduplicate real paths to avoid revisiting aliases or cycles while preserving the audited manifest and duplicate-instance checks.

Validated with strict npm ci, both typechecks, npm pack, a real pnpm layout, and a pnpm-installed tarball parsing all 19 grammars on main and worker threads.

* test(deps): cover pnpm layout and symlink cycles (#3555)

Exercise real package symlinks, transitive sibling dependencies, a self-cycle, metadata drift before writes, and rejection of distinct duplicate grammar instances.

The original script fails the pnpm regressions; the updated script passes all 10 bundle tests.

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Gergő Magyar 2026-10-10 16:01:20 +03:00 • committed by GitHub
parent f5c901ada4
commit a532e08ef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 148 additions and 26 deletions

View file

@ -160,14 +160,21 @@ Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow
The CLI pins and bundles the native `tree-sitter` runtime and its npm grammars.
Several compatible grammars still declare older runtime peer ranges. Root npm
`overrides` work in this checkout but do not propagate to consumers, so
`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` runs during `prepack` to add the
tested runtime version to eight audited dependency manifests. It leaves grammar
sources and native binaries unchanged and rejects unexpected versions, peer
ranges, or dependency layouts before writing any manifests.
`overrides` do not propagate to consumers, and older npm versions validate bundled
peers against their original ranges even when an override is configured. The
lockfile therefore pins the same eight audited peers to exactly `0.25.1`.
`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` runs during `postinstall`
and `prepack` to keep installed dependency manifests consistent with that lockfile
and the published bundle. It leaves grammar sources and native binaries unchanged
and rejects unexpected versions, peer ranges, or dependency layouts before writing
any manifests. The upstream ranges remain in the script only as validation inputs;
installed and published audited manifests require exactly `0.25.1`.
When upgrading these packages, update the exact pins, lockfile, and audited peer
list together. Run the parser, extraction, CFG, and worker tests, then use a real
list together. If regenerating the lockfile restores upstream's older peer ranges,
restore the same audited `0.25.1` peer pins before committing it. Verify ordinary
`npm ci` and a subsequent `npm install` complete without peer-resolution warnings.
Run the parser, extraction, CFG, and worker tests, then use a real
`npm pack` (including `prepack`) to test a fresh consumer's `npm install`,
`npm ci`, `npm ls`, and a subsequent dependency install. Check native parser
loading on the supported Node.js versions and release platforms. A successful

View file

@ -4738,7 +4738,7 @@
"node-gyp-build": "^4.8.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4779,7 +4779,7 @@
"tree-sitter-c": "^0.23.1"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4819,7 +4819,7 @@
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4859,7 +4859,7 @@
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.22.4"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4899,7 +4899,7 @@
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4919,7 +4919,7 @@
"node-gyp-build": "^4.8.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4940,7 +4940,7 @@
"tree-sitter-javascript": "^0.23.1"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
@ -4960,7 +4960,7 @@
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
"tree-sitter": "0.25.1"
},
"peerDependenciesMeta": {
"tree-sitter": {

View file

@ -60,7 +60,7 @@
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"test:benchmarks": "node --import tsx scripts/run-benchmarks.ts",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"postinstall": "node scripts/prepare-tree-sitter-bundle.cjs && node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/prepare-tree-sitter-bundle.cjs && node scripts/assert-publish-grammar-coverage.cjs && node scripts/assert-publish-fts-coverage.cjs && node scripts/build.js --web && node scripts/assert-web-assets.mjs web",

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Prepare the tested native parser bundle for npm pack/publish.
* Prepare the tested native parser dependencies for installation and publication.
*
* A dependency's npm overrides do not apply in a consumer project. Several
* ABI-compatible grammars still advertise older runtime peers, so bundling
@ -11,6 +11,7 @@ const fs = require('node:fs');
const path = require('node:path');
const RUNTIME = '0.25.1';
// Upstream metadata is an input to validate, never the peer range we ship.
const AUDITED_PEERS = new Map([
['tree-sitter-c@0.23.6', '^0.22.1'],
['tree-sitter-cpp@0.23.4', '^0.21.1'],
@ -46,29 +47,50 @@ function prepareTreeSitterBundle(packageRoot = path.resolve(__dirname, '..')) {
}
const patches = [];
const visited = new Set();
function visit(modules) {
if (!fs.existsSync(modules)) return;
modules = fs.realpathSync(modules);
if (visited.has(modules)) return;
visited.add(modules);
for (const entry of fs.readdirSync(modules, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
if (entry.name.startsWith('.')) continue;
const dir = path.join(modules, entry.name);
if (
!entry.isDirectory() &&
(!entry.isSymbolicLink() || !fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
) {
continue;
}
if (entry.name.startsWith('@')) {
visit(dir);
continue;
}
const file = path.join(dir, 'package.json');
const realDir = fs.realpathSync(dir);
if (visited.has(realDir)) continue;
visited.add(realDir);
const file = path.join(realDir, 'package.json');
if (fs.existsSync(file)) {
const pkg = readJson(file);
const original = AUDITED_PEERS.get(`${pkg.name}@${pkg.version}`);
if (original) {
const peer = `${original} || ${RUNTIME}`;
if (![original, peer].includes(pkg.peerDependencies?.['tree-sitter'])) {
// Also accept bundles prepared before peers were pinned exactly, so
// an existing checkout can migrate through ordinary npm install.
const accepted = [original, `${original} || ${RUNTIME}`, RUNTIME];
if (!accepted.includes(pkg.peerDependencies?.['tree-sitter'])) {
throw new Error(`Unexpected tree-sitter peer metadata for ${pkg.name}@${pkg.version}`);
}
pkg.peerDependencies['tree-sitter'] = peer;
pkg.peerDependencies['tree-sitter'] = RUNTIME;
patches.push({ file, pkg });
}
}
visit(path.join(dir, 'node_modules'));
visit(path.join(realDir, 'node_modules'));
// pnpm links packages into node_modules but places their dependencies
// beside the real package. Follow that container without traversing the
// entire virtual store; realpaths deduplicate aliases and break cycles.
let parent = path.dirname(realDir);
if (path.basename(parent).startsWith('@')) parent = path.dirname(parent);
if (path.basename(parent) === 'node_modules') visit(parent);
}
}
visit(modulesRoot);

View file

@ -1,6 +1,14 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@ -68,12 +76,45 @@ function fixture() {
return root;
}
function pnpmFixture() {
const root = fixture();
const modules = path.join(root, 'node_modules');
const relocate = (source: string, name: string, version: string) => {
const target = path.join(modules, '.pnpm', `${name}@${version}`, 'node_modules', name);
mkdirSync(path.dirname(target), { recursive: true });
renameSync(source, target);
return target;
};
const c = relocate(path.join(modules, 'tree-sitter-c'), 'tree-sitter-c', '0.23.6');
const javascript = relocate(
path.join(modules, 'tree-sitter-typescript/node_modules/tree-sitter-javascript'),
'tree-sitter-javascript',
'0.23.1',
);
rmSync(path.join(modules, 'tree-sitter-typescript/node_modules'), { recursive: true });
for (const [name, version] of Object.entries(directVersions)) {
const target = relocate(path.join(modules, name), name, version);
symlinkSync(target, path.join(modules, name), 'junction');
if (name === 'tree-sitter-cpp') {
symlinkSync(c, path.join(path.dirname(target), 'tree-sitter-c'), 'junction');
}
if (name === 'tree-sitter-typescript') {
symlinkSync(
javascript,
path.join(path.dirname(target), 'tree-sitter-javascript'),
'junction',
);
}
}
return { root, c, javascript };
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('native parser bundle preparation', () => {
it('admits the tested runtime for direct and transitive peers without changing binaries', () => {
it('pins direct and transitive peers to the tested runtime without changing binaries', () => {
const root = fixture();
const native = path.join(root, 'node_modules/tree-sitter-cpp/parser.node');
writeFileSync(native, Buffer.from([0, 1, 128, 255]));
@ -83,7 +124,7 @@ describe('native parser bundle preparation', () => {
read(path.join(root, 'node_modules/tree-sitter-cpp/package.json')).peerDependencies[
'tree-sitter'
],
).toBe('^0.21.1 || 0.25.1');
).toBe('0.25.1');
expect(
read(
path.join(
@ -91,7 +132,7 @@ describe('native parser bundle preparation', () => {
'node_modules/tree-sitter-typescript/node_modules/tree-sitter-javascript/package.json',
),
).peerDependencies['tree-sitter'],
).toBe('^0.21.1 || 0.25.1');
).toBe('0.25.1');
expect(
read(path.join(root, 'node_modules/tree-sitter-javascript/package.json')).peerDependencies[
'tree-sitter'
@ -102,6 +143,58 @@ describe('native parser bundle preparation', () => {
expect(read(path.join(root, 'package.json')).dependencies).toEqual(directVersions);
});
it('migrates previously widened peer ranges to the exact runtime', () => {
const root = fixture();
const file = path.join(root, 'node_modules/tree-sitter-cpp/package.json');
const pkg = read(file);
pkg.peerDependencies['tree-sitter'] = '^0.21.1 || 0.25.1';
write(file, pkg);
expect(prepareTreeSitterBundle(root)).toHaveLength(8);
expect(read(file).peerDependencies['tree-sitter']).toBe('0.25.1');
expect(prepareTreeSitterBundle(root)).toHaveLength(8);
});
it('prepares pnpm symlinks, transitive siblings, and aliased cycles exactly once', () => {
const { root, c, javascript } = pnpmFixture();
const aliases = path.join(c, 'node_modules');
mkdirSync(aliases);
symlinkSync(c, path.join(aliases, 'tree-sitter-c'), 'junction');
expect(prepareTreeSitterBundle(root)).toHaveLength(8);
for (const dir of [c, javascript, path.join(root, 'node_modules/tree-sitter-cpp')]) {
expect(read(path.join(dir, 'package.json')).peerDependencies['tree-sitter']).toBe('0.25.1');
}
expect(
read(path.join(root, 'node_modules/tree-sitter-javascript/package.json')).peerDependencies[
'tree-sitter'
],
).toBe('^0.25.0');
expect(prepareTreeSitterBundle(root)).toHaveLength(8);
});
it('rejects drift behind pnpm symlinks before writing any package', () => {
const { root, c, javascript } = pnpmFixture();
const first = path.join(c, 'package.json');
const before = readFileSync(first, 'utf8');
const last = path.join(javascript, 'package.json');
const pkg = read(last);
pkg.peerDependencies['tree-sitter'] = '^0.26.0';
write(last, pkg);
expect(() => prepareTreeSitterBundle(root)).toThrow('Unexpected tree-sitter peer metadata');
expect(readFileSync(first, 'utf8')).toBe(before);
});
it('rejects distinct copies of an audited grammar before writing any package', () => {
const root = fixture();
const first = path.join(root, 'node_modules/tree-sitter-c/package.json');
const before = readFileSync(first, 'utf8');
write(
path.join(root, 'node_modules/tree-sitter-cpp/node_modules/tree-sitter-c/package.json'),
read(first),
);
expect(() => prepareTreeSitterBundle(root)).toThrow('dependency layout changed');
expect(readFileSync(first, 'utf8')).toBe(before);
});
it('rejects changed peer metadata before writing any package', () => {
const root = fixture();
const first = path.join(root, 'node_modules/tree-sitter-c/package.json');