Merge branch 'main' into feat/cfg-layer-m1

This commit is contained in:
Gergő Magyar 2026-06-10 06:34:16 +01:00 • committed by GitHub
commit 113b7ca899
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 1355269 additions and 605 deletions

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.6",
"version": "1.6.7",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -310,8 +310,8 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`; the `tree-sitter-kotlin` optional dependency (third-party npm package, source-only — no upstream prebuilds) compiles its native binding earlier, during npm's own dependency install, and is then probed by `build-tree-sitter-kotlin.cjs`. Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip the Dart/Proto/Swift builds and silence the Kotlin probe — but npm still compiles `tree-sitter-kotlin` unless you also pass `--omit=optional`. Each loses parsing for the affected language(s); the install still succeeds.
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.

View file

@ -0,0 +1,266 @@
#!/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).
const GRAMMARS = {
c: {
name: 'tree-sitter-c',
npm: 'tree-sitter-c',
hold: 'ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping',
},
swift: { name: 'tree-sitter-swift', npm: 'tree-sitter-swift' },
kotlin: { name: 'tree-sitter-kotlin', npm: 'tree-sitter-kotlin' },
dart: { name: 'tree-sitter-dart', github: 'UserNobody14/tree-sitter-dart' },
proto: { name: 'tree-sitter-proto', github: 'coder3101/tree-sitter-proto' },
};
const sh = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
const clean = (v) =>
String(v || '')
.replace(/^[v^~]/, '')
.trim();
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)
}
function detect() {
const report = [];
for (const [key, g] of Object.entries(GRAMMARS)) {
const have = vendoredVersion(g);
let up;
try {
up = resolveUpstream(g);
} catch (err) {
report.push({ grammar: key, error: String(err.message || err) });
continue;
}
const newer = up.kind === 'npm' ? up.version !== have : !have || up.ref.slice(0, 7) !== have;
let abi = null;
if (newer) {
try {
abi = readAbi(fetchSource(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.
*/
function apply(key) {
const g = GRAMMARS[key];
if (!g) {
console.error(`unknown grammar '${key}'`);
process.exit(2);
}
if (g.hold) {
console.error(
`${key}: report-only (${g.hold}); not auto-applied. Re-vendor manually if intended.`,
);
process.exit(3);
}
const have = vendoredVersion(g);
const up = resolveUpstream(g);
const newer = up.kind === 'npm' ? up.version !== have : !have || up.version !== have;
if (!newer) {
console.error(`${key}: already current (${have}); nothing to apply.`);
process.exit(0);
}
const srcRoot = fetchSource(g, up.ref);
const abi = readAbi(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi)) {
console.error(
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
);
process.exit(3);
}
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) {
if (process.argv[2] === '--apply') {
apply(process.argv[3]);
} else {
process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
}
}
export { detect, apply, resolveUpstream, readAbi, vendoredVersion, GRAMMARS, COMPATIBLE_ABI };

View file

@ -0,0 +1,529 @@
name: Build tree-sitter prebuilds
# Cross-builds the native tree-sitter prebuilds GitNexus vendors itself, so that
# grammars whose upstream packages ship SOURCE ONLY (no usable prebuilds/) never
# require a C/C++ toolchain at a user's install. This is the "no operational
# risk for any tree-sitter grammar" pipeline.
#
# Grammars covered here (the at-risk set — everything else already ships 6
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a
# REQUIRED grammar)
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
# - tree-sitter-kotlin (vendored source; built from the published npm package —
# upstream ships source only)
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
# prebuilds were originally upstream-shipped, now
# GitNexus-cross-built like the rest for uniformity)
#
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
# N-API, so one ABI-stable .node per platform-arch works across all Node majors.
#
# COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
# runs only:
# 1. on manual dispatch (workflow_dispatch); or
# 2. when a covered grammar's recorded version actually CHANGES — the `guard`
# job is the real gate (it diffs the recorded version vs the PR base); the
# `paths:` filter below only makes ordinary code PRs cost ZERO matrix time.
# Net effect: an ordinary code PR triggers nothing; bumping one grammar costs
# exactly one matrix run for that grammar, which opens a PR committing its rebuilt
# binaries.
#
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
#
# NOTE: every action below is pinned to a release commit SHA (with the matching
# `# vX.Y.Z` tag comment verified against the GitHub API). If a future bump adds
# a new action, pin its real release SHA and allowlist it in .github/zizmor.yml /
# Scorecard before merge.
on:
workflow_dispatch:
inputs:
grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
required: false
type: string
default: 'all'
ref:
description: 'Upstream version/tag/sha override (only honored when exactly one grammar is selected).'
required: false
type: string
default: ''
force:
description: 'Build even if the recorded version is unchanged (re-cut a broken prebuild).'
required: false
type: boolean
default: false
open_pr:
description: 'Open a PR with the rebuilt prebuilds (false = artifacts only).'
required: false
type: boolean
default: true
pull_request:
branches: [main]
paths:
# Vendored grammars: their version lives in the vendor snapshot package.json.
- 'gitnexus/vendor/tree-sitter-c/package.json'
- 'gitnexus/vendor/tree-sitter-dart/package.json'
- 'gitnexus/vendor/tree-sitter-proto/package.json'
- 'gitnexus/vendor/tree-sitter-kotlin/package.json'
- 'gitnexus/vendor/tree-sitter-swift/package.json'
# Transition window: kotlin's pin still lives here until it is vendored.
- 'gitnexus/package.json'
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
- '.github/workflows/build-tree-sitter-prebuilds.yml'
# Least privilege by default; only `aggregate` opts up.
permissions:
contents: read
# One slot per ref. Collapse PR re-pushes, but never cancel a manual re-cut.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ── Gate: decide which grammars (if any) need a native rebuild, and emit the
# {grammar x platform-arch} matrix the build job consumes. ───────────────
guard:
name: Decide what to build
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
any: ${{ steps.decide.outputs.any }}
matrix: ${{ steps.decide.outputs.matrix }}
release_app: ${{ steps.relapp.outputs.configured }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need base history to diff recorded versions
persist-credentials: false
- name: Decide
id: decide
env:
EVENT: ${{ github.event_name }}
# Untrusted dispatch inputs — read via env only, validated in JS.
INPUT_GRAMMARS: ${{ inputs.grammars }}
INPUT_REF: ${{ inputs.ref }}
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force || 'false' }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
node --input-type=module - <<'NODE'
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { appendFileSync } from 'node:fs';
// Registry of the at-risk grammars this workflow owns. `kind` drives
// how the build job resolves source: 'npm' pulls the published package;
// 'vendored' builds from gitnexus/vendor/<name> (which carries the C
// source + binding.gyp). Extend this list to cover a new grammar.
const REGISTRY = {
// c is vendored prebuild-only but BUILT from the published npm
// package (kind 'npm'), held at 0.21.4 — it closes upstream's 4/6
// ARM gap (#2116) for a REQUIRED grammar that otherwise hard-fails
// install on toolchain-less ARM.
c: { name: 'tree-sitter-c', kind: 'npm' },
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
kotlin: { name: 'tree-sitter-kotlin', kind: 'npm' },
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
// were originally upstream-shipped; rebuilding them here unifies it.
swift: { name: 'tree-sitter-swift', kind: 'vendored' },
};
const PLATFORMS = [
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
{ platform_arch: 'linux-arm64', os: 'ubuntu-24.04-arm' },
{ platform_arch: 'darwin-arm64', os: 'macos-15' },
{ platform_arch: 'darwin-x64', os: 'macos-15-intel' }, // macos-13 retired Dec-2025; Intel EOL ~Aug-2027
{ platform_arch: 'win32-x64', os: 'windows-2022' },
{ platform_arch: 'win32-arm64', os: 'windows-11-arm' },
];
const clean = (v) => (v || '').replace(/^[\^~]/, '').trim();
const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
// Durable version key for a grammar at a checkout root. Prefer the
// vendor snapshot (the post-vendor source of truth); fall back to the
// optionalDependencies pin during the transition window. (A guard keyed
// on the node_modules lock entry would self-disable once a grammar is
// vendored, because that entry is deleted.)
function recordedVersion(root, name) {
const v = json(`${root}/gitnexus/vendor/${name}/package.json`);
if (v && v.version) return clean(v.version);
const pkg = json(`${root}/gitnexus/package.json`);
const od = pkg && (pkg.optionalDependencies || {});
const d = pkg && (pkg.dependencies || {});
return clean((od && od[name]) || (d && d[name]) || '');
}
const event = process.env.EVENT;
const force = process.env.FORCE === 'true';
// Select which grammar shortnames are in play.
let selected;
if (event === 'workflow_dispatch') {
const raw = (process.env.INPUT_GRAMMARS || 'all').trim();
selected = raw === 'all' ? Object.keys(REGISTRY)
: raw.split(',').map((s) => s.trim()).filter(Boolean);
for (const s of selected) if (!REGISTRY[s]) throw new Error(`unknown grammar '${s}'`);
} else {
selected = Object.keys(REGISTRY);
}
// Resolve the base-ref recorded versions (pull_request only) so we can
// diff. On dispatch, base is irrelevant (manual intent / force wins).
const baseRoot = `${process.env.RUNNER_TEMP}/base`;
if (event === 'pull_request') {
const baseSha = process.env.BASE_SHA;
for (const s of selected) {
const name = REGISTRY[s].name;
for (const rel of [`gitnexus/vendor/${name}/package.json`, `gitnexus/package.json`]) {
const dst = `${baseRoot}/${rel}`;
fs.mkdirSync(dst.slice(0, dst.lastIndexOf('/')), { recursive: true });
try {
const buf = execSync(`git show ${baseSha}:${rel}`, { stdio: ['ignore', 'pipe', 'ignore'] });
fs.writeFileSync(dst, buf);
} catch { /* file absent at base — fine */ }
}
}
}
// The single-ref override is only meaningful for a one-grammar dispatch.
const refOverride = clean(process.env.INPUT_REF);
if (refOverride && !(event === 'workflow_dispatch' && selected.length === 1)) {
throw new Error('ref override requires exactly one grammar selected');
}
const safeRef = (r) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(r);
const include = [];
const built = [];
for (const short of selected) {
const { name, kind } = REGISTRY[short];
const head = recordedVersion('.', name);
const ref = refOverride || head;
if (!ref) { console.log(`skip ${short}: no recorded version`); continue; }
if (!safeRef(ref)) throw new Error(`unsafe ref for ${short}: '${ref}'`);
let build = false;
if (event === 'workflow_dispatch') {
build = true; // manual intent (force toggles only the unchanged-guard, which is bypassed here)
} else {
const base = recordedVersion(baseRoot, name);
build = !!head && head !== base;
console.log(`${short}: head='${head || '<absent>'}' base='${base || '<absent>'}' -> ${build ? 'BUILD' : 'skip'}`);
}
if (force) build = true;
if (!build) continue;
built.push(short);
for (const p of PLATFORMS) include.push({ grammar: short, name, kind, ref, ...p });
}
const out = process.env.GITHUB_OUTPUT;
appendFileSync(out, `any=${include.length > 0}\n`);
appendFileSync(out, `matrix=${JSON.stringify({ include })}\n`);
if (include.length === 0) {
console.log('::notice::No covered grammar version changed — skipping native matrix.');
} else {
console.log(`Building: ${built.join(', ')} (${include.length} jobs)`);
}
NODE
# The aggregate job opens a PR via a GitHub App token; without the App
# secrets it would hard-fail AFTER a full native build. Surface their
# presence as a guard output so aggregate skips cleanly (the build job's
# artifacts still upload). secrets aren't available in a job-level `if:`,
# so we compute the boolean here (a step CAN read secrets) and gate on it.
- name: Check release App secret
id: relapp
env:
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
run: |
set -euo pipefail
echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
if [ "$HAS_APP" != "true" ]; then
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
fi
# ── Build one native prebuild per (grammar, platform-arch). No cross-compile. ─
build:
name: ${{ matrix.grammar }} ${{ matrix.platform_arch }}
needs: guard
if: needs.guard.outputs.any == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.guard.outputs.matrix) }}
runs-on: ${{ matrix.os }}
# 45 (not 30) for headroom: the kotlin parser.c is ~23 MB and swift's ~18 MB,
# and compiling them under emulation on the arm runners is slow.
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false # this job uploads artifacts (artipacked)
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Ensure Python (arm64 Windows only)
if: matrix.platform_arch == 'win32-arm64'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- name: Build prebuild
id: build
shell: bash
env:
GRAMMAR: ${{ matrix.grammar }}
NAME: ${{ matrix.name }}
KIND: ${{ matrix.kind }}
REF: ${{ matrix.ref }}
PLATFORM_ARCH: ${{ matrix.platform_arch }}
run: |
set -euo pipefail
work="$RUNNER_TEMP/ts-build"
rm -rf "$work"; mkdir -p "$work"; cd "$work"
npm init -y >/dev/null
# node-addon-api must match what the grammar's binding.cc expects.
# GitNexus hoists ^8 for the vendored grammars; npm grammars declare
# their own (do NOT pin it for npm grammars — let the dep resolve it).
if [ "$KIND" = "vendored" ]; then
# Build from the vendored C source (carries parser.c + binding.gyp).
srcdir="$work/$NAME"
cp -R "$GITHUB_WORKSPACE/gitnexus/vendor/$NAME" "$srcdir"
rm -rf "$srcdir/prebuilds" "$srcdir/build" "$srcdir/node_modules"
npm install --no-audit --no-fund --ignore-scripts \
prebuildify@^6 node-gyp@^11 node-addon-api@^8
pkgdir="$srcdir"
export npm_config_node_gyp="$work/node_modules/node-gyp/bin/node-gyp.js"
else
# Pull the published source-only package.
npm install --no-audit --no-fund --ignore-scripts \
"$NAME@${REF}" prebuildify@^6 node-gyp@^11
pkgdir="$work/node_modules/$NAME"
fi
test -f "$pkgdir/binding.gyp" || { echo "::error::no binding.gyp for $NAME@$REF"; exit 1; }
# Drop any prebuilds the package shipped in its own tarball before we
# build. The tree-sitter-org npm grammars (e.g. tree-sitter-c) bundle
# prebuilds/ for all 6 tuples; left in place, the `find ... -print -quit`
# below would pick a non-host tuple (e.g. win32-x64 on a linux runner)
# and the assertion would wrongly fail. prebuildify rebuilds THIS host's
# tuple from the source the tarball also ships. (Vendored grammars are
# already cleaned above; this also covers the npm branch.)
rm -rf "$pkgdir/prebuilds"
# N-API, stripped, single ABI-stable binary for THIS host's arch. No
# `-t <node-version>`: an N-API prebuild is Node-version-agnostic, and
# prebuildify parses a bare `-t 22` as the NUMBER 22 and crashes
# (`v.indexOf is not a function`). prebuildify emits
# prebuilds/<platform>-<arch>/<something>.node.
( cd "$pkgdir" && npx --no-install prebuildify --napi --strip )
out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit)
test -n "$out" || { echo "::error::prebuildify produced no .node"; exit 1; }
produced=$(basename "$(dirname "$out")")
[ "$produced" = "$PLATFORM_ARCH" ] || { echo "::error::built $produced, expected $PLATFORM_ARCH"; exit 1; }
stage="$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH"; mkdir -p "$stage"
cp "$out" "$stage/$NAME.node"
echo "stage=$stage" >> "$GITHUB_OUTPUT"
- name: Validate the .node loads and parses on this arch
shell: bash
env:
GRAMMAR: ${{ matrix.grammar }}
NAME: ${{ matrix.name }}
PLATFORM_ARCH: ${{ matrix.platform_arch }}
EXPECT_ARCH: ${{ contains(matrix.platform_arch, 'arm64') && 'arm64' || 'x64' }}
run: |
set -euo pipefail
probe="$RUNNER_TEMP/probe"; rm -rf "$probe"
mkdir -p "$probe/prebuilds/$PLATFORM_ARCH"
cp "$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH/$NAME.node" \
"$probe/prebuilds/$PLATFORM_ARCH/$NAME.node"
cd "$probe"
# Pin tree-sitter to the repo's exact runtime peer so an ABI mismatch
# fails HERE, not in a user's install (mirrors the #1922 ABI gate).
# NOT --ignore-scripts: tree-sitter@0.21.1's tarball ships prebuilds for
# the common tuples but NOT linux-arm64 / win32-arm64, so on the arm64
# runners node-gyp-build must source-build the runtime — give it node-gyp
# + node-addon-api to do so. Where tree-sitter ships a prebuild (x64,
# darwin-arm64) node-gyp-build uses it and nothing compiles. The grammar
# .node we built is still loaded as a prebuild; only the runtime peer may
# compile. The grammar-vs-runtime ABI check still fires at setLanguage.
npm install --no-audit --no-fund \
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
# The node script is single-quoted on purpose — its ${...} are JS
# template literals read from the environment, not shell expansions.
# shellcheck disable=SC2016
GRAMMAR="$GRAMMAR" EXPECT_ARCH="$EXPECT_ARCH" node -e '
const expect = process.env.EXPECT_ARCH;
// Catch an emulated x64 Node silently mis-passing on an arm64 runner.
if (process.arch !== expect) throw new Error(`runner arch ${process.arch} != ${expect}`);
const snippets = {
c: "int main(void) { return 0; }",
dart: "void main() { print(\"hi\"); }",
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
kotlin: "fun main() { println(\"hi\") }",
swift: "func greet() { print(\"hi\") }",
};
const lang = require("node-gyp-build")(process.cwd());
const Parser = require("tree-sitter");
const p = new Parser(); p.setLanguage(lang);
const tree = p.parse(snippets[process.env.GRAMMAR]);
if (!tree || !tree.rootNode || tree.rootNode.hasError) {
throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type));
}
console.log("OK", process.env.GRAMMAR, process.platform + "-" + process.arch, tree.rootNode.type);
'
- name: Upload prebuild artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ts-prebuild-${{ matrix.grammar }}-${{ matrix.platform_arch }}
path: ${{ steps.build.outputs.stage }}/${{ matrix.name }}.node
if-no-files-found: error
retention-days: 7
# ── Aggregate every grammar's six prebuilds, assert completeness, open a PR. ─
aggregate:
name: Vendor prebuilds + open PR
needs: [guard, build]
# Open the prebuild PR on a non-fork pull_request that bumped a grammar
# version (the documented version-change -> prebuild-PR flow), or on a manual
# dispatch with open_pr=true. Event-gating is explicit so we never rely on
# GHA coercing a null `inputs.open_pr` on pull_request events (Codex F4):
# `inputs.open_pr` is null off-dispatch, and `null != false` is direction-
# ambiguous, so `open_pr` is only consulted on workflow_dispatch.
if: >-
needs.guard.outputs.any == 'true' &&
needs.guard.outputs.release_app == 'true' &&
github.event.pull_request.head.repo.fork != true &&
(github.event_name == 'pull_request' || inputs.open_pr == true)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read # actual writes use a short-lived App token below
id-token: write # SLSA provenance attestation
attestations: write
steps:
- name: Mint GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Download all prebuild artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ${{ runner.temp }}/dl
pattern: ts-prebuild-*
- name: Place prebuilds, assert each built grammar has all 6, write SHA256SUMS
id: place
shell: bash
env:
MATRIX: ${{ needs.guard.outputs.matrix }}
DL: ${{ runner.temp }}/dl
run: |
set -euo pipefail
node --input-type=module - <<'NODE'
import fs from 'node:fs';
import { execSync } from 'node:child_process';
const include = JSON.parse(process.env.MATRIX).include;
const dl = process.env.DL;
const byGrammar = {};
for (const e of include) (byGrammar[e.grammar] ||= { name: e.name, archs: [] }).archs.push(e.platform_arch);
const PLATFORMS = ['linux-x64','linux-arm64','darwin-arm64','darwin-x64','win32-x64','win32-arm64'];
const changed = [];
for (const [grammar, { name }] of Object.entries(byGrammar)) {
const dest = `gitnexus/vendor/${name}/prebuilds`;
// A vendored grammar with 5/6 prebuilds silently breaks node-gyp-build
// on the 6th platform — refuse a partial result.
for (const pa of PLATFORMS) {
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
}
execSync(`cd ${dest} && find . -name '*.node' | sort | xargs sha256sum > SHA256SUMS`);
changed.push(name);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `grammars=${changed.join(',')}\n`);
console.log('Vendored prebuilds for:', changed.join(', '));
NODE
- name: Attest build provenance (SLSA)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
- name: Create or update PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GRAMMARS: ${{ steps.place.outputs.grammars }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { execSync } = require('node:child_process');
const run = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const grammars = process.env.GRAMMARS;
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
run('git add gitnexus/vendor/tree-sitter-*/prebuilds');
if (!run('git status --porcelain -- gitnexus/vendor/tree-sitter-*/prebuilds')) {
core.notice('Prebuilds byte-identical to vendor; nothing to commit.');
return;
}
run('git config user.name "gitnexus-release-bot[bot]"');
run('git config user.email "gitnexus-release-bot[bot]@users.noreply.github.com"');
run(`git checkout -b "${branch}"`);
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})\n\nBuilt by ${process.env.RUN_URL}"`);
const { owner, repo } = context.repo;
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
// Plain --force, not --force-with-lease: the branch is ephemeral and
// unique per run (keyed by context.runId), written ONLY by this job, so
// there is no concurrent writer to protect against. --force-with-lease
// would compare against a remote-tracking ref this fresh checkout never
// fetched, so re-running the SAME run (branch already pushed by attempt
// 1) fails with "stale info" instead of overwriting.
run(`git push --force "${remote}" "HEAD:${branch}"`);
const body = [
`Rebuilt the vendored native prebuilds for: **${grammars}**.`,
'',
`Builder run: ${process.env.RUN_URL}`,
'Each `.node` was `require()`-loaded + parsed a real snippet on its target',
'platform-arch before upload. SLSA build-provenance attested; `SHA256SUMS`',
'committed alongside each grammar.',
].join('\n');
const { data: pr } = await github.rest.pulls.create({
owner, repo, head: branch, base: 'main',
title: `chore(vendor): tree-sitter prebuilds (${grammars})`, body,
});
core.info(`Opened PR #${pr.number}`);

View file

@ -94,8 +94,9 @@ jobs:
# 1. Static, offline: assert every grammar's compiled ABI loads on the
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
# static check can't introspect) fails on the platform it ships to.
# ABI-incompatible committed vendor prebuilt (e.g. Swift's — the static
# check introspects source, not the shipped .node) fails on the platform
# it ships to.
abi-assert:
name: tree-sitter ABI (${{ matrix.os }})
strategy:

View file

