GitNexus/.github/scripts/update-vendored-grammars.mjs
Gergő Magyar 96dc368d96
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(ci): align tree-sitter readiness + grammar-update workflows on a shared manifest (#858) (#2187)
* chore(ci): add shared vendored-grammars manifest; monitor reads it

.github/vendored-grammars.json is the single source of truth for the vendored
tree-sitter grammars (c/swift/kotlin/dart/proto): name, upstream coords, and
policy holds. update-vendored-grammars.mjs now builds its GRAMMARS map from the
manifest (behavior-preserving — same exported shape). Adds manifest-agreement
tests so the loader can't silently skew from the file.

* fix(ci): classify vendored grammars from manifest, drop bare "?" (#858)

The readiness report decided "is this vendored?" via is_vendored_pin (a file:
package.json spec) — but the 5 vendored grammars aren't in package.json, so they
were misrouted through the npm path and rendered bare "?" for ABI (read from an
empty node_modules), plus a spurious "? (fetch failed)" for github-only proto.

Now vendored grammars are classified by membership in the shared manifest and
their ABI is read from gitnexus/vendor/<name>/src/parser.c (always in a
checkout). github-only vendored grammars skip the npm peer-dep fetch; the
tree-sitter-c hold is surfaced from the manifest (held, not plain "Ready"); and
every remaining unintrospectable value renders a labeled token, never a bare
"?". --assert-current now covers the vendored grammars too instead of skipping
them. Adds a stdlib unittest suite incl. a manifest⇄vendor-dir consistency
guard.

* docs(ci): document the shared vendored-grammars manifest

Both tree-sitter workflow headers now point at .github/vendored-grammars.json as
the shared source of truth; the readiness workflow gains a PR-path trigger on the
manifest + test, and runs the readiness unit tests on validation events.
CONTRIBUTING.md documents the manifest contract under CI automation contracts.

* fix(review): apply autofix feedback

- Guard manifest reads in both scripts with a clear error (was an opaque
  module-import traceback that crashed the script and test collection).
- Never render a bare "?": relabel the npm-path ABI/version/peer sentinels and
  the vendored upstream-ABI miss to labeled tokens; the report is now ?-free
  regardless of node_modules/network, and the test is hermetic.
- Add a VENDORED_NAMES ⊆ GRAMMARS guard + manifest-missing error test.
- Drop now-dead is_vendored_pin/is_vendored/_(vendored)_.
- Compose held + out-of-range vendored blocker reasons instead of overwriting.
- Reword the shared-manifest docs to not over-claim shared upstream coords.

* fix(ci): apply root prettier formatting to mjs + ts test

The quality/format gate runs root `prettier --check .` (printWidth 100, the
gitnexus-local config differs and falsely passed locally).

* test(ci): make both tree-sitter scripts testable offline

The scripts hit live npm/GitHub, which makes the report run flaky and the
monitor's detect/apply logic untestable. Add hermetic seams:

- readiness: --offline flag (+ GITNEXUS_TS_READINESS_OFFLINE env) no-ops the npm
  registry + upstream fetches; the report renders deterministically (vendored
  ABIs from the repo, npm columns marked 'offline', no bare '?'). 3 tests assert
  an offline run touches ZERO network (urlopen patched to raise).
- monitor: detect() and apply() accept injected deps (vendoredVersion/
  resolveUpstream/fetchSource/readAbi) so the newer/ABI/hold gating runs offline
  with fixtures; apply gains --dry-run (validates but writes nothing). 6 tests
  cover newer/same-version/held-c/ABI-15/applicable + a no-mutation dry-run.

* fix(review): keep --assert-current hermetic + harden the no-bare-? invariant

Tri-review findings (PR #2187):
- P2 REGRESSION: --assert-current (documented 'hermetic and offline', run in CI
  without --offline) routed the 5 vendored grammars through vendored_drift_summary,
  which fetches upstream parser.c + commit sha — 10 discarded network calls per run.
  Fix: read the vendored ABI locally via a new vendored_abi_from_repo() helper (also
  used by vendored_drift_summary). Now verifiably network-free.
- Unify the upstream-ABI miss sentinel: prose said 'n/a (generated at build)' while
  the matrix said 'n/a' — and 'generated at build' is a wrong cause (swift HAS a
  committed parser.c). Both now render neutral 'n/a'.
- Fix the stale assert_current docstring claiming swift is prebuilt-only/no parser.c.
- Guard the last latent bare-? path (vendor package.json missing 'version').

Tests: AssertCurrent (network-free guard + out-of-range via the new injection
point), malformed-JSON manifest, detect() error-path, explicit npm/github
undefined assertions. 17 Python + 15 vitest, all hermetic.

* fix(review): use a single unittest import style (CodeQL 753)

CodeQL py/import-and-import-from flagged `import unittest` + `from unittest
import mock`. Collapse to `from unittest import TestCase, main, mock`.

* fix(review): explicit raise in _matrix_row (CodeQL 754)

CodeQL py/mixed-returns flagged the implicit fall-through after self.fail()
(which it doesn't model as NoReturn). End with an explicit raise AssertionError.

* test(review): replace non-null assertions with a must() guard

@typescript-eslint/no-non-null-assertion flagged 4 `!` operators. Add a
narrowing must<T>(value, message) helper (throws on undefined) and a named
baseResolveUpstream, removing every non-null assertion.

* fix(review): unguessable heredoc delimiter for the report output

The report embeds the manifest `hold` field (fork-PR-editable); a fixed
DRIFT_EOF delimiter in a hold value could close the $GITHUB_OUTPUT heredoc
early and inject output keys. Use DRIFT_EOF_$(openssl rand -hex 16) — a value
the report cannot contain. (Randomized delimiter over base64: keeps REPORT raw
markdown, no consumer-side decode.)

* fix(review): scope issues:write to scheduled runs (two-job split)

GitHub Actions has no step-level permissions, so the only way to keep PR runs
(incl. forks) from receiving `issues: write` is to split the job. A `report`
job (contents:read, all events) renders the report + the PR `:⚠️:` and
exposes report/exit_code as job outputs; a schedule-only `upsert-issue` job
(needs: report, issues:write, no checkout) consumes them for the issue upsert +
close. The 'Check upgrade readiness' check name is preserved.

* fix(review): launder npm-version '?' in disposition prose

The disposition bucket prose interpolated r['npm_version'] raw, so a successful
200 npm /latest response lacking a 'version' key would render a bare '?' (the
matrix cell already laundered it). Add npm_version_label ('unknown' for '?') and
use it in all five bucket renderers. Test a version-less npm response.

* refactor(review): load_vendored_manifest returns only the consumed 'hold'

The readiness script reads only the grammar names + 'hold'; the 'key' and
'upstream' fields were phantom data (upstream-drift coords live in the script's
own GRAMMARS map). Narrow the return to {hold}.

* fix(review): unify detect()/apply() 'newer' check for github grammars

detect() compared the bare sha7 while apply() compared up.version (the full
<base>-g<sha7> provenance string apply() also writes). After the bot re-vendored
a github grammar once, detect() reported a perpetual false 'update available'
while apply() correctly saw 'already current' — a noisy job summary + wasted
--apply subprocess (the PR-exists guard absorbed it before any duplicate PR).
Extract a shared isNewer(up, have) helper used by both. Tests cover equal-
provenance (false), first-vendoring plain-version (true, not suppressed), and
sha-advanced (true). Coupled with U12 (the detect⇄apply agreement assertion
lives there once apply()'s not-newer path returns instead of process.exit).

* test(review): cover main()'s out-of-range + prebuilt-only vendored ABI branches

main()'s vendored-ABI classification reads through vendored_abi_from_repo (the
local-read seam --assert-current uses), so patching it drives the
'Vendored (ABI out of range)' blocker branch and the prebuilt-only (vendored_abi
None → 'prebuilt' cell, not '?') branch — neither reachable today since all 5
vendor dirs ship parser.c at ABI 14.

* test(review): monitor-side manifest⇄vendor-dir consistency guard

Mirror the Python consistency guard on the monitor side — the monitor consumes
the same manifest and is the side that WRITES files from manifest `name`, so
manifest/vendor-dir drift must fail CI here too.

* fix(review): validate grammar names at manifest load (path-traversal guard)

The manifest `name` is joined into gitnexus/vendor/<name> paths in both scripts
(and apply() WRITES there), so reject any name not matching tree-sitter-[a-z0-9-]+
at the single load chokepoint — defense-in-depth even though the live trust
boundary already prevents exploitation. loadManifestGrammars gains an injectable
`raw` arg + export for testing; tests reject a '../etc' name in both scripts.

* refactor(review): apply() throws ApplyExit; CLI maps to exit codes

apply()'s 4 process.exit calls killed the vitest worker, blocking in-process
tests of its error branches. Replace them with a thrown ApplyExit{code}; the
not-newer (already-current) path returns `have` instead of exit(0). The isMain
CLI block try/catches and maps ApplyExit.code → process.exit, so the monitor's
subprocess contract (exit 0/2/3) is byte-identical (verified via subprocess
smoke). Tests cover unknown-key=2, held=3, ABI-reject=3, and not-newer (returns
current, no throw, no write).

* refactor(review): extract vendored render helper; trim docstrings (<1000 lines)

Extract the 'Vendored parsers' prose render into _render_vendored_section() so
main() coordinates named phases rather than inlining a ~450-line monolith, and
condense the most verbose docstrings/comments. The script drops from 1092 to 999
lines (under the 1000 bar the maintainability review flagged). Behavior-preserving:
the deterministic --offline render is byte-identical before/after (verified
in-place), --assert-current still passes, and the full unit suite is green.

* fix(review): row-diff regex captures only the Status cell

The change-detection regex captured the whole row tail as group 2, so any
non-status cell drift (e.g. an upstream-ABI bump) emitted a false-positive
'change' line. Capture only the Status cell ([^|]+? before the final |$).
The workflow parseRows regex and the Python _ROW_DIFF_RE stay byte-identical;
the stability test now asserts group 2 is the status string (e.g. c →
'Vendored — held') and contains no pipe.

* fix(ci): hoist intro string out of the list literal (CodeQL 755)

The U13 extraction moved the 'Vendored parsers' intro paragraph (implicitly
concatenated string literals) INTO a list literal, tripping CodeQL
py/implicit-string-concatenation-in-list (reads as a possibly-missing comma
between elements). Hoist it into a parenthesized `intro` variable. Render is
byte-identical.
2026-06-13 16:15:49 +01:00

363 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Vendored tree-sitter grammar update monitor.
*
* Checks each vendored grammar against its upstream source-of-origin and, for an
* available AND ABI-compatible update, re-vendors the grammar source in place so
* a PR can be opened. The version bump in vendor/<name>/package.json then triggers
* .github/workflows/build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
* validates the prebuilds — so even an imperfect re-vendor can never silently
* ship: its PR's CI goes red.
*
* ABI awareness is load-bearing. Every grammar is pinned to tree-sitter@0.21.1
* (LANGUAGE_VERSION 1314, the #1922 gate). Most upstream grammar releases target
* a newer tree-sitter, so a blind "bump to latest" would pull an ABI-incompatible
* parser and open doomed PRs. This monitor fetches the candidate source, reads its
* parser.c `#define LANGUAGE_VERSION`, and only re-vendors when it is 13 or 14;
* incompatible updates are reported (and surfaced as a workflow notice), not
* applied.
*
* Usage:
* node update-vendored-grammars.mjs # detect only → JSON report on stdout
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
*
* tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
* available c update is detected + reported but never auto-applied — even if it is
* ABI-13/14. A maintainer re-vendors it deliberately.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor');
const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION range
// Source-of-origin per grammar. npm grammars resolve `latest` via the registry;
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
// reason makes a grammar report-only: updates are detected + surfaced but never
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
//
// The vendored set lives in .github/vendored-grammars.json — the SHARED source of
// truth this monitor and .github/scripts/check-tree-sitter-upgrade-readiness.py both
// read, so the two tree-sitter workflows can never disagree about which grammars are
// vendored or where their upstream lives. We reshape the manifest's
// `{ upstream: { npm | github } }` form into the flat `{ npm? , github? }` shape the
// rest of this script consumes. This is a local file read (import-safe, no network).
const MANIFEST = path.join(REPO_ROOT, '.github', 'vendored-grammars.json');
// `raw` is injectable for testing; production reads the manifest file.
function loadManifestGrammars(raw = null) {
if (raw === null) {
// Fail loud with a pointer, not a bare ENOENT/SyntaxError: this runs at import.
try {
raw = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
} catch (e) {
throw new Error(
`Could not load the vendored-grammars manifest at ${MANIFEST} ` +
`(shared source of truth — see CONTRIBUTING.md → CI automation contracts): ${e.message}`,
);
}
}
return Object.fromEntries(
Object.entries(raw.grammars || {}).map(([key, g]) => {
if (!g.name)
throw new Error(`manifest entry '${key}' is missing a 'name' field (${MANIFEST})`);
// Defense-in-depth: `name` is joined into gitnexus/vendor/<name> paths (and
// apply() WRITES there), so reject anything that isn't a plain grammar name
// before it can traverse the filesystem (#2187).
if (!/^tree-sitter-[a-z0-9-]+$/.test(g.name))
throw new Error(
`manifest entry '${key}' has an invalid grammar name '${g.name}' ` +
`(must match tree-sitter-[a-z0-9-]+)`,
);
return [
key,
{
name: g.name,
...(g.upstream?.npm ? { npm: g.upstream.npm } : {}),
...(g.upstream?.github ? { github: g.upstream.github } : {}),
...(g.hold ? { hold: g.hold } : {}),
},
];
}),
);
}
const GRAMMARS = loadManifestGrammars();
const sh = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
const clean = (v) =>
String(v || '')
.replace(/^[v^~]/, '')
.trim();
// Shared "is the candidate newer than what we ship?" check, used by BOTH detect()
// and apply() so they can never disagree. up.version is the comparable identity for
// both kinds: a plain semver for npm, and the `<base>-g<sha7>` provenance string for
// github (which apply() also writes to package.json). detect() previously compared
// the bare sha7 for github, so after the bot re-vendored a github grammar once it
// reported a perpetual false "update available" while apply() saw "already current"
// (#2187 review). Comparing up.version on both sides removes that asymmetry.
const isNewer = (up, have) => !have || up.version !== have;
// apply() throws this (instead of calling process.exit) so its error branches are
// exercisable in-process by tests; the CLI entrypoint maps `.code` back to the
// original exit code, keeping the monitor's subprocess contract identical (#2187).
class ApplyExit extends Error {
constructor(message, code) {
super(message);
this.name = 'ApplyExit';
this.code = code;
}
}
function vendoredVersion(g) {
const p = path.join(VENDOR, g.name, 'package.json');
return clean(JSON.parse(fs.readFileSync(p, 'utf8')).version);
}
/** Resolve the upstream candidate: { version, ref, kind }. */
function resolveUpstream(g) {
if (g.npm) {
const version = clean(sh('npm', ['view', g.npm, 'version']));
return { version, ref: version, kind: 'npm' };
}
// github: no reliable release tags here, so track the default branch HEAD sha.
const meta = JSON.parse(sh('gh', ['api', `repos/${g.github}`]));
const branch = meta.default_branch;
const sha = JSON.parse(sh('gh', ['api', `repos/${g.github}/commits/${branch}`])).sha;
// Version key: "<upstreamPkgVersion>-g<sha7>" — safeRef-compatible (no `+`,
// which the build workflow's ref validator rejects) and changes on every commit.
let base = '0.0.0';
try {
const pkg = JSON.parse(
Buffer.from(
JSON.parse(sh('gh', ['api', `repos/${g.github}/contents/package.json?ref=${sha}`])).content,
'base64',
).toString('utf8'),
);
if (pkg.version) base = clean(pkg.version);
} catch {
/* no upstream package.json — base stays 0.0.0 */
}
return { version: `${base}-g${sha.slice(0, 7)}`, ref: sha, kind: 'github' };
}
/** Fetch the candidate source into a temp dir; return the package root. */
function fetchSource(g, ref) {
const work = fs.mkdtempSync(
path.join(os.tmpdir(), `revendor-${Object.keys(GRAMMARS).find((k) => GRAMMARS[k] === g)}-`),
);
if (g.npm) {
sh('npm', ['pack', `${g.npm}@${ref}`, '--silent'], { cwd: work });
const tgz = fs.readdirSync(work).find((f) => f.endsWith('.tgz'));
sh('tar', ['xzf', tgz], { cwd: work });
return path.join(work, 'package');
}
// github tarball at the resolved sha. Download + extract WITHOUT a shell
// (no `bash -c`/redirect): `gh api` writes the binary tarball to stdout, which
// we capture as a Buffer and write to a fixed path, then extract with execFile.
// Avoids the shell-command-injection surface CodeQL flags when an API-derived
// ref is interpolated into a `bash -c` string.
const tgz = path.join(work, 'src.tgz');
fs.writeFileSync(
tgz,
execFileSync('gh', ['api', `repos/${g.github}/tarball/${ref}`], {
maxBuffer: 512 * 1024 * 1024,
}),
);
sh('tar', ['xzf', tgz], { cwd: work });
const dir = fs.readdirSync(work).find((f) => fs.statSync(path.join(work, f)).isDirectory());
return path.join(work, dir);
}
/** Read parser.c's LANGUAGE_VERSION (ABI). Prefer the ABI-14 default parser.c. */
function readAbi(srcRoot) {
const candidates = ['src/parser.c', 'parser.c'];
for (const rel of candidates) {
const p = path.join(srcRoot, rel);
if (!fs.existsSync(p)) continue;
// Read only the head — the #define is near the top.
const head = fs.readFileSync(p, 'utf8').slice(0, 4000);
const m = head.match(/#define\s+LANGUAGE_VERSION\s+(\d+)/);
if (m) return Number(m[1]);
}
return null; // unknown (e.g. parser.c only generated at build time)
}
// `deps` injects the network/filesystem seams (vendoredVersion / resolveUpstream /
// fetchSource / readAbi) so the classification logic — newer-detection, the ABI
// gate, and the policy-hold gate — can be unit-tested offline with fixtures, never
// touching live npm/GitHub. Production passes nothing and gets the real functions.
function detect(deps = {}) {
const getVendored = deps.vendoredVersion || vendoredVersion;
const resolveUp = deps.resolveUpstream || resolveUpstream;
const fetchSrc = deps.fetchSource || fetchSource;
const readAbiFn = deps.readAbi || readAbi;
const report = [];
for (const [key, g] of Object.entries(GRAMMARS)) {
const have = getVendored(g);
let up;
try {
up = resolveUp(g);
} catch (err) {
report.push({ grammar: key, error: String(err.message || err) });
continue;
}
const newer = isNewer(up, have);
let abi = null;
if (newer) {
try {
abi = readAbiFn(fetchSrc(g, up.ref));
} catch {
/* fetch/abi best-effort; null = unknown */
}
}
report.push({
grammar: key,
vendored: have,
upstream: up.version,
ref: up.ref,
kind: up.kind,
update: newer,
abi,
abiCompatible: abi == null ? null : COMPATIBLE_ABI.has(abi),
hold: g.hold || null,
// Auto-appliable only when there's an update, the ABI is known-compatible,
// AND the grammar is not on a policy hold (c).
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
});
}
return report;
}
const copyFile = (srcRoot, dest, rel) => {
const from = path.join(srcRoot, rel);
if (!fs.existsSync(from)) return false;
const to = path.join(dest, rel);
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(from, to);
return true;
};
/**
* Re-vendor one grammar in place from its ABI-compatible upstream candidate.
* Copies ONLY the generated source-build + runtime files; deliberately KEEPS the
* GitNexus-hardened binding.gyp (Windows cflags, target_name), README (vendor
* notice), LICENSE, and prebuilds/ (the build workflow refreshes those). Bumps the
* stripped vendor package.json version + provenance — never re-introduces
* scripts/dependencies (#836/#1728). Returns the new version.
*
* opts.dryRun resolves + ABI-validates the candidate but writes NOTHING — it logs
* what it would re-vendor and returns the version, so the flow can be rehearsed
* (locally or in CI) without mutating gitnexus/vendor/. opts.deps injects the
* network/fs seams for offline testing (same shape as detect()).
*/
function apply(key, opts = {}) {
const dryRun = opts.dryRun || false;
const deps = opts.deps || {};
const getVendored = deps.vendoredVersion || vendoredVersion;
const resolveUp = deps.resolveUpstream || resolveUpstream;
const fetchSrc = deps.fetchSource || fetchSource;
const readAbiFn = deps.readAbi || readAbi;
const g = GRAMMARS[key];
if (!g) throw new ApplyExit(`unknown grammar '${key}'`, 2);
if (g.hold)
throw new ApplyExit(
`${key}: report-only (${g.hold}); not auto-applied. Re-vendor manually if intended.`,
3,
);
const have = getVendored(g);
const up = resolveUp(g);
const newer = isNewer(up, have);
if (!newer) {
// Already current: nothing to apply. Return (exit 0 via the CLI) — NOT an error.
console.error(`${key}: already current (${have}); nothing to apply.`);
return have;
}
const srcRoot = fetchSrc(g, up.ref);
const abi = readAbiFn(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi))
throw new ApplyExit(
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
3,
);
if (dryRun) {
console.log(
`${key}: [dry-run] would re-vendor ${g.name}${up.version} (ABI ${abi}); no files written.`,
);
return up.version;
}
const dest = path.join(VENDOR, g.name);
// The source-build inputs + runtime entrypoints that change between versions.
// binding.gyp / README / LICENSE / prebuilds are intentionally NOT touched.
for (const rel of [
'src/parser.c',
'src/scanner.c',
'src/node-types.json',
'src/tree_sitter/alloc.h',
'src/tree_sitter/array.h',
'src/tree_sitter/parser.h',
'bindings/node/binding.cc',
'bindings/node/index.js',
'bindings/node/index.d.ts',
]) {
copyFile(srcRoot, dest, rel);
}
const pkgPath = path.join(dest, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.version = up.version;
pkg._vendoredBy =
`gitnexus - re-vendored from ${g.npm ? `npm ${g.npm}@${up.version}` : `${g.github}@${up.ref}`} ` +
`by grammar-update-monitor on ABI ${abi}. Source-build inputs (parser.c/scanner.c/src/) refreshed; ` +
`the GitNexus-hardened binding.gyp + vendor README + prebuilds are preserved (prebuilds are ` +
`rebuilt by build-tree-sitter-prebuilds.yml on this version change). No scripts/dependencies here ` +
`(#836/#1728).`;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`${key}: re-vendored ${g.name}${up.version} (ABI ${abi}).`);
return up.version;
}
// Run the CLI only when invoked directly (not when imported by a test) — detect()
// makes live network calls, so importing must be side-effect-free.
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
if (args[0] === '--apply') {
// `--apply <grammar> [--dry-run]` — --dry-run previews without writing.
// Map apply()'s thrown ApplyExit back to the original exit codes (0/2/3) so
// the monitor workflow's subprocess (which only distinguishes zero vs non-zero)
// sees identical behavior.
try {
apply(args[1], { dryRun });
} catch (e) {
console.error(e.message);
process.exit(e instanceof ApplyExit ? e.code : 1);
}
} else {
process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
}
}
export {
detect,
apply,
resolveUpstream,
readAbi,
vendoredVersion,
loadManifestGrammars,
GRAMMARS,
COMPATIBLE_ABI,
};