mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-10 22:43:40 +00:00
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
* 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.
363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
#!/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 13–14, 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,
|
||
};
|