@ -0,0 +1,146 @@
name: Vendored grammar update monitor
# Periodically checks each vendored tree-sitter grammar against its
# source-of-origin and opens a PR re-vendoring any update that is ABI-COMPATIBLE
# with the pinned tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14, #1922). The version
# bump then triggers build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
# validates the prebuilds — so a re-vendor that is subtly wrong can never silently
# ship: its PR's CI goes red.
#
# ABI-INCOMPATIBLE updates (the common case — upstreams move to newer tree-sitter)
# are reported as a notice + job summary, NOT applied, so the monitor never opens
# doomed PRs. tree-sitter-c is MONITORED but report-only: it is ABI-pinned at
# 0.21.4 (#1242/#858), so an available c update is surfaced (notice + summary) but
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
# upgrade.
#
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
on:
schedule:
- cron: '17 6 * * 1' # weekly, Monday 06:17 UTC
workflow_dispatch:
# Least privilege; the actual writes use a short-lived App token minted below.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
monitor:
name: Check upstreams + open update PRs
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
# secrets aren't usable in a job/step `if:`, so compute presence here.
- name: Check release App secret
id: relapp
env:
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
run: echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
- name: Mint GitHub App token
id: app-token
if: steps.relapp.outputs.configured == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Detect updates, re-vendor ABI-compatible ones, open PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
HAS_APP: ${{ steps.relapp.outputs.configured }}
# App token writes; falls back to the read-only job token (PRs then skip).
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
with:
github-token: ${{ steps.app-token.outputs.token || github.token }}
script: |
const { execFileSync } = require('node:child_process');
const SCRIPT = '.github/scripts/update-vendored-grammars.mjs';
const run = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
const report = JSON.parse(run('node', [SCRIPT]));
const { owner, repo } = context.repo;
const hasApp = process.env.HAS_APP === 'true';
const applied = [], held = [], errors = [], skipped = [];
run('git', ['config', 'user.name', 'gitnexus-release-bot[bot]']);
run('git', ['config', 'user.email', 'gitnexus-release-bot[bot]@users.noreply.github.com']);
const baseSha = run('git', ['rev-parse', 'HEAD']).trim();
for (const r of report) {
if (r.error) { errors.push(r); continue; }
if (!r.update) continue;
if (!r.applicable) { held.push(r); continue; } // ABI-incompatible / unknown
const name = `tree-sitter-${r.grammar}`;
const branch = `chore/update-${name}-${r.upstream}`.replace(/[^a-z0-9._/-]+/gi, '-');
// Idempotency: don't reopen an existing PR for this exact version.
const existing = await github.rest.pulls.list({ owner, repo, head: `${owner}:${branch}`, state: 'all' });
if (existing.data.length > 0) { skipped.push({ ...r, reason: 'PR exists' }); continue; }
// Re-vendor in place (refuses + exits non-zero if ABI turns out wrong).
try {
run('node', [SCRIPT, '--apply', r.grammar]);
} catch (e) {
errors.push({ ...r, error: `apply failed: ${String(e.message || e).slice(0, 200)}` });
run('git', ['checkout', '--', 'gitnexus/vendor']);
continue;
}
if (!hasApp) {
skipped.push({ ...r, reason: 'no RELEASE_APP secret — PR not opened' });
run('git', ['checkout', '--', 'gitnexus/vendor']);
continue;
}
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
run('git', ['checkout', '-B', branch, baseSha]);
run('git', ['add', `gitnexus/vendor/${name}`]);
run('git', ['commit', '-m', `chore(vendor): update ${name} to ${r.upstream}`]);
run('git', ['push', '--force-with-lease', remote, `HEAD:${branch}`]);
const body = [
`Automated re-vendor of **${name}** to \`${r.upstream}\` (from ${r.kind === 'npm' ? `npm \`${name}\`` : `\`${r.ref}\``}).`,
'',
`Verified ABI **${r.abi}** — compatible with the pinned \`tree-sitter@0.21.1\` (13–14).`,
'Source-build inputs refreshed; the GitNexus binding.gyp / README / prebuilds are preserved.',
'The version bump triggers `build-tree-sitter-prebuilds.yml` to rebuild + ABI-validate the',
'prebuilds — review its result before merging.',
].join('\n');
const pr = await github.rest.pulls.create({
owner, repo, head: branch, base: 'main',
title: `chore(vendor): update ${name} to ${r.upstream}`, body,
});
applied.push({ ...r, pr: pr.data.number });
run('git', ['checkout', '--force', baseSha]);
}
// Summary
const s = core.summary.addHeading('Vendored grammar update monitor');
if (applied.length) s.addRaw(`\n**Opened PRs:** ${applied.map((a) => `${a.grammar}→${a.upstream} (#${a.pr})`).join(', ')}\n`);
if (held.length) s.addRaw(`\n**Held (not auto-applied):** ${held.map((h) => `${h.grammar} ${h.upstream} (${h.hold ? 'report-only: ' + h.hold : 'ABI ' + (h.abi ?? '?') + ' — needs the tree-sitter runtime upgrade'})`).join(', ')}\n`);
if (skipped.length) s.addRaw(`\n**Skipped:** ${skipped.map((x) => `${x.grammar} (${x.reason})`).join(', ')}\n`);
if (errors.length) s.addRaw(`\n**Errors:** ${errors.map((e) => `${e.grammar}: ${e.error}`).join('; ')}\n`);
if (!applied.length && !held.length && !skipped.length && !errors.length) s.addRaw('\nAll vendored grammars are up to date. ✅\n');
await s.write();
for (const h of held) core.notice(`${h.grammar}: update to ${h.upstream} available — ${h.hold ? `report-only (${h.hold})` : `ABI ${h.abi ?? 'unknown'} (need 13/14), held until the tree-sitter runtime upgrade`}.`);
if (!hasApp && (applied.length || skipped.some((x) => /secret/.test(x.reason)))) {
core.notice('RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY not configured — update PRs were not opened. Provision the App to enable auto-PRs.');
}

View file

@ -173,6 +173,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
### Gotchas
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.

View file

@ -36,6 +36,17 @@ RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
# `npm prune` removes anything not in package.json's dependency tree — which
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
# postinstall, but not declared as deps) and their freshly-built native bindings.
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
# must survive into the runtime layer. Re-run the grammar postinstall here in the
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
# as a former `dependency` it used to survive prune; vendored, it would not.
RUN npm run postinstall --prefix gitnexus
# -- Runtime -----------------------------------------------------------
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime

View file

@ -117,9 +117,9 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` — those three won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. This variable does **not** control `tree-sitter-kotlin` (a third-party npm `optionalDependency` that npm compiles via its own `node-gyp-build` step regardless); to skip the Kotlin compile too, add `npm install --omit=optional` — which also drops the `node-gyp-build`/`node-addon-api` build deps and so disables the vendored builds as well. See the `tree-sitter-kotlin` note below.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. See the `tree-sitter-kotlin` note below.
>
> **About `tree-sitter-kotlin`:** unlike the vendored grammars, Kotlin support comes from a third-party npm `optionalDependency` that ships **source only** (no upstream prebuilt binaries) and compiles via node-gyp at install time. On a host without a C/C++ toolchain its native build soft-fails: npm skips the optional dependency, the `gitnexus` install still **succeeds**, and only Kotlin (`.kt`/`.kts`) parsing is unavailable. An install-time probe surfaces a single clear warning when the binding is missing (suppressed only if you opted out with `--omit=optional`), instead of leaving raw node-gyp output as the only signal, and it honors `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. (GitNexus does **not** yet ship prebuilt Kotlin binaries. GitNexus already vendors its own self-built Swift prebuilds and could do the same for Kotlin — that's deferred Swift-parity follow-up work tracked in [#2107](https://github.com/abhigyanpatwari/GitNexus/issues/2107), not an upstream blocker.)
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream `tree-sitter-kotlin` ships **source only** (no prebuilt binaries), so GitNexus builds the Kotlin platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline now used for Dart, Proto, and Swift (Swift's prebuilds were originally copied from upstream; they're now GitNexus-cross-built too). `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
### MCP Setup
@ -225,6 +225,7 @@ args = ["-y", "gitnexus@latest", "mcp"]
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@ -261,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Embeddings node limit
@ -330,7 +333,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time, and silences GitNexus's `tree-sitter-kotlin` probe. It does **not** stop npm from compiling `tree-sitter-kotlin` (a third-party `optionalDependency` with its own `node-gyp-build` step) — use `npm install --omit=optional` to skip that compile too. Without a toolchain the Kotlin build soft-fails, npm skips it, the install still succeeds, and only Kotlin parsing is lost. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; willing to skip Dart/Proto/Swift parsing (and, with `--omit=optional`, Kotlin). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
#### Publishing to understand-quickly (opt-in)
@ -344,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| Tool | What It Does | `repo` Param |
| ----------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.6",
"version": "1.6.7",
"author": {
"name": "GitNexus"
},

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -13,6 +13,26 @@ node_modules/
vendor/**/node_modules
vendor/**/build
# ── Lean publish (FUTURE optimization — NOT done here) ─────────────────────────
# Once the build-tree-sitter-prebuilds workflow has committed 6/6 prebuilds for
# EVERY vendored grammar (c, dart, proto, kotlin, swift), the ~50 MB of generated
# source (parser.c etc.) can be dropped from the tarball — node-gyp-build never
# needs the source when a prebuild matches.
#
# IMPORTANT: this CANNOT be done from this file. package.json's `files: ["vendor"]`
# allow-list OVERRIDES .npmignore for the vendor/ subtree (verified: an active
# `vendor/**/src/parser.c` line here does NOT exclude it from `npm pack`). To slim
# the tarball, narrow the `files` field instead — replace the blanket "vendor"
# with the non-source subpaths only (vendor/**/prebuilds/**,
# vendor/**/bindings/node/index.*, vendor/**/src/node-types.json,
# vendor/**/package.json, vendor/**/LICENSE, vendor/**/README.md).
#
# Whatever the mechanism, the prepack guard
# (scripts/assert-publish-grammar-coverage.cjs, also `npm run
# assert-publish-coverage`) inspects the EFFECTIVE `npm pack` file list and FAILS
# the publish whenever a grammar with <6 prebuilds loses a source-build input — so
# the slim can never silently ship a dead grammar. Do not bypass it.
# Package lock (consumers use their own)
package-lock.json

View file

@ -4,10 +4,34 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.7] - 2026-06-09
### Added
- **Taint/PDG substrate (M0)** — foundational schema + seams for reliable taint analysis on a PDG-expandable substrate (#2080, Epic #2087). Adds the `BasicBlock` node label and `CFG` / `REACHING_DEF` / `TAINTED` / `SANITIZES` / `TAINT_PATH` relationship types to the graph schema (round-trip through the bulk-COPY path), a phase-registry seam (`registerPhase` / `enabledWhen`) generalising the graph-phase opt-in guard, and a per-language source/sink/sanitizer config registry seam. All additive and inert — no phase emits the new nodes/edges yet, and a default `analyze` run is byte-identical to before. De-risking spikes (LadybugDB rel-property indexing, post-dominator feasibility) recorded on the issue.
- **Control-flow-graph layer (M1, opt-in `--pdg`)** — populates the M0 `BasicBlock`/`CFG` substrate for TypeScript & JavaScript (#2081, Epic #2087). The parse worker builds a per-function control-flow graph from the tree-sitter AST (if/else, while/do-while/for/for-in/for-of, switch fallthrough, try/catch/finally post-domination, labeled break/continue, early return/throw), serializes it onto `ParsedFile.cfgSideChannel`, and scope-resolution emits `BasicBlock` nodes + `CFG` edges from it while the disk-backed ParsedFile store is still live. Gated behind `--pdg` (also a `.gitnexusrc` key); **off by default — a default `analyze` run remains byte-identical** (golden-graph parity). The opt-in is folded into the parse-cache key so a pdg-off warm cache is never reused on a `--pdg` run, and a per-function edge cap bounds graph growth (logs dropped edges; no silent truncation).
- **Toolchain-free tree-sitter install** — the `c`, `dart`, `proto`, `kotlin`, and `swift` grammars now ship vendored native prebuilds (six platform/arch each — linux/darwin/win32 × x64/arm64, every `.node` load-and-parse verified with committed `SHA256SUMS` and SLSA build provenance), so a fresh install no longer requires a C/C++ toolchain; `kotlin` moved off its `optionalDependency` into the vendored path, `dart`/`proto` keep a source-build fallback when no prebuild matches, and a registry-parameterized CI workflow builds, load-validates, and vendors the binaries (#2113, #2125, #2110)
- **`gitnexus uninstall`** — reverses `gitnexus setup` target-by-target, surgically removing GitNexus MCP server entries (Cursor, Claude Code, Antigravity, OpenCode, Codex), installed skill directories, and Claude Code / Antigravity hook entries with their bundled scripts; idempotent, JSONC-preserving, dry-run by default with `--force` to apply (#2062, #2060)
- **MCP `list_repos` pagination** — bounded `limit`/`offset` paging so clients can reliably enumerate every indexed repository instead of having the unpaginated array truncated by LLM token limits; the result is now a `{ repositories, pagination }` object (page until `pagination.hasMore` is false), with deterministic `(lower-cased name, path)` ordering (#2120, #2119)
- **C++ inheritance-lattice member lookup** — receiver members now resolve through the inheritance lattice with dominance hiding, ambiguous-base suppression, virtual-diamond deduplication, and overload ranking, and class-scope `using Base::member` declarations are no longer mistaken for namespace imports (#2077, #1891)
- **Taint/PDG substrate (M0)** — foundational graph schema and pipeline seams for reliable taint analysis on a PDG-expandable substrate: the `BasicBlock` node label and `CFG` / `REACHING_DEF` / `TAINTED` / `SANITIZES` / `TAINT_PATH` relationship types (round-tripped through the bulk-COPY path), a phase-registry seam (`registerPhase` / `enabledWhen`) generalising the graph-phase opt-in guard, and a per-language source/sink/sanitizer config registry. All additive and inert — no phase emits the new nodes/edges yet and a default `analyze` run is byte-identical to before (#2092, #2080)
### Fixed
- **Optional grammars lazy-loaded so `analyze` never crashes when one is missing** — the swift/dart/kotlin `query.ts` modules no longer statically import their tree-sitter binding at module load, so a missing optional grammar can no longer abort `gitnexus analyze` (or the MCP server, `doctor`, and `.githooks` auto-reindex) with `ERR_MODULE_NOT_FOUND` regardless of the repo's actual languages; grammars now resolve lazily at first use inside the worker, `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` is honored at runtime, the scope-resolution phase excludes unavailable-language files, and skip diagnostics/precheck globs were corrected (#2101, #2091, #2093)
- **`tree-sitter-kotlin` optional-grammar install** — install now fails soft when no C/C++ toolchain is present, emitting one clear warning and always exiting 0 (mirroring the Swift/Dart/Proto probes) instead of breaking `gitnexus` install; optional-grammar/toolchain docs corrected to include Kotlin (#2110, #2107)
- **CLI image FTS keyword search** — the full-text-search extension is now baked into the CLI Docker image so a containerized `serve` does offline keyword search instead of silently degrading to vector-only (#2108)
### Changed
- **Tree-sitter prebuild CI matrix greened and made re-run-safe** — dropped the broken `-t 22` flag from the `prebuildify` invocation that crashed every matrix job (`v.indexOf is not a function`; N-API prebuilds are Node-version-agnostic, so no target is needed) (#2121), cleared npm-bundled `prebuilds/` before prebuildify so the host tuple is detected (not a stray `win32-x64`) and source-built the `tree-sitter` runtime peer on `linux-arm64` where upstream ships no prebuild (#2122), and switched the vendor-prebuilds push to `git push --force` so re-running a workflow no longer fails with a stale-lease rejection (#2123)
### Performance
- **MCP `query` enrichment batched** — the `query` tool now batches its per-symbol enrichment lookups (3N sequential pool round-trips collapsed to 2–3 `WHERE n.id IN $nodeIds` queries), cutting N+1 round-trips with byte-identical output (#2108)
### Chore / Dependencies
- **`@ladybugdb/core` bumped 0.17.0 → 0.17.1 in /gitnexus** (#2098)
- **Claude plugin manifests synced to the release version** — bumped `plugin.json` and the `gitnexus` `marketplace.json` entry to match the published npm version (stale `1.3.x` manifests had blocked marketplace updates), added a Vitest guard asserting all three manifests advertise one version, and documented the sync step in `CONTRIBUTING.md` (#2090)
## [1.6.6] - 2026-06-08

View file

@ -126,7 +126,7 @@ Your AI agent gets these tools automatically:
| Tool | What It Does | `repo` Param |
| ---------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@ -159,6 +159,7 @@ Your AI agent gets these tools automatically:
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.6",
"version": "1.6.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.6",
"version": "1.6.7",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@ -27,13 +27,14 @@
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
@ -64,11 +65,6 @@
},
"engines": {
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-kotlin": "^0.3.8"
}
},
"../gitnexus-shared": {
@ -1814,9 +1810,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@ -3436,9 +3432,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@ -4971,25 +4977,6 @@
"node-gyp-build": "^4.8.0"
}
},
"node_modules/tree-sitter-c": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz",
"integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.1"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-c-sharp": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz",
@ -5085,33 +5072,6 @@
}
}
},
"node_modules/tree-sitter-kotlin": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz",
"integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-kotlin/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/tree-sitter-php": {
"version": "0.23.12",
"resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.6",
"version": "1.6.7",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@ -49,9 +49,10 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs && node scripts/build-tree-sitter-kotlin.cjs",
"postinstall": "node scripts/materialize-vendor-grammars.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/build.js"
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
@ -71,13 +72,14 @@
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
@ -90,11 +92,6 @@
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-kotlin": "^0.3.8"
},
"devDependencies": {
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",

View file

@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* Publish guard: every vendored tree-sitter grammar must ship a loadable binding.
*
* The npm tarball includes gitnexus/vendor/ (package.json `files`). A grammar is
* "covered" on a platform-arch tuple if EITHER a prebuild ships for it OR the
* grammar's full source-build set ships (so the install can source-build it,
* toolchain permitting). A future lean publish — dropping the ~50 MB of generated
* source to ship prebuilds only — is safe ONLY once every grammar has all six
* prebuilds; doing it while any grammar still lacks a prebuild would ship a
* grammar with NO loadable binding (neither prebuild nor buildable source) → that
* language is silently dead for users.
*
* HOW SOURCE INCLUSION IS DECIDED. The `files` allow-list OVERRIDES `.npmignore`
* for the vendored subtree (verified: an active "vendor/(star-star)/src/parser.c"
* in .npmignore does NOT drop it from `npm pack`). So `.npmignore` can never
* exclude vendored source — the ONLY lever is the `files` field. A broad `vendor`
* ships the whole subtree (source + prebuilds); a lean publish narrows `files` to
* non-source subpaths. This guard therefore reads `files` directly rather than
* shelling out to `npm pack` (which, in prepack, would re-enter this guard and,
* on npm versions that don't honor --ignore-scripts for prepare/prepack, run the
* full build — slow enough to time out and fragile).
*
* Wired via `prepack`, so it fails `npm pack` / `npm publish` if the invariant is
* violated.
*/
const fs = require('fs');
const path = require('path');
const TUPLES = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64',
];
// Source-build inputs (relative to vendor/<name>/) whose presence makes a grammar
// source-buildable. Per-grammar we only require the ones that exist on disk (e.g.
// tree-sitter-c has no external scanner.c).
const SOURCE_BUILD_REL = [
'binding.gyp',
'bindings/node/binding.cc',
'src/parser.c',
'src/scanner.c',
'src/tree_sitter/parser.h',
];
/**
* Does the package.json `files` allow-list ship the WHOLE vendor subtree (and
* therefore the vendored grammar source)? A bare `vendor` (optionally with a
* trailing slash or `/**`/`/*`) includes everything under vendor/. A lean publish
* replaces that with non-source subpaths, so this returns false and grammars must
* then rely on prebuilds.
*/
function filesShipsVendorSource(filesField) {
return (filesField || []).some((f) => {
const n = String(f)
.replace(/\\/g, '/')
.replace(/\/+$/, '')
.replace(/\/\*\*?$/, '');
return n === 'vendor';
});
}
/** The on-disk source-build inputs for a grammar (relative paths). */
function sourceBuildSet(grammarDir) {
return SOURCE_BUILD_REL.filter((rel) => fs.existsSync(path.join(grammarDir, rel)));
}
/** True when a grammar can be source-built from its vendored files (has gyp + parser). */
function isBuildableFromSource(grammarDir) {
const set = sourceBuildSet(grammarDir);
return set.includes('binding.gyp') && set.includes('src/parser.c');
}
/** Count platform-arch tuples with a committed prebuilt .node on disk. */
function countPrebuiltTuples(grammarDir) {
const pdir = path.join(grammarDir, 'prebuilds');
let n = 0;
for (const t of TUPLES) {
const td = path.join(pdir, t);
try {
if (fs.statSync(td).isDirectory() && fs.readdirSync(td).some((f) => f.endsWith('.node'))) {
n++;
}
} catch {
/* tuple dir absent — not covered */
}
}
return n;
}
/**
* Pure core (exported for tests). `grammars` is a list of
* `{ name, prebuilt: 0..6, shipsSource: boolean }`. Returns human-readable
* problem strings; an empty array means the pack is publish-safe.
*/
function findCoverageProblems({ grammars }) {
const problems = [];
for (const g of grammars) {
if (g.prebuilt < 6 && !g.shipsSource) {
const missing = 6 - g.prebuilt;
problems.push(
`${g.name}: ${g.prebuilt}/6 prebuilds and its vendored source is not shipped ` +
`(the package.json \`files\` field excludes it, or it is not buildable) — would ship ` +
`with no loadable binding on ${missing} platform-arch tuple(s).`,
);
}
}
return problems;
}
function collectGrammars(vendorDir, shipsVendorSource) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.map((name) => {
const dir = path.join(vendorDir, name);
return {
name,
prebuilt: countPrebuiltTuples(dir),
// Source ships when `files` includes the vendor subtree AND the grammar
// actually carries a buildable source set on disk.
shipsSource: shipsVendorSource && isBuildableFromSource(dir),
};
});
}
function main() {
const gitnexusRoot = path.join(__dirname, '..');
const vendorDir = path.join(gitnexusRoot, 'vendor');
const pkg = JSON.parse(fs.readFileSync(path.join(gitnexusRoot, 'package.json'), 'utf8'));
const shipsVendorSource = filesShipsVendorSource(pkg.files);
const grammars = collectGrammars(vendorDir, shipsVendorSource);
if (grammars.length === 0) {
console.error(`[publish-guard] No vendored tree-sitter grammars found under ${vendorDir}.`);
process.exit(1);
}
const problems = findCoverageProblems({ grammars });
if (problems.length > 0) {
console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
for (const p of problems) console.error(` - ${p}`);
console.error(
'\nFix: either commit the missing prebuilds (run the build-tree-sitter-prebuilds\n' +
'workflow) or keep the vendored source in the package.json `files` field.',
);
process.exit(1);
}
const sourceShippers = grammars.filter((g) => g.shipsSource).length;
console.log(
`[publish-guard] OK — ${grammars.length} vendored grammar(s) covered ` +
`(${sourceShippers} shipping source, ${grammars.length - sourceShippers} prebuilds-only).`,
);
}
if (require.main === module) main();
module.exports = {
findCoverageProblems,
filesShipsVendorSource,
isBuildableFromSource,
sourceBuildSet,
countPrebuiltTuples,
collectGrammars,
TUPLES,
SOURCE_BUILD_REL,
};

View file

@ -1,57 +0,0 @@
#!/usr/bin/env node
/**
* Build tree-sitter-dart native binding in node_modules/ after materialize-vendor-grammars.cjs.
* Vendored source lives in vendor/ only; see #836 and #1728.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Opt-out: skip the native rebuild entirely. Dart parsing becomes
// unavailable but `npm install gitnexus` finishes much faster on machines
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
// (read as a string), and any other value all fall through to the rebuild.
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
console.warn(
'[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
);
process.exit(0);
}
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
const bindingGyp = path.join(dartDir, 'binding.gyp');
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
try {
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
process.exit(0);
}
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
'[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).',
resolveErr.message,
);
console.warn(
'[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
);
process.exit(0);
}
console.log('[tree-sitter-dart] Building native binding...');
execSync('npx node-gyp rebuild', {
cwd: dartDir,
stdio: 'pipe',
timeout: 180000,
});
console.log('[tree-sitter-dart] Native binding built successfully');
} catch (err) {
console.warn('[tree-sitter-dart] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.',
);
process.exit(0);
}

View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Activate the vendored tree-sitter native bindings after
* materialize-vendor-grammars.cjs. One registry-driven script replaces the
* former per-grammar build-tree-sitter-<name>.cjs files (they were ~95%
* identical).
*
* For each grammar the resolution order is identical:
* 1. If the package isn't materialized (no binding.gyp) or the binding is
* already built, do nothing.
* 2. Prefer a committed prebuild for this platform-arch (toolchain-free) via
* node-gyp-build — the goal once build-tree-sitter-prebuilds.yml has
* populated all six tuples.
* 3. Otherwise source-build from the vendored grammar source (binding.gyp +
* src/) so parsing still works on any toolchain host — e.g. CI, before the
* prebuilds land.
*
* HARD INVARIANT: this runs in `gitnexus`'s postinstall, so it MUST NEVER throw
* or exit non-zero — a failure for any single grammar must not break the install.
*
* Opt-out: GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (strict '1') skips the OPTIONAL
* grammars only. tree-sitter-c is REQUIRED (it backstops upstream's 4/6 ARM
* prebuild gap, #2116) and is always built.
*
* Usage:
* node build-tree-sitter-grammars.cjs # all grammars (postinstall)
* node build-tree-sitter-grammars.cjs swift c # only the named grammars
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Registry. `display`/`ext` drive the human-readable warnings; `required`
// grammars ignore the opt-out gate. Insertion order == build order (c first).
const GRAMMARS = {
c: { required: true, display: 'C', ext: '.c' },
dart: { required: false, display: 'Dart', ext: '.dart' },
proto: { required: false, display: 'Proto', ext: '.proto' },
swift: { required: false, display: 'Swift', ext: '.swift' },
kotlin: { required: false, display: 'Kotlin', ext: '.kt/.kts' },
};
const skipOptional = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1';
function buildGrammar(short) {
const cfg = GRAMMARS[short];
const tag = `[tree-sitter-${short}]`;
if (!cfg.required && skipOptional) {
console.warn(
`${tag} Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). ${cfg.display} parsing will be unavailable until reinstalled without the env var.`,
);
return;
}
const dir = path.join(__dirname, '..', 'node_modules', `tree-sitter-${short}`);
const bindingGyp = path.join(dir, 'binding.gyp');
const bindingNode = path.join(dir, 'build', 'Release', `tree_sitter_${short}_binding.node`);
try {
// Not materialized (no source), or already built — nothing to do.
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
return;
}
// Prefer a committed prebuild for this platform-arch (no toolchain needed).
try {
require('node-gyp-build').path(dir);
return;
} catch {
// No matching prebuild — fall through to the source build below.
}
// The hoisted build deps must be resolvable to source-build.
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
`${tag} Skipping build: hoisted build deps not resolvable (${resolveErr.message}).`,
);
console.warn(
`${tag} ${cfg.display} parsing will be unavailable until a prebuild or toolchain is present.`,
);
return;
}
console.log(`${tag} No prebuild for this platform — building native binding from source...`);
execSync('npx node-gyp rebuild', { cwd: dir, stdio: 'pipe', timeout: 180000 });
console.log(`${tag} Native binding built successfully`);
} catch (err) {
console.warn(`${tag} Could not build native binding:`, err.message);
console.warn(
`${tag} ${cfg.display} (${cfg.ext}) parsing will be unavailable. Non-${cfg.display} functionality is unaffected.`,
);
}
}
function main() {
const args = process.argv.slice(2).filter(Boolean);
const targets = args.length > 0 ? args : Object.keys(GRAMMARS);
for (const short of targets) {
if (!GRAMMARS[short]) {
console.warn(`[tree-sitter] Unknown grammar '${short}' — skipping.`);
continue;
}
// Defensive: never let an unexpected throw escape and fail the install.
try {
buildGrammar(short);
} catch (err) {
console.warn(`[tree-sitter-${short}] Unexpected build error (ignored): ${err.message}`);
}
}
// Hard guarantee: postinstall must never exit non-zero.
process.exit(0);
}
if (require.main === module) main();
module.exports = { GRAMMARS, buildGrammar };

View file

@ -1,74 +0,0 @@
#!/usr/bin/env node
/**
* Probe tree-sitter-kotlin native-binding availability at install time.
*
* Unlike Dart/Proto/Swift (vendored under vendor/ and materialized into
* node_modules/ at postinstall), tree-sitter-kotlin is a third-party npm
* `optionalDependency`. It ships SOURCE ONLY — no upstream `prebuilds/` dir —
* and its own `install` script runs `node-gyp-build`, which compiles the
* native binding from source via node-gyp. On a host without a C/C++ toolchain
* that build soft-fails: npm skips the optional dependency and the `gitnexus`
* install still succeeds. This probe surfaces a single, friendly install-time
* warning when the Kotlin binding is unavailable — whether npm pruned the
* optional dependency after a toolchain-less build failure (its dir is gone,
* which is the common case) or the dir survives but the binding won't load —
* instead of leaving a raw node-gyp error or a first-use runtime failure as the
* only signal. A deliberate opt-out (`--omit=optional`) stays silent. The probe
* does not copy, register, or mutate anything; the runtime require() path in
* parser-loader does the actual load. This probe MUST NEVER throw or exit
* non-zero — it must never break `gitnexus` install.
*/
const fs = require('fs');
const path = require('path');
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
console.warn(
'[tree-sitter-kotlin] Skipping native-binding probe (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1).',
);
process.exit(0);
}
const kotlinDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-kotlin');
// `--omit=optional` / `--no-optional` / `.npmrc omit=optional` surface to
// lifecycle scripts as `npm_config_omit` containing `optional` (a comma- or
// space-separated list, e.g. `dev,optional`). That is a deliberate opt-out, so
// an absent package for that reason should stay silent. Any OTHER absence means
// npm attempted the optional dependency's native build and pruned the package
// after it soft-failed (the toolchain-less case) — exactly when the guidance
// below is worth surfacing.
const omitsOptional = /(^|[,\s])optional([,\s]|$)/.test(process.env.npm_config_omit || '');
function warnKotlinUnavailable(err) {
if (err) {
console.warn('[tree-sitter-kotlin] Native-binding probe failed:', err.message);
}
console.warn(
'[tree-sitter-kotlin] Kotlin (.kt/.kts) parsing will be unavailable. Non-Kotlin functionality is unaffected.',
);
console.warn(
'[tree-sitter-kotlin] This is expected on hosts without a C/C++ toolchain: tree-sitter-kotlin ships source only (no upstream prebuilt binaries) and compiles via node-gyp at install. Set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 to skip this probe.',
);
}
try {
if (!fs.existsSync(path.join(kotlinDir, 'bindings', 'node', 'index.js'))) {
// The package never materialized. If the user deliberately omitted optional
// dependencies, stay silent — they opted out. Otherwise npm pruned the
// package after its native build soft-failed (no toolchain), and this is the
// dominant real-world failure case: surface the guidance the raw node-gyp
// error would otherwise be the only signal of.
if (!omitsOptional) {
warnKotlinUnavailable();
}
process.exit(0);
}
const nodeGypBuild = require('node-gyp-build');
nodeGypBuild(kotlinDir);
} catch (err) {
// The package is present but its native binding can't be loaded (e.g. the dir
// survived with --ignore-scripts, or a partial/ABI-mismatched build).
warnKotlinUnavailable(err);
process.exit(0);
}

View file

@ -1,92 +0,0 @@
#!/usr/bin/env node
/**
* Build tree-sitter-proto native binding.
*
* Why this script exists:
* tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/
* and copied into node_modules/ by materialize-vendor-grammars.cjs. Previously, the vendored
* package had its own `dependencies` and `install` script, which caused
* npm to create `vendor/tree-sitter-proto/node_modules/` and
* `vendor/tree-sitter-proto/build/` during install. Those directories
* blocked `rmdir` on global-install upgrade, producing:
*
* ENOTEMPTY: directory not empty, rmdir
* '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api'
*
* (See https://github.com/abhigyanpatwari/GitNexus/issues/836.)
*
* We stripped `dependencies` and the `install` script from the vendored
* package.json, hoisted `node-addon-api` and `node-gyp-build` into
* gitnexus's own optionalDependencies, and moved native compilation here.
*
* What this does:
* Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`.
* Build output lands in
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
* — under npm-managed territory, safe on upgrade.
*
* Mirrors the tree-sitter-dart build helper. Best-effort: if any
* precondition fails (optional dep absent, no toolchain, --ignore-scripts),
* warn and exit 0 so gitnexus install still succeeds.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Opt-out: skip the native rebuild entirely. Proto parsing becomes
// unavailable but `npm install gitnexus` finishes much faster on machines
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
// (read as a string), and any other value all fall through to the rebuild.
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
console.warn(
'[tree-sitter-proto] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Proto parsing will be unavailable until reinstalled without the env var.',
);
process.exit(0);
}
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
const bindingGyp = path.join(protoDir, 'binding.gyp');
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
try {
if (!fs.existsSync(bindingGyp)) {
// tree-sitter-proto is an optionalDependency; absent when install
// skipped optional deps or the file: dep was not resolved.
process.exit(0);
}
// Skip if the native binding already exists (idempotent re-run).
if (fs.existsSync(bindingNode)) {
process.exit(0);
}
// Pre-flight: the hoisted build deps must be resolvable.
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
'[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).',
resolveErr.message,
);
console.warn(
'[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
);
process.exit(0);
}
console.log('[tree-sitter-proto] Building native binding...');
execSync('npx node-gyp rebuild', {
cwd: protoDir,
stdio: 'pipe',
timeout: 180000,
});
console.log('[tree-sitter-proto] Native binding built successfully');
} catch (err) {
console.warn('[tree-sitter-proto] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.',
);
// Exit 0: optionalDependency failures must not fail the gitnexus install.
process.exit(0);
}

View file

@ -1,39 +0,0 @@
#!/usr/bin/env node
/**
* Probe tree-sitter-swift prebuild availability at install time.
*
* The vendored package ships platform prebuilds; node-gyp-build selects the
* correct binary at require time. This script calls node-gyp-build once
* against the materialized package so a missing-prebuild failure surfaces
* as an install-time warning (with the rest of the gitnexus install
* succeeding) rather than as a runtime error the first time Swift parsing
* is requested. The result is discarded — it does not copy, register, or
* mutate anything; the runtime require() path in parser-loader does the
* actual load. Running this probe here instead of an npm `install` script
* on the vendored package preserves the #836 hygiene (no scripts.install
* inside vendor/).
*/
const fs = require('fs');
const path = require('path');
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
console.warn('[tree-sitter-swift] Skipping prebuild probe (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1).');
process.exit(0);
}
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
try {
if (!fs.existsSync(path.join(swiftDir, 'bindings', 'node', 'index.js'))) {
process.exit(0);
}
const nodeGypBuild = require('node-gyp-build');
nodeGypBuild(swiftDir);
} catch (err) {
console.warn('[tree-sitter-swift] Prebuild probe failed:', err.message);
console.warn(
'[tree-sitter-swift] Swift parsing will be unavailable. Non-Swift functionality is unaffected.',
);
process.exit(0);
}

View file

@ -13,14 +13,28 @@ const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const VENDORED_GRAMMARS = ['tree-sitter-dart', 'tree-sitter-proto', 'tree-sitter-swift'];
// tree-sitter-c is a REQUIRED grammar that we vendor prebuild-only purely to
// close upstream's ARM prebuild gap (#2116) — it needs no toolchain and is not a
// language the user opts out of, so it is always materialized, even under
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS. The rest are optional (user-skippable, and
// Dart/Proto compile from source) and honor the skip flag.
const REQUIRED_VENDORED = ['tree-sitter-c'];
const OPTIONAL_VENDORED = [
'tree-sitter-dart',
'tree-sitter-proto',
'tree-sitter-swift',
'tree-sitter-kotlin',
];
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
const skipOptional = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1';
if (skipOptional) {
console.warn(
'[gitnexus] Skipping vendored grammar materialize (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart/Proto/Swift parsing will be unavailable.',
'[gitnexus] GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1: skipping optional Dart/Proto/Swift/Kotlin materialize (required C is still materialized).',
);
process.exit(0);
}
const VENDORED_GRAMMARS = skipOptional
? REQUIRED_VENDORED
: [...REQUIRED_VENDORED, ...OPTIONAL_VENDORED];
for (const name of VENDORED_GRAMMARS) {
const src = path.join(ROOT, 'vendor', name);
@ -49,20 +63,31 @@ for (const name of VENDORED_GRAMMARS) {
fs.renameSync(partial, dest);
} catch (renameErr) {
// Best-effort rollback: restore the previous dest from backup.
let restored = false;
if (fs.existsSync(backup)) {
try {
fs.renameSync(backup, dest);
restored = true;
} catch {
// If rollback also fails, the prior backup directory still exists on
// disk — the catch block below surfaces both errors via the warning.
// Rollback also failed — dest is now missing. Leave the backup in
// place (the catch below will NOT remove it) and surface where it is.
}
}
if (!restored && fs.existsSync(backup)) {
console.warn(
`[gitnexus] CRITICAL: could not materialize vendor/${name} AND could not restore the ` +
`previous node_modules/${name}. A recoverable copy remains at ${backup} — ` +
`restore it (e.g. \`mv ${backup} ${dest}\`) or reinstall to recover ${name}.`,
);
}
throw renameErr;
}
fs.rmSync(backup, { recursive: true, force: true });
} catch (err) {
// Fail-soft: a single locked/inaccessible file (common on Windows) must not
// abort the whole gitnexus install. Matches build-tree-sitter-*.cjs pattern.
// Only remove the scratch `partial`; never the `backup` (it may be the sole
// recoverable copy after a failed rollback above).
fs.rmSync(partial, { recursive: true, force: true });
console.warn(`[gitnexus] Could not materialize vendor/${name}: ${err.message}`);
console.warn(

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -0,0 +1,187 @@
/**
* Editor targets — the single source of truth for *where* GitNexus writes its
* per-editor configuration and *how* its entries are identified.
*
* `setup` (writes these) and `uninstall` (removes them) both consume this
* module so the two stay structurally in lock-step: add or change a target
* here and both sides follow. This is declarative metadata only — file
* locations, JSON key paths, hook event names, command needles, and script
* directories, plus the shared `detectIndentation` formatting helper. The
* format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
* command array, Gemini's hook schema) deliberately stays in setup.ts /
* uninstall.ts.
*
* The `setup → uninstall` round-trip integration test verifies the two
* implementations remain behaviourally symmetrical on top of this shared
* structure.
*/
import os from 'os';
import path from 'path';
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's MCP config file. */
file: string;
/**
* JSON path of the gitnexus server entry within that file. Typed as
* `string[]` (all our keys are object keys) so it satisfies both setup's
* `mergeJsoncFile(string[])` and uninstall's `removeJsoncKey(JSONPath)`
* without either side needing a cast.
*/
keyPath: string[];
}
/** Codex stores MCP config as a TOML table, not JSONC. */
export interface CodexMcpTarget {
id: 'codex';
label: string;
/** Absolute path to ~/.codex/config.toml. */
configFile: string;
/** The TOML table header (without brackets) setup writes / uninstall strips. */
tomlSection: string;
}
export interface SkillTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's skills directory. */
dir: string;
}
export interface HookTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's settings file (JSONC). */
settingsFile: string;
/** Hook event arrays that may hold a gitnexus entry. */
events: string[];
/** Substring identifying the gitnexus command within a hook entry. */
needle: string;
/** Absolute path to the bundled hook-script directory setup writes. */
scriptDir: string;
}
export interface EditorTargets {
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
mcpJsonc: McpJsoncTarget[];
/** Codex MCP (TOML). */
codex: CodexMcpTarget;
/** Skill install directories, one per editor that supports skills. */
skills: SkillTarget[];
/** Hook registrations + their bundled script directories. */
hooks: HookTarget[];
}
/**
* Resolve all editor targets for the given home directory. Defaults to
* `os.homedir()`; call sites pass it through so tests can point HOME at a temp
* dir. Paths are computed at call time (not module load) so a test setting
* `process.env.HOME` before invoking sees the right locations.
*/
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
const mcpJsonc: McpJsoncTarget[] = [
{
id: 'cursor',
label: 'Cursor',
file: path.join(home, '.cursor', 'mcp.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'claude',
label: 'Claude Code',
file: path.join(home, '.claude.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'antigravity',
label: 'Antigravity',
file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'opencode',
label: 'OpenCode',
file: path.join(home, '.config', 'opencode', 'opencode.json'),
// OpenCode nests servers under `mcp`, not `mcpServers`.
keyPath: ['mcp', 'gitnexus'],
},
];
const codex: CodexMcpTarget = {
id: 'codex',
label: 'Codex',
configFile: path.join(home, '.codex', 'config.toml'),
tomlSection: 'mcp_servers.gitnexus',
};
const skills: SkillTarget[] = [
{ id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
{
id: 'antigravity',
label: 'Antigravity',
dir: path.join(home, '.gemini', 'antigravity', 'skills'),
},
{ id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
{ id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
];
const hooks: HookTarget[] = [
{
id: 'claude',
label: 'Claude Code',
settingsFile: path.join(home, '.claude', 'settings.json'),
events: ['PreToolUse', 'PostToolUse'],
needle: 'gitnexus-hook',
scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
},
{
id: 'antigravity',
label: 'Antigravity',
settingsFile: path.join(home, '.gemini', 'settings.json'),
events: ['AfterTool'],
needle: 'gitnexus-antigravity-hook',
scriptDir: path.join(home, '.gemini', 'config', 'hooks', 'gitnexus'),
},
];
return { mcpJsonc, codex, skills, hooks };
}
/** Look up a single JSONC MCP target by editor id (throws if unknown). */
export function mcpTarget(id: EditorId, home?: string): McpJsoncTarget {
const t = getEditorTargets(home).mcpJsonc.find((m) => m.id === id);
if (!t) throw new Error(`No JSONC MCP target for editor "${id}"`);
return t;
}
/** Look up a single skill target by editor id (throws if unknown). */
export function skillTarget(id: EditorId, home?: string): SkillTarget {
const t = getEditorTargets(home).skills.find((s) => s.id === id);
if (!t) throw new Error(`No skill target for editor "${id}"`);
return t;
}
/** Look up a single hook target by editor id (throws if unknown). */
export function hookTarget(id: EditorId, home?: string): HookTarget {
const t = getEditorTargets(home).hooks.find((h) => h.id === id);
if (!t) throw new Error(`No hook target for editor "${id}"`);
return t;
}
/**
* Detect indentation style from file content so JSONC edits preserve the file's
* existing formatting. Shared by setup (writes) and uninstall (removes).
*/
export function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}

View file

@ -32,7 +32,11 @@
import http from 'http';
import { isIPv4, isIPv6 } from 'node:net';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import {
LocalBackend,
type RepoListing,
type ListReposPagination,
} from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn, cliError } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
@ -265,13 +269,22 @@ export function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
export function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';
export function formatListReposResult(result: {
repositories: RepoListing[];
pagination?: ListReposPagination;
}): string {
// `list_repos` always returns the paginated { repositories, pagination } object (#2119).
const repos = result.repositories;
const pg = result.pagination;
if (repos.length === 0) {
return pg && pg.total > 0
? `No repositories on this page (offset ${pg.offset} of ${pg.total} total).`
: 'No indexed repositories.';
}
const lines = ['Indexed repositories:\n'];
for (const r of result) {
for (const r of repos) {
const stats = r.stats || {};
lines.push(
` ${r.name} — ${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`,
@ -279,6 +292,13 @@ export function formatListReposResult(result: any): string {
lines.push(` Path: ${r.path}`);
lines.push(` Indexed: ${r.indexedAt}`);
}
if (pg) {
lines.push('');
lines.push(
` Showing ${repos.length} of ${pg.total} (offset ${pg.offset}).` +
(pg.hasMore ? ` More available — re-run with offset ${pg.nextOffset}.` : ''),
);
}
return lines.join('\n');
}
@ -325,6 +345,9 @@ function getNextStepHint(toolName: string): string {
case 'detect_changes':
return '\n---\nNext: Run gitnexus-context "<symbol>" on high-risk changed symbols to check their callers.';
case 'list_repos':
return '\n---\nNext: READ gitnexus://repo/{name}/context for a repo above. If pagination.hasMore is true, re-run list_repos with offset set to pagination.nextOffset to page through the rest.';
default:
return '';
}

View file

@ -12,6 +12,7 @@ const TITLE_KEYS = {
const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
@ -69,6 +70,7 @@ const OPTION_DESCRIPTION_KEYS = {
'index|--allow-non-git': 'help.option.index.allowNonGit',
'serve|-p, --port <port>': 'help.option.port',
'serve|--host <host>': 'help.option.serve.host',
'uninstall|-f, --force': 'help.option.uninstall.force',
'clean|-f, --force': 'help.option.force.confirmation',
'clean|--all': 'help.option.clean.all',
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',

View file

@ -106,6 +106,8 @@ export const en = {
'help.option.version': 'output the version number',
'help.command.setup.description':
'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
@ -185,6 +187,7 @@ export const en = {
'help.option.port': 'Port number',
'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
'help.option.clean.all': 'Clean all indexed repos',
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',

View file

@ -108,6 +108,8 @@ export const zhCN = {
'help.option.help': '显示命令帮助',
'help.option.version': '输出版本号',
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
@ -174,6 +176,7 @@ export const zhCN = {
'help.option.port': '端口号',
'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
'help.option.force.confirmation': '跳过确认提示',
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
'help.option.clean.all': '清理所有已索引仓库',
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',

View file

@ -23,6 +23,14 @@ program
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('uninstall')
.description(
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
)
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('analyze [path]')
.description('Index a repository (full analysis)')

View file

@ -15,6 +15,13 @@ import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
getEditorTargets,
mcpTarget,
skillTarget,
hookTarget,
detectIndentation,
} from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -162,17 +169,6 @@ function getOpenCodeMcpEntry() {
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
}
/**
* Detect indentation style from file content.
* Returns formatting options matching the file's existing style.
*/
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}
/**
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
@ -233,9 +229,9 @@ async function setupCursor(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(cursorDir, 'mcp.json');
const { file: mcpPath, keyPath } = mcpTarget('cursor');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Cursor');
} else {
@ -254,9 +250,9 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
}
// Claude Code stores MCP config in ~/.claude.json
const mcpPath = path.join(os.homedir(), '.claude.json');
const { file: mcpPath, keyPath } = mcpTarget('claude');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Claude Code');
} else {
@ -276,7 +272,7 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const skillsDir = path.join(claudeDir, 'skills');
const skillsDir = skillTarget('claude').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -422,13 +418,14 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const settingsPath = path.join(claudeDir, 'settings.json');
const claudeHook = hookTarget('claude');
const settingsPath = claudeHook.settingsFile;
// Source hooks bundled within the gitnexus package (hooks/claude/)
const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
// Copy unified hook script to ~/.claude/hooks/gitnexus/
const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
const destHooksDir = claudeHook.scriptDir;
try {
await fs.mkdir(destHooksDir, { recursive: true });
@ -494,7 +491,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PreToolUse',
value: {
@ -510,7 +507,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
},
});
}
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PostToolUse',
value: {
@ -566,9 +563,9 @@ async function setupAntigravity(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
const { file: mcpPath, keyPath } = mcpTarget('antigravity');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Antigravity');
} else {
@ -590,7 +587,7 @@ async function installAntigravitySkills(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const skillsDir = path.join(antigravityDir, 'skills');
const skillsDir = skillTarget('antigravity').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -618,9 +615,9 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const geminiDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiDir, 'settings.json');
const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
const antigravityHook = hookTarget('antigravity');
const settingsPath = antigravityHook.settingsFile;
const destHooksDir = antigravityHook.scriptDir;
// The antigravity adapter shares its lock/probe helpers with the claude
// adapter — same DB, same concurrency rules — so we reuse those CJS files
@ -694,7 +691,7 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', antigravityHook.needle)) {
// Matcher follows the Gemini CLI built-in tool naming (snake_case).
// search_file_content / glob cover content + filename search; run_shell_command
// catches rg/grep invocations and the git commit family for stale-index hints.
@ -742,9 +739,9 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
return;
}
const configPath = path.join(opencodeDir, 'opencode.json');
const { file: configPath, keyPath } = mcpTarget('opencode');
try {
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
const ok = await mergeJsoncFile(configPath, keyPath, getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
@ -764,7 +761,7 @@ function getCodexMcpTomlSection(): string {
const entry = getMcpEntry();
const command = JSON.stringify(entry.command);
const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
return `[${getEditorTargets().codex.tomlSection}]\ncommand = ${command}\nargs = ${args}\n`;
}
/**
@ -778,7 +775,7 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
existing = '';
}
if (existing.includes('[mcp_servers.gitnexus]')) {
if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
return;
}
@ -809,7 +806,7 @@ async function setupCodex(result: SetupResult): Promise<void> {
}
try {
const configPath = path.join(codexDir, 'config.toml');
const configPath = getEditorTargets().codex.configFile;
await upsertCodexConfigToml(configPath);
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
} catch (err: any) {
@ -920,7 +917,7 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
if (!(await dirExists(cursorDir))) return;
const skillsDir = path.join(cursorDir, 'skills');
const skillsDir = skillTarget('cursor').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -938,7 +935,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
if (!(await dirExists(opencodeDir))) return;
const skillsDir = path.join(opencodeDir, 'skills');
const skillsDir = skillTarget('opencode').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@ -958,7 +955,7 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
if (!(await dirExists(codexDir))) return;
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
const skillsDir = skillTarget('codex').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {

View file

@ -0,0 +1,518 @@
/**
* Uninstall Command
*
* Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
* skills, and hooks that setup writes into each detected AI editor's
* global configuration. The set of targets (paths, key paths, hook events,
* needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
* two stay in lock-step.
*
* Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
* removed. Unrelated user config (other MCP servers, other hooks, JSONC
* comments, indentation) is preserved. Files that are absent or that
* never contained a gitnexus entry are left untouched.
*
* Ownership is by name: skill directories are matched by the bundled gitnexus
* skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
* needle. There is no per-install provenance marker yet (a user dir that
* happens to share a bundled skill name, or files a user added inside an
* installed skill dir, are matched purely by name) — which is why uninstall is
* a dry-run preview by default and prints the exact paths it will remove.
* Richer provenance tracking is a tracked follow-up.
*
* Intentionally NOT done here (printed as hints instead, since both are
* destructive in ways setup never caused):
* - per-repo indexes → `gitnexus clean --all`
* - the global npm package → `npm uninstall -g gitnexus`
*
* Default is a dry-run preview; pass --force to apply.
*/
import fs from 'fs/promises';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import {
parseTree,
modify,
applyEdits,
findNodeAtLocation,
parse as parseJsonc,
type ParseError,
type JSONPath,
} from 'jsonc-parser';
import { getEditorTargets, detectIndentation } from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execFileAsync = promisify(execFile);
interface UninstallResult {
removed: string[];
skipped: string[];
errors: string[];
}
type RemovalStatus = 'removed' | 'absent' | 'corrupt' | 'missing';
/**
* Remove a single key (by JSON path) from a JSONC file, preserving the
* surrounding comments and formatting. Returns:
* - 'missing': file does not exist
* - 'absent': file exists but the key isn't there (nothing to do)
* - 'corrupt': file isn't valid JSONC — left untouched on purpose
* - 'removed': the key was present (and removed unless dryRun)
*/
async function removeJsoncKey(
filePath: string,
keyPath: JSONPath,
dryRun: boolean,
): Promise<RemovalStatus> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return 'missing';
}
if (raw.trim().length === 0) return 'absent';
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) return 'corrupt';
if (!findNodeAtLocation(tree, keyPath)) return 'absent';
if (!dryRun) {
const formattingOptions = detectIndentation(raw);
const edits = modify(raw, keyPath, undefined, { formattingOptions });
await fs.writeFile(filePath, applyEdits(raw, edits), 'utf-8');
}
return 'removed';
}
/**
* Remove the gitnexus hook command(s) — those whose command string contains
* `commandNeedle` — from the given `eventNames` arrays in a JSONC settings
* file. Mirrors the idempotency probes in setup.ts (hasGitnexusHook /
* geminiHasGitnexusHook). Returns how many event entries contained a gitnexus
* command.
*
* Removal is element-granular to honor the "other hooks are preserved"
* contract: only the matching command object inside an entry's `hooks[]` is
* deleted. The surrounding matcher entry is removed only when it becomes
* empty (i.e. it held nothing but gitnexus commands — which is exactly what
* setup creates). A user who hand-added their own command alongside ours
* keeps it. Edits are applied highest-index-first so earlier indices stay
* valid across edits.
*/
async function removeHookEntries(
filePath: string,
eventNames: string[],
commandNeedle: string,
dryRun: boolean,
): Promise<{ status: RemovalStatus; count: number }> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return { status: 'missing', count: 0 };
}
if (raw.trim().length === 0) return { status: 'absent', count: 0 };
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
return { status: 'corrupt', count: 0 };
}
const parsed = parseJsonc(raw);
const formattingOptions = detectIndentation(raw);
let current = raw;
let total = 0;
const isGitnexusHook = (hh: any): boolean =>
typeof hh?.command === 'string' && hh.command.includes(commandNeedle);
for (const eventName of eventNames) {
const entries = parsed?.hooks?.[eventName];
if (!Array.isArray(entries)) continue;
// Walk entries high → low so removing a later one never shifts the
// index of an earlier one.
for (let entryIdx = entries.length - 1; entryIdx >= 0; entryIdx--) {
const entry = entries[entryIdx];
if (!Array.isArray(entry?.hooks)) continue;
const hookIdxs: number[] = [];
entry.hooks.forEach((hh: any, hi: number) => {
if (isGitnexusHook(hh)) hookIdxs.push(hi);
});
if (hookIdxs.length === 0) continue;
total += 1;
if (dryRun) continue;
if (hookIdxs.length === entry.hooks.length) {
// The entry held only gitnexus command(s) — drop the whole entry.
const edits = modify(current, ['hooks', eventName, entryIdx], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
} else {
// The entry also holds user command(s) — delete only ours, keep
// the rest. Highest hook index first to keep lower indices valid.
for (const hi of hookIdxs.reverse()) {
const edits = modify(current, ['hooks', eventName, entryIdx, 'hooks', hi], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
}
}
}
}
if (total === 0) return { status: 'absent', count: 0 };
if (!dryRun) await fs.writeFile(filePath, current, 'utf-8');
return { status: 'removed', count: total };
}
/**
* Remove a directory tree if it exists. Returns true when something was
* (or would be) removed.
*/
async function removeDir(dirPath: string, dryRun: boolean): Promise<boolean> {
try {
await fs.access(dirPath);
} catch {
return false;
}
if (!dryRun) await fs.rm(dirPath, { recursive: true, force: true });
return true;
}
/**
* The exact set of skill directory names setup installs, derived from the
* bundled `skills/` source the same way installSkillsTo does (flat
* `{name}.md` and `{name}/SKILL.md` layouts). Deriving the set — rather
* than globbing `gitnexus-*` — ensures we never delete a user's own
* similarly-named skill folder.
*/
async function listGitnexusSkillNames(): Promise<string[]> {
const skillsRoot =
process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
const names = new Set<string>();
try {
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
// Guard against a bare `.md` file: basename('.md', '.md') === '',
// which would later resolve to the skills dir itself and wipe it.
const base = path.basename(entry.name, '.md');
if (base) names.add(base);
} else if (entry.isDirectory()) {
try {
await fs.access(path.join(skillsRoot, entry.name, 'SKILL.md'));
names.add(entry.name);
} catch {
// Not a skill directory — skip.
}
}
}
} catch {
return [];
}
return [...names];
}
/**
* Remove the gitnexus skill directories from a target skills folder. Returns
* the absolute paths that were removed (or would be removed in dryRun) so the
* caller can show the user exactly what is affected.
*/
async function removeSkillsFrom(
targetDir: string,
skillNames: string[],
dryRun: boolean,
): Promise<string[]> {
const removed: string[] = [];
for (const name of skillNames) {
// Defense in depth: an empty/relative/absolute name would resolve back to
// targetDir (or escape it) and wipe unrelated content. Only act on a
// plain child directory name.
if (
!name ||
name.includes('/') ||
name.includes('\\') ||
name === '.' ||
name === '..' ||
path.isAbsolute(name)
) {
continue;
}
const dir = path.join(targetDir, name);
if (await removeDir(dir, dryRun)) removed.push(dir);
}
return removed;
}
/**
* Remove the `[mcp_servers.gitnexus]` table — and any of its descendant
* sub-tables (`[mcp_servers.gitnexus.env]`, `[[mcp_servers.gitnexus.x]]`) —
* from Codex's config.toml. Used only as a fallback when the `codex` binary
* isn't on PATH; the CLI's `codex mcp remove` is preferred.
*
* Hand-rolled (no TOML dependency), but careful about the cases a naive
* line-scan gets wrong:
* - descendant sub-tables of the section are also removed (else they'd be
* left dangling, referencing a server that no longer exists);
* - `[...]`-shaped lines inside a multiline string (`"""`/`'''`) are NOT
* treated as table headers;
* - unrelated whitespace/formatting elsewhere in the file is left intact
* (no global blank-line reflow). Only a single blank separator line
* directly above the removed section is dropped.
*/
function stripTomlSection(raw: string, sectionName: string): string {
const header = `[${sectionName}]`;
const childTable = `[${sectionName}.`;
const childArray = `[[${sectionName}.`;
// Capture group 1 is the bracket token only, so a trailing inline comment
// (`[mcp_servers.gitnexus] # note`) is stripped before classification —
// otherwise an exact `=== header` check fails and the section is left behind.
const headerRe = /^(\[\[?[^[\]]+\]\]?)\s*(#.*)?$/;
const isSectionHeader = (token: string): boolean =>
token === header || token.startsWith(childTable) || token.startsWith(childArray);
// Return the multiline-string delimiter still OPEN at the end of `line`,
// given the state at its start (null = outside any multiline string). Scans
// left→right so the delimiter that actually opens first wins — a line with an
// odd count of BOTH `"""` and `'''` (e.g. `x = '''has """ inside`) no longer
// mis-picks the wrong delimiter and desyncs the scanner.
const multilineStateAfter = (line: string, startState: string | null): string | null => {
let state = startState;
let i = 0;
while (i < line.length) {
if (state) {
const close = line.indexOf(state, i);
if (close === -1) return state; // still open at end of line
i = close + state.length;
state = null;
} else {
const a = line.indexOf('"""', i);
const b = line.indexOf("'''", i);
if (a === -1 && b === -1) return null;
const useA = b === -1 || (a !== -1 && a < b);
state = useA ? '"""' : "'''";
i = (useA ? a : b) + 3;
}
}
return state;
};
const lines = raw.split(/\r?\n/);
const out: string[] = [];
let skipping = false;
let mlDelim: string | null = null;
for (const line of lines) {
if (mlDelim) {
// Inside a multiline string: brackets here are data, not headers.
mlDelim = multilineStateAfter(line, mlDelim);
if (!skipping) out.push(line);
continue;
}
const trimmed = line.trim();
const headerMatch = trimmed.match(headerRe);
if (headerMatch) {
if (isSectionHeader(headerMatch[1])) {
// Drop a single blank separator line immediately above the section.
if (!skipping && out.length > 0 && out[out.length - 1].trim() === '') out.pop();
skipping = true;
continue;
}
// A non-descendant header ends the section.
skipping = false;
out.push(line);
continue;
}
// Track whether this (non-header) line opens a multiline string so a
// bracketed line inside it isn't mistaken for a header.
mlDelim = multilineStateAfter(line, null);
if (!skipping) out.push(line);
}
// Preserve the file's line endings: a CRLF (Windows) config.toml should not
// be silently rewritten to LF. Rejoin with the dominant EOL of the input.
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
let result = out.join(eol);
if (!result.endsWith(eol)) result += eol;
return result;
}
async function uninstallCodex(
result: UninstallResult,
dryRun: boolean,
configPath: string,
tomlSection: string,
): Promise<void> {
let raw: string;
try {
raw = await fs.readFile(configPath, 'utf-8');
} catch {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (!raw.includes(`[${tomlSection}]`)) {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (dryRun) {
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
return;
}
// Prefer the official CLI (mirrors setup's `codex mcp add`); fall back
// to editing config.toml directly when the binary isn't on PATH.
try {
await execFileAsync('codex', ['mcp', 'remove', 'gitnexus'], {
shell: process.platform === 'win32',
windowsHide: true,
timeout: 10000,
});
result.removed.push("Codex MCP server — via 'codex mcp remove gitnexus'");
return;
} catch {
// Fall through to manual edit.
}
try {
await fs.writeFile(configPath, stripTomlSection(raw, tomlSection), 'utf-8');
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
} catch (err: any) {
result.errors.push(`Codex: ${err.message}`);
}
}
// ─── Main command ──────────────────────────────────────────────────
export const uninstallCommand = async (options?: { force?: boolean }) => {
const dryRun = !options?.force;
const targets = getEditorTargets();
console.log('');
console.log(' GitNexus Uninstall');
console.log(' ==================');
console.log('');
if (dryRun) {
console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
console.log('');
}
const result: UninstallResult = { removed: [], skipped: [], errors: [] };
// ─── MCP server entries (JSONC editors) ──────────────────────────
for (const target of targets.mcpJsonc) {
try {
const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
if (status === 'removed')
result.removed.push(
`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`,
);
else if (status === 'corrupt')
result.errors.push(
`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`,
);
else result.skipped.push(`${target.label} MCP (not configured)`);
} catch (err: any) {
result.errors.push(`${target.label}: ${err.message}`);
}
}
await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
// ─── Hooks ───────────────────────────────────────────────────────
for (const hook of targets.hooks) {
try {
const { status, count } = await removeHookEntries(
hook.settingsFile,
hook.events,
hook.needle,
dryRun,
);
if (status === 'removed')
result.removed.push(`${hook.label} hooks (${count}) — ${hook.settingsFile}`);
else if (status === 'corrupt')
result.errors.push(
`${hook.label} hooks: ${path.basename(hook.settingsFile)} is corrupt — left untouched`,
);
// Don't delete the hook script while a registered entry may still point
// at it (corrupt = we couldn't parse/remove the entry) — that would
// leave the editor invoking a missing script on every matched tool call.
if (status !== 'corrupt' && (await removeDir(hook.scriptDir, dryRun)))
result.removed.push(`${hook.label} hook scripts — ${hook.scriptDir}`);
} catch (err: any) {
result.errors.push(`${hook.label} hooks: ${err.message}`);
}
}
// ─── Skills ──────────────────────────────────────────────────────
// Skill directories are identified by the bundled gitnexus skill names; the
// exact paths are listed below so the user can see what will be removed.
const skillNames = await listGitnexusSkillNames();
for (const target of targets.skills) {
try {
const removedDirs = await removeSkillsFrom(target.dir, skillNames, dryRun);
for (const dir of removedDirs) result.removed.push(`${target.label} skill — ${dir}`);
} catch (err: any) {
result.errors.push(`${target.label} skills: ${err.message}`);
}
}
// ─── Report ──────────────────────────────────────────────────────
const verb = dryRun ? 'Would remove' : 'Removed';
if (result.removed.length > 0) {
console.log(` ${verb}:`);
for (const name of result.removed) console.log(` - ${name}`);
} else {
console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
}
if (result.skipped.length > 0) {
console.log('');
console.log(' Skipped:');
for (const name of result.skipped) console.log(` - ${name}`);
}
if (result.errors.length > 0) {
console.log('');
console.log(' Errors:');
for (const err of result.errors) console.log(` ! ${err}`);
// Signal partial failure to callers/CI without aborting the remaining
// cleanup (which has already run by this point).
process.exitCode = 1;
}
console.log('');
console.log(' Note: skill directories are matched by bundled gitnexus skill name. If you');
console.log(' customized files inside an installed skill dir, back them up before --force.');
console.log('');
console.log(' Not removed automatically:');
console.log(' - Per-repo indexes — run: gitnexus clean --all');
console.log(' - The global npm package — run: npm uninstall -g gitnexus');
if (dryRun && result.removed.length > 0) {
console.log('');
console.log(' Re-run with --force to apply the changes above.');
}
console.log('');
};

View file

@ -1,9 +1,23 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
import Cpp from 'tree-sitter-cpp';
// `tree-sitter-c` is vendored prebuild-only (#2116) and may be absent on a
// toolchain-less / `--ignore-scripts` install. Load it via a guarded `_require`
// rather than a top-level `import C from 'tree-sitter-c'`, which would throw
// ERR_MODULE_NOT_FOUND at module-load and crash analyze (#2091/#2093). When the
// binding is absent, `getLanguageForFile` returns null for `.c`/`.h` so C
// include-extraction is skipped (C++ is unaffected — its binding always ships).
const _require = createRequire(import.meta.url);
let C: unknown = null;
try {
C = _require('tree-sitter-c');
} catch {
/* C grammar unavailable — C include extraction degrades to a no-op. */
}
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';

View file

@ -1,5 +1,15 @@
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
import { SupportedLanguages } from 'gitnexus-shared';
// `tree-sitter-c` is vendored prebuild-only (#2116) and may be absent on a
// toolchain-less / `--ignore-scripts` install. It is loaded lazily + guarded via
// parser-loader rather than statically imported: this module is pulled onto the
// main thread eagerly by the scope-resolution registry and the language-provider
// index, so a top-level `import C from 'tree-sitter-c'` would throw
// ERR_MODULE_NOT_FOUND at module-load and crash `analyze` even for repos with no
// C files (#2091, #2093). The grammar is only ever needed inside the lazy getters
// below, and the main-thread `isLanguageAvailable` filter ensures they are
// reached only when the binding is present.
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
const C_SCOPE_QUERY = `
;; Scopes
@ -167,14 +177,19 @@ let _query: Parser.Query | null = null;
export function getCParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(C as Parameters<Parser['setLanguage']>[0]);
_parser.setLanguage(
getLanguageGrammar(SupportedLanguages.C) as Parameters<Parser['setLanguage']>[0],
);
}
return _parser;
}
export function getCScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(C as Parameters<Parser['setLanguage']>[0], C_SCOPE_QUERY);
_query = new Parser.Query(
getLanguageGrammar(SupportedLanguages.C) as Parameters<Parser['setLanguage']>[0],
C_SCOPE_QUERY,
);
}
return _query;
}

View file

@ -4,7 +4,6 @@ import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
import Java from 'tree-sitter-java';
import C from 'tree-sitter-c';
import CPP from 'tree-sitter-cpp';
// Explicit subpath import — see parser-loader.ts for rationale (#1013).
import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js';
@ -67,6 +66,16 @@ let Kotlin: TreeSitterLanguage | null = null;
try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
// tree-sitter-c is now vendored prebuild-only (#2116) and may be absent on a
// toolchain-less / `--ignore-scripts` install. Guard it like Swift/Dart/Kotlin so
// a missing binding cannot crash the worker at module-load (#2091/#2093); the
// main-thread `isLanguageAvailable` filter keeps C files from being dispatched
// here when the entry is absent.
let C: TreeSitterLanguage | null = null;
try {
C = _require('tree-sitter-c');
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
buildConcreteTypedefDefinitionRanges,
@ -418,7 +427,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
[`${SupportedLanguages.TypeScript}:tsx`]: TypeScript.tsx,
[SupportedLanguages.Python]: Python,
[SupportedLanguages.Java]: Java,
[SupportedLanguages.C]: C,
...(C ? { [SupportedLanguages.C]: C } : {}),
[SupportedLanguages.CPlusPlus]: CPP,
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,

View file

@ -121,25 +121,26 @@ const SOURCES: Record<string, GrammarSource> = {
'Vue parsing piggybacks on `tree-sitter-typescript`. Check the install and native binding.',
},
// tree-sitter-c is a required dependency, but its native binding has
// historically been ABI-incompatible with the bundled tree-sitter@0.21.1
// runtime on some platforms (#1242, #858). Loading it through the
// optional machinery turns a would-be segfault into a clean degradation
// while preserving every other language's analysis. Severity is pinned
// to `error` because the package is in `dependencies`: a failure here
// is always an install/platform problem the user needs to see, never an
// expected "user opted out" condition like Swift/Dart/Kotlin.
// tree-sitter-c is a core grammar, vendored prebuild-only (under
// gitnexus/vendor/tree-sitter-c) with GitNexus-built prebuilds for every
// supported platform-arch — upstream ships only 4/6 (#2116) and C is a
// required grammar whose source build hard-fails install on a toolchain-less
// ARM host. Loading through the optional machinery turns a would-be ABI
// segfault (#1242, #858) into a clean degradation while preserving every
// other language's analysis. Severity stays `error` because C is not a
// user-opt-out grammar like Swift/Dart/Kotlin: a failure here is always an
// install/platform problem the user needs to see.
[SupportedLanguages.C]: {
load: () => _require('tree-sitter-c'),
optional: true,
severity: 'error',
unavailableNote:
'C parsing disabled: `tree-sitter-c` could not be loaded. ' +
'This package is in `dependencies` and prebuilds ship for all supported ' +
'platforms (win32/darwin/linux x64+arm64, Node 18/20/22), so this ' +
'usually indicates a corrupted install, an unsupported Node version, ' +
'or a native ABI mismatch with the bundled tree-sitter runtime. ' +
'Try `npm rebuild tree-sitter-c` or reinstalling, then re-run analyze. ' +
'C parsing disabled: vendored `tree-sitter-c` (under ' +
'`gitnexus/vendor/tree-sitter-c`) could not be loaded. GitNexus ships ' +
'prebuilt binaries for all supported platforms (win32/darwin/linux ' +
'x64+arm64, N-API), so this usually indicates a corrupted install or a ' +
'native ABI mismatch with the bundled tree-sitter@0.21.1 runtime. ' +
'Try reinstalling, then re-run analyze. ' +
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
},
@ -170,8 +171,10 @@ const SOURCES: Record<string, GrammarSource> = {
optional: true,
userSkippable: true,
unavailableNote:
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
'and is not installed (or its native binding failed to build).',
'Kotlin parsing disabled: vendored `tree-sitter-kotlin` (under ' +
'`gitnexus/vendor/tree-sitter-kotlin`) failed to load. ' +
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
`See ${ISSUES_URL}/2107.`,
},
};

View file

@ -51,6 +51,7 @@ import {
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
import { logger } from '../../core/logger.js';
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -353,6 +354,84 @@ interface ImpactParams {
summaryOnly?: boolean;
}
/**
* One repository entry as returned by {@link LocalBackend.listRepos} and in each
* `list_repos` page. Named so the `listRepos`/`listReposPage` return types read
* clearly instead of an opaque `Awaited<ReturnType<…>>` expression.
*/
export interface RepoListing {
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}
/** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
export interface ListReposPagination {
/** Total repositories across all pages. */
total: number;
/** Effective page size used (equals the requested limit; out-of-range is rejected, not clamped). */
limit: number;
/** Offset this page started at. */
offset: number;
/** Number of repositories actually returned in this page. */
returned: number;
/** True when more repositories remain past this page. */
hasMore: boolean;
/** Offset to request next; present only when `hasMore` is true. */
nextOffset?: number;
}
/**
* Validate and normalise `list_repos` pagination arguments.
*
* @internal Exported for unit testing; not part of the public API surface.
*
* There is NO MCP-SDK-level enforcement of a tool's advertised `inputSchema`
* (the SDK validates only the JSON-RPC envelope), and `callTool` is reachable
* directly, so the backend is the real validation boundary. Malformed values —
* non-number, `NaN`, non-integer, `limit < 1`, `limit > maxLimit`, or
* `offset < 0` — are REJECTED with a clear error. `limit` is bounded but NOT
* silently clamped: an over-max value throws (symmetric with the other bounds)
* so a client never receives a smaller page than it asked for without knowing.
* An omitted value (only `undefined`) falls back to the default.
*/
export function parseListReposPagination(
params: { limit?: unknown; offset?: unknown } | null | undefined,
opts: { defaultLimit: number; maxLimit: number },
): { limit: number; offset: number } {
const requireInt = (value: unknown, field: string, min: number, max?: number): number => {
const valid =
typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
(max === undefined || value <= max);
if (!valid) {
const bound = max === undefined ? `>= ${min}` : `between ${min} and ${max}`;
throw new Error(
`list_repos: "${field}" must be an integer ${bound} (received ${JSON.stringify(value)})`,
);
}
return value;
};
let limit = opts.defaultLimit;
if (params?.limit !== undefined) {
limit = requireInt(params.limit, 'limit', 1, opts.maxLimit);
}
let offset = 0;
if (params?.offset !== undefined) {
offset = requireInt(params.offset, 'offset', 0);
}
return { limit, offset };
}
export class LocalBackend {
private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map();
@ -841,18 +920,7 @@ export class LocalBackend {
* that another clone of the same logical repo is registered).
* - `remoteUrl`: the canonical origin URL recorded at index time.
*/
async listRepos(): Promise<
Array<{
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}>
> {
async listRepos(): Promise<RepoListing[]> {
await this.refreshRepos();
const handles = [...this.repos.values()];
@ -906,6 +974,58 @@ export class LocalBackend {
});
}
/**
* Paginated view over {@link listRepos} for the `list_repos` MCP tool (#2119).
*
* `listRepos()` itself still returns the FULL array — its resource and CLI
* consumers (`gitnexus://repos`, `gitnexus://setup`, startup logs) need every
* entry, so pagination lives ONLY here, on the tool surface, to keep the
* response under MCP/LLM token-truncation limits.
*
* Determinism: a single registry snapshot is taken per call, then sorted by
* lower-cased name with the repository path as a tie-breaker. Sibling clones
* share a name but never a path (#2054), so `(name, path)` is a total order —
* paging never skips or duplicates an entry while the registry is unchanged.
* Codepoint comparison (not `localeCompare`) keeps page boundaries stable
* across machines/locales, matching the existing `refreshRepos` ordering.
*/
async listReposPage(params?: { limit?: unknown; offset?: unknown } | null): Promise<{
repositories: RepoListing[];
pagination: ListReposPagination;
}> {
const { limit, offset } = parseListReposPagination(params, {
defaultLimit: LIST_REPOS_DEFAULT_LIMIT,
maxLimit: LIST_REPOS_MAX_LIMIT,
});
// One consistent snapshot per call (listRepos refreshes the registry once),
// sorted into a stable total order before slicing.
const all = await this.listRepos();
all.sort((a, b) => {
const an = a.name.toLowerCase();
const bn = b.name.toLowerCase();
if (an !== bn) return an < bn ? -1 : 1;
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
});
const total = all.length;
const repositories = all.slice(offset, offset + limit);
const returned = repositories.length;
const hasMore = offset + returned < total;
return {
repositories,
pagination: {
total,
limit,
offset,
returned,
hasMore,
...(hasMore && { nextOffset: offset + returned }),
},
};
}
/**
* Best-effort sibling-clone drift warning.
*
@ -967,7 +1087,10 @@ export class LocalBackend {
async callTool(method: string, params: any): Promise<any> {
if (method === 'list_repos') {
return this.listRepos();
// Paginated tool surface (#2119). `listRepos()` is unchanged for internal
// callers; the tool wraps it in { repositories, pagination } and forwards
// the limit/offset args that this dispatch previously discarded.
return this.listReposPage(params);
}
if (method.startsWith('group_')) {

View file

@ -44,7 +44,7 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
switch (toolName) {
case 'list_repos':
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness.`;
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness. If pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset to fetch the rest.`;
case 'query':
return `\n\n---\n**Next:** To understand a specific symbol in depth, use context({name: "<symbol_name>"${repoParam}}) to see categorized refs and process participation.`;

View file

@ -51,12 +51,25 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
openWorldHint: false,
};
/**
* Pagination bounds for the `list_repos` tool. Exported so the backend
* validation (`local-backend.ts`) and the schema below stay a single source of
* truth. `list_repos` is paginated to keep its response under MCP/LLM token
* truncation limits when many repos are indexed (#2119); the default page is
* small enough to render safely, and `LIST_REPOS_MAX_LIMIT` caps how much a
* caller can pull in one request.
*/
export const LIST_REPOS_DEFAULT_LIMIT = 50;
export const LIST_REPOS_MAX_LIMIT = 200;
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
description: `List all indexed repositories available to GitNexus.
description: `List indexed repositories available to GitNexus (paginated).
Returns each repo's name, path, indexed date, last commit, and stats.
Returns a page of repositories — each with name, path, indexed date, last commit, and stats — plus a "pagination" object: { total, limit, offset, returned, hasMore, nextOffset }.
PAGINATION: Results are paginated so a large registry is not truncated by MCP/LLM token limits. "limit" sets the page size (default ${LIST_REPOS_DEFAULT_LIMIT}, max ${LIST_REPOS_MAX_LIMIT}; values above the max are rejected, not capped). "offset" selects the start. To enumerate EVERY repository: when pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset, and repeat until hasMore is false. Repositories are returned in a stable order, so paging never skips or duplicates an entry while the registry is unchanged.
WHEN TO USE: First step when multiple repos are indexed, or to discover available repos.
AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work with.
@ -66,7 +79,22 @@ on other tools (query, context, impact, etc.) to target the correct one.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
properties: {
limit: {
type: 'integer',
description: `Max repositories to return in this page (default: ${LIST_REPOS_DEFAULT_LIMIT}, min: 1, max: ${LIST_REPOS_MAX_LIMIT}). Values outside [1, ${LIST_REPOS_MAX_LIMIT}] are rejected.`,
default: LIST_REPOS_DEFAULT_LIMIT,
minimum: 1,
maximum: LIST_REPOS_MAX_LIMIT,
},
offset: {
type: 'integer',
description:
'Number of repositories to skip before this page (default: 0). Pass pagination.nextOffset from the previous response to fetch the next page.',
default: 0,
minimum: 0,
},
},
required: [],
},
},

View file

@ -189,7 +189,7 @@ function spawnMcpServer(): SpawnedServer {
}
describe('MCP server end-to-end startup', () => {
it('preserves JSON-RPC stdout discipline through initialize + tools/list', async () => {
it('preserves JSON-RPC stdout discipline through initialize + tools/list + tools/call', async () => {
if (!fs.existsSync(DIST_CLI)) {
throw new Error(
`dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`,
@ -253,6 +253,53 @@ describe('MCP server end-to-end startup', () => {
expect(toolNames).toContain(t);
}
// tools/call list_repos — proves the paginated { repositories, pagination }
// shape survives the real request → backend.callTool → JSON.stringify →
// content[0].text serialization path (#2119), independent of repo count.
server.send({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'list_repos', arguments: { limit: 5 } },
});
const callResponse = (await server.nextMessage()) as {
id: number;
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
};
expect(callResponse.id).toBe(3);
expect(callResponse.result?.isError).not.toBe(true);
const callText = callResponse.result!.content![0].text;
// The server appends a non-JSON next-step hint after the JSON payload.
// Extract the leading JSON object with a string-aware brace scan so a repo
// path containing braces can never truncate the parse (more robust than
// splitting on the hint's separator).
const jsonStart = callText.indexOf('{');
let depth = 0;
let inStr = false;
let esc = false;
let jsonEnd = callText.length;
for (let i = jsonStart; i < callText.length; i++) {
const ch = callText[i];
if (esc) {
esc = false;
} else if (ch === '\\') {
esc = true;
} else if (ch === '"') {
inStr = !inStr;
} else if (!inStr && ch === '{') {
depth++;
} else if (!inStr && ch === '}' && --depth === 0) {
jsonEnd = i + 1;
break;
}
}
const payload = JSON.parse(callText.slice(jsonStart, jsonEnd));
expect(Array.isArray(payload.repositories)).toBe(true);
expect(typeof payload.pagination.total).toBe('number');
expect(payload.pagination.limit).toBe(5);
expect(payload.pagination.offset).toBe(0);
expect(payload.repositories.length).toBeLessThanOrEqual(5);
// The headline assertion: every byte the server emitted on stdout
// must reassemble into a valid JSON-RPC frame. Any leftover is a
// protocol-corruption regression.

View file

@ -4,15 +4,17 @@
* The scope-resolution registry (`scope-resolution/pipeline/registry.ts`) and
* the language-provider index statically import all 16 language providers. Each
* per-language `query.ts` used to do a top-level `import X from 'tree-sitter-Y'`.
* For the OPTIONAL grammars (swift/dart/kotlin) that import resolved — and on a
* default install where the vendored/optional binding is absent, THREW
* `ERR_MODULE_NOT_FOUND` — at module-load on the main thread, before any runtime
* gate, crashing `gitnexus analyze` regardless of the repo's actual languages.
* For the prebuild-only / optional grammars (swift/dart/kotlin, and — since
* #2116 — vendored-prebuild-only C) that import resolved — and on a default
* install where the binding is absent, THREW `ERR_MODULE_NOT_FOUND` — at
* module-load on the main thread, before any runtime gate, crashing
* `gitnexus analyze` regardless of the repo's actual languages.
*
* The fix routes those three `query.ts` modules through the lazy, guarded
* The fix routes those `query.ts` modules through the lazy, guarded
* `parser-loader.getLanguageGrammar()` so the grammar binding is only required
* at first use (inside the worker, for a file of that language) — never at
* module-load.
* module-load. (C joined this set when it became vendored prebuild-only; it used
* to be an always-present npm dependency.)
*
* This test locks the fix in WITHOUT needing to simulate a missing grammar:
* spawn a child Node process, import the built scope-resolution `registry.js`
@ -57,10 +59,13 @@ const PROBE = `
process.stdout.write(JSON.stringify([...after].filter((k) => !before.has(k))));
`;
const OPTIONAL_GRAMMAR_RE = /tree-sitter-(swift|dart|kotlin)[\\/]/;
// `tree-sitter-c[\\/]` matches only the exact `tree-sitter-c/` package — NOT
// `tree-sitter-cpp/` or `tree-sitter-c-sharp/` (those need a non-separator after
// the `c`), so the required C++/C# eager loads are unaffected.
const OPTIONAL_GRAMMAR_RE = /tree-sitter-(swift|dart|kotlin|c)[\\/]/;
describe('optional-grammar static-import closure (#2091/#2093)', () => {
it('importing the scope-resolution registry loads NO optional grammar binding', () => {
describe('optional-grammar static-import closure (#2091/#2093, #2116)', () => {
it('importing the scope-resolution registry loads NO lazy grammar binding (swift/dart/kotlin/c)', () => {
if (!fs.existsSync(DIST_REGISTRY)) {
throw new Error(
`${DIST_REGISTRY} missing — run \`npm run build\` first (or \`npm run test:integration\`, ` +
@ -117,13 +122,13 @@ describe('optional-grammar static-import closure (#2091/#2093)', () => {
`Newly-loaded (${newlyLoaded.length}):\n${newlyLoaded.join('\n')}`,
).toBeGreaterThan(0);
// Headline assertion: no OPTIONAL grammar binding (swift/dart/kotlin) is
// Headline assertion: no lazy grammar binding (swift/dart/kotlin/c) is
// loaded at registry static-import time — they must load lazily.
const optionalLoaded = newlyLoaded.filter((p) => OPTIONAL_GRAMMAR_RE.test(p));
expect(
optionalLoaded,
`Optional tree-sitter grammar binding(s) loaded at registry static-import time. ` +
`query.ts must load swift/dart/kotlin lazily via parser-loader, not via a ` +
`Lazy tree-sitter grammar binding(s) loaded at registry static-import time. ` +
`query.ts must load swift/dart/kotlin/c lazily via parser-loader, not via a ` +
`top-level \`import\`. Offending paths:\n${optionalLoaded.join('\n')}`,
).toEqual([]);
});

View file

@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { getEditorTargets } from '../../src/cli/editor-targets.js';
// Force the Codex path through the TOML fallback (no `codex` binary) so the
// round-trip is observable on config.toml, and make `which/where gitnexus`
// miss so getMcpEntry uses the npx form. Mirrors the unit-test mocks.
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(new Error('not available'), '', '');
});
const execFileSyncMock = vi.fn(() => {
throw new Error('not found');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
}));
/** Read a value at a JSON key path, or undefined if any segment is missing. */
function valueAtPath(obj: any, keyPath: string[]): unknown {
return keyPath.reduce((o: any, k) => (o == null ? undefined : o[k]), obj);
}
/** Does any of `events` hold a hook entry whose command contains `needle`? */
function hasHookNeedle(settings: any, events: string[], needle: string): boolean {
return events.some(
(ev) =>
Array.isArray(settings?.hooks?.[ev]) &&
settings.hooks[ev].some(
(entry: any) =>
Array.isArray(entry?.hooks) &&
entry.hooks.some(
(h: any) => typeof h?.command === 'string' && h.command.includes(needle),
),
),
);
}
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readJsonc(p: string): Promise<any> {
return parseJsonc(await fs.readFile(p, 'utf-8'));
}
/**
* setup → uninstall round-trip. This is the drift tripwire for #2062: it
* iterates over getEditorTargets() (the shared source of truth that both
* setup.ts and uninstall.ts consume), so if one side gains/loses/relocates a
* target without the other following, this fails in CI — in both directions.
*/
describe('setup → uninstall round-trip', () => {
let tempHome: string;
let skillsRoot: string;
const saved: Record<string, string | undefined> = {};
let savedExitCode: typeof process.exitCode;
// Two fixture skills exercise both source layouts (flat + directory).
const flatSkill = 'gitnexus-roundtrip-flat';
const dirSkill = 'gitnexus-roundtrip-dir';
const skillNames = [flatSkill, dirSkill];
beforeEach(async () => {
vi.clearAllMocks();
saved.HOME = process.env.HOME;
saved.USERPROFILE = process.env.USERPROFILE;
saved.SKILLS = process.env.GITNEXUS_TEST_SKILLS_ROOT;
savedExitCode = process.exitCode;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-roundtrip-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Mark every editor as "installed" so setup configures all of them.
for (const dir of ['.cursor', '.claude', '.codex']) {
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
}
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true });
// Fixture skills consumed by both setup (install) and uninstall (derive).
skillsRoot = path.join(tempHome, 'pkg-skills');
await fs.mkdir(path.join(skillsRoot, dirSkill), { recursive: true });
await fs.writeFile(
path.join(skillsRoot, `${flatSkill}.md`),
`---\nname: ${flatSkill}\ndescription: flat\n---\n\n# Flat`,
'utf-8',
);
await fs.writeFile(
path.join(skillsRoot, dirSkill, 'SKILL.md'),
`---\nname: ${dirSkill}\ndescription: dir\n---\n\n# Dir`,
'utf-8',
);
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = saved.HOME;
process.env.USERPROFILE = saved.USERPROFILE;
if (saved.SKILLS === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
else process.env.GITNEXUS_TEST_SKILLS_ROOT = saved.SKILLS;
process.exitCode = savedExitCode;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('setup writes every target and uninstall removes all of them', async () => {
const targets = getEditorTargets(tempHome);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// ── After setup: every target artifact is present ──
for (const t of targets.mcpJsonc) {
const cfg = await readJsonc(t.file);
expect(valueAtPath(cfg, t.keyPath), `setup should write ${t.label} MCP`).toBeDefined();
}
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).toContain(
`[${targets.codex.tomlSection}]`,
);
for (const t of targets.skills) {
for (const name of skillNames) {
expect(
await exists(path.join(t.dir, name, 'SKILL.md')),
`setup should install ${name} into ${t.label}`,
).toBe(true);
}
}
for (const h of targets.hooks) {
const settings = await readJsonc(h.settingsFile);
expect(
hasHookNeedle(settings, h.events, h.needle),
`setup should register ${h.label} hook`,
).toBe(true);
expect(await exists(h.scriptDir), `setup should install ${h.label} hook scripts`).toBe(true);
}
// ── Round-trip: uninstall removes everything setup wrote ──
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
for (const t of targets.mcpJsonc) {
const cfg = await readJsonc(t.file);
expect(valueAtPath(cfg, t.keyPath), `uninstall should remove ${t.label} MCP`).toBeUndefined();
}
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).not.toContain(
`[${targets.codex.tomlSection}]`,
);
for (const t of targets.skills) {
for (const name of skillNames) {
expect(
await exists(path.join(t.dir, name)),
`uninstall should remove ${name} from ${t.label}`,
).toBe(false);
}
}
for (const h of targets.hooks) {
const settings = await readJsonc(h.settingsFile);
expect(
hasHookNeedle(settings, h.events, h.needle),
`uninstall should remove ${h.label} hook`,
).toBe(false);
expect(await exists(h.scriptDir), `uninstall should remove ${h.label} hook scripts`).toBe(
false,
);
}
});
it('uninstall preserves a co-located user MCP server and hook', async () => {
const targets = getEditorTargets(tempHome);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// Add a user-owned MCP server alongside gitnexus in Cursor's config, and a
// user hook alongside gitnexus in Claude's PreToolUse.
const cursor = targets.mcpJsonc.find((t) => t.id === 'cursor')!;
const cursorCfg = await readJsonc(cursor.file);
cursorCfg.mcpServers.mine = { command: 'mine' };
await fs.writeFile(cursor.file, JSON.stringify(cursorCfg, null, 2), 'utf-8');
const claudeHook = targets.hooks.find((h) => h.id === 'claude')!;
const settings = await readJsonc(claudeHook.settingsFile);
settings.hooks.PreToolUse.push({
matcher: 'Read',
hooks: [{ type: 'command', command: 'my-own-hook' }],
});
await fs.writeFile(claudeHook.settingsFile, JSON.stringify(settings, null, 2), 'utf-8');
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
await uninstallCommand({ force: true });
const afterCursor = await readJsonc(cursor.file);
expect(afterCursor.mcpServers.gitnexus).toBeUndefined();
expect(afterCursor.mcpServers.mine).toEqual({ command: 'mine' });
const afterSettings = await readJsonc(claudeHook.settingsFile);
const userHookSurvives = afterSettings.hooks.PreToolUse.some((e: any) =>
e.hooks?.some((h: any) => h.command === 'my-own-hook'),
);
expect(userHookSurvives).toBe(true);
expect(hasHookNeedle(afterSettings, claudeHook.events, claudeHook.needle)).toBe(false);
});
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
/**
* Coverage for the publish guard `scripts/assert-publish-grammar-coverage.cjs`.
*
* The guard refuses to pack/publish if a vendored grammar would ship with no
* loadable binding — i.e. the package.json `files` field was narrowed to drop the
* vendored source while a grammar still lacks 6/6 prebuilds. (`.npmignore` can't
* exclude the vendored subtree — `files` overrides it — so `files` is the only
* lever, and the guard reads it directly rather than shelling out to `npm pack`.)
* We test the pure decision core + the `files` check directly, and assert the real
* repo state is publish-safe (catching a premature narrowing in CI).
*/
const requireCjs = createRequire(import.meta.url);
const SCRIPT = fileURLToPath(
new URL('../../scripts/assert-publish-grammar-coverage.cjs', import.meta.url),
);
const { findCoverageProblems, filesShipsVendorSource } = requireCjs(SCRIPT);
describe('findCoverageProblems (pure decision core)', () => {
it('passes when source ships, even with incomplete prebuilds (transitional state)', () => {
const grammars = [{ name: 'tree-sitter-kotlin', prebuilt: 0, shipsSource: true }];
expect(findCoverageProblems({ grammars })).toEqual([]);
});
it('fails when source is not shipped and a grammar lacks 6/6 prebuilds', () => {
const grammars = [{ name: 'tree-sitter-kotlin', prebuilt: 4, shipsSource: false }];
const problems = findCoverageProblems({ grammars });
expect(problems).toHaveLength(1);
expect(problems[0]).toContain('tree-sitter-kotlin');
expect(problems[0]).toContain('not shipped');
expect(problems[0]).toContain('2 platform-arch tuple(s)');
});
it('passes when source is not shipped but every grammar has all 6 prebuilds', () => {
const grammars = [
{ name: 'tree-sitter-swift', prebuilt: 6, shipsSource: false },
{ name: 'tree-sitter-c', prebuilt: 6, shipsSource: false },
];
expect(findCoverageProblems({ grammars })).toEqual([]);
});
it('fails when a grammar has neither prebuilds nor shipped source', () => {
const grammars = [{ name: 'tree-sitter-x', prebuilt: 0, shipsSource: false }];
const problems = findCoverageProblems({ grammars });
expect(problems).toHaveLength(1);
expect(problems[0]).toContain('no loadable binding');
});
});
describe('filesShipsVendorSource', () => {
it('ships when a broad vendor entry is present', () => {
expect(filesShipsVendorSource(['dist', 'vendor', 'web'])).toBe(true);
expect(filesShipsVendorSource(['vendor/'])).toBe(true);
expect(filesShipsVendorSource(['vendor/**'])).toBe(true);
expect(filesShipsVendorSource(['vendor/*'])).toBe(true);
});
it('does NOT ship when files is narrowed to non-source subpaths (lean publish)', () => {
expect(
filesShipsVendorSource([
'dist',
'vendor/**/prebuilds/**',
'vendor/**/package.json',
'vendor/**/bindings/node/index.js',
]),
).toBe(false);
expect(filesShipsVendorSource([])).toBe(false);
expect(filesShipsVendorSource(undefined)).toBe(false);
});
});
describe('real repo publish-safety (guards against premature files narrowing)', () => {
it('the script exits 0 against the committed repo state', () => {
// Deterministic: reads package.json + walks vendor/ — no npm pack, fast.
const r = spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8', timeout: 20_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toContain('[publish-guard] OK');
});
});

View file

@ -0,0 +1,125 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Behavioral coverage for the consolidated activation script
* `scripts/build-tree-sitter-grammars.cjs` (replaces the per-grammar
* build-tree-sitter-<name>.cjs files).
*
* For each grammar it prefers a committed prebuild (toolchain-free); if none
* matches it source-builds from the vendored source. Its hard invariant is that
* it MUST NEVER exit non-zero — it runs in `gitnexus`'s postinstall, so a
* non-zero exit would break `npm install gitnexus`. This suite runs the real
* script bytes (targeting one grammar via the CLI arg) across its branches and
* asserts exit code 0 every time, plus the required-vs-optional opt-out split.
*
* The script is copied into an isolated temp `scripts/` dir so its
* `__dirname`-relative `../node_modules/tree-sitter-<name>` resolves under our
* control. The temp dir has no reachable `node-gyp-build` / `node-addon-api`, so
* the source-build path stops at the "hoisted build deps not resolvable" guard
* (still exit 0) instead of invoking a real compile.
*/
const scriptSource = readFileSync(
fileURLToPath(new URL('../../scripts/build-tree-sitter-grammars.cjs', import.meta.url)),
'utf8',
);
let tmpRoot: string;
let scriptPath: string;
beforeAll(() => {
tmpRoot = mkdtempSync(path.join(tmpdir(), 'gn-grammars-build-'));
mkdirSync(path.join(tmpRoot, 'scripts'), { recursive: true });
scriptPath = path.join(tmpRoot, 'scripts', 'build-tree-sitter-grammars.cjs');
writeFileSync(scriptPath, scriptSource);
});
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function runBuild(grammar: string, overrides: Record<string, string | undefined>) {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
delete env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete env[k];
else env[k] = v;
}
return spawnSync(process.execPath, [scriptPath, grammar], {
env,
encoding: 'utf8',
timeout: 30_000,
});
}
function materializeShell(grammar: string) {
// A package shell with a binding.gyp present but no prebuild / built binary.
const pkg = path.join(tmpRoot, 'node_modules', `tree-sitter-${grammar}`);
mkdirSync(path.join(pkg, 'bindings', 'node'), { recursive: true });
writeFileSync(path.join(pkg, 'binding.gyp'), '{ "targets": [] }');
writeFileSync(path.join(pkg, 'bindings', 'node', 'index.js'), '');
}
describe('build-tree-sitter-grammars.cjs consolidated activation', () => {
it('optional grammar: exits 0 and reports skipping under GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1', () => {
const r = runBuild('swift', { GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '1' });
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).toContain('[tree-sitter-swift] Skipping build');
expect(r.stderr).not.toContain('Swift (.swift) parsing will be unavailable');
});
it('REQUIRED grammar (c): ignores GITNEXUS_SKIP_OPTIONAL_GRAMMARS (no skip message)', () => {
// c is required — the opt-out must NOT short-circuit it. With nothing
// materialized it silently exits 0 at the binding.gyp-absent check.
const r = runBuild('c', { GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '1' });
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).not.toContain('Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1)');
});
it('exits 0 silently when the materialized package is absent (no binding.gyp)', () => {
const r = runBuild('kotlin', {});
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).not.toContain('Kotlin (.kt/.kts) parsing will be unavailable');
});
it('exits 0 (warning) when a grammar has a binding.gyp but no prebuild/build deps', () => {
materializeShell('kotlin');
try {
const r = runBuild('kotlin', {});
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).toMatch(/hoisted build deps not resolvable|Could not build native binding/);
expect(r.stderr).not.toContain('built successfully');
} finally {
rmSync(path.join(tmpRoot, 'node_modules'), { recursive: true, force: true });
}
});
it('unknown grammar arg: warns and exits 0', () => {
const r = runBuild('haskell', {});
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).toContain("Unknown grammar 'haskell'");
});
it('never exits non-zero across grammars and env permutations (postinstall hard invariant)', () => {
for (const grammar of ['c', 'dart', 'proto', 'swift', 'kotlin']) {
for (const overrides of [{ GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '1' }, {}]) {
const r = runBuild(grammar, overrides);
expect(r.status, `${grammar} ${JSON.stringify(overrides)}`).toBe(0);
expect(r.signal).toBeNull();
}
}
});
});

View file

@ -1,115 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Behavioral coverage for the postinstall probe `scripts/build-tree-sitter-kotlin.cjs`.
*
* The probe's hard invariant is that it MUST NEVER exit non-zero — it runs in
* `gitnexus`'s postinstall, so a non-zero exit would break `npm install gitnexus`
* for every user. The static package.json assertion in `cli-commands.test.ts`
* only checks wiring (the script is referenced in `postinstall`); it never runs
* the probe, so a regression that turned an `exit(0)` into `exit(1)`/`throw`
* would ship undetected. This suite executes the real script bytes across its
* branches and asserts exit code 0 every time.
*
* To exercise the "package absent" branches without mutating the repo's real
* node_modules, the probe is copied into an isolated temp `scripts/` dir; its
* `__dirname`-relative `../node_modules/tree-sitter-kotlin` then resolves to a
* non-existent path — the exact state npm leaves behind after it prunes the
* failed optional dependency on a toolchain-less host (see #2107 / PR #2110).
*/
const probeSource = readFileSync(
fileURLToPath(new URL('../../scripts/build-tree-sitter-kotlin.cjs', import.meta.url)),
'utf8',
);
const UNAVAILABLE = 'Kotlin (.kt/.kts) parsing will be unavailable';
let tmpRoot: string;
let scriptPath: string;
beforeAll(() => {
tmpRoot = mkdtempSync(path.join(tmpdir(), 'gn-kotlin-probe-'));
const scriptsDir = path.join(tmpRoot, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
scriptPath = path.join(scriptsDir, 'build-tree-sitter-kotlin.cjs');
writeFileSync(scriptPath, probeSource);
});
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function runProbe(overrides: Record<string, string | undefined>) {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
// Normalize the two variables under test so the case is deterministic even
// when the test runner itself was launched under npm with these set.
delete env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
delete env.npm_config_omit;
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete env[k];
else env[k] = v;
}
return spawnSync(process.execPath, [scriptPath], { env, encoding: 'utf8', timeout: 10_000 });
}
describe('build-tree-sitter-kotlin.cjs install probe', () => {
it('exits 0 and reports skipping when GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1', () => {
const r = runProbe({ GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '1' });
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).toContain('Skipping native-binding probe');
expect(r.stderr).not.toContain(UNAVAILABLE);
});
it('warns (and exits 0) when the package is absent and optionals were not omitted', () => {
// Regression guard for #2107 / PR #2110: npm prunes the failed optional
// dependency on a toolchain-less host, so the probe must surface its guidance
// on the dir-absent branch rather than silently exiting.
const r = runProbe({});
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
expect(r.stderr).toContain(UNAVAILABLE);
});
it('stays silent (and exits 0) when optionals were deliberately omitted (omit=optional)', () => {
const r = runProbe({ npm_config_omit: 'optional' });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain(UNAVAILABLE);
});
it('treats a comma-joined list (dev,optional) as an opt-out and stays silent', () => {
const r = runProbe({ npm_config_omit: 'dev,optional' });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain(UNAVAILABLE);
});
it('still warns when only non-optional groups are omitted (omit=dev)', () => {
const r = runProbe({ npm_config_omit: 'dev' });
expect(r.status).toBe(0);
expect(r.stderr).toContain(UNAVAILABLE);
});
it('never exits non-zero across env permutations (postinstall hard invariant)', () => {
const permutations: Record<string, string | undefined>[] = [
{ GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '1' },
{},
{ npm_config_omit: 'optional' },
{ npm_config_omit: 'dev,optional' },
{ npm_config_omit: 'dev' },
];
for (const overrides of permutations) {
const r = runProbe(overrides);
expect(r.status).toBe(0);
expect(r.signal).toBeNull();
}
});
});

View file

@ -85,7 +85,11 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend, REPO_ID_HASH_LENGTH } from '../../src/mcp/local/local-backend.js';
import {
LocalBackend,
REPO_ID_HASH_LENGTH,
parseListReposPagination,
} from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js';
import { getGitRoot } from '../../src/storage/git.js';
import { _captureLogger } from '../../src/core/logger.js';
@ -276,9 +280,18 @@ describe('LocalBackend.callTool', () => {
});
it('routes list_repos without needing repo param', async () => {
// No-arg compatibility: callTool('list_repos', {}) returns the first page as
// a { repositories, pagination } object (Strategy A — always paginated, #2119).
const result = await backend.callTool('list_repos', {});
expect(Array.isArray(result)).toBe(true);
expect(result[0].name).toBe('test-project');
expect(Array.isArray(result.repositories)).toBe(true);
expect(result.repositories[0].name).toBe('test-project');
expect(result.pagination).toEqual({
total: 1,
limit: 50,
offset: 0,
returned: 1,
hasMore: false,
});
});
it('throws for unknown tool name', async () => {
@ -1165,7 +1178,7 @@ describe('LocalBackend.resolveRepo', () => {
it('resolves single repo without param', async () => {
setupSingleRepo();
await backend.init();
const result = await backend.callTool('list_repos', {});
const result = await backend.listRepos();
expect(result).toHaveLength(1);
});
@ -1383,6 +1396,25 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
}
});
it('serves all sibling clones through the list_repos tool with siblings/remoteUrl intact (#2054, #2119)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
// Exercise the real TOOL surface (callTool → listReposPage), not just
// listRepos(): the paginated wrapper must not drop sibling-clone fields
// during its sort + slice.
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(4);
expect(page.pagination.total).toBe(4);
const paths = page.repositories.map((r: any) => path.resolve(r.path)).sort();
expect(paths).toEqual(dirs.map((d) => path.resolve(d)).sort());
for (const entry of page.repositories) {
expect(entry.remoteUrl).toBe('git@github.com:MYCOMPANY/REPO.git');
expect(entry.siblings).toHaveLength(3);
}
});
it('lists all four sibling clones that share a name and remote (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
@ -1394,7 +1426,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
expect(await backend.init()).toBe(true);
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(4);
// Every distinct on-disk clone survives exactly once — no silent overwrite.
@ -1416,7 +1448,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
}
// Re-running list_repos (which re-reads the registry) is idempotent.
const again = await backend.callTool('list_repos', {});
const again = await backend.listRepos();
expect(again).toHaveLength(4);
});
@ -1474,7 +1506,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
it('refresh stability: reorder, remove-one, and re-add never drop a different clone (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
const listedPaths = async () =>
(await backend.callTool('list_repos', {})).map((r: any) => path.resolve(r.path)).sort();
(await backend.listRepos()).map((r: any) => path.resolve(r.path)).sort();
const allPaths = dirs.map((d) => path.resolve(d)).sort();
(listRegisteredRepos as any).mockResolvedValue(entries);
@ -1627,7 +1659,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(6);
// All six ids are distinct (clones 3–6 exercise the sha256 fallback tier).
@ -1643,7 +1675,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
(listRegisteredRepos as any).mockResolvedValue(noRemote);
await backend.init();
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(2); // both present, not collapsed
for (const e of listed) {
expect(e.remoteUrl).toBeUndefined();
@ -1782,14 +1814,14 @@ describe('LocalBackend.listRepos', () => {
it('returns empty array when no repos', async () => {
setupNoRepos();
await backend.init();
const repos = await backend.callTool('list_repos', {});
const repos = await backend.listRepos();
expect(repos).toEqual([]);
});
it('returns repo metadata', async () => {
setupSingleRepo();
await backend.init();
const repos = await backend.callTool('list_repos', {});
const repos = await backend.listRepos();
expect(repos).toHaveLength(1);
expect(repos[0]).toEqual(
expect.objectContaining({
@ -1804,13 +1836,272 @@ describe('LocalBackend.listRepos', () => {
it('re-reads registry on each listRepos call', async () => {
setupSingleRepo();
await backend.init();
await backend.callTool('list_repos', {});
await backend.callTool('list_repos', {});
await backend.listRepos();
await backend.listRepos();
// listRegisteredRepos called: once in init, once per listRepos
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
});
});
// ─── list_repos pagination (#2119) ─────────────────────────────────────
describe('parseListReposPagination', () => {
const opts = { defaultLimit: 50, maxLimit: 200 };
it('applies defaults when nothing is supplied', () => {
expect(parseListReposPagination(undefined, opts)).toEqual({ limit: 50, offset: 0 });
expect(parseListReposPagination({}, opts)).toEqual({ limit: 50, offset: 0 });
});
it('accepts valid integer limit/offset', () => {
expect(parseListReposPagination({ limit: 10, offset: 20 }, opts)).toEqual({
limit: 10,
offset: 20,
});
});
it('rejects a limit above the maximum (does not silently clamp)', () => {
expect(() => parseListReposPagination({ limit: 201 }, opts)).toThrow(/limit/);
expect(() => parseListReposPagination({ limit: 99999 }, opts)).toThrow(/limit/);
});
it('accepts a valid in-range limit, including the boundary', () => {
expect(parseListReposPagination({ limit: 200 }, opts).limit).toBe(200);
expect(parseListReposPagination({ limit: 199 }, opts).limit).toBe(199);
});
it('rejects malformed limit values', () => {
for (const bad of [0, -5, 1.5, NaN, Infinity, '5', null, true, {}]) {
expect(() => parseListReposPagination({ limit: bad as any }, opts)).toThrow(/limit/);
}
});
it('rejects malformed offset values', () => {
for (const bad of [-1, 2.5, NaN, Infinity, '0', null, false]) {
expect(() => parseListReposPagination({ offset: bad as any }, opts)).toThrow(/offset/);
}
});
});
describe('LocalBackend.listReposPage / callTool list_repos pagination (#2119)', () => {
let backend: LocalBackend;
// Build N registry entries with unique, lexically-ordered names + paths and
// no remoteUrl (so no sibling grouping). Zero-padding makes lexical order
// equal numeric order, so page boundaries are predictable.
const id = (i: number) => `repo-${String(i).padStart(4, '0')}`;
const makeRepoEntries = (count: number) =>
Array.from({ length: count }, (_, i) => ({
...MOCK_REPO_ENTRY,
name: id(i),
path: `/tmp/repos/${id(i)}`,
storagePath: `/tmp/repos/${id(i)}/.gitnexus`,
}));
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
});
it('default page caps a large registry and reports continuation metadata', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(50);
expect(page.pagination).toEqual({
total: 437,
limit: 50,
offset: 0,
returned: 50,
hasMore: true,
nextOffset: 50,
});
// First page starts at the first repo in deterministic order.
expect(page.repositories[0].name).toBe(id(0));
});
it('limit controls the page size', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 100 });
expect(page.repositories).toHaveLength(100);
expect(page.pagination.limit).toBe(100);
expect(page.pagination.nextOffset).toBe(100);
});
it('offset selects a middle page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 50, offset: 50 });
expect(page.repositories[0].name).toBe(id(50));
expect(page.repositories[49].name).toBe(id(99));
// Assert total + limit too (a total miscalculation at non-zero offset would
// otherwise slip past this targeted middle-page test).
expect(page.pagination).toEqual({
total: 437,
limit: 50,
offset: 50,
returned: 50,
hasMore: true,
nextOffset: 100,
});
});
it('returns the final partial page with hasMore=false and no nextOffset', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 50, offset: 400 });
expect(page.repositories).toHaveLength(37); // 437 - 400
expect(page.pagination.returned).toBe(37);
expect(page.pagination.hasMore).toBe(false);
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('limit larger than the remaining count returns only the remaining entries', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 200, offset: 400 });
expect(page.repositories).toHaveLength(37);
expect(page.pagination.hasMore).toBe(false);
});
it('offset equal to total returns an empty page (total preserved)', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: 437 });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('offset beyond total returns an empty page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: 1000 });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({
total: 437,
offset: 1000,
returned: 0,
hasMore: false,
});
});
it('accepts a negative-zero offset (treated as the first page)', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 5, offset: -0 });
expect(page.repositories[0].name).toBe(id(0));
expect(page.pagination.returned).toBe(5);
// -0 is accepted (not rejected) and behaves as offset 0 (=== treats them equal).
expect(page.pagination.offset === 0).toBe(true);
});
it('accepts a MAX_SAFE_INTEGER offset and returns an empty page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: Number.MAX_SAFE_INTEGER });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('returns the full set with metadata when everything fits on one page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
await backend.init();
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(3);
expect(page.pagination).toEqual({
total: 3,
limit: 50,
offset: 0,
returned: 3,
hasMore: false,
});
});
it('rejects a limit above the maximum through the real callTool path', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
await expect(backend.callTool('list_repos', { limit: 99999 })).rejects.toThrow(/limit/);
// A request at the documented maximum is still accepted.
const page = await backend.callTool('list_repos', { limit: 200 });
expect(page.repositories).toHaveLength(200);
expect(page.pagination.limit).toBe(200);
expect(page.pagination.hasMore).toBe(true);
});
it('rejects malformed limit/offset through the real callTool path', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
await backend.init();
await expect(backend.callTool('list_repos', { limit: 0 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: -5 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: 1.5 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: 'all' as any })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { offset: -1 })).rejects.toThrow(/offset/);
await expect(backend.callTool('list_repos', { offset: 2.5 })).rejects.toThrow(/offset/);
});
it('traverses every repository exactly once across pages (the #2119 guarantee)', async () => {
const entries = makeRepoEntries(437);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const collected: string[] = [];
let offset = 0;
const limit = 50;
// Hard cap iterations to avoid an infinite loop if hasMore were ever wrong.
for (let guard = 0; guard < 100; guard++) {
const page = await backend.callTool('list_repos', { limit, offset });
collected.push(...page.repositories.map((r: any) => r.path));
expect(page.pagination.total).toBe(437);
if (!page.pagination.hasMore) break;
offset = page.pagination.nextOffset;
}
expect(collected).toHaveLength(437);
expect(new Set(collected).size).toBe(437); // no duplicates
expect(new Set(collected)).toEqual(new Set(entries.map((e) => e.path))); // exact set
});
it('orders pages deterministically by name then path, stable across calls', async () => {
// Scrambled input order; two entries deliberately SHARE a name (collision)
// and must be tie-broken by path, never collapsed.
const entries = [
{ ...MOCK_REPO_ENTRY, name: 'zeta', path: '/tmp/z', storagePath: '/tmp/z/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/b', storagePath: '/tmp/b/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'Alpha', path: '/tmp/a', storagePath: '/tmp/a/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/a2', storagePath: '/tmp/a2/.gitnexus' },
];
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const first = await backend.callTool('list_repos', {});
const order = first.repositories.map((r: any) => `${r.name}@${r.path}`);
// lower-cased name primary (Alpha < shared < zeta), path tie-break for the
// two "shared" entries (/tmp/a2 < /tmp/b).
expect(order).toEqual(['Alpha@/tmp/a', 'shared@/tmp/a2', 'shared@/tmp/b', 'zeta@/tmp/z']);
expect(first.repositories).toHaveLength(4); // collision not collapsed
// Re-listing yields identical page boundaries.
const second = await backend.callTool('list_repos', {});
expect(second.repositories.map((r: any) => `${r.name}@${r.path}`)).toEqual(order);
});
});
// ─── Cypher LadybugDB not ready ────────────────────────────────────────
describe('cypher tool LadybugDB not ready', () => {

View file

@ -84,7 +84,24 @@ describe('CLI commands', () => {
expect(pkg.default.files).toContain('vendor');
});
it('keeps vendored Swift runtime with prebuilds and hoisted activation script', async () => {
it('declares node-gyp-build/node-addon-api as regular dependencies (runtime-load contract)', async () => {
// Every vendored grammar's index.js does `require("node-gyp-build")` at
// runtime to load even a prebuilt .node, so node-gyp-build must always be
// present. They were optionalDependencies (surviving --omit=optional only
// via tree-sitter's transitive edge); promote them so the contract is
// explicit and robust to a future tree-sitter change.
const pkg = await import('../../package.json', { with: { type: 'json' } });
const deps = pkg.default.dependencies ?? {};
const optional = (pkg.default as { optionalDependencies?: Record<string, string> })
.optionalDependencies;
expect(deps['node-gyp-build']).toBeDefined();
expect(deps['node-addon-api']).toBeDefined();
// No grammar/native-build entries linger in optionalDependencies.
expect(optional?.['node-gyp-build']).toBeUndefined();
expect(optional?.['node-addon-api']).toBeUndefined();
});
it('keeps vendored Swift runtime with vendored source + GitNexus-built prebuilds and hoisted activation script', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const swiftPkg = await import('../../vendor/tree-sitter-swift/package.json', {
with: { type: 'json' },
@ -93,21 +110,57 @@ describe('CLI commands', () => {
// gate's assumptions (setTimeoutMicros semantics, ABI 13–14 grammar
// range) can't drift under a minor bump.
expect(pkg.default.dependencies['tree-sitter']).toBe('0.21.1');
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-swift.cjs');
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(swiftPkg.default.version).toBe('0.7.1');
// No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene).
expect(swiftPkg.default.scripts?.install).toBeUndefined();
expect(swiftPkg.default.dependencies).toBeUndefined();
expect(swiftPkg.default.peerDependencies['tree-sitter']).toContain('^0.21.1');
// Swift is now unified with Dart/Proto/Kotlin/C: the grammar SOURCE is
// vendored so build-tree-sitter-grammars.cjs can source-build the binding
// when no committed prebuild matches (e.g. CI before prebuilds land).
const bindingGyp = await fs.readFile(
path.join(REPO_ROOT, 'gitnexus/vendor/tree-sitter-swift/binding.gyp'),
'utf8',
);
expect(bindingGyp).toContain('tree_sitter_swift_binding');
expect(bindingGyp).toContain('src/parser.c');
await expect(
fs.stat(path.join(REPO_ROOT, 'gitnexus/vendor/tree-sitter-swift/src/parser.c')),
).resolves.toBeDefined();
});
it('declares tree-sitter-kotlin as an optionalDependency probed at postinstall (#2107)', async () => {
it('keeps vendored Kotlin runtime with GitNexus-built prebuilds and hoisted activation script (#2107)', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const kotlinPkg = await import('../../vendor/tree-sitter-kotlin/package.json', {
with: { type: 'json' },
});
const optional = pkg.default.optionalDependencies ?? {};
// Kotlin is a third-party npm optionalDependency (not vendored), so npm
// skips it when its source-only native build soft-fails — the gitnexus
// install still succeeds.
expect(optional['tree-sitter-kotlin']).toBeDefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-kotlin.cjs');
// Kotlin is now VENDORED (like Swift/Dart/Proto), not a third-party npm
// optionalDependency. Its prebuilds are GitNexus-cross-built (upstream
// ships source only) and materialized into node_modules/ at postinstall.
expect(optional['tree-sitter-kotlin']).toBeUndefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(kotlinPkg.default.version).toBe('0.3.8');
// No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene).
expect(kotlinPkg.default.scripts?.install).toBeUndefined();
expect(kotlinPkg.default.dependencies).toBeUndefined();
expect(kotlinPkg.default.peerDependencies['tree-sitter']).toContain('^0.21');
});
it('vendors tree-sitter-c prebuild-only at the 0.21.4 ABI pin instead of an npm dependency (#2116/#1242)', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const cPkg = await import('../../vendor/tree-sitter-c/package.json', {
with: { type: 'json' },
});
// c is a REQUIRED grammar that hard-fails install on toolchain-less ARM
// (upstream ships 4/6). Vendored with GitNexus-built prebuilds for all 6,
// held at 0.21.4 for ABI safety (#1242) — so it is NOT an npm dependency.
expect(pkg.default.dependencies['tree-sitter-c']).toBeUndefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(cPkg.default.version).toBe('0.21.4');
expect(cPkg.default.scripts?.install).toBeUndefined();
expect(cPkg.default.dependencies).toBeUndefined();
});
});

View file

@ -414,22 +414,70 @@ describe('formatDetectChangesResult', () => {
// ─── formatListReposResult ───────────────────────────────────────────
describe('formatListReposResult', () => {
it('handles empty/null input', () => {
expect(formatListReposResult([])).toBe('No indexed repositories.');
expect(formatListReposResult(null)).toBe('No indexed repositories.');
it('handles an empty page (no pagination)', () => {
expect(formatListReposResult({ repositories: [] })).toBe('No indexed repositories.');
});
it('formats repo list', () => {
const result = formatListReposResult([
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
stats: { nodes: 100, edges: 200, processes: 10 },
},
]);
it('formats a repo list (no pagination → no footer)', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('100 symbols');
expect(result).not.toContain('Showing'); // no pagination → no footer
});
it('formats a paginated { repositories, pagination } result with a continuation footer', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
pagination: { total: 437, limit: 50, offset: 0, returned: 1, hasMore: true, nextOffset: 50 },
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('Showing 1 of 437');
expect(result).toContain('offset 50'); // continuation hint
});
it('formats the final page (hasMore false) without a continuation hint', () => {
const result = formatListReposResult({
repositories: [
{
name: 'only',
path: '/p/only',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: {},
},
],
pagination: { total: 1, limit: 50, offset: 0, returned: 1, hasMore: false },
});
expect(result).toContain('Showing 1 of 1');
expect(result).not.toContain('More available');
});
it('reports an empty page using pagination metadata', () => {
const result = formatListReposResult({
repositories: [],
pagination: { total: 437, limit: 50, offset: 1000, returned: 0, hasMore: false },
});
expect(result).toContain('No repositories on this page');
expect(result).toContain('437');
});
});

View file

@ -0,0 +1,78 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/**
* Unit coverage for the ABI gate in the vendored-grammar update monitor
* (.github/scripts/update-vendored-grammars.mjs). The gate is load-bearing: every
* grammar is pinned to tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14), so an update
* is only auto-applied when the candidate parser.c's ABI is 13 or 14 — otherwise
* the monitor would open PRs that can't build. We test the pure pieces (no
* network): reading the ABI from a parser.c and the compatibility set. The module
* is import-safe (its CLI is guarded behind an isMain check).
*/
const MOD = pathToFileURL(
path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../.github/scripts/update-vendored-grammars.mjs',
),
).href;
let mod: {
readAbi: (root: string) => number | null;
COMPATIBLE_ABI: Set<number>;
GRAMMARS: Record<string, { name: string; npm?: string; github?: string; hold?: string }>;
};
let tmp: string;
beforeAll(async () => {
mod = await import(MOD);
tmp = mkdtempSync(path.join(tmpdir(), 'gum-'));
});
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
function fixture(abiLine: string): string {
const root = mkdtempSync(path.join(tmp, 'g-'));
mkdirSync(path.join(root, 'src'), { recursive: true });
writeFileSync(path.join(root, 'src', 'parser.c'), `${abiLine}\n#define STATE_COUNT 10\n`);
return root;
}
describe('readAbi', () => {
it('reads LANGUAGE_VERSION 14 from src/parser.c', () => {
expect(mod.readAbi(fixture('#define LANGUAGE_VERSION 14'))).toBe(14);
});
it('reads LANGUAGE_VERSION 15 (an incompatible upstream)', () => {
expect(mod.readAbi(fixture('#define LANGUAGE_VERSION 15'))).toBe(15);
});
it('returns null when parser.c is absent (generated-at-build-time grammars)', () => {
expect(mod.readAbi(mkdtempSync(path.join(tmp, 'empty-')))).toBeNull();
});
});
describe('COMPATIBLE_ABI gate', () => {
it('accepts ABI 13 and 14, rejects 12 and 15', () => {
expect(mod.COMPATIBLE_ABI.has(13)).toBe(true);
expect(mod.COMPATIBLE_ABI.has(14)).toBe(true);
expect(mod.COMPATIBLE_ABI.has(12)).toBe(false);
expect(mod.COMPATIBLE_ABI.has(15)).toBe(false);
});
});
describe('GRAMMARS registry', () => {
it('covers all five grammars (swift/kotlin npm, dart/proto github, c npm)', () => {
expect(Object.keys(mod.GRAMMARS).sort()).toEqual(['c', 'dart', 'kotlin', 'proto', 'swift']);
expect(mod.GRAMMARS.swift.npm).toBe('tree-sitter-swift');
expect(mod.GRAMMARS.dart.github).toContain('tree-sitter-dart');
});
it('monitors c but marks it report-only (ABI-pinned hold); the rest are auto-updatable', () => {
expect(mod.GRAMMARS.c.npm).toBe('tree-sitter-c');
expect(mod.GRAMMARS.c.hold).toBeTruthy(); // detected/reported, never auto-applied
for (const k of ['swift', 'kotlin', 'dart', 'proto']) {
expect(mod.GRAMMARS[k].hold).toBeUndefined();
}
});
});

View file

@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Regression guard: every tree-sitter grammar GitNexus ships must provide a
* loadable native binding for EVERY platform-arch we support, on the ABI we
* support — so a toolchain-less install never silently loses a language.
*
* "The ABI we support":
* - Node native ABI: engines.node >= 22 → all grammars are N-API
* (node-addon-api), i.e. one ABI-stable `.node` per platform-arch loads
* across Node majors. We assert each prebuilt binary exports the N-API
* entry symbol `napi_register_module_v1` (a node-ABI-pinned binary would
* not) — this works cross-platform because the symbol name is an ASCII
* string in the binary on linux/macOS/Windows alike.
* - tree-sitter language ABI: pinned `tree-sitter@0.21.1` (#1922) — verified
* by the load+parse smoke in parser-loader-abi.test.ts.
*
* Two cohorts:
* 1. VENDORED grammars (gitnexus/vendor/tree-sitter-*) — GitNexus owns these
* prebuilds (cross-built by .github/workflows/build-tree-sitter-prebuilds.yml;
* Swift's were originally upstream-shipped, now rebuilt the same way). Each
* one that does NOT also vendor its build source MUST cover all 6 tuples.
* 2. npm-dependency grammars — upstream owns their prebuilds. We assert 6/6
* too, with documented exceptions (see KNOWN_NPM_GAPS).
*/
const TUPLES = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64',
];
const NAPI_SYMBOL = 'napi_register_module_v1';
const GITNEXUS_ROOT = fileURLToPath(new URL('../..', import.meta.url));
const VENDOR_DIR = path.join(GITNEXUS_ROOT, 'vendor');
const NODE_MODULES = path.join(GITNEXUS_ROOT, 'node_modules');
/**
* Known, tracked upstream coverage gaps for npm-dependency grammars. Each entry
* is the EXACT set of tuples the upstream package omits — the test fails if a
* grammar drops MORE than its allow-listed gap (a new silent regression) OR if
* an allow-listed gap is closed upstream (prompting allow-list removal).
*
* (tree-sitter-c@0.21.4 ships only 4/6 — no linux-arm64/win32-arm64, #2116 — but
* it is now VENDORED with GitNexus-built prebuilds for all 6, so it falls under
* the vendored cohort below, not here.)
*/
const KNOWN_NPM_GAPS: Record<string, string[]> = {};
/**
* Vendored grammars declared "fully prebuilt": GitNexus has committed 6/6
* prebuilds for them, so they MUST keep all six even though they also vendor
* source (binding.gyp). Without this list the strict 6/6 assertion is dormant for
* every grammar that carries source — a dropped prebuild would pass CI silently.
* Grammars graduate into this set as the build-tree-sitter-prebuilds workflow
* lands their binaries (today only Swift ships 6/6; c/dart/proto/kotlin are
* source-build-only until the workflow runs).
*/
const FULLY_PREBUILT = new Set<string>(['tree-sitter-swift']);
function isNapiBinary(file: string): boolean {
return readFileSync(file).includes(NAPI_SYMBOL);
}
function prebuiltTuples(grammarDir: string): { covered: Set<string>; nonNapi: string[] } {
const pdir = path.join(grammarDir, 'prebuilds');
const covered = new Set<string>();
const nonNapi: string[] = [];
if (!existsSync(pdir)) return { covered, nonNapi };
for (const tuple of TUPLES) {
const td = path.join(pdir, tuple);
if (!existsSync(td) || !statSync(td).isDirectory()) continue;
const nodes = readdirSync(td).filter((f) => f.endsWith('.node'));
if (nodes.length === 0) continue;
covered.add(tuple);
for (const n of nodes) if (!isNapiBinary(path.join(td, n))) nonNapi.push(`${tuple}/${n}`);
}
return { covered, nonNapi };
}
const vendoredGrammars = existsSync(VENDOR_DIR)
? readdirSync(VENDOR_DIR).filter((d) => /^tree-sitter-/.test(d))
: [];
describe('vendored grammar prebuild coverage (toolchain-free on every supported platform)', () => {
it('discovers the vendored grammars', () => {
// Sanity: if vendor/ ever empties, the per-grammar assertions would vacuously
// pass — fail loudly instead.
expect(vendoredGrammars.length).toBeGreaterThan(0);
});
for (const grammar of vendoredGrammars) {
const grammarDir = path.join(VENDOR_DIR, grammar);
const { covered, nonNapi } = prebuiltTuples(grammarDir);
const missing = TUPLES.filter((t) => !covered.has(t));
// A grammar that vendors its build sources (binding.gyp) can source-build the
// gaps on any toolchain host (e.g. CI), so an incomplete prebuild set is
// tolerated for it — the build-tree-sitter-prebuilds workflow fills the
// prebuilds to make it toolchain-free. Every grammar GitNexus currently
// vendors carries its source (incl. swift, unified with the rest), so the
// strict branch below is defensive: a hypothetical prebuild-only grammar (no
// binding.gyp) MUST ship all six, or it is dead on the missing platform.
const hasSourceFallback = existsSync(path.join(grammarDir, 'binding.gyp'));
// A declared-fully-prebuilt grammar must ship all six EVEN THOUGH it has a
// source fallback — otherwise the strict 6/6 assertion is dormant for every
// source-carrying grammar and a dropped prebuild slips through CI.
const mustBeFullyPrebuilt = FULLY_PREBUILT.has(grammar);
it(
mustBeFullyPrebuilt
? `${grammar}: ships an N-API prebuild for ALL 6 tuples (declared fully-prebuilt)`
: hasSourceFallback
? `${grammar}: present prebuilds are N-API (source-build fallback covers any gaps)`
: `${grammar}: ships an N-API prebuild for all 6 platform-arch tuples`,
() => {
// Any prebuild that IS present must be a loadable N-API binary — always.
expect(nonNapi, `${grammar} has non-N-API prebuilds: ${nonNapi.join(', ')}`).toEqual([]);
if (mustBeFullyPrebuilt || !hasSourceFallback) {
// Either declared fully-prebuilt, or prebuild-only (no source fallback):
// all six are required. Run the build-tree-sitter-prebuilds workflow to
// (re)generate any that are missing.
expect(
missing,
`${grammar} is missing prebuilds for: ${missing.join(', ') || 'none'} ` +
(mustBeFullyPrebuilt
? `(declared fully-prebuilt in FULLY_PREBUILT — its 6/6 set must stay complete)`
: `(prebuild-only — run the build-tree-sitter-prebuilds workflow)`),
).toEqual([]);
}
},
);
}
});
describe('npm-dependency grammar prebuild coverage', () => {
const pkg = JSON.parse(readFileSync(path.join(GITNEXUS_ROOT, 'package.json'), 'utf8'));
const npmGrammars = Object.keys(pkg.dependencies ?? {})
.filter((d) => /^tree-sitter-/.test(d))
.sort();
it('discovers the npm grammar dependencies', () => {
expect(npmGrammars.length).toBeGreaterThan(0);
});
for (const grammar of npmGrammars) {
const grammarDir = path.join(NODE_MODULES, grammar);
it(`${grammar}: upstream ships N-API prebuilds for all 6 tuples (minus tracked gaps)`, () => {
if (!existsSync(grammarDir)) {
// node_modules must be installed for this check (CI coverage job / local).
throw new Error(`${grammar} not installed at ${grammarDir} — run npm install`);
}
const { covered, nonNapi } = prebuiltTuples(grammarDir);
const allowedGap = new Set(KNOWN_NPM_GAPS[grammar] ?? []);
const unexpectedMissing = TUPLES.filter((t) => !covered.has(t) && !allowedGap.has(t));
const unexpectedlyClosed = [...allowedGap].filter((t) => covered.has(t));
expect(
unexpectedMissing,
`${grammar} is missing prebuilds for: ${unexpectedMissing.join(', ')} ` +
`(new gap — upstream dropped a platform, or pin a version that ships it)`,
).toEqual([]);
expect(
unexpectedlyClosed,
`${grammar} now ships prebuilds for ${unexpectedlyClosed.join(', ')} — ` +
`remove it from KNOWN_NPM_GAPS (and close the tracking issue)`,
).toEqual([]);
expect(nonNapi, `${grammar} has non-N-API prebuilds: ${nonNapi.join(', ')}`).toEqual([]);
});
}
});

View file

@ -8,7 +8,11 @@
* - Optional repo parameter is present on tools that need it
*/
import { describe, it, expect } from 'vitest';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
import {
GITNEXUS_TOOLS,
LIST_REPOS_DEFAULT_LIMIT,
LIST_REPOS_MAX_LIMIT,
} from '../../src/mcp/tools.js';
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
@ -129,10 +133,34 @@ describe('GITNEXUS_TOOLS', () => {
expect(detectTool.inputSchema.required).toEqual([]);
});
it('list_repos tool has no parameters', () => {
it('list_repos tool exposes optional limit/offset pagination params', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
expect(Object.keys(listTool.inputSchema.properties)).toHaveLength(0);
const props = listTool.inputSchema.properties;
expect(props.limit).toBeDefined();
expect(props.limit.type).toBe('integer');
expect(props.offset).toBeDefined();
expect(props.offset.type).toBe('integer');
// Pagination is opt-in: zero-arg callers must still be valid.
expect(listTool.inputSchema.required).toEqual([]);
// No `repo` param on list_repos (it lists all repos).
expect(props.repo).toBeUndefined();
// Description must teach an LLM to page through every repository.
expect(listTool.description.toLowerCase()).toContain('paginat');
expect(listTool.description).toContain('nextOffset');
expect(listTool.description).toContain('hasMore');
});
it('list_repos schema bounds match the exported pagination constants', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
const { limit, offset } = listTool.inputSchema.properties;
expect(limit.minimum).toBe(1);
expect(limit.maximum).toBe(LIST_REPOS_MAX_LIMIT);
expect(limit.default).toBe(LIST_REPOS_DEFAULT_LIMIT);
expect(offset.minimum).toBe(0);
expect(offset.default).toBe(0);
// Sane, documented bounds (guards against accidental constant drift).
expect(LIST_REPOS_DEFAULT_LIMIT).toBeLessThanOrEqual(LIST_REPOS_MAX_LIMIT);
expect(LIST_REPOS_DEFAULT_LIMIT).toBeGreaterThan(0);
});
it('per-repo tools have optional repo parameter for backend selection', () => {

View file

@ -0,0 +1,561 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
// Codex uninstall shells out to `codex mcp remove`; make it fail by default
// so the TOML-strip fallback path is exercised in tests that don't override it.
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') {
callback(new Error('codex not found'), '', '');
}
});
vi.mock('child_process', () => ({
execFile: execFileMock,
}));
describe('uninstallCommand', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalSkillsRoot: string | undefined;
let originalExitCode: typeof process.exitCode;
let skillsRoot: string;
const importUninstall = async () => (await import('../../src/cli/uninstall.js')).uninstallCommand;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
originalExitCode = process.exitCode;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-uninstall-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Stage a fixture skills source so listGitnexusSkillNames() resolves
// deterministically without depending on __dirname under Vitest.
skillsRoot = path.join(tempHome, 'pkg-skills');
await fs.mkdir(skillsRoot, { recursive: true });
await fs.writeFile(path.join(skillsRoot, 'gitnexus-exploring.md'), '# explore', 'utf-8');
await fs.writeFile(path.join(skillsRoot, 'gitnexus-cli.md'), '# cli', 'utf-8');
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot;
// The command sets process.exitCode=1 on partial failure; restore it so a
// test exercising that path doesn't make the whole vitest run exit non-zero.
process.exitCode = originalExitCode;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('removes the gitnexus MCP entry from ~/.claude.json, preserving others', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
await fs.writeFile(
claudeJson,
JSON.stringify({
existingKey: 'keep-me',
mcpServers: {
gitnexus: { command: 'gitnexus', args: ['mcp'] },
other: { command: 'foo' },
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(claudeJson, 'utf-8'));
expect(config.mcpServers.gitnexus).toBeUndefined();
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.existingKey).toBe('keep-me');
});
it('dry run (no --force) leaves files untouched', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
const raw = JSON.stringify({
mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } },
});
await fs.writeFile(claudeJson, raw, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand();
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(raw);
});
it('removes gitnexus hook entries and the hook-script dir, preserving other hooks', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
await fs.writeFile(
settingsPath,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] },
],
PostToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
}),
'utf-8',
);
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
expect(config.hooks.PreToolUse).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-hook');
expect(config.hooks.PostToolUse).toHaveLength(0);
await expect(fs.access(hookDir)).rejects.toThrow();
});
it('removes installed gitnexus skill directories from ~/.claude/skills', async () => {
const skillsDir = path.join(tempHome, '.claude', 'skills');
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
await fs.mkdir(path.join(skillsDir, 'gitnexus-cli'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), '# y', 'utf-8');
// A user's own skill that must survive.
await fs.mkdir(path.join(skillsDir, 'my-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'my-skill', 'SKILL.md'), '# mine', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
await expect(fs.access(path.join(skillsDir, 'gitnexus-cli'))).rejects.toThrow();
await expect(fs.access(path.join(skillsDir, 'my-skill'))).resolves.toBeUndefined();
});
it('strips the [mcp_servers.gitnexus] section from Codex config.toml, keeping other tables', async () => {
const codexDir = path.join(tempHome, '.codex');
await fs.mkdir(codexDir, { recursive: true });
const configPath = path.join(codexDir, 'config.toml');
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'args = ["mcp"]',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'args = ["mcp"]',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const result = await fs.readFile(configPath, 'utf-8');
expect(result).not.toContain('[mcp_servers.gitnexus]');
expect(result).toContain('[mcp_servers.other]');
expect(result).toContain('command = "other"');
});
it('leaves a corrupt JSON config untouched', async () => {
const claudeJson = path.join(tempHome, '.claude.json');
const corrupt = '{ not valid json !!!';
await fs.writeFile(claudeJson, corrupt, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(corrupt);
});
it('is a no-op when nothing is configured', async () => {
const uninstallCommand = await importUninstall();
await expect(uninstallCommand({ force: true })).resolves.toBeUndefined();
});
// ── #1: empty derived skill name must not wipe the whole skills dir ──
it('does not wipe the skills dir when the source has a bare ".md" file', async () => {
// A stray bare ".md" → basename('.md', '.md') === '' → would resolve to
// the skills dir itself if unguarded.
await fs.writeFile(path.join(skillsRoot, '.md'), 'stray', 'utf-8');
const skillsDir = path.join(tempHome, '.claude', 'skills');
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
await fs.mkdir(path.join(skillsDir, 'user-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'user-skill', 'SKILL.md'), '# mine', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
// The skills dir and the user skill survive; only the gitnexus skill went.
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
await expect(fs.access(path.join(skillsDir, 'user-skill'))).resolves.toBeUndefined();
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
});
// ── #2: corrupt settings.json must not orphan the hook script ──
it('keeps the hook-script dir when settings.json is corrupt (avoids dangling hook)', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const corrupt = '{ not valid json !!!';
await fs.writeFile(settingsPath, corrupt, 'utf-8');
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
// Entry couldn't be removed → script dir must stay, and we flag failure.
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(corrupt);
await expect(fs.access(hookDir)).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
});
// ── #3: element-granular hook removal preserves a co-located user hook ──
it('removes only the gitnexus command from a shared hook entry, keeping user commands', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
await fs.writeFile(
settingsPath,
JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' },
{ type: 'command', command: 'my-own-inline-hook' },
],
},
],
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
// Entry survives because it still holds the user's command.
expect(config.hooks.PreToolUse).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks).toHaveLength(1);
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-inline-hook');
});
// ── OpenCode MCP uses a distinct keyPath: ['mcp', 'gitnexus'] ──
it('removes the gitnexus entry from OpenCode opencode.json (mcp.gitnexus), preserving others', async () => {
const opencodeJson = path.join(tempHome, '.config', 'opencode', 'opencode.json');
await fs.mkdir(path.dirname(opencodeJson), { recursive: true });
await fs.writeFile(
opencodeJson,
JSON.stringify({
mcp: {
gitnexus: { type: 'local', command: ['gitnexus', 'mcp'] },
other: { type: 'local', command: ['foo'] },
},
}),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const config = JSON.parse(await fs.readFile(opencodeJson, 'utf-8'));
expect(config.mcp.gitnexus).toBeUndefined();
expect(config.mcp.other).toEqual({ type: 'local', command: ['foo'] });
});
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
await fs.mkdir(path.dirname(mcpPath), { recursive: true });
await fs.writeFile(
mcpPath,
JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } } }),
'utf-8',
);
const geminiSettings = path.join(tempHome, '.gemini', 'settings.json');
await fs.writeFile(
geminiSettings,
JSON.stringify({
hooks: {
AfterTool: [
{
matcher: 'search_file_content|glob|run_shell_command',
hooks: [{ type: 'command', command: 'node ".../gitnexus-antigravity-hook.cjs"' }],
},
{ matcher: 'glob', hooks: [{ type: 'command', command: 'user-after-tool' }] },
],
},
}),
'utf-8',
);
const adapterDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
await fs.mkdir(adapterDir, { recursive: true });
await fs.writeFile(path.join(adapterDir, 'gitnexus-antigravity-hook.cjs'), '// a', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(JSON.parse(await fs.readFile(mcpPath, 'utf-8')).mcpServers.gitnexus).toBeUndefined();
const settings = JSON.parse(await fs.readFile(geminiSettings, 'utf-8'));
expect(settings.hooks.AfterTool).toHaveLength(1);
expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('user-after-tool');
await expect(fs.access(adapterDir)).rejects.toThrow();
});
// ── Codex success path: prefer `codex mcp remove`, don't touch the file ──
it('uses `codex mcp remove` when the binary succeeds and leaves config.toml untouched', async () => {
execFileMock.mockImplementationOnce((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(null, '', '');
});
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = ['[mcp_servers.gitnexus]', 'command = "gitnexus"', 'args = ["mcp"]', ''].join('\n');
await fs.writeFile(configPath, raw, 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
expect(execFileMock).toHaveBeenCalledWith(
'codex',
['mcp', 'remove', 'gitnexus'],
expect.any(Object),
expect.any(Function),
);
// CLI path handles removal; we must not also rewrite the file.
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
});
// ── #5: TOML fallback also strips descendant sub-tables ──
it('strips a [mcp_servers.gitnexus.env] sub-table along with the parent', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
'[mcp_servers.gitnexus.env]',
'FOO = "bar"',
'',
'[mcp_servers.zeta]',
'command = "zeta"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('[mcp_servers.gitnexus]');
expect(out).not.toContain('[mcp_servers.gitnexus.env]');
expect(out).not.toContain('FOO = "bar"');
expect(out).toContain('[mcp_servers.other]');
expect(out).toContain('[mcp_servers.zeta]');
expect(out).toContain('command = "zeta"');
});
// ── #5: TOML fallback ignores a [header] inside a multiline string ──
it('does not truncate a multiline string that contains a [mcp_servers.gitnexus] literal', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'note = """',
'[mcp_servers.gitnexus]',
'not a real header',
'"""',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
// The multiline literal is preserved...
expect(out).toContain('not a real header');
expect(out).toContain('command = "other"');
// ...but the real section's body is gone.
expect(out).not.toContain('command = "gitnexus"');
});
// ── #5 (regression): a multiline line containing an odd count of BOTH
// delimiters must not desync the scanner (it previously stuck in multiline
// mode and failed to strip the real section). ──
it('strips the real section even when a multiline string mixes \'\'\' and """ on one line', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
// Opens a ''' literal; the """ on this same line is data, not an opener.
'note = \'\'\'has """ inside',
'still in string [mcp_servers.gitnexus]',
"'''",
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
// The multiline literal (incl. the fake header line) is preserved...
expect(out).toContain('still in string [mcp_servers.gitnexus]');
expect(out).toContain('command = "other"');
// ...and the real section was actually removed (the bug left it behind).
expect(out).not.toContain('command = "gitnexus"');
});
// ── sweep: a section header with a trailing inline comment is still stripped ──
it('strips a [mcp_servers.gitnexus] header that has a trailing inline comment', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus] # GitNexus MCP',
'command = "gitnexus"',
'',
].join('\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('mcp_servers.gitnexus');
expect(out).not.toContain('command = "gitnexus"');
expect(out).toContain('[mcp_servers.other]');
});
// ── sweep: CRLF config.toml keeps its line endings (no silent LF rewrite) ──
it('preserves CRLF line endings when stripping the Codex section', async () => {
const configPath = path.join(tempHome, '.codex', 'config.toml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
[
'[mcp_servers.other]',
'command = "other"',
'',
'[mcp_servers.gitnexus]',
'command = "gitnexus"',
'',
].join('\r\n'),
'utf-8',
);
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
const out = await fs.readFile(configPath, 'utf-8');
expect(out).not.toContain('[mcp_servers.gitnexus]');
expect(out).toContain('[mcp_servers.other]');
expect(out).toContain('\r\n');
// No bare LF: every newline is part of a CRLF.
expect(out).not.toMatch(/[^\r]\n/);
});
// ── dry-run leaves hooks and skills intact ──
it('dry run does not remove hooks, hook scripts, or skills', async () => {
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
const settingsRaw = JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
},
],
},
});
await fs.writeFile(settingsPath, settingsRaw, 'utf-8');
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await fs.mkdir(hookDir, { recursive: true });
const skillsDir = path.join(tempHome, '.claude', 'skills', 'gitnexus-cli');
await fs.mkdir(skillsDir, { recursive: true });
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), '# y', 'utf-8');
const uninstallCommand = await importUninstall();
await uninstallCommand(); // no --force
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(settingsRaw);
await expect(fs.access(hookDir)).resolves.toBeUndefined();
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
});
// ── skills: directory-layout source ({name}/SKILL.md) is recognized ──
it('removes a directory-layout skill ({name}/SKILL.md) from a target', async () => {
await fs.mkdir(path.join(skillsRoot, 'gitnexus-dir-skill'), { recursive: true });
await fs.writeFile(path.join(skillsRoot, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
// Use a non-Claude target to also exercise a second skill destination.
const opencodeSkills = path.join(tempHome, '.config', 'opencode', 'skills');
await fs.mkdir(path.join(opencodeSkills, 'gitnexus-dir-skill'), { recursive: true });
await fs.writeFile(path.join(opencodeSkills, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
await fs.mkdir(path.join(opencodeSkills, 'keep-me'), { recursive: true });
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
await expect(fs.access(path.join(opencodeSkills, 'gitnexus-dir-skill'))).rejects.toThrow();
await expect(fs.access(path.join(opencodeSkills, 'keep-me'))).resolves.toBeUndefined();
});
});

21
gitnexus/vendor/tree-sitter-c/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Max Brunsfeld
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
gitnexus/vendor/tree-sitter-c/README.md vendored Normal file
View file

@ -0,0 +1,41 @@
## GitNexus vendor notice
This directory is a GitNexus-managed **runtime** package derived from
`tree-sitter-c@0.21.4` (tree-sitter/tree-sitter-c). It carries the runtime files
(`bindings/node/`, `src/node-types.json`, `LICENSE`), the native `prebuilds/`,
**and** the grammar source (`binding.gyp`, `src/parser.c`, `src/tree_sitter/`).
The prebuilds make C parsing toolchain-free; the source lets
`build-tree-sitter-grammars.cjs` compile the binding on a toolchain host when no
prebuild matches (e.g. CI before the prebuilds are vendored).
### Why this is vendored (unlike the other npm grammars)
`tree-sitter-c` is the one grammar dependency upstream ships **incomplete**
prebuilds for: only 4 of 6 platform-archs (no `linux-arm64` / `win32-arm64`,
[#2116](https://github.com/abhigyanpatwari/GitNexus/issues/2116)). And unlike
the optional grammars, `tree-sitter-c` is a **required** grammar whose own
`install` script (`node-gyp-build`) compiles from source when no prebuild
matches — which **hard-fails `npm install`** on a toolchain-less ARM host
(`node-gyp rebuild` exits non-zero for a required dependency). To make C parsing
toolchain-free on every platform, GitNexus builds all six prebuilds itself (via
the `build-tree-sitter-prebuilds` workflow) and vendors them; `node-gyp-build`
selects the right `.node` at require time.
### Held at 0.21.4 (do not bump here)
The version is pinned to **0.21.4** for ABI compatibility with the bundled
`tree-sitter@0.21.1` runtime — `tree-sitter-c@0.23.x` prebuilds segfault under
0.21.1 on Windows ([#1242](https://github.com/abhigyanpatwari/GitNexus/issues/1242),
[#858](https://github.com/abhigyanpatwari/GitNexus/issues/858)). Vendoring 0.21.4
*preserves* that pin while closing the ARM prebuild gap. Bump only as part of the
deliberate tree-sitter 0.21→0.23 runtime upgrade.
### Updating this vendor package
1. (Runtime upgrade only) bump `version` in `package.json` + refresh
`bindings/node/*` and `src/node-types.json` from the new `tree-sitter-c`
release, and refresh `_vendoredBy`.
2. Regenerate the six prebuilds by running the **`build-tree-sitter-prebuilds`**
workflow.
3. Verify the packed tarball can `require('tree-sitter-c')` and parse C on each
target platform-arch (the workflow's validate step does this in CI).

View file

@ -0,0 +1,29 @@
{
"targets": [
{
"target_name": "tree_sitter_c_binding",
"dependencies": [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
],
"include_dirs": [
"src",
],
"sources": [
"bindings/node/binding.cc",
"src/parser.c",
],
"conditions": [
["OS!='win'", {
"cflags_c": [
"-std=c11",
],
}, { # OS == "win"
"cflags_c": [
"/std:c11",
"/utf-8",
],
}],
],
}
]
}

View file

@ -0,0 +1,20 @@
#include <napi.h>
typedef struct TSLanguage TSLanguage;
extern "C" TSLanguage *tree_sitter_c();
// "tree-sitter", "language" hashed with BLAKE2
const napi_type_tag LANGUAGE_TYPE_TAG = {
0x8AF2E5212AD58ABF, 0xD5006CAD83ABBA16
};
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports["name"] = Napi::String::New(env, "c");
auto language = Napi::External<TSLanguage>::New(env, tree_sitter_c());
language.TypeTag(&LANGUAGE_TYPE_TAG);
exports["language"] = language;
return exports;
}
NODE_API_MODULE(tree_sitter_c_binding, Init)

View file

@ -0,0 +1,28 @@
type BaseNode = {
type: string;
named: boolean;
};
type ChildNode = {
multiple: boolean;
required: boolean;
types: BaseNode[];
};
type NodeInfo =
| (BaseNode & {
subtypes: BaseNode[];
})
| (BaseNode & {
fields: { [name: string]: ChildNode };
children: ChildNode[];
});
type Language = {
name: string;
language: unknown;
nodeTypeInfo: NodeInfo[];
};
declare const language: Language;
export = language;

View file

@ -0,0 +1,7 @@
const root = require("path").join(__dirname, "..", "..");
module.exports = require("node-gyp-build")(root);
try {
module.exports.nodeTypeInfo = require("../../src/node-types.json");
} catch (_) {}

View file

@ -0,0 +1,18 @@
{
"name": "tree-sitter-c",
"version": "0.21.4",
"description": "C grammar for tree-sitter",
"repository": "https://github.com/tree-sitter/tree-sitter-c",
"license": "MIT",
"main": "bindings/node/index.js",
"types": "bindings/node/index.d.ts",
"_vendoredBy": "gitnexus - runtime package derived from tree-sitter-c@0.21.4 (tree-sitter/tree-sitter-c). HELD at 0.21.4 for ABI compatibility with the bundled tree-sitter@0.21.1 runtime (#1242/#858) — do not bump without the runtime upgrade. Vendored because upstream ships native prebuilds for only 4 of 6 platforms (no linux-arm64/win32-arm64, #2116), and tree-sitter-c is a REQUIRED grammar whose source build hard-fails `npm install` on a toolchain-less ARM host. GitNexus cross-builds all six prebuilds via .github/workflows/build-tree-sitter-prebuilds.yml; the C source (binding.gyp + src/) is ALSO vendored so build-tree-sitter-c.cjs can source-build the binding on a toolchain host when no prebuild matches (e.g. CI before prebuilds land). Copied to node_modules/ by materialize-vendor-grammars.cjs (no scripts.install here — #836/#1728).",
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
}

View file

View file

@ -0,0 +1,6 @@
0ef1ad3da01eacfbad1c83a4e6f39e5dfb563cb3ba663447ef563677e43f5802 ./darwin-arm64/tree-sitter-c.node
5bea052ac3530f56c6c4849d7ad435469eb684a05fb641c4b1df5a0047a224b3 ./darwin-x64/tree-sitter-c.node
c0b20e0ddbbf7f15441bc09855892e0720623113dd24cbbb81f3f6fec800650e ./linux-arm64/tree-sitter-c.node
85e912b183a49111b0f4c3a6c8908375732e70aca759ab0124501913cbbd942a ./linux-x64/tree-sitter-c.node
d15bd0478e24f6cedab5aea5b34a423b6918babb45fd81b6d05be740ac83f71e ./win32-arm64/tree-sitter-c.node
ce42c8f63ea0fbb21b36486c6aef8bd2208abb45cb478d1071bb44fda8d6558a ./win32-x64/tree-sitter-c.node

File diff suppressed because it is too large Load diff

113852
gitnexus/vendor/tree-sitter-c/src/parser.c vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
#ifndef TREE_SITTER_ALLOC_H_
#define TREE_SITTER_ALLOC_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
// Allow clients to override allocation functions
#ifdef TREE_SITTER_REUSE_ALLOCATOR
extern void *(*ts_current_malloc)(size_t);
extern void *(*ts_current_calloc)(size_t, size_t);
extern void *(*ts_current_realloc)(void *, size_t);
extern void (*ts_current_free)(void *);
#ifndef ts_malloc
#define ts_malloc ts_current_malloc
#endif
#ifndef ts_calloc
#define ts_calloc ts_current_calloc
#endif
#ifndef ts_realloc
#define ts_realloc ts_current_realloc
#endif
#ifndef ts_free
#define ts_free ts_current_free
#endif
#else
#ifndef ts_malloc
#define ts_malloc malloc
#endif
#ifndef ts_calloc
#define ts_calloc calloc
#endif
#ifndef ts_realloc
#define ts_realloc realloc
#endif
#ifndef ts_free
#define ts_free free
#endif
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ALLOC_H_

View file

@ -0,0 +1,290 @@
#ifndef TREE_SITTER_ARRAY_H_
#define TREE_SITTER_ARRAY_H_
#ifdef __cplusplus
extern "C" {
#endif
#include "./alloc.h"
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef _MSC_VER
#pragma warning(disable : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable"
#endif
#define Array(T) \
struct { \
T *contents; \
uint32_t size; \
uint32_t capacity; \
}
/// Initialize an array.
#define array_init(self) \
((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL)
/// Create an empty array.
#define array_new() \
{ NULL, 0, 0 }
/// Get a pointer to the element at a given `index` in the array.
#define array_get(self, _index) \
(assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index])
/// Get a pointer to the first element in the array.
#define array_front(self) array_get(self, 0)
/// Get a pointer to the last element in the array.
#define array_back(self) array_get(self, (self)->size - 1)
/// Clear the array, setting its size to zero. Note that this does not free any
/// memory allocated for the array's contents.
#define array_clear(self) ((self)->size = 0)
/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is
/// less than the array's current capacity, this function has no effect.
#define array_reserve(self, new_capacity) \
_array__reserve((Array *)(self), array_elem_size(self), new_capacity)
/// Free any memory allocated for this array. Note that this does not free any
/// memory allocated for the array's contents.
#define array_delete(self) _array__delete((Array *)(self))
/// Push a new `element` onto the end of the array.
#define array_push(self, element) \
(_array__grow((Array *)(self), 1, array_elem_size(self)), \
(self)->contents[(self)->size++] = (element))
/// Increase the array's size by `count` elements.
/// New elements are zero-initialized.
#define array_grow_by(self, count) \
do { \
if ((count) == 0) break; \
_array__grow((Array *)(self), count, array_elem_size(self)); \
memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \
(self)->size += (count); \
} while (0)
/// Append all elements from one array to the end of another.
#define array_push_all(self, other) \
array_extend((self), (other)->size, (other)->contents)
/// Append `count` elements to the end of the array, reading their values from the
/// `contents` pointer.
#define array_extend(self, count, contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), (self)->size, \
0, count, contents \
)
/// Remove `old_count` elements from the array starting at the given `index`. At
/// the same index, insert `new_count` new elements, reading their values from the
/// `new_contents` pointer.
#define array_splice(self, _index, old_count, new_count, new_contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), _index, \
old_count, new_count, new_contents \
)
/// Insert one `element` into the array at the given `index`.
#define array_insert(self, _index, element) \
_array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element))
/// Remove one element from the array at the given `index`.
#define array_erase(self, _index) \
_array__erase((Array *)(self), array_elem_size(self), _index)
/// Pop the last element off the array, returning the element by value.
#define array_pop(self) ((self)->contents[--(self)->size])
/// Assign the contents of one array to another, reallocating if necessary.
#define array_assign(self, other) \
_array__assign((Array *)(self), (const Array *)(other), array_elem_size(self))
/// Swap one array with another
#define array_swap(self, other) \
_array__swap((Array *)(self), (Array *)(other))
/// Get the size of the array contents
#define array_elem_size(self) (sizeof *(self)->contents)
/// Search a sorted array for a given `needle` value, using the given `compare`
/// callback to determine the order.
///
/// If an existing element is found to be equal to `needle`, then the `index`
/// out-parameter is set to the existing value's index, and the `exists`
/// out-parameter is set to true. Otherwise, `index` is set to an index where
/// `needle` should be inserted in order to preserve the sorting, and `exists`
/// is set to false.
#define array_search_sorted_with(self, compare, needle, _index, _exists) \
_array__search_sorted(self, 0, compare, , needle, _index, _exists)
/// Search a sorted array for a given `needle` value, using integer comparisons
/// of a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_with`.
#define array_search_sorted_by(self, field, needle, _index, _exists) \
_array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists)
/// Insert a given `value` into a sorted array, using the given `compare`
/// callback to determine the order.
#define array_insert_sorted_with(self, compare, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_with(self, compare, &(value), &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
/// Insert a given `value` into a sorted array, using integer comparisons of
/// a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_by`.
#define array_insert_sorted_by(self, field, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_by(self, field, (value) field, &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
// Private
typedef Array(void) Array;
/// This is not what you're looking for, see `array_delete`.
static inline void _array__delete(Array *self) {
if (self->contents) {
ts_free(self->contents);
self->contents = NULL;
self->size = 0;
self->capacity = 0;
}
}
/// This is not what you're looking for, see `array_erase`.
static inline void _array__erase(Array *self, size_t element_size,
uint32_t index) {
assert(index < self->size);
char *contents = (char *)self->contents;
memmove(contents + index * element_size, contents + (index + 1) * element_size,
(self->size - index - 1) * element_size);
self->size--;
}
/// This is not what you're looking for, see `array_reserve`.
static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) {
if (new_capacity > self->capacity) {
if (self->contents) {
self->contents = ts_realloc(self->contents, new_capacity * element_size);
} else {
self->contents = ts_malloc(new_capacity * element_size);
}
self->capacity = new_capacity;
}
}
/// This is not what you're looking for, see `array_assign`.
static inline void _array__assign(Array *self, const Array *other, size_t element_size) {
_array__reserve(self, element_size, other->size);
self->size = other->size;
memcpy(self->contents, other->contents, self->size * element_size);
}
/// This is not what you're looking for, see `array_swap`.
static inline void _array__swap(Array *self, Array *other) {
Array swap = *other;
*other = *self;
*self = swap;
}
/// This is not what you're looking for, see `array_push` or `array_grow_by`.
static inline void _array__grow(Array *self, uint32_t count, size_t element_size) {
uint32_t new_size = self->size + count;
if (new_size > self->capacity) {
uint32_t new_capacity = self->capacity * 2;
if (new_capacity < 8) new_capacity = 8;
if (new_capacity < new_size) new_capacity = new_size;
_array__reserve(self, element_size, new_capacity);
}
}
/// This is not what you're looking for, see `array_splice`.
static inline void _array__splice(Array *self, size_t element_size,
uint32_t index, uint32_t old_count,
uint32_t new_count, const void *elements) {
uint32_t new_size = self->size + new_count - old_count;
uint32_t old_end = index + old_count;
uint32_t new_end = index + new_count;
assert(old_end <= self->size);
_array__reserve(self, element_size, new_size);
char *contents = (char *)self->contents;
if (self->size > old_end) {
memmove(
contents + new_end * element_size,
contents + old_end * element_size,
(self->size - old_end) * element_size
);
}
if (new_count > 0) {
if (elements) {
memcpy(
(contents + index * element_size),
elements,
new_count * element_size
);
} else {
memset(
(contents + index * element_size),
0,
new_count * element_size
);
}
}
self->size += new_count - old_count;
}
/// A binary search routine, based on Rust's `std::slice::binary_search_by`.
/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`.
#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \
do { \
*(_index) = start; \
*(_exists) = false; \
uint32_t size = (self)->size - *(_index); \
if (size == 0) break; \
int comparison; \
while (size > 1) { \
uint32_t half_size = size / 2; \
uint32_t mid_index = *(_index) + half_size; \
comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \
if (comparison <= 0) *(_index) = mid_index; \
size -= half_size; \
} \
comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \
if (comparison == 0) *(_exists) = true; \
else if (comparison < 0) *(_index) += 1; \
} while (0)
/// Helper macro for the `_sorted_by` routines below. This takes the left (existing)
/// parameter by reference in order to work with the generic sorting function above.
#define _compare_int(a, b) ((int)*(a) - (int)(b))
#ifdef _MSC_VER
#pragma warning(default : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ARRAY_H_

View file

@ -0,0 +1,265 @@
#ifndef TREE_SITTER_PARSER_H_
#define TREE_SITTER_PARSER_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define ts_builtin_sym_error ((TSSymbol)-1)
#define ts_builtin_sym_end 0
#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024
#ifndef TREE_SITTER_API_H_
typedef uint16_t TSStateId;
typedef uint16_t TSSymbol;
typedef uint16_t TSFieldId;
typedef struct TSLanguage TSLanguage;
#endif
typedef struct {
TSFieldId field_id;
uint8_t child_index;
bool inherited;
} TSFieldMapEntry;
typedef struct {
uint16_t index;
uint16_t length;
} TSFieldMapSlice;
typedef struct {
bool visible;
bool named;
bool supertype;
} TSSymbolMetadata;
typedef struct TSLexer TSLexer;
struct TSLexer {
int32_t lookahead;
TSSymbol result_symbol;
void (*advance)(TSLexer *, bool);
void (*mark_end)(TSLexer *);
uint32_t (*get_column)(TSLexer *);
bool (*is_at_included_range_start)(const TSLexer *);
bool (*eof)(const TSLexer *);
};
typedef enum {
TSParseActionTypeShift,
TSParseActionTypeReduce,
TSParseActionTypeAccept,
TSParseActionTypeRecover,
} TSParseActionType;
typedef union {
struct {
uint8_t type;
TSStateId state;
bool extra;
bool repetition;
} shift;
struct {
uint8_t type;
uint8_t child_count;
TSSymbol symbol;
int16_t dynamic_precedence;
uint16_t production_id;
} reduce;
uint8_t type;
} TSParseAction;
typedef struct {
uint16_t lex_state;
uint16_t external_lex_state;
} TSLexMode;
typedef union {
TSParseAction action;
struct {
uint8_t count;
bool reusable;
} entry;
} TSParseActionEntry;
typedef struct {
int32_t start;
int32_t end;
} TSCharacterRange;
struct TSLanguage {
uint32_t version;
uint32_t symbol_count;
uint32_t alias_count;
uint32_t token_count;
uint32_t external_token_count;
uint32_t state_count;
uint32_t large_state_count;
uint32_t production_id_count;
uint32_t field_count;
uint16_t max_alias_sequence_length;
const uint16_t *parse_table;
const uint16_t *small_parse_table;
const uint32_t *small_parse_table_map;
const TSParseActionEntry *parse_actions;
const char * const *symbol_names;
const char * const *field_names;
const TSFieldMapSlice *field_map_slices;
const TSFieldMapEntry *field_map_entries;
const TSSymbolMetadata *symbol_metadata;
const TSSymbol *public_symbol_map;
const uint16_t *alias_map;
const TSSymbol *alias_sequences;
const TSLexMode *lex_modes;
bool (*lex_fn)(TSLexer *, TSStateId);
bool (*keyword_lex_fn)(TSLexer *, TSStateId);
TSSymbol keyword_capture_token;
struct {
const bool *states;
const TSSymbol *symbol_map;
void *(*create)(void);
void (*destroy)(void *);
bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist);
unsigned (*serialize)(void *, char *);
void (*deserialize)(void *, const char *, unsigned);
} external_scanner;
const TSStateId *primary_state_ids;
};
static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) {
uint32_t index = 0;
uint32_t size = len - index;
while (size > 1) {
uint32_t half_size = size / 2;
uint32_t mid_index = index + half_size;
TSCharacterRange *range = &ranges[mid_index];
if (lookahead >= range->start && lookahead <= range->end) {
return true;
} else if (lookahead > range->end) {
index = mid_index;
}
size -= half_size;
}
TSCharacterRange *range = &ranges[index];
return (lookahead >= range->start && lookahead <= range->end);
}
/*
* Lexer Macros
*/
#ifdef _MSC_VER
#define UNUSED __pragma(warning(suppress : 4101))
#else
#define UNUSED __attribute__((unused))
#endif
#define START_LEXER() \
bool result = false; \
bool skip = false; \
UNUSED \
bool eof = false; \
int32_t lookahead; \
goto start; \
next_state: \
lexer->advance(lexer, skip); \
start: \
skip = false; \
lookahead = lexer->lookahead;
#define ADVANCE(state_value) \
{ \
state = state_value; \
goto next_state; \
}
#define ADVANCE_MAP(...) \
{ \
static const uint16_t map[] = { __VA_ARGS__ }; \
for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \
if (map[i] == lookahead) { \
state = map[i + 1]; \
goto next_state; \
} \
} \
}
#define SKIP(state_value) \
{ \
skip = true; \
state = state_value; \
goto next_state; \
}
#define ACCEPT_TOKEN(symbol_value) \
result = true; \
lexer->result_symbol = symbol_value; \
lexer->mark_end(lexer);
#define END_STATE() return result;
/*
* Parse Table Macros
*/
#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT)
#define STATE(id) id
#define ACTIONS(id) id
#define SHIFT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value) \
} \
}}
#define SHIFT_REPEAT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value), \
.repetition = true \
} \
}}
#define SHIFT_EXTRA() \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.extra = true \
} \
}}
#define REDUCE(symbol_name, children, precedence, prod_id) \
{{ \
.reduce = { \
.type = TSParseActionTypeReduce, \
.symbol = symbol_name, \
.child_count = children, \
.dynamic_precedence = precedence, \
.production_id = prod_id \
}, \
}}
#define RECOVER() \
{{ \
.type = TSParseActionTypeRecover \
}}
#define ACCEPT_INPUT() \
{{ \
.type = TSParseActionTypeAccept \
}}
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_PARSER_H_

View file

@ -0,0 +1,6 @@
423e36a7cc1e3186d5b5d34435916c9746883d3745de33be21ec0c9db19a0d0c ./darwin-arm64/tree-sitter-dart.node
da526be2e8c6caf0d6b5421c33a59ff0ffcba3609172bd0399bfe98abd1550f2 ./darwin-x64/tree-sitter-dart.node
18ee4afbaaa883416ee671c551ba3c69b0dc7ff0bccc42463c9d8e32eea2e704 ./linux-arm64/tree-sitter-dart.node
209d3949337ebc59b0f31b19333345e1a45c800f81808cf9f811a3b469166549 ./linux-x64/tree-sitter-dart.node
535954d7ffe0b436551c9837be5cf5cf5d7b9cdb6a0d68dd9def2d154164cb51 ./win32-arm64/tree-sitter-dart.node
0c3790d0d9e440c11ea90d9a48b308cef4fe0e1e1865243131e4c3e7dfe7e2ff ./win32-x64/tree-sitter-dart.node

View file

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2019 fwcd
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,43 @@
## GitNexus vendor notice
This directory is a GitNexus-managed minimal **runtime** package derived from
`tree-sitter-kotlin@0.3.8` (fwcd). It carries only what the runtime needs:
`bindings/node/`, `src/node-types.json`, `LICENSE`, and the native
`prebuilds/`. The C source (`parser.c`, `scanner.c`, `binding.gyp`) is **not**
vendored — `parser.c` alone is ~23 MB, and the prebuilds are produced from the
published npm package, so committing the source would bloat git history for no
runtime benefit.
### Why this is vendored (unlike the npm grammars)
Upstream `tree-sitter-kotlin` ships **source only** — its npm tarball has no
`prebuilds/` — so a plain `npm install` compiles the native binding from source
and requires a C/C++ toolchain (`python3`/`make`/`g++`). To make Kotlin parsing
toolchain-free on every host (Swift parity), GitNexus builds the platform
prebuilds itself and vendors them here. `node-gyp-build` selects the correct
binary at require time; `build-tree-sitter-grammars.cjs` activates the binding
(prefer prebuild, else source-build) at install time.
`tree-sitter-swift` is handled the same way now: its prebuilds were originally
**copied from upstream** (Swift ships them), but it is unified with this pipeline —
its source is vendored and its prebuilds are **GitNexus-cross-built** too, so all
of Dart/Proto/Swift/Kotlin go through one uniform build path.
### Updating this vendor package
1. Bump the upstream version: update `version` in `package.json` (this is the
value the `build-tree-sitter-prebuilds` workflow diffs to decide whether to
rebuild) and refresh `_vendoredBy`.
2. Refresh `bindings/node/*` and `src/node-types.json` from the new upstream
`tree-sitter-kotlin` npm release.
3. Regenerate the six native prebuilds by running the
**`build-tree-sitter-prebuilds`** GitHub Actions workflow (it builds
`{linux,darwin,win32}-{x64,arm64}` from the published package and opens a PR
committing them under `prebuilds/`).
4. Verify the packed GitNexus tarball can `require('tree-sitter-kotlin')` and
parse a Kotlin snippet on each target platform-arch (the workflow's validate
step does this in CI).
> Note: `darwin-x64` prebuilds depend on GitHub's `macos-15-intel` image, whose
> x86_64 macOS runners sunset ~Aug 2027. After that, darwin-x64 needs
> cross-compilation or dropping.

View file

@ -0,0 +1,30 @@
{
"targets": [
{
"target_name": "tree_sitter_kotlin_binding",
"dependencies": [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
],
"include_dirs": [
"src",
],
"sources": [
"bindings/node/binding.cc",
"src/parser.c",
"src/scanner.c"
],
"conditions": [
["OS!='win'", {
"cflags_c": [
"-std=c11",
],
}, { # OS == "win"
"cflags_c": [
"/std:c11",
"/utf-8",
],
}],
],
}
]
}

View file

@ -0,0 +1,20 @@
#include <napi.h>
typedef struct TSLanguage TSLanguage;
extern "C" TSLanguage *tree_sitter_kotlin();
// "tree-sitter", "language" hashed with BLAKE2
const napi_type_tag LANGUAGE_TYPE_TAG = {
0x8AF2E5212AD58ABF, 0xD5006CAD83ABBA16
};
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports["name"] = Napi::String::New(env, "kotlin");
auto language = Napi::External<TSLanguage>::New(env, tree_sitter_kotlin());
language.TypeTag(&LANGUAGE_TYPE_TAG);
exports["language"] = language;
return exports;
}
NODE_API_MODULE(tree_sitter_kotlin_binding, Init)

View file

@ -0,0 +1,28 @@
type BaseNode = {
type: string;
named: boolean;
};
type ChildNode = {
multiple: boolean;
required: boolean;
types: BaseNode[];
};
type NodeInfo =
| (BaseNode & {
subtypes: BaseNode[];
})
| (BaseNode & {
fields: { [name: string]: ChildNode };
children: ChildNode[];
});
type Language = {
name: string;
language: unknown;
nodeTypeInfo: NodeInfo[];
};
declare const language: Language;
export = language;

View file

@ -0,0 +1,7 @@
const root = require("path").join(__dirname, "..", "..");
module.exports = require("node-gyp-build")(root);
try {
module.exports.nodeTypeInfo = require("../../src/node-types.json");
} catch (_) {}

View file

@ -0,0 +1,18 @@
{
"name": "tree-sitter-kotlin",
"version": "0.3.8",
"description": "Kotlin grammar for tree-sitter",
"repository": "https://github.com/fwcd/tree-sitter-kotlin",
"license": "MIT",
"main": "bindings/node/index.js",
"types": "bindings/node/index.d.ts",
"_vendoredBy": "gitnexus - runtime package derived from tree-sitter-kotlin@0.3.8 (fwcd). Unlike Swift's upstream-shipped prebuilds, upstream tree-sitter-kotlin ships SOURCE ONLY (no prebuilds/); the native prebuilds/ here are GitNexus-cross-built by .github/workflows/build-tree-sitter-prebuilds.yml. The grammar source (parser.c/scanner.c/binding.gyp + src/) is ALSO vendored so build-tree-sitter-kotlin.cjs can source-build the binding on a toolchain host when no prebuild matches (e.g. CI before prebuilds land). The generated parser.c is large (~23 MB on disk; it compresses heavily in git); once the prebuilds cover every platform-arch the source serves only as the fallback. Copied to node_modules/ by materialize-vendor-grammars.cjs (no scripts.install here — #836/#1728).",
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
}

View file

View file

@ -0,0 +1,6 @@
11e63706f960303259b842f98030bf7c5845f312ed9cfa1fcc44e6ff4c002841 ./darwin-arm64/tree-sitter-kotlin.node
e5cd10bf993a2d20f8187d39486e3cbcb0567ca192576fae745c70e8be43f223 ./darwin-x64/tree-sitter-kotlin.node
a73110ce49a421b4d09acfbbdbb05bc1d1a54b6c88017d2e59199144bb9c1853 ./linux-arm64/tree-sitter-kotlin.node
7409baf83f363d15bdcbc09698acc9ca8e7e25ccb62eb6074e787166ff0bf9ef ./linux-x64/tree-sitter-kotlin.node
1467a068fd28de07cd333e3e577ee2ccc34631780b7bd34b151c4c8118dddaf6 ./win32-arm64/tree-sitter-kotlin.node
c53bcf3c651e33d2778c83e9ef7cb7f18e547e1941132f6ade69d0717b9325eb ./win32-x64/tree-sitter-kotlin.node

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,530 @@
#include "tree_sitter/array.h"
#include "tree_sitter/parser.h"
#include <string.h>
#include <wctype.h>
// Mostly a copy paste of tree-sitter-javascript/src/scanner.c
enum TokenType {
AUTOMATIC_SEMICOLON,
IMPORT_LIST_DELIMITER,
SAFE_NAV,
MULTILINE_COMMENT,
STRING_START,
STRING_END,
STRING_CONTENT,
};
/* Pretty much all of this code is taken from the Julia tree-sitter
parser.
Julia has similar problems with multiline comments that can be nested,
line comments, as well as line and multiline strings.
The most heavily edited section is `scan_string_content`,
particularly with respect to interpolation.
*/
// Block comments are easy to parse, but strings require extra-attention.
// The main problems that arise when parsing strings are:
// 1. Triple quoted strings allow single quotes inside. e.g. """ "foo" """.
// 2. Non-standard string literals don't allow interpolations or escape
// sequences, but you can always write \" and \`.
// To efficiently store a delimiter, we take advantage of the fact that:
// (int)'"' == 34 && (34 & 1) == 0
// i.e. " has an even numeric representation, so we can store a triple
// quoted delimiter as (delimiter + 1).
#define DELIMITER_LENGTH 3
typedef char Delimiter;
// We use a stack to keep track of the string delimiters.
typedef Array(Delimiter) Stack;
static inline void stack_push(Stack *stack, char chr, bool triple) {
if (stack->size >= TREE_SITTER_SERIALIZATION_BUFFER_SIZE) abort();
array_push(stack, (Delimiter)(triple ? (chr + 1) : chr));
}
static inline Delimiter stack_pop(Stack *stack) {
if (stack->size == 0) abort();
return array_pop(stack);
}
static inline void skip(TSLexer *lexer) { lexer->advance(lexer, true); }
static inline void advance(TSLexer *lexer) { lexer->advance(lexer, false); }
// Scanner functions
static bool scan_string_start(TSLexer *lexer, Stack *stack) {
if (lexer->lookahead != '"') return false;
advance(lexer);
lexer->mark_end(lexer);
for (unsigned count = 1; count < DELIMITER_LENGTH; ++count) {
if (lexer->lookahead != '"') {
// It's not a triple quoted delimiter.
stack_push(stack, '"', false);
return true;
}
advance(lexer);
}
lexer->mark_end(lexer);
stack_push(stack, '"', true);
return true;
}
static bool scan_string_content(TSLexer *lexer, Stack *stack) {
if (stack->size == 0) return false; // Stack is empty. We're not in a string.
Delimiter end_char = stack->contents[stack->size - 1]; // peek
bool is_triple = false;
bool has_content = false;
if (end_char & 1) {
is_triple = true;
end_char -= 1;
}
while (lexer->lookahead) {
if (lexer->lookahead == '$') {
// if we did not just start reading stuff, then we should stop
// lexing right here, so we can offer the opportunity to lex a
// interpolated identifier
if (has_content) {
lexer->result_symbol = STRING_CONTENT;
return has_content;
}
// otherwise, if this is the start, determine if it is an
// interpolated identifier.
// otherwise, it's just string content, so continue
advance(lexer);
if (iswalpha(lexer->lookahead) || lexer->lookahead == '{') {
// this must be a string interpolation, let's
// fail so we parse it as such
return false;
}
lexer->result_symbol = STRING_CONTENT;
lexer->mark_end(lexer);
return true;
}
if (lexer->lookahead == '\\') {
// if we see a \, then this might possibly escape a dollar sign
// in which case, we should not defer to the interpolation
advance(lexer);
// this dollar sign is escaped, so it must be content.
// we consume it here so we don't enter the dollar sign case above,
// which leaves the possibility that it is an interpolation
if (lexer->lookahead == '$') {
advance(lexer);
// however this leaves an edgecase where an escaped dollar sign could
// appear at the end of a string (e.g "aa\$") which isn't handled
// correctly; if we were at the end of the string, terminate properly
if (lexer->lookahead == end_char) {
stack_pop(stack);
advance(lexer);
lexer->mark_end(lexer);
lexer->result_symbol = STRING_END;
return true;
}
}
} else if (lexer->lookahead == end_char) {
if (is_triple) {
lexer->mark_end(lexer);
for (unsigned count = 1; count < DELIMITER_LENGTH; ++count) {
advance(lexer);
if (lexer->lookahead != end_char) {
lexer->mark_end(lexer);
lexer->result_symbol = STRING_CONTENT;
return true;
}
}
/* This is so if we lex something like
"""foo"""
^
where we are at the `f`, we should quit after
reading `foo`, and ascribe it to STRING_CONTENT.
Then, we restart and try to read the end.
This is to prevent `foo` from being absorbed into
the STRING_END token.
*/
if (has_content && lexer->lookahead == end_char) {
lexer->result_symbol = STRING_CONTENT;
return true;
}
/* Since the string internals are all hidden in the syntax
tree anyways, there's no point in going to the effort of
specifically separating the string end from string contents.
If we see a bunch of quotes in a row, then we just go until
they stop appearing, then stop lexing and call it the
string's end.
*/
lexer->result_symbol = STRING_END;
lexer->mark_end(lexer);
while (lexer->lookahead == end_char) {
advance(lexer);
lexer->mark_end(lexer);
}
stack_pop(stack);
return true;
}
if (has_content) {
lexer->mark_end(lexer);
lexer->result_symbol = STRING_CONTENT;
return true;
}
stack_pop(stack);
advance(lexer);
lexer->mark_end(lexer);
lexer->result_symbol = STRING_END;
return true;
}
advance(lexer);
has_content = true;
}
return false;
}
static bool scan_multiline_comment(TSLexer *lexer) {
if (lexer->lookahead != '/') return false;
advance(lexer);
if (lexer->lookahead != '*') return false;
advance(lexer);
bool after_star = false;
unsigned nesting_depth = 1;
for (;;) {
switch (lexer->lookahead) {
case '*':
advance(lexer);
after_star = true;
break;
case '/':
advance(lexer);
if (after_star) {
after_star = false;
nesting_depth -= 1;
if (nesting_depth == 0) {
lexer->result_symbol = MULTILINE_COMMENT;
lexer->mark_end(lexer);
return true;
}
} else {
after_star = false;
if (lexer->lookahead == '*') {
nesting_depth += 1;
advance(lexer);
}
}
break;
case '\0':
return false;
default:
advance(lexer);
after_star = false;
break;
}
}
}
static bool scan_whitespace_and_comments(TSLexer *lexer) {
while (iswspace(lexer->lookahead)) skip(lexer);
return lexer->lookahead != '/';
}
static bool scan_for_word(TSLexer *lexer, const char* word, unsigned len) {
skip(lexer);
for (unsigned i = 0; i < len; ++i) {
if (lexer->lookahead != word[i]) return false;
skip(lexer);
}
return true;
}
static bool scan_automatic_semicolon(TSLexer *lexer) {
lexer->result_symbol = AUTOMATIC_SEMICOLON;
lexer->mark_end(lexer);
bool sameline = true;
for (;;) {
if (lexer->eof(lexer)) return true;
if (lexer->lookahead == ';') {
advance(lexer);
lexer->mark_end(lexer);
return true;
}
if (!iswspace(lexer->lookahead)) break;
if (lexer->lookahead == '\n') {
skip(lexer);
sameline = false;
break;
}
if (lexer->lookahead == '\r') {
skip(lexer);
if (lexer->lookahead == '\n') skip(lexer);
sameline = false;
break;
}
skip(lexer);
}
// Skip whitespace and comments
if (!scan_whitespace_and_comments(lexer))
return false;
if (sameline) {
switch (lexer->lookahead) {
// Don't insert a semicolon before an else
case 'e':
return !scan_for_word(lexer, "lse", 3);
case 'i':
return scan_for_word(lexer, "mport", 5);
case ';':
advance(lexer);
lexer->mark_end(lexer);
return true;
default:
return false;
}
}
switch (lexer->lookahead) {
case ',':
case '.':
case ':':
case '*':
case '%':
case '>':
case '<':
case '=':
case '{':
case '[':
case '(':
case '?':
case '|':
case '&':
case '/':
return false;
// Insert a semicolon before `--` and `++`, but not before binary `+` or `-`.
// Insert before +/-Float
case '+':
skip(lexer);
if (lexer->lookahead == '+') return true;
return iswdigit(lexer->lookahead);
case '-':
skip(lexer);
if (lexer->lookahead == '-') return true;
return iswdigit(lexer->lookahead);
// Don't insert a semicolon before `!=`, but do insert one before a unary `!`.
case '!':
skip(lexer);
return lexer->lookahead != '=';
// Don't insert a semicolon before an else
case 'e':
return !scan_for_word(lexer, "lse", 3);
// Don't insert a semicolon before `in` or `instanceof`, but do insert one
// before an identifier or an import.
case 'i':
skip(lexer);
if (lexer->lookahead != 'n') return true;
skip(lexer);
if (!iswalpha(lexer->lookahead)) return false;
return !scan_for_word(lexer, "stanceof", 8);
case ';':
advance(lexer);
lexer->mark_end(lexer);
return true;
default:
return true;
}
}
static bool scan_safe_nav(TSLexer *lexer) {
lexer->result_symbol = SAFE_NAV;
lexer->mark_end(lexer);
// skip white space
if (!scan_whitespace_and_comments(lexer))
return false;
if (lexer->lookahead != '?')
return false;
advance(lexer);
if (!scan_whitespace_and_comments(lexer))
return false;
if (lexer->lookahead != '.')
return false;
advance(lexer);
lexer->mark_end(lexer);
return true;
}
static bool scan_line_sep(TSLexer *lexer) {
// Line Seps: [ CR, LF, CRLF ]
int state = 0;
while (true) {
switch(lexer->lookahead) {
case ' ':
case '\t':
case '\v':
// Skip whitespace
advance(lexer);
break;
case '\n':
advance(lexer);
return true;
case '\r':
if (state == 1)
return true;
state = 1;
advance(lexer);
break;
default:
// We read a CR
if (state == 1)
return true;
return false;
}
}
}
static bool scan_import_list_delimiter(TSLexer *lexer) {
// Import lists are terminated either by an empty line or a non import statement
lexer->result_symbol = IMPORT_LIST_DELIMITER;
lexer->mark_end(lexer);
// if eof; return true
if (lexer->eof(lexer))
return true;
// Scan for the first line seperator
if (!scan_line_sep(lexer))
return false;
// if line.sep line.sep; return true
if (scan_line_sep(lexer)) {
lexer->mark_end(lexer);
return true;
}
// if line.sep [^import]; return true
while (true) {
switch (lexer->lookahead) {
case ' ':
case '\t':
case '\v':
// Skip whitespace
advance(lexer);
break;
case 'i':
return !scan_for_word(lexer, "mport", 5);
default:
return true;
}
return false;
}
}
bool tree_sitter_kotlin_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) {
if (valid_symbols[AUTOMATIC_SEMICOLON]) {
bool ret = scan_automatic_semicolon(lexer);
if (!ret && valid_symbols[SAFE_NAV] && lexer->lookahead == '?') {
return scan_safe_nav(lexer);
}
// if we fail to find an automatic semicolon, it's still possible that we may
// want to lex a string or comment later
if (ret) return ret;
}
if (valid_symbols[IMPORT_LIST_DELIMITER]) {
return scan_import_list_delimiter(lexer);
}
// content or end
if (valid_symbols[STRING_CONTENT] && scan_string_content(lexer, payload)) {
return true;
}
// a string might follow after some whitespace, so we can't lookahead
// until we get rid of it
while (iswspace(lexer->lookahead)) skip(lexer);
if (valid_symbols[STRING_START] && scan_string_start(lexer, payload)) {
lexer->result_symbol = STRING_START;
return true;
}
if (valid_symbols[MULTILINE_COMMENT] && scan_multiline_comment(lexer)) {
return true;
}
if (valid_symbols[SAFE_NAV]) {
return scan_safe_nav(lexer);
}
return false;
}
void *tree_sitter_kotlin_external_scanner_create() {
Stack *stack = ts_calloc(1, sizeof(Stack));
if (stack == NULL) abort();
array_init(stack);
return stack;
}
void tree_sitter_kotlin_external_scanner_destroy(void *payload) {
Stack *stack = (Stack *)payload;
array_delete(stack);
ts_free(stack);
}
unsigned tree_sitter_kotlin_external_scanner_serialize(void *payload, char *buffer) {
Stack *stack = (Stack *)payload;
memcpy(buffer, stack->contents, stack->size);
return stack->size;
}
void tree_sitter_kotlin_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
Stack *stack = (Stack *)payload;
if (length > 0) {
array_reserve(stack, length);
memcpy(stack->contents, buffer, length);
stack->size = length;
} else {
array_clear(stack);
}
}

View file

@ -0,0 +1,54 @@
#ifndef TREE_SITTER_ALLOC_H_
#define TREE_SITTER_ALLOC_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
// Allow clients to override allocation functions
#ifdef TREE_SITTER_REUSE_ALLOCATOR
extern void *(*ts_current_malloc)(size_t);
extern void *(*ts_current_calloc)(size_t, size_t);
extern void *(*ts_current_realloc)(void *, size_t);
extern void (*ts_current_free)(void *);
#ifndef ts_malloc
#define ts_malloc ts_current_malloc
#endif
#ifndef ts_calloc
#define ts_calloc ts_current_calloc
#endif
#ifndef ts_realloc
#define ts_realloc ts_current_realloc
#endif
#ifndef ts_free
#define ts_free ts_current_free
#endif
#else
#ifndef ts_malloc
#define ts_malloc malloc
#endif
#ifndef ts_calloc
#define ts_calloc calloc
#endif
#ifndef ts_realloc
#define ts_realloc realloc
#endif
#ifndef ts_free
#define ts_free free
#endif
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ALLOC_H_

View file

@ -0,0 +1,290 @@
#ifndef TREE_SITTER_ARRAY_H_
#define TREE_SITTER_ARRAY_H_
#ifdef __cplusplus
extern "C" {
#endif
#include "./alloc.h"
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef _MSC_VER
#pragma warning(disable : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable"
#endif
#define Array(T) \
struct { \
T *contents; \
uint32_t size; \
uint32_t capacity; \
}
/// Initialize an array.
#define array_init(self) \
((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL)
/// Create an empty array.
#define array_new() \
{ NULL, 0, 0 }
/// Get a pointer to the element at a given `index` in the array.
#define array_get(self, _index) \
(assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index])
/// Get a pointer to the first element in the array.
#define array_front(self) array_get(self, 0)
/// Get a pointer to the last element in the array.
#define array_back(self) array_get(self, (self)->size - 1)
/// Clear the array, setting its size to zero. Note that this does not free any
/// memory allocated for the array's contents.
#define array_clear(self) ((self)->size = 0)
/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is
/// less than the array's current capacity, this function has no effect.
#define array_reserve(self, new_capacity) \
_array__reserve((Array *)(self), array_elem_size(self), new_capacity)
/// Free any memory allocated for this array. Note that this does not free any
/// memory allocated for the array's contents.
#define array_delete(self) _array__delete((Array *)(self))
/// Push a new `element` onto the end of the array.
#define array_push(self, element) \
(_array__grow((Array *)(self), 1, array_elem_size(self)), \
(self)->contents[(self)->size++] = (element))
/// Increase the array's size by `count` elements.
/// New elements are zero-initialized.
#define array_grow_by(self, count) \
do { \
if ((count) == 0) break; \
_array__grow((Array *)(self), count, array_elem_size(self)); \
memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \
(self)->size += (count); \
} while (0)
/// Append all elements from one array to the end of another.
#define array_push_all(self, other) \
array_extend((self), (other)->size, (other)->contents)
/// Append `count` elements to the end of the array, reading their values from the
/// `contents` pointer.
#define array_extend(self, count, contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), (self)->size, \
0, count, contents \
)
/// Remove `old_count` elements from the array starting at the given `index`. At
/// the same index, insert `new_count` new elements, reading their values from the
/// `new_contents` pointer.
#define array_splice(self, _index, old_count, new_count, new_contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), _index, \
old_count, new_count, new_contents \
)
/// Insert one `element` into the array at the given `index`.
#define array_insert(self, _index, element) \
_array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element))
/// Remove one element from the array at the given `index`.
#define array_erase(self, _index) \
_array__erase((Array *)(self), array_elem_size(self), _index)
/// Pop the last element off the array, returning the element by value.
#define array_pop(self) ((self)->contents[--(self)->size])
/// Assign the contents of one array to another, reallocating if necessary.
#define array_assign(self, other) \
_array__assign((Array *)(self), (const Array *)(other), array_elem_size(self))
/// Swap one array with another
#define array_swap(self, other) \
_array__swap((Array *)(self), (Array *)(other))
/// Get the size of the array contents
#define array_elem_size(self) (sizeof *(self)->contents)
/// Search a sorted array for a given `needle` value, using the given `compare`
/// callback to determine the order.
///
/// If an existing element is found to be equal to `needle`, then the `index`
/// out-parameter is set to the existing value's index, and the `exists`
/// out-parameter is set to true. Otherwise, `index` is set to an index where
/// `needle` should be inserted in order to preserve the sorting, and `exists`
/// is set to false.
#define array_search_sorted_with(self, compare, needle, _index, _exists) \
_array__search_sorted(self, 0, compare, , needle, _index, _exists)
/// Search a sorted array for a given `needle` value, using integer comparisons
/// of a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_with`.
#define array_search_sorted_by(self, field, needle, _index, _exists) \
_array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists)
/// Insert a given `value` into a sorted array, using the given `compare`
/// callback to determine the order.
#define array_insert_sorted_with(self, compare, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_with(self, compare, &(value), &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
/// Insert a given `value` into a sorted array, using integer comparisons of
/// a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_by`.
#define array_insert_sorted_by(self, field, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_by(self, field, (value) field, &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
// Private
typedef Array(void) Array;
/// This is not what you're looking for, see `array_delete`.
static inline void _array__delete(Array *self) {
if (self->contents) {
ts_free(self->contents);
self->contents = NULL;
self->size = 0;
self->capacity = 0;
}
}
/// This is not what you're looking for, see `array_erase`.
static inline void _array__erase(Array *self, size_t element_size,
uint32_t index) {
assert(index < self->size);
char *contents = (char *)self->contents;
memmove(contents + index * element_size, contents + (index + 1) * element_size,
(self->size - index - 1) * element_size);
self->size--;
}
/// This is not what you're looking for, see `array_reserve`.
static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) {
if (new_capacity > self->capacity) {
if (self->contents) {
self->contents = ts_realloc(self->contents, new_capacity * element_size);
} else {
self->contents = ts_malloc(new_capacity * element_size);
}
self->capacity = new_capacity;
}
}
/// This is not what you're looking for, see `array_assign`.
static inline void _array__assign(Array *self, const Array *other, size_t element_size) {
_array__reserve(self, element_size, other->size);
self->size = other->size;
memcpy(self->contents, other->contents, self->size * element_size);
}
/// This is not what you're looking for, see `array_swap`.
static inline void _array__swap(Array *self, Array *other) {
Array swap = *other;
*other = *self;
*self = swap;
}
/// This is not what you're looking for, see `array_push` or `array_grow_by`.
static inline void _array__grow(Array *self, uint32_t count, size_t element_size) {
uint32_t new_size = self->size + count;
if (new_size > self->capacity) {
uint32_t new_capacity = self->capacity * 2;
if (new_capacity < 8) new_capacity = 8;
if (new_capacity < new_size) new_capacity = new_size;
_array__reserve(self, element_size, new_capacity);
}
}
/// This is not what you're looking for, see `array_splice`.
static inline void _array__splice(Array *self, size_t element_size,
uint32_t index, uint32_t old_count,
uint32_t new_count, const void *elements) {
uint32_t new_size = self->size + new_count - old_count;
uint32_t old_end = index + old_count;
uint32_t new_end = index + new_count;
assert(old_end <= self->size);
_array__reserve(self, element_size, new_size);
char *contents = (char *)self->contents;
if (self->size > old_end) {
memmove(
contents + new_end * element_size,
contents + old_end * element_size,
(self->size - old_end) * element_size
);
}
if (new_count > 0) {
if (elements) {
memcpy(
(contents + index * element_size),
elements,
new_count * element_size
);
} else {
memset(
(contents + index * element_size),
0,
new_count * element_size
);
}
}
self->size += new_count - old_count;
}
/// A binary search routine, based on Rust's `std::slice::binary_search_by`.
/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`.
#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \
do { \
*(_index) = start; \
*(_exists) = false; \
uint32_t size = (self)->size - *(_index); \
if (size == 0) break; \
int comparison; \
while (size > 1) { \
uint32_t half_size = size / 2; \
uint32_t mid_index = *(_index) + half_size; \
comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \
if (comparison <= 0) *(_index) = mid_index; \
size -= half_size; \
} \
comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \
if (comparison == 0) *(_exists) = true; \
else if (comparison < 0) *(_index) += 1; \
} while (0)
/// Helper macro for the `_sorted_by` routines below. This takes the left (existing)
/// parameter by reference in order to work with the generic sorting function above.
#define _compare_int(a, b) ((int)*(a) - (int)(b))
#ifdef _MSC_VER
#pragma warning(default : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ARRAY_H_

Some files were not shown because too many files have changed in this diff Show more