Merge branch 'main' into feat/private-repo-token-auth

This commit is contained in:
Gergő Magyar 2026-06-10 06:41:48 +01:00 • committed by GitHub
commit 83fe1b2428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
175 changed files with 1359025 additions and 561 deletions

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.3.3",
"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`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
- **`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

@ -157,7 +157,12 @@ routes between two modes based on the triggering event:
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
are expected to tag from `main` as a convention; the workflow itself does
not enforce branch reachability. No Docker build (RC-only).
not enforce branch reachability. No Docker build (RC-only). Before cutting a
stable release, keep `gitnexus/package.json`,
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
versions drift.
- **Release-candidate mode** — runs on every push to `main` (typically a
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
via `paths-ignore`. Publishes to the `rc` dist-tag with version

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
@ -67,6 +78,28 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
# unreachable from $PATH.
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
# at runtime. The server runs the default `load-only` extension policy (the read
# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
# INSTALLs — the extension must already exist in the runtime user's HOME
# extension dir, or every keyword search silently degrades (no FTS indexes are
# written and ranking falls back to vector-only with only a `warning` field).
# Run the installer as the `node` user with the SAME HOME the server runs under,
# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
# does not derive HOME from `USER`, so without it build-install and runtime-load
# would resolve different paths. Requires network egress for the one-time
# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
# The second `--verify-only` step re-LOADs the extension in a FRESH process
# under the same HOME, so a HOME/extension-dir mismatch fails the build here
# rather than silently degrading keyword search to vector-only at runtime.
ENV HOME=/home/node \
GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
USER node
# The web UI defaults to http://localhost:4747 - keep that contract.

View file

@ -117,7 +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 vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
> **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`:** 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
@ -223,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
@ -259,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
@ -328,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 vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
| `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)
@ -342,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.3.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

@ -44,7 +44,10 @@ export type NodeLabel =
| 'Template'
| 'Section'
| 'Route'
| 'Tool';
| 'Tool'
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
| 'BasicBlock';
export type NodeProperties = {
name: string;
@ -89,6 +92,8 @@ export type NodeProperties = {
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
// Extensible
[key: string]: unknown;
};
@ -131,7 +136,28 @@ export type RelationshipType =
* `reason` encodes the event name: `vue-emit: <eventName>`.
* Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the
* component File node reveals all (emitter, handler) pairs. */
| 'EMITS_EVENT';
| 'EMITS_EVENT'
// ── Taint/PDG substrate (issue #2080) ────────────────────────────────────
// Reserved edge types for the taint-first PDG substrate. No phase emits any
// of these yet; they are populated behind an opt-in by later milestones
// (CFG → M1 #2081, REACHING_DEF → M2 #2082, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4 #2083/#2084). Adding them here keeps the shared schema stable so
// downstream work does not re-ripple the exhaustiveness sites.
/** Control-flow edge between two BasicBlock nodes (intra-procedural CFG). */
| 'CFG'
/** Data-dependence edge: a definition of `variable` reaches a use of it.
* The `variable` name is stored in the relation's existing `reason` column
* (M0/S1 verdict: LadybugDB has no secondary index on relationship
* properties, so a dedicated indexed column would not speed the
* variable-filtered path query). */
| 'REACHING_DEF'
/** A tainted value flows from source toward sink. */
| 'TAINTED'
/** A sanitizer clears taint along a flow. */
| 'SANITIZES'
/** Materialized source→sink taint path. Working name — final name/representation
* is confirmed when M3/M4 emits it; no persisted edge exists before then. */
| 'TAINT_PATH';
export interface GraphNode {
id: string;

View file

@ -40,6 +40,8 @@ export const NODE_TABLES = [
'Module',
'Route',
'Tool',
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
'BasicBlock',
] as const;
export type NodeTableName = (typeof NODE_TABLES)[number];
@ -67,6 +69,14 @@ export const REL_TYPES = [
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
'CFG',
'REACHING_DEF',
'TAINTED',
'SANITIZES',
'TAINT_PATH',
] as const;
export type RelType = (typeof REL_TYPES)[number];

View file

@ -38,6 +38,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
};
// Node sizes by type - clear visual hierarchy with dramatic size differences
@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
Template: 3, // Like Type
Route: 5, // Like Enum
Tool: 5, // Like Enum
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
};
// Community color palette for cluster-based coloring

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,6 +4,35 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.7] - 2026-06-09
### Added
- **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
### Added

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

@ -18,11 +18,11 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "fd3d3768cdebbb4767d7cf18b8d2df19d61de969c816d7f4d6b599f947811356",
"fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",

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": {
@ -1159,9 +1155,9 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.17.0.tgz",
"integrity": "sha512-fg7EGEJUj6H5JJLpU3iD5R0pAEc9u2OkU7ufX10krph9bCIhQyt/Tk6y/g4+x9zi/IHXegOjAVfSpWZp1gpoAA==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.17.1.tgz",
"integrity": "sha512-K1bHnQrRy3bxkyrFHlxGqKUyIUS1LsRXKOSt14XGY/msBZHaDat/uBrlHiWpM4/24OtfOq/qwTqcTCXannnEjw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -1170,17 +1166,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.17.0",
"@ladybugdb/core-darwin-x64": "0.17.0",
"@ladybugdb/core-linux-arm64": "0.17.0",
"@ladybugdb/core-linux-x64": "0.17.0",
"@ladybugdb/core-win32-x64": "0.17.0"
"@ladybugdb/core-darwin-arm64": "0.17.1",
"@ladybugdb/core-darwin-x64": "0.17.1",
"@ladybugdb/core-linux-arm64": "0.17.1",
"@ladybugdb/core-linux-x64": "0.17.1",
"@ladybugdb/core-win32-x64": "0.17.1"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.17.0.tgz",
"integrity": "sha512-wghUBEmcQ9U10QOyOxXVQTZ6SHtkB8QV3sJHwCj2Dn5B/SsaB36kA4l2oKbgXpKDgjmSYiz3DfMs5yfDqen9UA==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.17.1.tgz",
"integrity": "sha512-JG/uzmolEh3wXJ/ME1EaTH5LTDQ9Cs+Q3Czul8pW2eWbWQZghQU3jjM++7ST7Bla5BX/WITqwPqPoC+sL+slfA==",
"cpu": [
"arm64"
],
@ -1191,9 +1187,9 @@
]
},
"node_modules/@ladybugdb/core-darwin-x64": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.17.0.tgz",
"integrity": "sha512-f0QRhmDY8NEMjAT3IbFELMEFxAnKq6trppn1vgAFIk9wTD/MKakfX/gtXOazpOZQHkwlfNgs+WSkJFfFvQ4JaQ==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.17.1.tgz",
"integrity": "sha512-Enjm+/V9/jpKmtzF2PB0muVkgpFUGHEvA7r16eJWxVRA/BeO8VPmngTKy9rf/4Yc6TWexjoHRug04BbTXEmerg==",
"cpu": [
"x64"
],
@ -1204,9 +1200,9 @@
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.17.0.tgz",
"integrity": "sha512-TS9nbkvLJZt3Tgm6zzd/QuZmTiVoyQTPfbbwPej0VfcCVAfa1sDpZtoMlwse9EVHOrowQ0SorJVPg194lYVxdg==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.17.1.tgz",
"integrity": "sha512-P+xM9o4I3JAQtXpX19ZuLj9EeO2gppa+IdmAqhpI8tuhyA3/a85Eaxby1fXOjsbrnOAEyFJczUdyoDkhCPSyiw==",
"cpu": [
"arm64"
],
@ -1217,9 +1213,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.17.0.tgz",
"integrity": "sha512-T/C0QKDoBCs8s/NQ2Udip8lZgJ8MzLqs2rRgreDd2dCP3aNnXu8eOBe10RSoRO5PiZZIZihXoz4Q8KMM6FtBGQ==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.17.1.tgz",
"integrity": "sha512-N2ujE0CrsToBpVBpou1iWwEkK7CgVxucnUNxteySrnDccZwICXFP5BlcFpKE0qq3Eqmqszh4ptR4GuSi6rKPGw==",
"cpu": [
"x64"
],
@ -1230,9 +1226,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.17.0.tgz",
"integrity": "sha512-XrQrbPD3h+MhP94jVu+4VgNnp8LKSskPll+/au+Ug3yqpXZ0We9lXX5+rW4NHuIYdAYy4iLheYz4OuozUS20qg==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.17.1.tgz",
"integrity": "sha512-9i3xNfFAMqFRuQG3F1hOCWYGna6eTg8HJ/XYhWVDGkeFJNUV3IdneEiYttF5B2qAtQYUd4sAikScsImrMRw+6g==",
"cpu": [
"x64"
],
@ -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",
"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

@ -0,0 +1,374 @@
#!/usr/bin/env node
// FTS evict→reload RSS repro (gitnexus-enterprise PR #222 / local U3).
//
// Settles ONE empirical question that no static read can answer: when a
// LadybugDB database that has `LOAD EXTENSION fts` applied is closed and a
// fresh one is opened + re-LOADed (the pool's evict→reload cycle), does the
// native FTS arena get reclaimed by `db.close()` — or is it stranded, so RSS
// climbs without bound over a long-lived MCP `serve` session?
//
// • PLATEAU across cycles → db.close() reclaims the FTS arena; the OSS pool's
// footprint is bounded by MAX_POOL_SIZE (~5 live arenas). No unbounded leak;
// the #222 worker-isolation rewrite (plan U4) is NOT justified for OSS.
// • MONOTONIC CLIMB → the FTS arena is stranded per reopen; the user's
// hypothesis holds and U4 (route FTS reads through a reclaimable worker) is
// justified.
//
// SCOPE OF THE VERDICT (read before citing it). A per-reload FTS-arena leak
// would be PROPORTIONAL to the index size. A small fixture therefore produces a
// small per-cycle increment that an absolute threshold can read as PLATEAU even
// when a production-scale graph would leak visibly. So:
// - `--rows` controls fixture size; run it LARGE (tens of thousands) before
// concluding "no leak". The default is deliberately not tiny.
// - The verdict (in fts-rss-verdict.mjs) keys on slope DECELERATION, not total
// delta, with a noise floor that scales with the working-set growth
// (peak−baseline) so sensitivity tracks fixture/arena size — NOT the pre-DB
// baseline RSS. A sustained sub-floor positive slope is INCONCLUSIVE (a slow
// creep RSS can't distinguish from noise), never a clean PLATEAU.
// - The PLATEAU verdict is only valid for the corpus size it was run at; the
// output states that size. The production-faithful confirmation is a
// `--via-pool` run against a real large analyzed repo over a long session.
//
// Two modes:
// (default) NATIVE — reproduces the native sequence doInitLbug()+closeOne()
// perform (open Database → new Connection → LOAD EXTENSION fts →
// QUERY_FTS_INDEX → close), against K self-built FTS fixtures, with no
// gitnexus build required. `--no-await-close` mirrors the pool's
// fire-and-forget close instead of awaiting (the production close shape).
// --via-pool <lbugPath> — drives the REAL gitnexus pool from compiled dist
// (initLbug → executeParameterized → closeLbug) against an existing analyzed
// repo, exercising the production path + the GITNEXUS_POOL_RSS_TRACE
// instrumentation. Probes ALL FTS indexes the repo has. Forces an explicit
// close+reinit each cycle. Run `node scripts/build.js` first so the dist
// reflects the current pool-adapter (incl. the RSS trace).
//
// Run with --expose-gc so RSS excludes V8-heap noise:
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs --rows 40000 --cycles 30
// GITNEXUS_POOL_RSS_TRACE=1 node --expose-gc \
// gitnexus/scripts/bench/fts-evict-reload-rss.mjs --via-pool /path/to/repo/.gitnexus/lbug
//
// Flags by mode: --rows/--repos/--read-write/--no-await-close apply to NATIVE
// only; --cycles applies to both. VIA-POOL warns when a NATIVE-only flag is set.
//
// Memory benches are noisy. Default is 24 cycles; trust the TREND (slope /
// first-third vs last-third), never a single delta. A flat trend at a LARGE
// fixture is a real NEGATIVE result (no unbounded leak), not a failed run.
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
// Pure verdict classifier (median, slopeMbPerCycle, classifyVerdict) lives in a
// side-effect-free sibling module so it is unit-testable without loading the
// native addon or running this bench. See fts-rss-verdict.mjs.
import { classifyVerdict, median, slopeMbPerCycle } from './fts-rss-verdict.mjs';
const require = createRequire(import.meta.url);
const lbugModule = require('@ladybugdb/core');
const lbug = lbugModule.default ?? lbugModule;
const LBUG_MAX_DB_SIZE = 16 * 1024 * 1024 * 1024;
// ── args ──────────────────────────────────────────────────────────────────
function argVal(flag, dflt) {
const i = process.argv.indexOf(flag);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
}
const CYCLES = Math.max(6, parseInt(argVal('--cycles', '24'), 10) || 24);
const REPOS = Math.max(1, parseInt(argVal('--repos', '6'), 10) || 6); // >5 mirrors LRU thrash
// Fixture size. Default is large enough that a size-proportional leak would be
// visible across cycles; raise it further before trusting a PLATEAU verdict.
const ROWS = Math.max(100, parseInt(argVal('--rows', '8000'), 10) || 8000);
const VIA_POOL = argVal('--via-pool', null);
const READONLY = !process.argv.includes('--read-write');
const AWAIT_CLOSE = !process.argv.includes('--no-await-close');
if (VIA_POOL) {
// These flags are consumed only by NATIVE mode; warn rather than ignore
// silently so a VIA-POOL run is not misread as honoring them.
const ignored = ['--rows', '--repos', '--read-write', '--no-await-close'].filter((f) =>
process.argv.includes(f),
);
if (ignored.length) {
console.error(
`[fts-rss] NOTE: ${ignored.join(', ')} apply to NATIVE mode only; ignored in --via-pool.`,
);
}
}
if (typeof global.gc !== 'function') {
console.error(
'[fts-rss] WARNING: run with --expose-gc for clean RSS samples ' +
'(`node --expose-gc <thisfile>`). Continuing without forced GC — results are noisier.',
);
}
const gc = () => {
if (typeof global.gc === 'function') {
global.gc();
global.gc();
}
};
const rssMb = () => Math.round(process.memoryUsage().rss / (1024 * 1024));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── fixture: a minimal FTS-bearing .lbug ────────────────────────────────────
const WORDS = [
'login auth session token user password validate verify credential',
'parse tree syntax node grammar lexer token ast traversal visitor',
'graph query cypher match relation node edge pattern aggregate index',
'memory pool buffer arena allocate reclaim evict cache resident heap',
'search rank score bm25 fts index stem porter keyword document corpus',
'worker fork process spawn kill reclaim isolate native binding addon',
];
function buildFixture(dir) {
fs.mkdirSync(dir, { recursive: true });
const dbPath = path.join(dir, 'fixture.lbug');
const db = new lbug.Database(dbPath, 0, false, false, LBUG_MAX_DB_SIZE);
const conn = new lbug.Connection(db);
return (async () => {
await conn.query('LOAD EXTENSION fts');
await conn.query(
'CREATE NODE TABLE Doc(id STRING, name STRING, content STRING, PRIMARY KEY(id))',
);
// Batch-insert via UNWIND so large fixtures (`--rows`) build in seconds
// instead of one round-trip per row. The fixture size drives the per-arena
// FTS allocation, which is what makes a size-proportional leak observable.
const rows = [];
for (let i = 0; i < ROWS; i++) {
const w = WORDS[i % WORDS.length];
const name = `sym_${i}`;
const content = `${w} ${name} block number ${i} ${WORDS[(i + 3) % WORDS.length]}`;
rows.push({ id: `doc:${i}`, name, content });
}
const INSERT_CHUNK = 2000;
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
const chunk = rows.slice(i, i + INSERT_CHUNK);
const stmt = await conn.prepare(
'UNWIND $rows AS r CREATE (:Doc {id: r.id, name: r.name, content: r.content})',
);
await conn.execute(stmt, { rows: chunk });
}
await conn.query(
"CALL CREATE_FTS_INDEX('Doc', 'doc_fts', ['name', 'content'], stemmer := 'porter')",
);
await conn.close();
await db.close();
return dbPath;
})();
}
const QUERIES = ['login token', 'parse node', 'memory arena', 'search index', 'worker reclaim'];
// ── NATIVE mode ─────────────────────────────────────────────────────────────
async function runNative() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-rss-'));
console.error(
`[fts-rss] NATIVE: ${REPOS} fixtures × ${ROWS} rows × ${CYCLES} cycles ` +
`(readOnly=${READONLY}, awaitClose=${AWAIT_CLOSE})`,
);
console.error(`[fts-rss] building ${REPOS} FTS fixture(s) under ${root} …`);
const srcDb = await buildFixture(path.join(root, 'src'));
const repoPaths = [];
for (let k = 0; k < REPOS; k++) {
const dst = path.join(root, `repo-${k}`);
fs.cpSync(path.dirname(srcDb), dst, { recursive: true });
repoPaths.push(path.join(dst, 'fixture.lbug'));
}
// Mirror the pool's evict→reload: each visit opens a FRESH Database, makes a
// Connection, LOADs fts, runs an FTS query, then closes — no caching, so every
// visit is a reload. K>5 amplifies the LRU-thrash signal the pool would see.
const series = [];
gc();
await sleep(50);
const baseline = rssMb();
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
for (let cycle = 0; cycle < CYCLES; cycle++) {
for (let k = 0; k < REPOS; k++) {
const db = new lbug.Database(repoPaths[k], 0, false, READONLY, LBUG_MAX_DB_SIZE);
const conn = new lbug.Connection(db);
try {
await conn.query('LOAD EXTENSION fts'); // the per-reload re-LOAD under test
const q = QUERIES[(cycle + k) % QUERIES.length];
const res = await conn.query(
`CALL QUERY_FTS_INDEX('Doc', 'doc_fts', '${q}') RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
);
// Drain so the query actually materializes results.
if (res && typeof res.getAll === 'function') await res.getAll();
} catch (e) {
console.error(`[fts-rss] query error (cycle ${cycle}, repo ${k}): ${e?.message || e}`);
} finally {
// AWAIT_CLOSE (default) is the best case for reclamation. --no-await-close
// mirrors the pool's fire-and-forget close (closeOne: db.close().catch())
// so a leak that only manifests without awaiting is not hidden.
if (AWAIT_CLOSE) {
try {
await conn.close();
await db.close();
} catch {
/* ignore */
}
} else {
conn.close().catch(() => {});
db.close().catch(() => {});
}
}
}
gc();
// Longer settle when not awaiting close, so fire-and-forget native teardown
// has a chance to complete before the RSS sample (avoids a false PLATEAU).
await sleep(AWAIT_CLOSE ? 20 : 200);
const rss = rssMb();
series.push(rss);
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
}
fs.rmSync(root, { recursive: true, force: true });
return { baseline, series, corpus: `${REPOS}×${ROWS} rows, native, awaitClose=${AWAIT_CLOSE}` };
}
// ── VIA-POOL mode (real gitnexus pool from compiled dist) ───────────────────
async function runViaPool(lbugPath) {
if (!fs.existsSync(lbugPath)) {
console.error(`[fts-rss] --via-pool path not found: ${lbugPath}`);
process.exit(2);
}
// Compiled dist is required (the pool pulls the native addon + many modules).
const distUrl = new URL('../../dist/core/lbug/pool-adapter.js', import.meta.url);
let pool;
try {
pool = await import(distUrl.href);
} catch (e) {
console.error(
`[fts-rss] could not import compiled pool-adapter (${e?.message}). ` +
`Run \`node scripts/build.js\` first, or use NATIVE mode.`,
);
process.exit(2);
}
const { initLbug, executeParameterized, closeLbug } = pool;
console.error(
`[fts-rss] VIA-POOL on ${lbugPath} × ${CYCLES} cycles ` +
`(explicit closeLbug+initLbug per cycle = forced evict→reload)`,
);
// Probe ALL FTS indexes the analyzed graph carries (mirrors fts-schema.ts
// FTS_INDEXES) so the per-cycle FTS arena load matches production, not a
// 2-of-5 subset that would understate it.
const FTS_INDEXES = [
{ table: 'File', indexName: 'file_fts' },
{ table: 'Function', indexName: 'function_fts' },
{ table: 'Class', indexName: 'class_fts' },
{ table: 'Method', indexName: 'method_fts' },
{ table: 'Interface', indexName: 'interface_fts' },
];
const series = [];
gc();
const baseline = rssMb();
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
for (let cycle = 0; cycle < CYCLES; cycle++) {
try {
await initLbug(lbugPath, lbugPath);
const q = QUERIES[cycle % QUERIES.length];
for (const { table, indexName } of FTS_INDEXES) {
await executeParameterized(
lbugPath,
`CALL QUERY_FTS_INDEX('${table}', '${indexName}', $q) RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
{ q },
).catch(() => []); // index may not exist for this graph — that's fine
}
await closeLbug(lbugPath); // force eviction → next cycle reopens + re-LOADs fts
} catch (e) {
console.error(`[fts-rss] pool cycle ${cycle} error: ${e?.message || e}`);
}
gc();
// closeLbug fires a fire-and-forget native close (pool closeOne:
// db.close().catch()), so settle longer than NATIVE's awaited close to let
// native teardown finish before sampling — else a real leak reads PLATEAU.
await sleep(200);
const rss = rssMb();
series.push(rss);
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
}
await closeLbug().catch(() => {});
return { baseline, series, corpus: `via-pool ${path.basename(path.dirname(lbugPath))}` };
}
// ── verdict ─────────────────────────────────────────────────────────────────
function verdict({ baseline, series, corpus }) {
const third = Math.max(1, Math.floor(series.length / 3));
const firstMed = median(series.slice(0, third));
const lastMed = median(series.slice(-third));
const delta = lastMed - firstMed;
const slope = slopeMbPerCycle(series);
// All label logic lives in the pure, unit-tested classifier (fts-rss-verdict.mjs):
// epsilon-first flat→PLATEAU, decelerated→PLATEAU, sustained-sub-floor→INCONCLUSIVE,
// ≥floor sustained→CLIMB, step→INCONCLUSIVE; floor scales with the working-set
// growth (peak−baseline), not the pre-DB baseline RSS.
const {
verdict: label,
firstHalfSlope,
secondHalfSlope,
decelRatio,
floor,
stepDiscontinuity,
maxJump,
peak,
} = classifyVerdict(series, baseline);
console.log('\n==================== FTS evict→reload RSS verdict ====================');
console.log(`corpus: ${corpus}`);
console.log(`samples (MB): ${series.join(' ')}`);
console.log(
`baseline=${baseline} firstThirdMed=${firstMed} lastThirdMed=${lastMed} delta=${delta}MB ` +
`peak=${peak} overallSlope=${slope.toFixed(2)} firstHalfSlope=${firstHalfSlope.toFixed(2)} ` +
`secondHalfSlope=${secondHalfSlope.toFixed(2)}MB/cycle floor=${floor.toFixed(2)} decelRatio=${decelRatio.toFixed(2)} ` +
`maxJump=${maxJump}MB step=${stepDiscontinuity} cycles=${series.length}`,
);
if (label === 'CLIMB') {
console.log(
'VERDICT: CLIMB — the per-cycle increment is SUSTAINED (second-half slope ≈ first-half),\n' +
' i.e. RSS rises ~linearly with no decay. The native FTS arena is NOT reclaimed\n' +
' by db.close(); the leak is real over a long-lived session.\n' +
' → plan U4 (worker/process isolation of the FTS read path) is JUSTIFIED.',
);
} else if (label === 'PLATEAU') {
console.log(
`VERDICT: PLATEAU at this corpus (${corpus}) — the per-cycle increment DECAYS to flat\n` +
' (second-half slope below the noise floor). db.close() reclaims the FTS arena;\n' +
' footprint is bounded (and the pool further caps it at MAX_POOL_SIZE). No\n' +
' unbounded leak. Caveat: synthetic fixture — confirm with a --via-pool run\n' +
' against a real large analyzed repo before fully closing plan U4.',
);
} else {
console.log(
`VERDICT: INCONCLUSIVE at this corpus (${corpus}) — the run is noisy (step discontinuity)\n` +
' or still decelerating without reaching flat, so neither a clean PLATEAU nor a\n' +
' sustained linear CLIMB can be asserted. NATIVE synthetic runs do not resolve\n' +
' this reliably at scale. The definitive test is a --via-pool run against a real\n' +
' large analyzed repo over many cycles (with GITNEXUS_POOL_RSS_TRACE=1). Plan U4\n' +
' stays GATED — neither closed nor built on this evidence.',
);
}
console.log(
`MACHINE: ${JSON.stringify({ mode: VIA_POOL ? 'via-pool' : 'native', corpus, baseline, firstMed, lastMed, delta, overallSlope: Number(slope.toFixed(3)), firstHalfSlope: Number(firstHalfSlope.toFixed(3)), secondHalfSlope: Number(secondHalfSlope.toFixed(3)), floor: Number(floor.toFixed(3)), decelRatio: Number(decelRatio.toFixed(3)), maxJump, stepDiscontinuity, peak, cycles: series.length, verdict: label })}`,
);
console.log('=====================================================================\n');
}
// ── main ────────────────────────────────────────────────────────────────────
(async () => {
const result = VIA_POOL ? await runViaPool(VIA_POOL) : await runNative();
verdict(result);
process.exit(0);
})().catch((e) => {
console.error('[fts-rss] fatal:', e?.stack || e);
process.exit(1);
});

View file

@ -0,0 +1,105 @@
// Pure, side-effect-free verdict classifier for the FTS evict→reload RSS bench
// (fts-evict-reload-rss.mjs). Extracted so it can be unit-tested WITHOUT importing
// the native LadybugDB addon or running the bench — this module has zero imports
// and zero module-scope side effects. Do not add imports or top-level statements.
//
// The discriminant between a real leak and allocator warmup is SLOPE DECELERATION,
// not total delta. A true per-reload leak (stranded FTS arena) rises ~linearly:
// the second-half slope stays ≈ the first-half slope. Allocator working-set warmup
// rises then flattens: the second-half slope decays to a fraction of the first.
//
// Thresholds:
// EPSILON (~0.1 MB/cycle) — below this the tail is effectively flat (no leak).
// SUSTAIN_FLOOR (0.5 MB/cycle) — the base noise floor.
// The floor SCALES with the working-set growth (peak − baseline), NOT the pre-DB
// `baseline` RSS: baseline is interpreter/addon overhead (and is LARGER in
// --via-pool mode), so a baseline-keyed floor would inflate and HIDE leaks. A
// bigger fixture has a bigger arena and bigger per-cycle noise, so the floor
// rises with the working set: floor = SUSTAIN_FLOOR · max(1, (peak−baseline)/REF).
export const EPSILON_MB_PER_CYCLE = 0.1;
export const SUSTAIN_FLOOR = 0.5;
// Reference working-set (MB) at which the floor equals SUSTAIN_FLOOR; the floor
// scales up linearly for larger arenas. ~200 MB ≈ a small FTS fixture's footprint.
export const FLOOR_REF_WORKINGSET_MB = 200;
export function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
}
export function slopeMbPerCycle(series) {
// Least-squares slope of rss vs cycle index.
const n = series.length;
if (n < 2) return 0;
const xs = series.map((_, i) => i);
const xMean = xs.reduce((a, b) => a + b, 0) / n;
const yMean = series.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (series[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
return den === 0 ? 0 : num / den;
}
/**
* Classify an RSS-per-cycle series into PLATEAU / CLIMB / INCONCLUSIVE.
* Pure: no I/O, no globals. `baseline` is the pre-DB RSS; `peak` defaults to the
* series max. Returns the label plus the diagnostics the bench prints.
*/
export function classifyVerdict(series, baseline, peak = Math.max(...series)) {
const cycles = series.length;
const half = Math.max(1, Math.floor(cycles / 2));
const firstHalfSlope = slopeMbPerCycle(series.slice(0, half));
const secondHalfSlope = slopeMbPerCycle(series.slice(-half));
const decelRatio = secondHalfSlope / Math.max(firstHalfSlope, 1e-9);
// Step discontinuity: a single cycle-to-cycle jump far larger than the typical
// per-cycle delta — a one-time allocator/arena reservation (then flat), not a
// per-reload leak, but a noisy run we won't claim a clean result on.
const deltas = series.slice(1).map((v, i) => v - series[i]);
const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b);
const medAbsDelta = absDeltas.length ? absDeltas[Math.floor(absDeltas.length / 2)] : 0;
const maxJump = deltas.length ? Math.max(...deltas) : 0;
const stepDiscontinuity = maxJump > Math.max(30, 5 * Math.max(medAbsDelta, 1));
// Working-set-scaled floor (see header). Guard against a negative working set.
const workingSet = Math.max(0, peak - baseline);
const floor = SUSTAIN_FLOOR * Math.max(1, workingSet / FLOOR_REF_WORKINGSET_MB);
const SUSTAINED = 0.6; // decelRatio at/above which the tail is "not decaying"
let verdict;
if (stepDiscontinuity) {
verdict = 'INCONCLUSIVE';
} else if (secondHalfSlope < EPSILON_MB_PER_CYCLE) {
// Effectively flat — no leak, regardless of decelRatio (a flat-from-start run
// has decelRatio ≈ 1 but is still PLATEAU). This gate is what keeps a true
// negative from being over-corrected into INCONCLUSIVE.
verdict = 'PLATEAU';
} else if (secondHalfSlope >= floor) {
// Tail is still substantial: sustained → real leak; decelerating → unresolved.
verdict = decelRatio >= SUSTAINED ? 'CLIMB' : 'INCONCLUSIVE';
} else if (decelRatio < SUSTAINED) {
// Below the floor AND decelerating — warmup converged toward flat → PLATEAU.
verdict = 'PLATEAU';
} else {
// Below the floor but SUSTAINED — a slow steady creep RSS can't distinguish
// from noise at this scale. The honest label is "not resolved", NEVER a clean
// PLATEAU ("no leak"). This is the headline tri-review fix.
verdict = 'INCONCLUSIVE';
}
return {
verdict,
firstHalfSlope,
secondHalfSlope,
decelRatio,
floor,
stepDiscontinuity,
maxJump,
peak,
};
}

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,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

@ -14,7 +14,7 @@ function parseLbugMaxDbSize(raw) {
return Math.floor(parsed);
}
async function installDuckDbExtension(extensionName) {
async function installDuckDbExtension(extensionName, verifyOnly = false) {
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
}
@ -22,9 +22,11 @@ async function installDuckDbExtension(extensionName) {
const require = createRequire(import.meta.url);
const lbugModule = require('@ladybugdb/core');
const lbug = lbugModule.default ?? lbugModule;
const lbugMaxDbSize = parseLbugMaxDbSize(
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
);
// argv[3] is the optional positional size; ignore it when it is actually a
// flag token (e.g. `--verify-only`) and fall back to the env default.
const sizeArg =
process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
const dbPath = path.join(tmpDir, 'install.lbug');
@ -34,7 +36,18 @@ async function installDuckDbExtension(extensionName) {
try {
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
await conn.query(`INSTALL ${extensionName}`);
if (verifyOnly) {
// Prove a previously-baked extension is resolvable by a FRESH process
// under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
// no network. Used as a Docker build-time gate so a HOME/extension-dir
// mismatch fails the build instead of silently degrading search at runtime.
await conn.query(`LOAD EXTENSION ${extensionName}`);
console.log(
`[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
);
} else {
await conn.query(`INSTALL ${extensionName}`);
}
} finally {
if (conn) await conn.close().catch(() => {});
if (db) await db.close().catch(() => {});
@ -42,7 +55,10 @@ async function installDuckDbExtension(extensionName) {
}
}
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
installDuckDbExtension(
process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
process.argv.includes('--verify-only'),
).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});

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

@ -0,0 +1,151 @@
/**
* Spike S1 (issue #2080, M0) — THROWAWAY benchmark. Not part of the build
* (scripts/ is excluded from tsconfig) or the test suite.
*
* Question: can LadybugDB serve the headline REACHING_DEF query
* [:REACHING_DEF*1..5 {variable}]
* fast enough, and what is the right storage shape for the `variable`?
*
* What it does:
* 1. Builds a synthetic ~100K-edge graph of BasicBlock nodes + REACHING_DEF
* edges (variable carried in the CodeRelation `reason` column) with a
* realistic per-variable fan-out distribution, and loads it through the
* real bulk-COPY path (loadGraphToLbug).
* 2. Probes whether LadybugDB supports a secondary index on a relationship
* property (the crux of the "edge property vs side table" decision).
* 3. Times the variable-filtered bounded var-length path query.
*
* Run: npx tsx scripts/spikes/s1-reaching-def-index-bench.ts [edgeCount]
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { performance } from 'node:perf_hooks';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
const EDGE_COUNT = Number(process.argv[2] ?? 30_000);
// Realistic-ish def-use shape: many short chains, variables reused across them.
const CHAIN_LEN = 6; // blocks per function-ish chain
const DISTINCT_VARS = Math.max(1, Math.floor(EDGE_COUNT / 20)); // ~20 edges/variable fan-out
const log = (m: string) => process.stdout.write(m + '\n');
function buildSynthGraph(edgeCount: number): KnowledgeGraph {
const g = createKnowledgeGraph();
let edges = 0;
let chain = 0;
while (edges < edgeCount) {
const base = `BasicBlock:synth/f${chain}.ts`;
for (let i = 0; i <= CHAIN_LEN; i++) {
g.addNode({
id: `${base}:${i}`,
label: 'BasicBlock',
properties: {
name: '',
filePath: `synth/f${chain}.ts`,
startLine: i,
endLine: i,
text: '',
},
});
}
for (let i = 0; i < CHAIN_LEN && edges < edgeCount; i++) {
const variable = `v${edges % DISTINCT_VARS}`;
g.addRelationship({
id: `${base}:${i}->${i + 1}:${variable}`,
sourceId: `${base}:${i}`,
targetId: `${base}:${i + 1}`,
type: 'REACHING_DEF',
confidence: 1.0,
reason: variable, // M0 storage: variable rides `reason`
});
edges++;
}
chain++;
}
return g;
}
async function main() {
const tmp = path.join(os.tmpdir(), `s1-spike-${Date.now()}`);
const storagePath = path.join(tmp, '.gitnexus');
const dbPath = path.join(storagePath, 'lbug');
await fs.mkdir(dbPath, { recursive: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
log(
`[S1] building synthetic graph: ~${EDGE_COUNT} REACHING_DEF edges, ` +
`${DISTINCT_VARS} distinct variables (~20 edges/var fan-out), chains of ${CHAIN_LEN}`,
);
const g = buildSynthGraph(EDGE_COUNT);
let t = performance.now();
await adapter.loadGraphToLbug(g, tmp, storagePath);
const loadMs = performance.now() - t;
const stats = await adapter.getLbugStats();
log(`[S1] bulk-COPY load: ${loadMs.toFixed(0)}ms (nodes=${stats.nodes}, edges=${stats.edges})`);
// (2) Probe: does LadybugDB support a secondary index on a REL property?
let relIndexSupported = false;
let relIndexErr = '';
for (const stmt of [
"CALL CREATE_REL_INDEX('CodeRelation', 'cr_reason_idx', 'reason')",
'CREATE INDEX cr_reason_idx ON CodeRelation(reason)',
]) {
try {
await adapter.executeQuery(stmt);
relIndexSupported = true;
break;
} catch (e: any) {
relIndexErr = String(e?.message ?? e).split('\n')[0];
}
}
log(
`[S1] rel-property secondary index supported? ${relIndexSupported} ` +
`(last error: ${relIndexErr})`,
);
// (3a) Single-hop variable filter — the common case M3 runs most.
const probeVar = 'v0';
t = performance.now();
const single = await adapter.executeQuery(
`MATCH (a:BasicBlock)-[r:CodeRelation {type: 'REACHING_DEF', reason: '${probeVar}'}]->(b:BasicBlock)
RETURN count(r) AS c`,
);
const singleMs = performance.now() - t;
log(`[S1] single-hop variable filter → ${single[0]?.c} edges in ${singleMs.toFixed(0)}ms`);
// (3b) SOURCE-ANCHORED bounded var-length path — the realistic taint query
// (anchor the source block, then walk REACHING_DEF up to 5 hops). The
// UNANCHORED global form ([:REACHING_DEF*1..5] from every block) is
// impractical at scale (path explosion) — that is itself an S1 finding:
// taint queries MUST be scoped to a source block, not run graph-wide.
const srcId = 'BasicBlock:synth/f0.ts:0';
t = performance.now();
const anchored = await adapter.executeQuery(
`MATCH p = (a:BasicBlock)-[:CodeRelation*1..5 {type: 'REACHING_DEF'}]->(b:BasicBlock)
WHERE a.id = '${srcId}' AND all(rel IN relationships(p) WHERE rel.reason = '${probeVar}')
RETURN count(p) AS paths`,
);
const pathMs = performance.now() - t;
log(
`[S1] source-anchored [:REACHING_DEF*1..5 {reason='${probeVar}'}] from one block → ` +
`${anchored[0]?.paths} paths in ${pathMs.toFixed(0)}ms`,
);
await adapter.closeLbug();
await fs.rm(tmp, { recursive: true, force: true });
log('\n[S1] VERDICT INPUTS:');
log(
` load_ms=${loadMs.toFixed(0)} single_hop_ms=${singleMs.toFixed(0)} anchored_path_ms=${pathMs.toFixed(0)} rel_index=${relIndexSupported}`,
);
}
main().catch((e) => {
console.error('[S1] FAILED:', e);
process.exit(1);
});

View file

@ -0,0 +1,162 @@
/**
* Spike S2 (issue #2080, M0) — THROWAWAY post-dominator feasibility prototype.
* Not part of the build (scripts/ excluded from tsconfig) or the test suite.
*
* Question (per maintainer review): does the post-dominator algorithm Epic B
* (#2085, CDG) depends on hold up on real TS/JS control-flow shapes — the
* classic CFG hazards — before Epic B commits to it?
*
* Scope boundary: post-dominators operate on a CFG, not on the AST directly.
* This prototype validates the ALGORITHM (iterative dataflow on the reverse
* CFG, EXIT-rooted, → immediate-post-dominator tree) against CFGs that model
* each hazard's real TS control flow (the TS source each CFG represents is
* shown inline). Building the CFG from a tree-sitter AST is M1's job (#2081);
* this spike deliberately does not reimplement it.
*
* Run: npx tsx scripts/spikes/s2-postdom-prototype.ts
*/
type CFG = {
name: string;
tsSource: string;
entry: string;
exit: string;
// adjacency: block -> successors
succ: Record<string, string[]>;
hazard: string;
};
// Iterative post-dominator dataflow on the reverse CFG.
// PostDom(EXIT) = {EXIT}; PostDom(n) = {n} ∪ (⋂ PostDom(s) for s ∈ succ(n)).
// Monotone over a finite lattice (powerset of blocks) ⇒ guaranteed to converge.
function postDominators(cfg: CFG): { pdom: Record<string, Set<string>>; iterations: number } {
const blocks = Object.keys(cfg.succ);
const all = new Set(blocks);
const pdom: Record<string, Set<string>> = {};
for (const b of blocks) pdom[b] = b === cfg.exit ? new Set([cfg.exit]) : new Set(all);
let changed = true;
let iterations = 0;
while (changed) {
changed = false;
iterations++;
for (const b of blocks) {
if (b === cfg.exit) continue;
const succs = cfg.succ[b] ?? [];
let inter: Set<string> | null = null;
for (const s of succs) {
if (inter === null) inter = new Set(pdom[s]);
else inter = new Set([...inter].filter((x) => pdom[s].has(x)));
}
const next = new Set<string>(inter ?? []);
next.add(b);
if (next.size !== pdom[b].size || [...next].some((x) => !pdom[b].has(x))) {
pdom[b] = next;
changed = true;
}
}
if (iterations > blocks.length + 5)
throw new Error('post-dom did not converge (suspected bug)');
}
return { pdom, iterations };
}
// Immediate post-dominator: the closest strict post-dominator.
function ipdom(cfg: CFG, pdom: Record<string, Set<string>>): Record<string, string | null> {
const res: Record<string, string | null> = {};
for (const b of Object.keys(cfg.succ)) {
if (b === cfg.exit) {
res[b] = null;
continue;
}
const strict = [...pdom[b]].filter((x) => x !== b);
// ipdom = the strict post-dom that does not post-dominate any other strict post-dom.
res[b] =
strict.find((cand) => strict.every((other) => other === cand || !pdom[other].has(cand))) ??
null;
}
return res;
}
const CFGS: CFG[] = [
{
name: 'early-return',
hazard: 'early return / multiple paths to EXIT',
tsSource: `function f(x){ if (x) { return 1; } g(); return 2; }`,
entry: 'ENTRY',
exit: 'EXIT',
succ: { ENTRY: ['ret1', 'g'], ret1: ['EXIT'], g: ['ret2'], ret2: ['EXIT'], EXIT: [] },
},
{
name: 'try-throw-finally',
hazard: 'try/throw/finally with multiple exits through finally',
tsSource: `function f(){ try { risky(); } catch(e){ handle(e); } finally { cleanup(); } done(); }`,
entry: 'ENTRY',
exit: 'EXIT',
// try → (normal | throw→catch) → finally → done → EXIT; finally also reached on rethrow
succ: {
ENTRY: ['try'],
try: ['finally', 'catch'],
catch: ['finally'],
finally: ['done', 'EXIT'],
done: ['EXIT'],
EXIT: [],
},
},
{
name: 'labeled-break',
hazard: 'labeled break/continue across nested loops',
tsSource: `outer: for(;;){ for(;;){ if (a) break outer; if (b) continue outer; work(); } }`,
entry: 'ENTRY',
exit: 'EXIT',
succ: {
ENTRY: ['outerHead'],
outerHead: ['innerHead', 'EXIT'],
innerHead: ['breakOuter', 'afterIf1'],
breakOuter: ['EXIT'],
afterIf1: ['contOuter', 'work'],
contOuter: ['outerHead'],
work: ['innerHead'],
EXIT: [],
},
},
{
name: 'if-else-diamond',
hazard: 'baseline reducible diamond (sanity)',
tsSource: `function f(x){ if (x) { a(); } else { b(); } c(); }`,
entry: 'ENTRY',
exit: 'EXIT',
succ: { ENTRY: ['a', 'b'], a: ['c'], b: ['c'], c: ['EXIT'], EXIT: [] },
},
];
function main() {
let allOk = true;
for (const cfg of CFGS) {
try {
const { pdom, iterations } = postDominators(cfg);
const idom = ipdom(cfg, pdom);
// Sanity invariants: EXIT post-dominates every block; ipdom tree reaches EXIT.
const exitPostDomsAll = Object.keys(cfg.succ).every((b) => pdom[b].has(cfg.exit));
console.log(`\n[S2] ${cfg.name} — ${cfg.hazard}`);
console.log(` TS: ${cfg.tsSource}`);
console.log(
` converged in ${iterations} iters; EXIT post-dominates all blocks: ${exitPostDomsAll}`,
);
console.log(
` ipdom tree: ${Object.entries(idom)
.map(([b, p]) => `${b}->${p ?? '∅'}`)
.join(' ')}`,
);
if (!exitPostDomsAll) allOk = false;
} catch (e) {
allOk = false;
console.log(`\n[S2] ${cfg.name} FAILED: ${(e as Error).message}`);
}
}
console.log(
`\n[S2] VERDICT INPUT: all hazard CFGs converged + EXIT post-dominates all = ${allOk}`,
);
}
main();

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

@ -37,7 +37,7 @@ import {
} from './analyze-config.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars } from './optional-grammars.js';
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError } from './cli-message.js';
@ -943,11 +943,13 @@ const analyzeCommandImpl = async (
}
// If the target repo contains files an optional grammar would parse but
// that grammar's native binding is absent, warn before analysis so users
// learn why those files end up unparsed instead of silently getting a
// degraded index.
// that grammar's native binding is absent (or disabled via
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS), warn before analysis so users learn why
// those files end up unparsed instead of silently getting a degraded index.
// The extension set is derived from OPTIONAL_GRAMMARS so it can't drift.
try {
const matches = await glob(['**/*.dart', '**/*.proto'], {
const optionalGlobs = getOptionalGrammarExtensions().map((e) => `**/*${e}`);
const matches = await glob(optionalGlobs, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
dot: false,

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

@ -4,18 +4,22 @@
* tree-sitter-dart, tree-sitter-proto, and tree-sitter-swift are vendored
* under vendor/ and materialized into node_modules/ at postinstall. Dart
* and Proto are built from source with node-gyp; Swift ships platform
* prebuilds activated via node-gyp-build. All three can be skipped via
* prebuilds activated via node-gyp-build. tree-sitter-kotlin is a declared
* optionalDependency (not vendored). All can be skipped via
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
* soft-fail when the toolchain is missing (Dart/Proto) or no prebuild
* matches the host platform (Swift).
* soft-fail when the toolchain is missing (Dart/Proto), when no prebuild
* matches the host platform (Swift), or when the optional install was
* skipped or its native build failed (Kotlin).
*
* Either path produces the same observable: the .node binding is absent
* at runtime. This helper detects that condition and surfaces a single
* stderr line per missing grammar so users learn why .dart/.proto/.swift
* stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt
* support is unavailable instead of silently getting a degraded index.
*/
import { createRequire } from 'module';
import { SupportedLanguages } from 'gitnexus-shared';
import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js';
import { cliWarn } from './cli-message.js';
const _require = createRequire(import.meta.url);
@ -27,17 +31,55 @@ interface OptionalGrammar {
pkg: string;
/** File extensions this grammar parses */
extensions: string[];
/**
* SupportedLanguages id, when this grammar backs an ingestion language.
* Used to ask `isGrammarRuntimeSkipped` whether the grammar was disabled via
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` (vs. genuinely missing). Omitted for
* `.proto`, which is a gRPC-extractor concern, not a SupportedLanguages.
*/
language?: SupportedLanguages;
}
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
{ name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
{
name: 'tree-sitter-dart',
pkg: 'tree-sitter-dart',
extensions: ['.dart'],
language: SupportedLanguages.Dart,
},
{ name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
{ name: 'tree-sitter-swift', pkg: 'tree-sitter-swift', extensions: ['.swift'] },
{
name: 'tree-sitter-swift',
pkg: 'tree-sitter-swift',
extensions: ['.swift'],
language: SupportedLanguages.Swift,
},
{
name: 'tree-sitter-kotlin',
pkg: 'tree-sitter-kotlin',
extensions: ['.kt', '.kts'],
language: SupportedLanguages.Kotlin,
},
];
/**
* The file extensions backed by an optional grammar — the single source for
* the `analyze` preflight glob (so the glob can't drift from this list).
*/
export function getOptionalGrammarExtensions(): string[] {
return [...new Set(OPTIONAL_GRAMMARS.flatMap((g) => g.extensions))];
}
export interface MissingGrammar {
name: string;
extensions: string[];
/**
* `missing` — the native binding could not be loaded (not installed / build
* soft-failed / no prebuild). `skipped` — the binding is fine but the user
* disabled it via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS`. Drives the warning text
* so a deliberate opt-out is not told to reinstall.
*/
reason: 'missing' | 'skipped';
}
/**
@ -59,6 +101,13 @@ export interface MissingGrammar {
export function detectMissingOptionalGrammars(): MissingGrammar[] {
const missing: MissingGrammar[] = [];
for (const g of OPTIONAL_GRAMMARS) {
// Deliberate runtime opt-out comes first: even an installed binding is
// treated as unavailable, with a `skipped` reason so the warning says so
// instead of suggesting a reinstall (#2101 review).
if (g.language !== undefined && isGrammarRuntimeSkipped(g.language)) {
missing.push({ name: g.name, extensions: g.extensions, reason: 'skipped' });
continue;
}
try {
_require(g.pkg);
} catch (err) {
@ -80,7 +129,7 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] {
{ grammar: g.name, extensions: g.extensions, error: msg },
);
}
missing.push({ name: g.name, extensions: g.extensions });
missing.push({ name: g.name, extensions: g.extensions, reason: 'missing' });
}
}
return missing;
@ -110,9 +159,16 @@ export function warnMissingOptionalGrammars(opts?: {
if (relevantExtensions && !g.extensions.some((e) => relevantExtensions.has(e))) {
continue;
}
cliWarn(
`GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`,
{ grammar: g.name, extensions: g.extensions, context: opts?.context },
);
const exts = g.extensions.join('/');
const message =
g.reason === 'skipped'
? `GitNexus${ctx}: optional grammar "${g.name}" is disabled via GITNEXUS_SKIP_OPTIONAL_GRAMMARS — ${exts} files will not be parsed. Unset the variable to re-enable.`
: `GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${exts} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`;
cliWarn(message, {
grammar: g.name,
extensions: g.extensions,
reason: g.reason,
context: opts?.context,
});
}
}

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

@ -42,6 +42,11 @@ import {
applyCppTwoPhaseSideChannel,
type CppTwoPhaseSideChannel,
} from './two-phase-lookup.js';
import {
applyCppMemberLookupSideChannel,
collectCppMemberLookupSideChannel,
type CppMemberLookupSideChannel,
} from './member-lookup.js';
/**
* Plain JSON-serializable composite of every C++ capture-time side-channel
@ -62,6 +67,7 @@ export interface CppCaptureSideChannel {
readonly inlineNamespaceRanges: readonly string[];
readonly fileLocal: CppFileLocalSideChannel;
readonly twoPhase: CppTwoPhaseSideChannel;
readonly memberLookup: CppMemberLookupSideChannel;
}
/**
@ -74,6 +80,7 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
const inlineNamespaceRanges = collectCppInlineNamespaceSideChannel(filePath);
const fileLocal = collectCppFileLocalSideChannel(filePath);
const twoPhase = collectCppTwoPhaseSideChannel(filePath);
const memberLookup = collectCppMemberLookupSideChannel(filePath);
const isEmpty =
adl.argInfoBySite.length === 0 &&
@ -82,10 +89,12 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
fileLocal.fileLocalNames.length === 0 &&
fileLocal.anonymousNamespaceRanges.length === 0 &&
twoPhase.dependentBases.length === 0 &&
twoPhase.dependentPackBaseClasses.length === 0;
twoPhase.dependentPackBaseClasses.length === 0 &&
memberLookup.baseEdges.length === 0 &&
memberLookup.memberUsings.length === 0;
if (isEmpty) return undefined;
return { kind: 'cpp', adl, inlineNamespaceRanges, fileLocal, twoPhase };
return { kind: 'cpp', adl, inlineNamespaceRanges, fileLocal, twoPhase, memberLookup };
}
/**
@ -108,4 +117,7 @@ export function applyCppCaptureSideChannel(parsed: ParsedFile): void {
}
if (data.fileLocal !== undefined) applyCppFileLocalSideChannel(parsed.filePath, data.fileLocal);
if (data.twoPhase !== undefined) applyCppTwoPhaseSideChannel(parsed.filePath, data.twoPhase);
if (data.memberLookup !== undefined) {
applyCppMemberLookupSideChannel(parsed.filePath, data.memberLookup);
}
}

View file

@ -20,6 +20,7 @@ import { markCppDependentBase, markCppDependentPackBase } from './two-phase-look
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
import { extractCppTemplateConstraints } from './constraint-extractor.js';
import { captureCppMemberLookupFacts } from './member-lookup.js';
export function emitCppScopeCaptures(
sourceText: string,
@ -464,6 +465,7 @@ export function emitCppScopeCaptures(
// and the resolver can suppress unqualified-call binding to those
// bases per ISO C++ two-phase lookup.
detectCppDependentBases(tree.rootNode, filePath);
captureCppMemberLookupFacts(tree.rootNode, filePath);
return out;
}

View file

@ -73,6 +73,12 @@ function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMat
*/
export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
if (node.type !== 'using_declaration') return null;
// A class-scope `using Base::member;` changes the derived class's member
// lookup set; it is not a namespace import. The C++ member-lookup sidecar
// captures it separately, so suppress import decomposition here.
for (let parent = node.parent; parent !== null; parent = parent.parent) {
if (parent.type === 'class_specifier' || parent.type === 'struct_specifier') return null;
}
// Check for "namespace" keyword among anonymous children
let hasNamespaceKeyword = false;

View file

@ -0,0 +1,616 @@
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ReceiverMemberResolution } from '../../scope-resolution/contract/scope-resolver.js';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { cppConstraintCompatibility } from './constraint-filter.js';
import { cppConversionRank } from './conversion-rank.js';
interface CapturedBaseEdge {
readonly childName: string;
readonly childQualifiedName?: string;
readonly baseName: string;
readonly baseQualifiedName?: string;
readonly isVirtual: boolean;
}
interface CapturedMemberUsing {
readonly childName: string;
readonly childQualifiedName?: string;
readonly baseName: string;
readonly baseQualifiedName?: string;
readonly memberName: string;
}
export interface CppMemberLookupSideChannel {
readonly baseEdges: readonly CapturedBaseEdge[];
readonly memberUsings: readonly CapturedMemberUsing[];
}
const capturedByFile = new Map<string, CppMemberLookupSideChannel>();
let directParentsByDefId = new Map<string, readonly string[]>();
let virtualEdges = new Set<string>();
let ancestorsByDefId = new Map<string, ReadonlySet<string>>();
let memberUsingsByDefId = new Map<
string,
readonly { readonly baseDefId: string; readonly memberName: string }[]
>();
let inheritedLookupCache = new Map<string, CachedInheritedLookup>();
const MAX_INHERITANCE_VISITS = 4096;
type CachedInheritedLookup =
| { readonly kind: 'none' }
| { readonly kind: 'candidates'; readonly definitions: readonly SymbolDefinition[] }
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
export function clearCppMemberLookupState(): void {
capturedByFile.clear();
directParentsByDefId = new Map();
virtualEdges = new Set();
ancestorsByDefId = new Map();
memberUsingsByDefId = new Map();
inheritedLookupCache = new Map();
}
export function captureCppMemberLookupFacts(root: SyntaxNode, filePath: string): void {
const baseEdges: CapturedBaseEdge[] = [];
const memberUsings: CapturedMemberUsing[] = [];
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
const childName = classNameOf(node);
const childQualifiedName = classQualifiedNameOf(node);
if (childName !== '') {
const baseClause = directChildOfType(node, 'base_class_clause');
if (baseClause !== null) {
captureBaseEdges(baseClause, childName, childQualifiedName, baseEdges);
}
const body = directChildOfType(node, 'field_declaration_list');
if (body !== null) {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
if (child?.type !== 'using_declaration') continue;
const parsed = parseMemberUsing(child, childName, childQualifiedName);
if (parsed !== undefined) memberUsings.push(parsed);
}
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) stack.push(child);
}
}
if (baseEdges.length === 0 && memberUsings.length === 0) {
capturedByFile.delete(filePath);
} else {
capturedByFile.set(filePath, { baseEdges, memberUsings });
}
}
export function collectCppMemberLookupSideChannel(filePath: string): CppMemberLookupSideChannel {
return capturedByFile.get(filePath) ?? { baseEdges: [], memberUsings: [] };
}
export function applyCppMemberLookupSideChannel(
filePath: string,
data: CppMemberLookupSideChannel,
): void {
if (!Array.isArray(data.baseEdges) || !Array.isArray(data.memberUsings)) return;
if (data.baseEdges.length === 0 && data.memberUsings.length === 0) {
capturedByFile.delete(filePath);
return;
}
capturedByFile.set(filePath, {
baseEdges: data.baseEdges.slice(),
memberUsings: data.memberUsings.slice(),
});
}
export function buildCppMemberLookupMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
populateResolvedHierarchy(graph, parsedFiles, nodeLookup);
return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
}
export function resolveCppReceiverMember(
ownerDef: SymbolDefinition,
memberName: string,
callsite: ReferenceSite,
_scopes: ScopeResolutionIndexes,
model: SemanticModel,
): ReceiverMemberResolution | undefined {
if (callsite.kind !== 'call') return undefined;
const ownMethods = model.methods.lookupAllByOwner(ownerDef.nodeId, memberName);
const introduced = introducedDefinitions(ownerDef.nodeId, memberName, model);
if (introduced.length > 0) {
return chooseOverload(uniqueDefinitions([...ownMethods, ...introduced]), callsite);
}
// Direct declarations hide every base declaration. Let the shared path
// retain its existing overload/static filtering for this common case.
if (ownMethods.length > 0) return undefined;
const lookup = inheritedLookupSet(ownerDef.nodeId, memberName, model);
if (lookup.kind === 'none') return undefined;
if (lookup.kind === 'ambiguous') return lookup;
return chooseOverload(lookup.definitions, callsite);
}
interface MemberOccurrence {
readonly ownerDefId: string;
readonly definitions: readonly SymbolDefinition[];
readonly path: readonly string[];
readonly virtualAnchor?: string;
}
function collectInheritedOccurrences(
ownerDefId: string,
memberName: string,
model: SemanticModel,
path: readonly string[],
virtualAnchor: string | undefined,
active: Set<string>,
budget: { remaining: number; truncated: boolean },
): MemberOccurrence[] {
if (budget.remaining <= 0) {
budget.truncated = true;
return [];
}
budget.remaining--;
if (active.has(ownerDefId)) return [];
const nextActive = new Set(active);
nextActive.add(ownerDefId);
const definitions = uniqueDefinitions([
...model.methods.lookupAllByOwner(ownerDefId, memberName),
...introducedDefinitions(ownerDefId, memberName, model),
]);
if (definitions.length > 0) {
return [{ ownerDefId, definitions, path, virtualAnchor }];
}
const results: MemberOccurrence[] = [];
for (const parentDefId of directParentsByDefId.get(ownerDefId) ?? []) {
const edgeKey = `${ownerDefId}\0${parentDefId}`;
results.push(
...collectInheritedOccurrences(
parentDefId,
memberName,
model,
[...path, parentDefId],
virtualEdges.has(edgeKey) ? parentDefId : virtualAnchor,
nextActive,
budget,
),
);
}
return results;
}
function inheritedLookupSet(
ownerDefId: string,
memberName: string,
model: SemanticModel,
): CachedInheritedLookup {
const cacheKey = `${ownerDefId}\0${memberName}`;
const cached = inheritedLookupCache.get(cacheKey);
if (cached !== undefined) return cached;
const budget = { remaining: MAX_INHERITANCE_VISITS, truncated: false };
const occurrences = collectInheritedOccurrences(
ownerDefId,
memberName,
model,
[],
undefined,
new Set(),
budget,
);
if (budget.truncated) {
const conservative: CachedInheritedLookup = {
kind: 'ambiguous',
candidateIds: uniqueDefinitions(occurrences.flatMap((entry) => entry.definitions)).map(
(definition) => definition.nodeId,
),
};
inheritedLookupCache.set(cacheKey, conservative);
return conservative;
}
if (occurrences.length === 0) {
const none: CachedInheritedLookup = { kind: 'none' };
inheritedLookupCache.set(cacheKey, none);
return none;
}
// A declaration can dominate another lookup set only when the latter is
// reached through a shared virtual subobject. Ordinary ancestry alone is
// insufficient: declarations in one non-virtual branch do not hide members
// reached through a sibling base subobject.
const undominated = occurrences.filter(
(candidate) =>
!(
candidate.virtualAnchor !== undefined &&
occurrences.some(
(other) =>
other.ownerDefId !== candidate.ownerDefId &&
isAncestor(candidate.ownerDefId, other.ownerDefId),
)
),
);
const groups = new Map<string, MemberOccurrence[]>();
for (const occurrence of undominated) {
const key =
occurrence.virtualAnchor !== undefined
? `virtual:${occurrence.virtualAnchor}:${occurrence.ownerDefId}`
: `path:${occurrence.path.join('>')}:${occurrence.ownerDefId}`;
const bucket = groups.get(key);
if (bucket === undefined) groups.set(key, [occurrence]);
else bucket.push(occurrence);
}
let result: CachedInheritedLookup;
if (groups.size !== 1) {
result = {
kind: 'ambiguous',
candidateIds: uniqueDefinitions(undominated.flatMap((entry) => entry.definitions)).map(
(definition) => definition.nodeId,
),
};
} else {
result = {
kind: 'candidates',
definitions: groups.values().next().value?.[0]?.definitions ?? [],
};
}
inheritedLookupCache.set(cacheKey, result);
return result;
}
function introducedDefinitions(
ownerDefId: string,
memberName: string,
model: SemanticModel,
): SymbolDefinition[] {
const definitions: SymbolDefinition[] = [];
for (const entry of memberUsingsByDefId.get(ownerDefId) ?? []) {
if (entry.memberName !== memberName) continue;
definitions.push(...model.methods.lookupAllByOwner(entry.baseDefId, memberName));
}
return definitions;
}
function uniqueDefinitions(definitions: readonly SymbolDefinition[]): SymbolDefinition[] {
return [...new Map(definitions.map((definition) => [definition.nodeId, definition])).values()];
}
function chooseOverload(
candidates: readonly SymbolDefinition[],
callsite: ReferenceSite,
): ReceiverMemberResolution | undefined {
if (candidates.length === 0) return undefined;
const narrowed = narrowOverloadCandidates(candidates, callsite.arity, callsite.argumentTypes, {
argumentTypeClasses: callsite.argumentTypeClasses,
conversionRankFn: cppConversionRank,
constraintCompatibility: cppConstraintCompatibility,
});
if (narrowed.length === 1) return { kind: 'resolved', definition: narrowed[0]! };
if (narrowed.length > 1 || isOverloadAmbiguousAfterNormalization(narrowed, callsite.arity)) {
return {
kind: 'ambiguous',
candidateIds: narrowed.map((candidate) => candidate.nodeId),
};
}
return undefined;
}
function populateResolvedHierarchy(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): void {
const defByGraphId = new Map<string, SymbolDefinition>();
const defById = new Map<string, SymbolDefinition>();
const defsByFileAndName = new Map<string, SymbolDefinition[]>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId === undefined) continue;
defByGraphId.set(graphId, def);
defById.set(def.nodeId, def);
const names = new Set([simpleName(def), definitionQualifiedName(def)]);
for (const name of names) {
if (name === '') continue;
const key = `${parsed.filePath}\0${name}`;
const bucket = defsByFileAndName.get(key);
if (bucket === undefined) defsByFileAndName.set(key, [def]);
else bucket.push(def);
}
}
}
const parents = new Map<string, string[]>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
const child = defByGraphId.get(rel.sourceId);
const parent = defByGraphId.get(rel.targetId);
if (child === undefined || parent === undefined) continue;
const bucket = parents.get(child.nodeId);
if (bucket === undefined) parents.set(child.nodeId, [parent.nodeId]);
else bucket.push(parent.nodeId);
}
directParentsByDefId = parents;
ancestorsByDefId = buildAncestorClosure(parents);
inheritedLookupCache = new Map();
const nextVirtualEdges = new Set<string>();
const nextUsings = new Map<
string,
{ readonly baseDefId: string; readonly memberName: string }[]
>();
for (const parsed of parsedFiles) {
const captured = capturedByFile.get(parsed.filePath);
if (captured === undefined) continue;
for (const edge of captured.baseEdges) {
if (!edge.isVirtual) continue;
for (const child of matchingChildren(
parsed.filePath,
edge.childName,
edge.childQualifiedName,
defsByFileAndName,
)) {
const parent = findCapturedParent(
parents.get(child.nodeId) ?? [],
edge.baseName,
edge.baseQualifiedName,
defById,
);
if (parent !== undefined) nextVirtualEdges.add(`${child.nodeId}\0${parent.nodeId}`);
}
}
for (const using of captured.memberUsings) {
const children = matchingChildren(
parsed.filePath,
using.childName,
using.childQualifiedName,
defsByFileAndName,
);
for (const child of children) {
const baseDef = findCapturedParent(
parents.get(child.nodeId) ?? [],
using.baseName,
using.baseQualifiedName,
defById,
);
if (baseDef === undefined) continue;
const bucket = nextUsings.get(child.nodeId);
const entry = { baseDefId: baseDef.nodeId, memberName: using.memberName };
if (bucket === undefined) nextUsings.set(child.nodeId, [entry]);
else bucket.push(entry);
}
}
}
virtualEdges = nextVirtualEdges;
memberUsingsByDefId = nextUsings;
}
function captureBaseEdges(
baseClause: SyntaxNode,
childName: string,
childQualifiedName: string,
output: CapturedBaseEdge[],
): void {
let segmentStart = 0;
for (let i = 0; i < baseClause.childCount; i++) {
const child = baseClause.child(i);
if (child === null) continue;
if (child.type === ',' || child.text === ',') {
segmentStart = i + 1;
continue;
}
if (
child.type !== 'type_identifier' &&
child.type !== 'template_type' &&
child.type !== 'qualified_identifier'
) {
continue;
}
let isVirtual = false;
for (let j = segmentStart; j < i; j++) {
const modifier = baseClause.child(j);
if (modifier?.text === 'virtual') isVirtual = true;
}
const baseQualifiedName = qualifiedTypeName(child.text);
const baseName = baseQualifiedName.split('.').at(-1) ?? '';
if (baseName !== '') {
output.push({
childName,
...(childQualifiedName !== childName ? { childQualifiedName } : {}),
baseName,
...(baseQualifiedName !== baseName ? { baseQualifiedName } : {}),
isVirtual,
});
}
}
}
function parseMemberUsing(
node: SyntaxNode,
childName: string,
childQualifiedName: string,
): CapturedMemberUsing | undefined {
const qualified = node.namedChildren.find((child) => child.type === 'qualified_identifier');
if (qualified === undefined) return undefined;
const parts = splitQualifiedSegments(qualified.text);
if (parts.length < 2) return undefined;
const memberName = stripTemplateSuffix(parts.at(-1) ?? '');
const baseParts = parts.slice(0, -1).map(stripTemplateSuffix).filter(Boolean);
const baseName = baseParts.at(-1) ?? '';
const baseQualifiedName = baseParts.join('.');
if (baseName === '' || memberName === '') return undefined;
return {
childName,
...(childQualifiedName !== childName ? { childQualifiedName } : {}),
baseName,
...(baseQualifiedName !== baseName ? { baseQualifiedName } : {}),
memberName,
};
}
function classNameOf(node: SyntaxNode): string {
const name = node.childForFieldName?.('name');
return name === null || name === undefined ? '' : trailingIdentifier(name.text);
}
function classQualifiedNameOf(node: SyntaxNode): string {
const parts = [classNameOf(node)];
let current = node.parent;
while (current !== null) {
if (current.type === 'class_specifier' || current.type === 'struct_specifier') {
const name = classNameOf(current);
if (name !== '') parts.unshift(name);
} else if (current.type === 'namespace_definition') {
const name = current.childForFieldName?.('name');
if (name !== null && name !== undefined) {
parts.unshift(
...splitQualifiedSegments(name.text).map(stripTemplateSuffix).filter(Boolean),
);
}
}
current = current.parent;
}
return parts.filter(Boolean).join('.');
}
function directChildOfType(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === type) return child;
}
return null;
}
function trailingIdentifier(value: string): string {
return stripTemplateSuffix(splitQualifiedSegments(value).at(-1) ?? '');
}
function qualifiedTypeName(value: string): string {
return splitQualifiedSegments(value).map(stripTemplateSuffix).filter(Boolean).join('.');
}
function splitQualifiedSegments(value: string): string[] {
const parts: string[] = [];
let angleDepth = 0;
let segmentStart = 0;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === '<') angleDepth++;
else if (char === '>' && angleDepth > 0) angleDepth--;
else if (char === ':' && value[i + 1] === ':' && angleDepth === 0) {
const segment = value.slice(segmentStart, i).trim();
if (segment !== '') parts.push(segment);
segmentStart = i + 2;
i++;
}
}
const tail = value.slice(segmentStart).trim();
if (tail !== '') parts.push(tail);
return parts;
}
function stripTemplateSuffix(value: string): string {
const templateStart = value.indexOf('<');
return (templateStart >= 0 ? value.slice(0, templateStart) : value).trim();
}
function simpleName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').at(-1) ?? '';
}
function definitionQualifiedName(def: SymbolDefinition): string {
const name = def.qualifiedName ?? '';
if (name === '' || def.namespacePrefix === undefined || def.namespacePrefix === '') return name;
return name.startsWith(`${def.namespacePrefix}.`) ? name : `${def.namespacePrefix}.${name}`;
}
function matchingChildren(
filePath: string,
childName: string,
childQualifiedName: string | undefined,
defsByFileAndName: ReadonlyMap<string, readonly SymbolDefinition[]>,
): readonly SymbolDefinition[] {
if (childQualifiedName !== undefined) {
const qualified = defsByFileAndName.get(`${filePath}\0${childQualifiedName}`) ?? [];
if (qualified.length > 0) return qualified;
}
const simple = defsByFileAndName.get(`${filePath}\0${childName}`) ?? [];
return simple.length === 1 ? simple : [];
}
function findCapturedParent(
parentIds: readonly string[],
baseName: string,
baseQualifiedName: string | undefined,
defById: ReadonlyMap<string, SymbolDefinition>,
): SymbolDefinition | undefined {
const candidates = parentIds
.map((id) => defById.get(id))
.filter((definition): definition is SymbolDefinition => definition !== undefined);
if (baseQualifiedName !== undefined) {
const qualified = candidates.filter((definition) => {
const name = definitionQualifiedName(definition);
return name === baseQualifiedName || name.endsWith(`.${baseQualifiedName}`);
});
if (qualified.length === 1) return qualified[0];
return undefined;
}
const simple = candidates.filter((definition) => simpleName(definition) === baseName);
return simple.length === 1 ? simple[0] : undefined;
}
function buildAncestorClosure(
parents: ReadonlyMap<string, readonly string[]>,
): Map<string, ReadonlySet<string>> {
const closure = new Map<string, ReadonlySet<string>>();
const visiting = new Set<string>();
const ancestorsOf = (defId: string): ReadonlySet<string> => {
const cached = closure.get(defId);
if (cached !== undefined) return cached;
if (visiting.has(defId)) return new Set();
visiting.add(defId);
const ancestors = new Set<string>();
for (const parent of parents.get(defId) ?? []) {
ancestors.add(parent);
for (const ancestor of ancestorsOf(parent)) ancestors.add(ancestor);
}
visiting.delete(defId);
closure.set(defId, ancestors);
return ancestors;
};
for (const defId of parents.keys()) ancestorsOf(defId);
return closure;
}
function isAncestor(ancestorDefId: string, descendantDefId: string): boolean {
return ancestorsByDefId.get(descendantDefId)?.has(ancestorDefId) === true;
}

View file

@ -4,7 +4,6 @@ import {
findEnclosingClassDef,
} from '../../scope-resolution/scope/walkers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
populateClassOwnedMembers,
tagNamespacePrefixes,
@ -42,6 +41,11 @@ import {
clearCppUserDefinedConversions,
populateCppUserDefinedConversions,
} from './user-defined-conversions.js';
import {
buildCppMemberLookupMro,
clearCppMemberLookupState,
resolveCppReceiverMember,
} from './member-lookup.js';
/**
* Per-pass memo of the augmented `#include`-resolution file set
@ -104,6 +108,7 @@ export const cppScopeResolver: ScopeResolver = {
clearCppAdlState();
clearCppInlineNamespaces();
clearCppUserDefinedConversions();
clearCppMemberLookupState();
return scanCppHeaderFiles(repoPath);
},
@ -137,8 +142,7 @@ export const cppScopeResolver: ScopeResolver = {
// `'unknown'` keeps the candidate, preserving "degrade not lie".
constraintCompatibility: cppConstraintCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
buildMro: buildCppMemberLookupMro,
// Worker-boundary restore (see `ScopeResolver.applyCaptureSideChannel`).
// `emitCppScopeCaptures` records per-file ADL call-site arg shapes
@ -261,6 +265,7 @@ export const cppScopeResolver: ScopeResolver = {
hoistTypeBindingsToModule: true,
// Enable receiver-bound explicit-`this` fallback only for C++.
resolveThisViaEnclosingClass: true,
resolveReceiverMember: resolveCppReceiverMember,
// The `isFileLocalDef` hook on the global free-call fallback names
// file-local linkage historically, but semantically gates "logically
// invisible cross-file" defs. C++ extends this to also reject class-

View file

@ -23,7 +23,15 @@
*/
import Parser from 'tree-sitter';
import Dart from 'tree-sitter-dart';
import { SupportedLanguages } from 'gitnexus-shared';
// `tree-sitter-dart` is an optional/vendored grammar that may be absent on a
// default install. 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 Dart from 'tree-sitter-dart'` would throw ERR_MODULE_NOT_FOUND at
// module-load and crash `analyze` even for repos with no Dart files (#2091,
// #2093). The grammar is only ever needed inside the lazy getters below.
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
const DART_SCOPE_QUERY = `
; ── Scopes ───────────────────────────────────────────────────────────────────
@ -134,14 +142,19 @@ let _query: Parser.Query | null = null;
export function getDartParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Dart as Parameters<Parser['setLanguage']>[0]);
_parser.setLanguage(
getLanguageGrammar(SupportedLanguages.Dart) as Parameters<Parser['setLanguage']>[0],
);
}
return _parser;
}
export function getDartScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Dart as Parameters<Parser['setLanguage']>[0], DART_SCOPE_QUERY);
_query = new Parser.Query(
getLanguageGrammar(SupportedLanguages.Dart) as Parameters<Parser['setLanguage']>[0],
DART_SCOPE_QUERY,
);
}
return _query;
}

View file

@ -1,5 +1,13 @@
import Parser from 'tree-sitter';
import Kotlin from 'tree-sitter-kotlin';
import { SupportedLanguages } from 'gitnexus-shared';
// `tree-sitter-kotlin` is an optionalDependency that may be absent on a default
// install (or fail its native build). 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 Kotlin from 'tree-sitter-kotlin'` would throw
// ERR_MODULE_NOT_FOUND at module-load and crash `analyze` even for repos with no
// Kotlin files (#2091, #2093). The grammar is only ever needed in the getters.
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
const KOTLIN_SCOPE_QUERY = `
;; Scopes
@ -179,14 +187,19 @@ let query: Parser.Query | null = null;
export function getKotlinParser(): Parser {
if (parser === null) {
parser = new Parser();
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
parser.setLanguage(
getLanguageGrammar(SupportedLanguages.Kotlin) as Parameters<Parser['setLanguage']>[0],
);
}
return parser;
}
export function getKotlinScopeQuery(): Parser.Query {
if (query === null) {
query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_SCOPE_QUERY);
query = new Parser.Query(
getLanguageGrammar(SupportedLanguages.Kotlin) as Parameters<Parser['setLanguage']>[0],
KOTLIN_SCOPE_QUERY,
);
}
return query;
}

View file

@ -43,7 +43,15 @@
*/
import Parser from 'tree-sitter';
import Swift from 'tree-sitter-swift';
import { SupportedLanguages } from 'gitnexus-shared';
// `tree-sitter-swift` is an optional/vendored grammar that may be absent on a
// default 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 Swift from 'tree-sitter-swift'` would throw ERR_MODULE_NOT_FOUND at
// module-load and crash `analyze` even for repos with no Swift files (#2091,
// #2093). The grammar is only ever needed inside the lazy getters below.
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
const SWIFT_SCOPE_QUERY = `
;; ── Scopes ──────────────────────────────────────────────────────────
@ -186,14 +194,19 @@ let _query: Parser.Query | null = null;
export function getSwiftParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Swift as Parameters<Parser['setLanguage']>[0]);
_parser.setLanguage(
getLanguageGrammar(SupportedLanguages.Swift) as Parameters<Parser['setLanguage']>[0],
);
}
return _parser;
}
export function getSwiftScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Swift as Parameters<Parser['setLanguage']>[0], SWIFT_SCOPE_QUERY);
_query = new Parser.Query(
getLanguageGrammar(SupportedLanguages.Swift) as Parameters<Parser['setLanguage']>[0],
SWIFT_SCOPE_QUERY,
);
}
return _query;
}

View file

@ -182,6 +182,9 @@ const LABEL_BEHAVIOR = {
Section: 'inert',
Route: 'inert',
Tool: 'inert',
// Taint/PDG substrate (issue #2080) — a control-flow node, never a
// symbol-resolution target. Inert: file index only, no owner scope.
BasicBlock: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior> &
// Cross-invariant 1 — every class-like label (participates in
// qualifiedName fallback in `SymbolTable.add()`) MUST be classified as

View file

@ -30,3 +30,4 @@ export { processesPhase, type ProcessesOutput } from './processes.js';
export { runPipeline } from './runner.js';
export type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
export { getPhaseOutput } from './types.js';
export { PhaseRegistry, type RegisterPhaseOptions } from './registry.js';

View file

@ -43,9 +43,13 @@ import {
type ExportedTypeMap,
} from '../call-processor.js';
import { createSemanticModel, type MutableSemanticModel } from '../model/index.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import {
type PipelineProgress,
getLanguageFromFilename,
SupportedLanguages,
} from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
import { isLanguageAvailable, isGrammarRuntimeSkipped } from '../../tree-sitter/parser-loader.js';
import {
createWorkerPool,
workerPoolDisabledByEnv,
@ -274,9 +278,18 @@ export async function runChunkedParseAndResolve(
}
}
for (const [lang, count] of skippedByLang) {
logger.warn(
`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`,
);
// Distinguish a deliberate runtime opt-out from a genuinely-missing binding
// so we don't tell a user who set GITNEXUS_SKIP_OPTIONAL_GRAMMARS to
// `npm rebuild` a grammar that built fine (#2091/#2093 review).
if (isGrammarRuntimeSkipped(lang as SupportedLanguages)) {
logger.warn(
`Skipping ${count} ${lang} file(s) — ${lang} parsing disabled via GITNEXUS_SKIP_OPTIONAL_GRAMMARS.`,
);
} else {
logger.warn(
`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`,
);
}
}
// Sort parseableScanned alphabetically for stable chunk membership

View file

@ -0,0 +1,73 @@
/**
* Phase registry seam (issue #2080, taint/PDG substrate M0).
*
* A small, behaviour-preserving abstraction over phase-list *assembly*. Today
* `buildPhaseList` is a hand-maintained array with a single ad-hoc
* `if (!skipGraphPhases)` guard; this registry generalises that guard into a
* per-phase `enabledWhen` predicate so later milestones can register opt-in
* phases (e.g. CFG → M1 #2081) without editing the array each time.
*
* M0 wires the seam with **no behaviour change**: `build(options)` must return
* a phase list identical in membership and order to the legacy array for every
* options combination. The registry covers only list assembly — the runner,
* topological sort, `PipelinePhase.execute`, and any result-extraction guards
* (e.g. the `skipGraphPhases` check in `runPipelineFromRepo`) are untouched.
*
* Generic over the options type so this module depends only on `PipelinePhase`
* (no import of `PipelineOptions`, which lives in `pipeline.ts` and would
* otherwise create an import cycle).
*/
import type { PipelinePhase } from './types.js';
/** Options accepted when registering a phase. */
export interface RegisterPhaseOptions<TOptions> {
/**
* Predicate deciding whether this phase is included for a given options
* object. Absent ⇒ the phase is always enabled. This is the generalised
* form of the legacy `if (!skipGraphPhases)` guard.
*
* `options` is required, not optional: callers normalize an absent options
* object once at `build()` (e.g. `buildPhaseList` passes `options ?? {}`), so
* individual predicates read `(o) => !o.skipGraphPhases` without a defensive
* `?.` on every phase (#2080 review S1).
*/
readonly enabledWhen?: (options: TOptions) => boolean;
}
interface PhaseRegistration<TOptions> {
readonly phase: PipelinePhase;
readonly enabledWhen?: (options: TOptions) => boolean;
}
/**
* Ordered registry of pipeline phases. Not a global singleton — callers
* construct a fresh registry (so registration order is deterministic and there
* is no import-order or test-isolation hazard) and `build()` it per run.
*/
export class PhaseRegistry<TOptions = unknown> {
private readonly registrations: PhaseRegistration<TOptions>[] = [];
/**
* Register a phase. This is the `registerPhase(phase, { enabledWhen })` seam
* named in issue #2080. Returns `this` for fluent chaining. Registration
* order is preserved by `build()`.
*/
register(phase: PipelinePhase, options?: RegisterPhaseOptions<TOptions>): this {
this.registrations.push({ phase, enabledWhen: options?.enabledWhen });
return this;
}
/**
* Build the ordered phase list for the given options. A phase is included
* iff it has no `enabledWhen` predicate or its predicate returns `true`.
* Order matches registration order. `options` is required — callers that may
* have no options normalize once at the call site (`options ?? {}`) so the
* predicates never see `undefined`.
*/
build(options: TOptions): PipelinePhase[] {
return this.registrations
.filter((r) => r.enabledWhen === undefined || r.enabledWhen(options))
.map((r) => r.phase);
}
}

View file

@ -35,6 +35,7 @@ import {
mroPhase,
communitiesPhase,
processesPhase,
PhaseRegistry,
type ScopeResolutionOutput,
type PipelinePhase,
type CommunitiesOutput,
@ -142,28 +143,36 @@ export interface PipelineOptions {
* → mro → communities → processes
*
* To add a new phase: create a file in pipeline-phases/, export the phase
* object, and add it to the appropriate position in this array.
* object, and `.register()` it at the appropriate position below. Opt-in
* phases pass an `enabledWhen` predicate (issue #2080 phase-registry seam) —
* the legacy `if (!skipGraphPhases)` guard is now expressed that way on the
* three graph phases, with no change in behaviour.
*
* Exported for the parity test (`pipeline-phase-registry.test.ts`), which
* asserts the produced list is byte-identical to the legacy array for every
* options combination.
*/
function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
const phases: PipelinePhase[] = [
scanPhase,
structurePhase,
markdownPhase,
cobolPhase,
parsePhase,
routesPhase,
toolsPhase,
ormPhase,
crossFilePhase,
scopeResolutionPhase,
pruneLocalSymbolsPhase,
];
if (!options?.skipGraphPhases) {
phases.push(mroPhase, communitiesPhase, processesPhase);
}
return phases;
export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
return (
new PhaseRegistry<PipelineOptions>()
.register(scanPhase)
.register(structurePhase)
.register(markdownPhase)
.register(cobolPhase)
.register(parsePhase)
.register(routesPhase)
.register(toolsPhase)
.register(ormPhase)
.register(crossFilePhase)
.register(scopeResolutionPhase)
.register(pruneLocalSymbolsPhase)
.register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
// Normalize a missing options object once here so phase predicates above
// take a required PipelineOptions and need no `?.` guard (#2080 review S1).
.build(options ?? {})
);
}
// ── Pipeline orchestrator ─────────────────────────────────────────────────

View file

@ -267,6 +267,7 @@ import type {
Callsite,
ConstraintContext,
ParsedFile,
ReferenceSite,
ScopeId,
SupportedLanguages,
SymbolDefinition,
@ -291,6 +292,10 @@ export type LinearizeStrategy = (
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
export type ReceiverMemberResolution =
| { readonly kind: 'resolved'; readonly definition: SymbolDefinition }
| { readonly kind: 'ambiguous'; readonly candidateIds: readonly string[] };
/** Re-exported for ScopeResolver consumers — same shape as
* `RegistryProviders.constraintCompatibility`'s third parameter. */
export type { ConstraintContext } from 'gitnexus-shared';
@ -407,7 +412,7 @@ export interface ScopeResolver {
* for the Tier-A predicate registry and Kleene 3-valued evaluator.
*/
readonly constraintCompatibility?: (
callsite: Callsite,
callsite: ReferenceSite,
def: SymbolDefinition,
ctx: ConstraintContext,
) => ArityVerdict;
@ -834,6 +839,21 @@ export interface ScopeResolver {
callsite?: Callsite,
) => SymbolDefinition | 'ambiguous' | undefined;
/**
* Optional language-specific member-lattice lookup. Runs for a resolved
* simple receiver type before the generic flattened-MRO walk. Languages
* with lookup-set semantics that cannot be represented by one linear MRO
* may resolve a member, report ambiguity (which suppresses fallback), or
* return undefined to retain the shared behavior.
*/
readonly resolveReceiverMember?: (
ownerDef: SymbolDefinition,
memberName: string,
callsite: Callsite,
scopes: ScopeResolutionIndexes,
model: SemanticModel,
) => ReceiverMemberResolution | undefined;
/**
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
* receivers (`this->m()` / `this.m()`) that resolves against the

View file

@ -82,6 +82,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
| 'constraintCompatibility'
@ -375,6 +376,51 @@ export function emitReceiverBoundCalls(
if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
const languageResolution = provider.resolveReceiverMember?.(
enclosingClass,
memberName,
site,
scopes,
model,
);
if (languageResolution?.kind === 'ambiguous') {
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'member-lookup-ambiguous',
candidateIds: languageResolution.candidateIds,
});
handledSites.add(siteKey);
continue;
}
if (languageResolution?.kind === 'resolved') {
const memberDef = languageResolution.definition;
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
const chain = [
enclosingClass.nodeId,
...scopes.methodDispatch.mroFor(enclosingClass.nodeId),
@ -722,6 +768,51 @@ export function emitReceiverBoundCalls(
);
}
if (ownerDef !== undefined) {
const languageResolution = provider.resolveReceiverMember?.(
ownerDef,
memberName,
site,
scopes,
model,
);
if (languageResolution?.kind === 'ambiguous') {
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'member-lookup-ambiguous',
candidateIds: languageResolution.candidateIds,
});
handledSites.add(siteKey);
continue;
}
if (languageResolution?.kind === 'resolved') {
const memberDef = languageResolution.definition;
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;

View file

@ -31,6 +31,7 @@ import type { ParseOutput } from '../../pipeline-phases/parse.js';
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
import { readFileContents } from '../../filesystem-walker.js';
import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
import { isLanguageAvailable } from '../../../tree-sitter/parser-loader.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { SCOPE_RESOLVERS } from './registry.js';
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
@ -170,6 +171,15 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
for (const f of scannedFiles) {
const fileLang = getLanguageFromFilename(f.path);
if (fileLang === null) continue;
// Skip files whose grammar isn't available (optional grammars like
// swift/dart/kotlin on an install where the binding is absent or the
// user set GITNEXUS_SKIP_OPTIONAL_GRAMMARS). The parse phase already
// excluded and warned about these (parse-impl.ts); without this guard the
// file would fall through to the main-thread re-extract in run.ts and
// throw "Unsupported language" (caught, but noisy, and it needlessly
// loads the grammar on the main thread). `isLanguageAvailable` is
// memoized, so this stays O(1) per language. (#2091, #2093)
if (!isLanguageAvailable(fileLang)) continue;
let bucket = filesByLang.get(fileLang);
if (bucket === undefined) {
bucket = [];

View file

@ -4,6 +4,7 @@ export type ResolutionSuppressionReason =
| 'adl-ordinary-lookup-blocked'
| 'conversion-rank-tied'
| 'inline-ns-ambiguous'
| 'member-lookup-ambiguous'
| 'overload-ambiguous'
| 'overload-ambiguous-normalization';

View file

@ -0,0 +1,38 @@
/**
* Source/sink/sanitizer config model (issue #2080, taint/PDG substrate M0).
*
* The per-language taint configuration *shape*. M0 ships only the type and an
* (empty) registry seam — no analysis consumes it yet. M3 (#2083, intra-proc
* taint) populates per-language specs and reads them when emitting TAINTED /
* SANITIZES edges.
*
* Kept deliberately minimal: enough for M3 to express "callable X is a
* source / sink / sanitizer, optionally for argument position N" without M0
* committing to matcher semantics it cannot yet validate. The shape is
* expected to grow (e.g. sanitizer escape conditions, return-position taint)
* when M3 makes contact with real flows; that is a forward-declared-interface
* design choice, not a finished contract.
*/
/**
* Identifies a callable that participates in taint flow. `name` is matched
* against a resolved callable (simple or qualified name — exact matching
* semantics are M3's call). `args` optionally narrows to specific 0-based
* argument positions that carry taint (for a source/sink) or clear it (for a
* sanitizer); omit to mean "unspecified / all".
*/
export interface TaintCallableMatcher {
readonly name: string;
readonly args?: readonly number[];
}
/**
* The taint configuration for a single language: which callables introduce
* taint (sources), which are dangerous to reach with tainted input (sinks),
* and which clear taint (sanitizers).
*/
export interface SourceSinkSanitizerSpec {
readonly sources: readonly TaintCallableMatcher[];
readonly sinks: readonly TaintCallableMatcher[];
readonly sanitizers: readonly TaintCallableMatcher[];
}

View file

@ -0,0 +1,42 @@
/**
* Per-language source/sink/sanitizer registry seam (issue #2080).
*
* A keyed registry of {@link SourceSinkSanitizerSpec} by language id. M0 stands
* up the empty seam — no language is registered and nothing in the pipeline
* reads it. M3 (#2083) registers per-language specs and queries this registry
* when emitting taint edges.
*
* The store is module-level (matching the codebase's other per-language
* registries). {@link clearSourceSinkRegistry} resets it for test isolation.
*/
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
const registry = new Map<string, SourceSinkSanitizerSpec>();
/**
* Register the taint config for a language. Last-write-wins: re-registering
* the same `languageId` overwrites the previous spec (so M3 can override a
* built-in default). Returns nothing.
*/
export function registerSourceSinkConfig(languageId: string, spec: SourceSinkSanitizerSpec): void {
registry.set(languageId, spec);
}
/**
* Look up the taint config for a language. Returns `undefined` when no spec is
* registered (the M0 default for every language) — never throws.
*/
export function getSourceSinkConfig(languageId: string): SourceSinkSanitizerSpec | undefined {
return registry.get(languageId);
}
/** Language ids that currently have a registered spec. Empty in M0. */
export function registeredTaintLanguages(): string[] {
return [...registry.keys()];
}
/** Reset the registry. Primarily for test isolation. */
export function clearSourceSinkRegistry(): void {
registry.clear();
}

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';
@ -36,6 +35,19 @@ import type {
/** Language grammar type accepted by Parser.setLanguage(). */
type TreeSitterLanguage = Parameters<typeof Parser.prototype.setLanguage>[0];
// ── Worker grammar loading — enforcement boundary (#2091/#2093, #2101) ───────
// The worker maintains its own grammar table (the guarded `_require`s below +
// `languageMap`) and intentionally does NOT consult the runtime
// `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out. It does not need to: the MAIN
// THREAD's `parseableScanned` filter (pipeline-phases/parse-impl.ts, gated on
// `parser-loader.isLanguageAvailable`, which honors the runtime opt-out and a
// genuinely-absent binding alike) excludes files of an unavailable/opted-out
// language BEFORE any chunk is dispatched, so the worker never receives them.
// That main-thread filter is the single enforcement point. Any future change
// that dispatches files to the worker WITHOUT first passing them through
// `isLanguageAvailable` must re-introduce the gate here. (The cleaner end-state
// — routing this table through `parser-loader.getLanguageGrammar` so there is
// one loader — is the deferred Tier-1 consolidation.)
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: TreeSitterLanguage | null = null;
@ -54,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,
@ -391,7 +413,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

@ -305,6 +305,13 @@ export const streamAllCSVsToDisk = async (
'id,name,filePath,description',
);
// BasicBlock nodes — taint/PDG substrate (issue #2080). No `name` column;
// blocks are identified by id + source span. Emitted by no phase yet.
const basicBlockWriter = new BufferedCSVWriter(
path.join(csvDir, 'basicblock.csv'),
'id,filePath,startLine,endLine,text',
);
// Multi-language node types share the same CSV shape (no isExported column)
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
const MULTI_LANG_TYPES = [
@ -478,6 +485,17 @@ export const streamAllCSVsToDisk = async (
].join(','),
);
break;
case 'BasicBlock':
await basicBlockWriter.addRow(
[
escapeCSVField(node.id),
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
escapeCSVField(node.properties.text || ''),
].join(','),
);
break;
default: {
// Code element nodes (Function, Class, Interface, CodeElement)
const writer = codeWriterMap[node.label];
@ -535,6 +553,7 @@ export const streamAllCSVsToDisk = async (
sectionWriter,
routeWriter,
toolWriter,
basicBlockWriter,
...multiLangWriters.values(),
];
await Promise.all(allWriters.map((w) => w.finish()));
@ -571,6 +590,7 @@ export const streamAllCSVsToDisk = async (
['Section' as NodeTableName, sectionWriter],
['Route' as NodeTableName, routeWriter],
['Tool' as NodeTableName, toolWriter],
['BasicBlock' as NodeTableName, basicBlockWriter],
...Array.from(multiLangWriters.entries()).map(
([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter],
),

View file

@ -1124,6 +1124,10 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => {
if (table === 'Tool') {
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'BasicBlock') {
// Taint/PDG substrate (issue #2080) — no name column.
return `COPY ${t}(id, filePath, startLine, endLine, text) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Method') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
@ -1176,6 +1180,9 @@ export const insertNodeToLbug = async (
? `, description: ${escapeValue(properties.description)}`
: '';
query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`;
} else if (label === 'BasicBlock') {
// Taint/PDG substrate (issue #2080) — no name column.
query = `CREATE (n:BasicBlock {id: ${escapeValue(properties.id)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, text: ${escapeValue(properties.text || '')}})`;
} else if (TABLES_WITH_EXPORTED.has(label)) {
const descPart = properties.description
? `, description: ${escapeValue(properties.description)}`
@ -1259,6 +1266,9 @@ export const batchInsertNodesToLbug = async (
? `, n.description = ${escapeValue(properties.description)}`
: '';
query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
} else if (label === 'BasicBlock') {
// Taint/PDG substrate (issue #2080) — no name column.
query = `MERGE (n:BasicBlock {id: ${escapeValue(properties.id)}}) SET n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.text = ${escapeValue(properties.text || '')}`;
} else if (TABLES_WITH_EXPORTED.has(label)) {
const descPart = properties.description
? `, n.description = ${escapeValue(properties.description)}`

View file

@ -103,6 +103,19 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
// Behavior-neutral RSS tracing for the FTS evict→reload memory repro
// (gitnexus/scripts/bench/fts-evict-reload-rss.mjs). Two invariants keep it safe
// in the pool init/close hot path: it writes ONLY to stderr (stdout is the MCP
// JSON-RPC channel), and the GITNEXUS_POOL_RSS_TRACE gate makes it a no-op — one
// env-var compare per call, nothing else — unless a harness explicitly enables it.
function traceRss(event: 'init' | 'close', repoId: string): void {
if (process.env.GITNEXUS_POOL_RSS_TRACE !== '1') return;
const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
process.stderr.write(
`[pool-rss] ${event} repo=${repoId} pool=${pool.size} dbCache=${dbCache.size} rssMB=${rssMb}\n`,
);
}
let idleTimer: ReturnType<typeof setInterval> | null = null;
// Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
@ -240,6 +253,8 @@ function closeOne(repoId: string): void {
// Isolate listener failures — teardown must complete.
}
}
traceRss('close', repoId);
}
/**
@ -611,6 +626,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
closed: false,
});
ensureIdleTimer();
traceRss('init', repoId);
}
/**
@ -673,6 +689,7 @@ export async function initLbugWithDb(
closed: false,
});
ensureIdleTimer();
traceRss('init', repoId);
}
/**

View file

@ -221,6 +221,23 @@ CREATE NODE TABLE Section (
PRIMARY KEY (id)
)`;
// Taint/PDG substrate (issue #2080) — intra-procedural control-flow node.
// Emitted by no phase yet; M1 (#2081) populates these behind an opt-in.
// REACHING_DEF carries its variable name in the relation's existing `reason`
// column (see RELATION_SCHEMA) — LadybugDB has no secondary index on rel
// properties, so a dedicated indexed column would buy nothing for the
// variable-filtered path query (M0/S1 verdict). No `name` column: blocks are
// identified by id + source span, not a symbol name.
export const BASICBLOCK_SCHEMA = `
CREATE NODE TABLE BasicBlock (
id STRING,
filePath STRING,
startLine INT64,
endLine INT64,
text STRING,
PRIMARY KEY (id)
)`;
// ============================================================================
// RELATION TABLE SCHEMA
// Single table with 'type' property - connects all node tables
@ -431,6 +448,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM CodeElement TO Process,
FROM Route TO Process,
FROM Tool TO Process,
FROM BasicBlock TO BasicBlock,
type STRING,
confidence DOUBLE,
reason STRING,
@ -521,6 +539,11 @@ export const NODE_SCHEMA_QUERIES = [
ROUTE_SCHEMA,
// MCP tools
TOOL_SCHEMA,
// Taint/PDG substrate (issue #2080) — must be appended here, not just
// declared above: SCHEMA_QUERIES (the list initLbug actually runs) is built
// from NODE_SCHEMA_QUERIES. Omitting this leaves the BasicBlock table
// uncreated and the bulk-COPY round-trip fails with "table does not exist".
BASICBLOCK_SCHEMA,
];
export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA];

View file

@ -39,6 +39,15 @@ interface GrammarSource {
unavailableNote: string;
optional?: boolean;
severity?: 'warn' | 'error';
/**
* When true, this grammar may be disabled at runtime via
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS`. Set ONLY on genuinely-optional grammars
* (optionalDependencies / vendored — swift/dart/kotlin). Required dependencies
* routed through the optional machinery for ABI safety (e.g. C, which is
* `optional: true` + `severity: 'error'`) must NOT set this — opting out of a
* required parser is always an install/platform problem, never a user choice.
*/
userSkippable?: boolean;
}
const ISSUES_URL = 'https://github.com/abhigyanpatwari/GitNexus/issues';
@ -112,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.`,
},
@ -139,6 +149,7 @@ const SOURCES: Record<string, GrammarSource> = {
[SupportedLanguages.Swift]: {
load: () => _require('tree-sitter-swift'),
optional: true,
userSkippable: true,
unavailableNote:
'Swift parsing disabled: vendored `tree-sitter-swift` (under ' +
'`gitnexus/vendor/tree-sitter-swift`) failed to load. ' +
@ -148,6 +159,7 @@ const SOURCES: Record<string, GrammarSource> = {
[SupportedLanguages.Dart]: {
load: () => _require('tree-sitter-dart'),
optional: true,
userSkippable: true,
unavailableNote:
'Dart parsing disabled: vendored `tree-sitter-dart` (under ' +
'`gitnexus/vendor/tree-sitter-dart`) failed to load. ' +
@ -157,9 +169,12 @@ const SOURCES: Record<string, GrammarSource> = {
[SupportedLanguages.Kotlin]: {
load: () => _require('tree-sitter-kotlin'),
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.`,
},
};
@ -189,6 +204,63 @@ type LoadResult =
const loadCache = new Map<string, LoadResult>();
const logged = new Set<string>();
/**
* Runtime opt-out for genuinely-optional grammars (Swift/Dart/Kotlin).
*
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` has historically been an *install-time*
* env only — the postinstall build scripts read it to skip building the
* vendored grammars. There was no way to disable an optional grammar at
* analyze time, so users on a platform with a broken/partial binding had no
* escape hatch short of uninstalling the package (#2091, #2093). This honors
* the same env name at runtime: when set, the named optional grammars report
* as unavailable and the pipeline skips their files (mirroring a genuinely
* absent binding) instead of attempting to load them.
*
* Accepts `1` / `true` / `all` / `*` (every skippable grammar), or a
* comma-separated list of language ids and/or package names
* (e.g. `swift,tree-sitter-dart`). Only grammars flagged `userSkippable` (the
* genuinely-optional swift/dart/kotlin) can be skipped — required dependencies
* routed through the optional machinery for ABI safety (C) carry no
* `userSkippable` and are never skippable here.
*/
type SkipDirective = 'all' | Set<string> | null;
// Parsed form of GITNEXUS_SKIP_OPTIONAL_GRAMMARS, resolved lazily ONCE per
// process. The env is set before analyze runs, so re-reading + re-allocating a
// Set on every call was wasted work (and a latent trap for any future per-file
// caller). `vi.resetModules()` gives the unit tests a fresh module — and thus a
// fresh memo — per case, so this stays test-friendly.
// 'all' → every userSkippable grammar; Set → only the named ids
// (and `tree-sitter-<id>` spellings); null → env unset/empty (nothing).
let _skipDirective: SkipDirective | undefined;
const skipDirective = (): SkipDirective => {
if (_skipDirective !== undefined) return _skipDirective;
const raw = (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS ?? '').trim().toLowerCase();
if (raw === '') return (_skipDirective = null);
if (raw === '1' || raw === 'true' || raw === 'all' || raw === '*')
return (_skipDirective = 'all');
return (_skipDirective = new Set(
raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.flatMap((s) => [s, s.replace(/^tree-sitter-/, '')]),
));
};
const isRuntimeSkippedGrammar = (key: string, source: GrammarSource): boolean => {
// Only grammars explicitly flagged user-skippable (swift/dart/kotlin) — never
// required deps that use the optional machinery for ABI safety (C carries no
// `userSkippable`).
if (source.userSkippable !== true) return false;
const directive = skipDirective();
if (directive === null) return false;
if (directive === 'all') return true;
// `key` is the SupportedLanguages value (e.g. `swift`); the directive Set
// already holds both the bare id and the `tree-sitter-<id>` spelling.
return directive.has(key) || directive.has(`tree-sitter-${key}`);
};
const logFailure = (key: string, result: LoadResult): void => {
if (result.ok === true) return;
if (logged.has(key)) return;
@ -227,6 +299,25 @@ const loadGrammar = (key: string): LoadResult => {
return result;
}
// Runtime opt-out: treat a user-skipped optional grammar exactly like an
// absent binding (non-fatal unavailable + one warning), without attempting
// the native load. See `isRuntimeSkippedGrammar`.
if (isRuntimeSkippedGrammar(key, source)) {
// Deliberate opt-out: emit an accurate "disabled on purpose" note rather
// than `source.unavailableNote` (which blames a missing/unbuilt binding and
// would mislead a user who set the env intentionally — #2101 review).
const result: LoadResult = {
ok: false,
error: new Error('runtime opt-out'),
note: `${key} parsing disabled via GITNEXUS_SKIP_OPTIONAL_GRAMMARS (unset it to re-enable).`,
fatal: false,
severity: 'warn',
};
loadCache.set(key, result);
logFailure(key, result);
return result;
}
let result: LoadResult;
try {
result = { ok: true, grammar: source.load() };
@ -248,6 +339,22 @@ const loadGrammar = (key: string): LoadResult => {
export const isLanguageAvailable = (language: SupportedLanguages, filePath?: string): boolean =>
loadGrammar(resolveLanguageKey(language, filePath)).ok;
/**
* True when `language`'s grammar is being treated as unavailable specifically
* because of the runtime GITNEXUS_SKIP_OPTIONAL_GRAMMARS opt-out — as opposed
* to a genuinely-missing/broken native binding. Lets callers surface an
* accurate "skipped on purpose" message instead of a spurious "npm rebuild"
* recovery hint. Returns false for required grammars and for an absent env.
*/
export const isGrammarRuntimeSkipped = (
language: SupportedLanguages,
filePath?: string,
): boolean => {
const key = resolveLanguageKey(language, filePath);
const source = SOURCES[key];
return source !== undefined && isRuntimeSkippedGrammar(key, source);
};
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): unknown => {
const key = resolveLanguageKey(language, filePath);
const result = loadGrammar(key);

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';
@ -177,6 +178,21 @@ function logQueryError(context: string, err: unknown): void {
logger.error({ context, err: msg }, 'GitNexus query failed');
}
/**
* A "missing table/label/relation" prepare error is benign for the query tool's
* best-effort enrichment: a repo analyzed without processes or communities simply
* has no `Process`/`Community` tables, so the `STEP_IN_PROCESS` / `MEMBER_OF`
* enrichment queries fail to prepare. That is a normal configuration, NOT a
* degraded result — it must not raise the `partial` flag (which callers would
* then learn to ignore). Real failures (timeouts, locks, native faults) do.
*/
function isBenignMissingTableError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err ?? '');
return /does not exist|no such (table|label|rel)|unknown (table|label)|not (defined|found)/i.test(
msg,
);
}
const isReadOnlyDbError = (err: unknown): boolean => {
// Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
// pool adapter's `{ cause }` wrapper) is still detected here — this is the
@ -338,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();
@ -826,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()];
@ -891,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.
*
@ -952,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_')) {
@ -1112,6 +1250,112 @@ export class LocalBackend {
>();
const definitions: any[] = []; // standalone symbols not in any process
// Batch-fetch process participation, cohesion, and (optionally) content for
// ALL matched symbols in 2-3 graph queries instead of 2-3 *per symbol*. The
// previous per-symbol loop issued up to 3N sequential pool round-trips
// (searchLimit symbols × {STEP_IN_PROCESS, MEMBER_OF, content}); on a warm
// repo the IPC + query-setup overhead of those round-trips dominated query
// latency. Collapsing to `WHERE n.id IN $nodeIds` preserves identical output
// (the aggregation loop below is unchanged) while cutting the round-trips.
// Array params bind through the pool exactly as bm25Search's
// `WHERE n.id IN $nodeIds` already does. (Ported from gitnexus-enterprise
// PR #222 — N+1 → 2-3 batched queries.)
const nodeIds = merged.map(([, m]) => m.data?.nodeId).filter((id): id is string => !!id);
const processRowsByNode = new Map<string, any[]>();
const cohesionByNode = new Map<string, { cohesion: number; module?: string }>();
const contentByNode = new Map<string, string>();
// Set when a batched enrichment query throws a REAL failure (timeout, lock,
// native fault) — NOT the benign "no Process/Community table" case, which is
// a normal config (a repo analyzed without processes/communities) and must
// not raise a `partial` flag callers would learn to ignore. See
// isBenignMissingTableError + the response build below.
let enrichmentDegraded = false;
// Chunk the IN-list like the impact path (CHUNK_SIZE=100) so a large result
// set never builds an unbounded `IN` parameter. Default batch is
// processLimit*maxSymbolsPerProcess (≤ one chunk), but chunk for robustness.
const QUERY_CHUNK_SIZE = 100;
for (let i = 0; i < nodeIds.length; i += QUERY_CHUNK_SIZE) {
const ids = nodeIds.slice(i, i + QUERY_CHUNK_SIZE);
// Processes each symbol participates in. `n.id AS nodeId` is prepended as
// column 0 so rows from many symbols can be re-associated to their symbol.
try {
const rows = await executeParameterized(
repo.lbugPath,
`
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE n.id IN $nodeIds
RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`,
{ nodeIds: ids },
);
for (const row of rows) {
const nid = row.nodeId ?? row[0];
let list = processRowsByNode.get(nid);
if (!list) processRowsByNode.set(nid, (list = []));
list.push(row);
}
} catch (e) {
logQueryError('query:process-lookup', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
}
// Cluster membership + cohesion. Keep the FIRST community row per node to
// mirror the prior per-symbol `LIMIT 1` (each symbol keeps ITS community,
// not one community for the whole batch).
try {
const rows = await executeParameterized(
repo.lbugPath,
`
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE n.id IN $nodeIds
RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
`,
{ nodeIds: ids },
);
for (const row of rows) {
const nid = row.nodeId ?? row[0];
if (!cohesionByNode.has(nid)) {
cohesionByNode.set(nid, {
cohesion: (row.cohesion ?? row[1]) || 0,
module: row.module ?? row[2],
});
}
}
} catch (e) {
logQueryError('query:cluster-info', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
}
// Optionally fetch content for every matched symbol.
if (includeContent) {
try {
const rows = await executeParameterized(
repo.lbugPath,
`
MATCH (n)
WHERE n.id IN $nodeIds
RETURN n.id AS nodeId, n.content AS content
`,
{ nodeIds: ids },
);
for (const row of rows) {
const nid = row.nodeId ?? row[0];
contentByNode.set(nid, row.content ?? row[1]);
}
} catch (e) {
logQueryError('query:content-fetch', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
}
}
}
// Aggregation is unchanged from the per-symbol version — it now reads the
// pre-fetched maps instead of issuing a query per symbol. Iterating `merged`
// in the same (sorted) order preserves processMap insertion order, the
// definitions order, and the item.score association exactly.
for (const [_, item] of merged) {
const sym = item.data;
if (!sym.nodeId) {
@ -1124,61 +1368,11 @@ export class LocalBackend {
continue;
}
// Find processes this symbol participates in
let processRows: any[] = [];
try {
processRows = await executeParameterized(
repo.lbugPath,
`
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`,
{ nodeId: sym.nodeId },
);
} catch (e) {
logQueryError('query:process-lookup', e);
}
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
let cohesion = 0;
let module: string | undefined;
try {
const cohesionRows = await executeParameterized(
repo.lbugPath,
`
MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
LIMIT 1
`,
{ nodeId: sym.nodeId },
);
if (cohesionRows.length > 0) {
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
module = cohesionRows[0].module ?? cohesionRows[0][1];
}
} catch (e) {
logQueryError('query:cluster-info', e);
}
// Optionally fetch content
let content: string | undefined;
if (includeContent) {
try {
const contentRows = await executeParameterized(
repo.lbugPath,
`
MATCH (n {id: $nodeId})
RETURN n.content AS content
`,
{ nodeId: sym.nodeId },
);
if (contentRows.length > 0) {
content = contentRows[0].content ?? contentRows[0][0];
}
} catch (e) {
logQueryError('query:content-fetch', e);
}
}
const processRows = processRowsByNode.get(sym.nodeId) ?? [];
const coh = cohesionByNode.get(sym.nodeId);
const cohesion = coh?.cohesion ?? 0;
const module = coh?.module;
const content = includeContent ? contentByNode.get(sym.nodeId) : undefined;
const symbolEntry = {
id: sym.nodeId,
@ -1197,12 +1391,13 @@ export class LocalBackend {
} else {
// Add to each process it belongs to
for (const row of processRows) {
const pid = row.pid ?? row[0];
const label = row.label ?? row[1];
const hLabel = row.heuristicLabel ?? row[2];
const pType = row.processType ?? row[3];
const stepCount = row.stepCount ?? row[4];
const step = row.step ?? row[5];
// Positional fallbacks shift +1 because `n.id AS nodeId` is column 0.
const pid = row.pid ?? row[1];
const label = row.label ?? row[2];
const hLabel = row.heuristicLabel ?? row[3];
const pType = row.processType ?? row[4];
const stepCount = row.stepCount ?? row[5];
const step = row.step ?? row[6];
if (!processMap.has(pid)) {
processMap.set(pid, {
@ -1276,15 +1471,29 @@ export class LocalBackend {
const timing = timer.summary();
logQueryTiming(searchQuery, timing);
// Compose a single `warning` from all degraded conditions (FTS-missing
// and/or a real enrichment failure) so neither overwrites the other, and
// flag `partial` when enrichment was lost. Both are omitted on the clean
// path, leaving the success-path response shape byte-identical.
const warnings: string[] = [];
if (!ftsUsed) {
warnings.push(
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
);
}
if (enrichmentDegraded) {
warnings.push(
'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).',
);
}
return {
processes,
process_symbols: dedupedSymbols,
definitions: definitions.slice(0, 20), // cap standalone definitions
timing,
...(!ftsUsed && {
warning:
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
}),
...(warnings.length > 0 && { warning: warnings.join(' ') }),
...(enrichmentDegraded && { partial: true }),
};
}

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

@ -344,9 +344,17 @@ const GRAPH_RELATIONSHIP_QUERY =
const quoteNodeTable = (table: string): string => `\`${table.replace(/`/g, '``')}\``;
const getNodeQuery = (table: string, includeContent: boolean): string => {
export const getNodeQuery = (table: string, includeContent: boolean): string => {
const tableLabel = quoteNodeTable(table);
if (table === 'BasicBlock') {
// Taint/PDG substrate (issue #2080) — BasicBlock has no name/content
// columns. Project only its declared columns: a default `n.name`
// projection raises a Ladybug "Cannot find property name" binder error
// (not matched by isIgnorableGraphQueryError), which would 500 the graph
// endpoint the moment BasicBlock joins NODE_TABLES, even on an empty table.
return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.text AS text`;
}
if (table === 'File') {
return includeContent
? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
@ -376,10 +384,17 @@ const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): Grap
id: row.id ?? row[0],
label: table as GraphNode['label'],
properties: {
name: row.name ?? row.label ?? row[1],
// `?? ''` keeps NodeProperties.name a `string` even for label rows that
// project no name/label column (BasicBlock — taint/PDG substrate #2080).
// Without it, BasicBlock rows carry name:undefined (masked by the cast
// below) and the web layer (Header search, circles/tree layout) derefs
// `.name` unguarded → TypeError once M1 emits blocks. `row.text` gives a
// BasicBlock a sensible fallback name before the empty-string floor.
name: row.name ?? row.label ?? row.text ?? row[1] ?? '',
filePath: row.filePath ?? row[2],
startLine: row.startLine,
endLine: row.endLine,
text: row.text,
content: includeContent ? row.content : undefined,
responseKeys: row.responseKeys,
errorKeys: row.errorKeys,

View file

@ -0,0 +1,5 @@
#pragma once
struct CrossFileBase {
void crossFile();
};

View file

@ -0,0 +1,142 @@
#include "base.h"
struct Left {
void collide();
};
struct Right {
void collide();
};
struct Ambiguous : Left, Right {
void callThis();
};
void ambiguousCall() {
Ambiguous value;
value.collide();
}
void Ambiguous::callThis() {
this->collide();
}
struct Dominant : Left, Right {
void collide();
};
void dominantCall() {
Dominant value;
value.collide();
}
struct Root {
void shared();
};
struct VirtualLeft : virtual Root {};
struct VirtualRight : virtual Root {};
struct VirtualDiamond : VirtualLeft, VirtualRight {};
void virtualDiamondCall() {
VirtualDiamond value;
value.shared();
}
struct PlainLeft : Root {};
struct PlainRight : Root {};
struct PlainDiamond : PlainLeft, PlainRight {};
void plainDiamondCall() {
PlainDiamond value;
value.shared();
}
struct Base {
void select(int);
};
struct Derived : Base {
using Base::select;
void select(double);
};
void usingCall() {
Derived value;
value.select(1);
}
struct OverrideRoot {
void overrideMember();
};
struct OverrideLeft : OverrideRoot {
void overrideMember();
};
struct OverrideRight : OverrideRoot {};
struct OverrideDiamond : OverrideLeft, OverrideRight {};
void nonVirtualOverrideCall() {
OverrideDiamond value;
value.overrideMember();
}
struct UsingRoot {
void inheritedUsing(int);
};
struct UsingMiddle : UsingRoot {
using UsingRoot::inheritedUsing;
void inheritedUsing(double);
};
struct UsingLeaf : UsingMiddle {};
void inheritedUsingCall() {
UsingLeaf value;
value.inheritedUsing(1);
}
namespace alpha {
struct SameNameBase {
void qualified(int);
};
}
namespace beta {
struct SameNameBase {
void qualified(double);
};
}
struct QualifiedBases : alpha::SameNameBase, beta::SameNameBase {
using alpha::SameNameBase::qualified;
};
void qualifiedUsingCall() {
QualifiedBases value;
value.qualified(1);
}
template <typename T>
struct TemplatedOuter {
template <typename U>
struct NestedBase {
void nestedTemplate();
};
};
struct TemplatedDerived : TemplatedOuter<int>::NestedBase<double> {};
void nestedTemplateCall() {
TemplatedDerived value;
value.nestedTemplate();
}
struct CrossFileDerived : CrossFileBase {};
void crossFileCall() {
CrossFileDerived value;
value.crossFile();
}

View file

@ -35,6 +35,12 @@ export const LOCAL_BACKEND_SEED_DATA = [
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
// func:validate is the terminalId of proc:beta-flow too — wiring its second
// STEP_IN_PROCESS edge makes it a genuine MULTI-process symbol, which the
// batched-query test uses to exercise the full row[1..6] positional shift
// (a single-process symbol can't expose an off-by-one in those fallbacks).
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:beta-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 3}]->(p)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:alpha' AND t.id = 'Tool:alpha'
CREATE (h)-[:CodeRelation {type: 'HANDLES_TOOL', confidence: 1.0, reason: 'tool-definition', step: 0}]->(t)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:beta' AND t.id = 'Tool:beta'

View file

@ -0,0 +1,154 @@
/**
* Integration test: BasicBlock + taint/PDG edge types round-trip the
* bulk-COPY load path (issue #2080, U5 / R4 / AC2).
*
* Exercises the real csv-generator → loadGraphToLbug → COPY → query path:
* - a BasicBlock node (id/filePath/startLine/endLine/text) round-trips
* - one edge of each new type (CFG/REACHING_DEF/TAINTED/SANITIZES/TAINT_PATH)
* between two BasicBlocks round-trips (asserts the new FROM/TO DDL pair +
* REL_TYPES load through bulk COPY)
* - REACHING_DEF carries its `variable` in the existing `reason` column
* (M0/S1 storage decision) and a variable-filtered query returns it
* - the DDL (BASICBLOCK_SCHEMA wired into NODE_SCHEMA_QUERIES) loads on a
* fresh DB — if BASICBLOCK_SCHEMA were not in SCHEMA_QUERIES, initLbug would
* never create the table and these COPYs would fail (F1 guard, end-to-end)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { NODE_TABLES } from 'gitnexus-shared';
import { buildTestGraph } from '../helpers/test-graph.js';
import { getNodeQuery } from '../../src/server/api.js';
let tmpBase: string;
let storagePath: string;
let dbPath: string;
const BB1 = 'BasicBlock:src/a.ts:0';
const BB2 = 'BasicBlock:src/a.ts:1';
const NEW_EDGE_TYPES = ['CFG', 'REACHING_DEF', 'TAINTED', 'SANITIZES', 'TAINT_PATH'] as const;
beforeAll(async () => {
tmpBase = path.join(os.tmpdir(), `gitnexus-bb-roundtrip-${Date.now()}-${process.pid}`);
storagePath = path.join(tmpBase, '.gitnexus');
dbPath = path.join(storagePath, 'lbug');
await fs.mkdir(dbPath, { recursive: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
// Two BasicBlock nodes + one edge of each new type between them. The
// REACHING_DEF edge stores its variable name ('x') in `reason`.
const graph = buildTestGraph(
[
{
id: BB1,
label: 'BasicBlock',
name: '', // BasicBlock has no name column; ignored by the writer
filePath: 'src/a.ts',
startLine: 1,
endLine: 3,
extra: { text: 'const x = req.body;' },
},
{
id: BB2,
label: 'BasicBlock',
name: '',
filePath: 'src/a.ts',
startLine: 4,
endLine: 6,
extra: { text: 'sink(x);' },
},
],
NEW_EDGE_TYPES.map((type) => ({
sourceId: BB1,
targetId: BB2,
type,
reason: type === 'REACHING_DEF' ? 'x' : `${type.toLowerCase()}-edge`,
})),
);
await adapter.loadGraphToLbug(graph, tmpBase, storagePath);
});
afterAll(async () => {
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.closeLbug();
} catch {
/* may not have opened */
}
if (tmpBase) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
await fs.rm(tmpBase, { recursive: true, force: true });
return;
} catch {
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
}
}
}
});
describe('BasicBlock + taint/PDG edge round-trip (#2080)', () => {
it('BasicBlock nodes round-trip with their source span and text', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
'MATCH (n:BasicBlock) RETURN n.id AS id, n.text AS text, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine ORDER BY n.id',
);
expect(rows).toHaveLength(2);
expect(rows[0].id).toBe(BB1);
expect(rows[0].text).toBe('const x = req.body;');
expect(rows[0].filePath).toBe('src/a.ts');
expect(Number(rows[0].startLine)).toBe(1);
expect(Number(rows[0].endLine)).toBe(3);
expect(rows[1].id).toBe(BB2);
expect(rows[1].text).toBe('sink(x);');
expect(rows[1].filePath).toBe('src/a.ts');
expect(Number(rows[1].endLine)).toBe(6);
});
// Regression guard: adding a node table whose columns differ from the
// default (BasicBlock has no name/content) must not break the server's
// graph read path. getNodeQuery is what /api/graph's buildGraph +
// streamGraphNdjson run per NODE_TABLE; a default `n.name` projection on
// BasicBlock raises a non-ignorable Ladybug binder error → HTTP 500 on
// every analyzed repo. Assert every NODE_TABLE's query binds + runs, and
// that BasicBlock returns its loaded rows.
it('getNodeQuery binds + runs for every NODE_TABLE against the real schema', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
for (const table of NODE_TABLES) {
for (const includeContent of [false, true]) {
const q = getNodeQuery(table, includeContent);
await expect(
adapter.executeQuery(q),
`getNodeQuery(${table}, includeContent=${includeContent}) should bind`,
).resolves.toBeDefined();
}
}
const bbRows = await adapter.executeQuery(getNodeQuery('BasicBlock', false));
expect(bbRows).toHaveLength(2);
});
it('each new edge type round-trips between the two BasicBlocks', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// All edges live in the single CodeRelation table, keyed by `type`.
for (const type of NEW_EDGE_TYPES) {
const rows = await adapter.executeQuery(
`MATCH (:BasicBlock)-[r:CodeRelation {type: '${type}'}]->(:BasicBlock) RETURN count(r) AS c`,
);
expect(Number(rows[0].c), `${type} edge should round-trip`).toBe(1);
}
});
it('REACHING_DEF carries its variable in reason and is queryable by it', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
"MATCH (a:BasicBlock)-[r:CodeRelation {type: 'REACHING_DEF', reason: 'x'}]->(b:BasicBlock) RETURN a.id AS from, b.id AS to",
);
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe(BB1);
expect(rows[0].to).toBe(BB2);
});
});

View file

@ -111,6 +111,78 @@ withTestLbugDB(
// At least one of the search phases must have fired for any
// non-error response — bm25 and/or vector always runs.
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
// Success path (FTS present + Process/Community tables exist): no degraded
// signal. Guards R6 — the response shape stays byte-identical when nothing
// fails (the `warning`/`partial` fields appear only on degradation).
expect(result).not.toHaveProperty('warning');
expect(result).not.toHaveProperty('partial');
});
// PR #222 port: the query tool batches per-symbol process/cohesion/content
// lookups (N+1 → 2-3 `WHERE n.id IN $nodeIds` queries). These assertions
// guard the batch-adaptation hazards that a naive cherry-pick would break:
// (1) each symbol keeps ITS OWN community (the per-node first-row pick that
// replaced the per-symbol `LIMIT 1`), and (2) content maps to the right
// node — both depend on the +1 positional-index shift after prepending
// `n.id AS nodeId`. func:login is MEMBER_OF comm:auth ("Authentication");
// func:validate has no community, so it must NOT inherit login's.
it('query batches per-symbol enrichment without cross-assigning community/content', async () => {
const findSym = (res: any, id: string) =>
(res.process_symbols ?? []).find((s: any) => s.id === id) ??
(res.definitions ?? []).find((s: any) => s.id === id);
const loginRes = await backend.callTool('query', {
query: 'login',
include_content: true,
});
expect(loginRes).not.toHaveProperty('error');
const login = findSym(loginRes, 'func:login');
expect(login).toBeDefined();
// Community correctly associated to its own node (not dropped, not leaked).
expect(login.module).toBe('Authentication');
// Content correctly mapped to its own node (positional [1] after nodeId).
expect(login.content).toBe('function login() {}');
const validateRes = await backend.callTool('query', {
query: 'validate',
include_content: true,
});
expect(validateRes).not.toHaveProperty('error');
const validate = findSym(validateRes, 'func:validate');
expect(validate).toBeDefined();
// validate has no MEMBER_OF edge — a flat batched `LIMIT 1` would have
// leaked some other node's community onto it. It must have none.
expect(validate.module).toBeUndefined();
expect(validate.content).toBe('function validate() {}');
});
// PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
// +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
// single process row, `row.pid ?? row[1]` succeeds whether the shift is
// right or wrong. func:validate is a step in BOTH proc:login-flow (step 2)
// and proc:beta-flow (step 3), so both rows for the one node must be parsed
// (pid=row[1], step=row[6]); an off-by-one would drop a process or mis-pair
// pid↔step. Also pins process ranking (totalScore via the regroup-by-nodeId).
it('query batches a multi-process symbol and ranks processes (positional shift across rows)', async () => {
const res = await backend.callTool('query', { query: 'validate' });
expect(res).not.toHaveProperty('error');
const processIds = (res.processes ?? []).map((p: any) => p.id);
// Both of validate's processes must appear — both STEP_IN_PROCESS rows
// were parsed and grouped by the correct pid (row[1]).
expect(processIds).toContain('proc:login-flow');
expect(processIds).toContain('proc:beta-flow');
// process_symbols dedups by id, so validate appears once carrying the
// pid+step of its top-ranked process — they must come from the SAME
// shifted row: login-flow⇒step 2, beta-flow⇒step 3.
const v = (res.process_symbols ?? []).find((s: any) => s.id === 'func:validate');
expect(v).toBeDefined();
expect(v.step_index).toBe(v.process_id === 'proc:beta-flow' ? 3 : 2);
// Ranking: 'login' surfaces proc:login-flow as the top process.
const loginRes = await backend.callTool('query', { query: 'login' });
expect((loginRes.processes ?? [])[0]?.id).toBe('proc:login-flow');
});
it('tool_map returns per-tool flows without cross-attributing same-file tools', async () => {

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

@ -0,0 +1,135 @@
/**
* Optional-grammar static-import-closure regression test (#2091, #2093).
*
* 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 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 `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. (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`
* (the crash-chain root), and assert no OPTIONAL tree-sitter binding
* (swift/dart/kotlin) appears in the module cache. Pre-fix the static imports
* loaded those bindings at import time (this assertion fails); post-fix they
* are lazy (it passes). Required grammars (python/typescript/...) still load
* eagerly via their own `query.ts` — that is expected and NOT asserted against.
*
* Characterization-first: this MUST fail against the pre-fix code (run against
* the parent commit to verify the regression signal works).
*/
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const DIST_REGISTRY = path.join(
REPO_ROOT,
'dist',
'core',
'ingestion',
'scope-resolution',
'pipeline',
'registry.js',
);
const DIST_REGISTRY_URL = pathToFileURL(DIST_REGISTRY).href;
// Import the registry, then report every newly-loaded CJS-cache key. The cache
// tracks native/.node bindings loaded by either ESM or CJS importers, which is
// exactly how a tree-sitter grammar binding surfaces.
const PROBE = `
import { createRequire } from 'node:module';
const req = createRequire(import.meta.url);
const before = new Set(Object.keys(req.cache));
await import(process.env.PROBE_TARGET);
const after = new Set(Object.keys(req.cache));
process.stdout.write(JSON.stringify([...after].filter((k) => !before.has(k))));
`;
// `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, #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\`, ` +
`which builds via pretest:integration).`,
);
}
const result = spawnSync(process.execPath, ['--input-type=module', '-e', PROBE], {
cwd: REPO_ROOT,
// NODE_OPTIONS cleared so a session-pinned --max-old-space-size etc. can't
// perturb the child. The skip env is cleared so install state is probed.
env: {
...process.env,
PROBE_TARGET: DIST_REGISTRY_URL,
NODE_OPTIONS: '',
GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '',
},
timeout: 60_000,
encoding: 'utf8',
});
// Post-fix, importing the registry must not throw even though the chain
// reaches swift/dart/kotlin query.ts. (Pre-fix on a machine missing a
// grammar this would be ERR_MODULE_NOT_FOUND; here the grammar is present
// so pre-fix it would instead surface as a loaded binding below.)
if (result.status !== 0) {
// status is null when the child was killed by a signal (e.g. a native
// addon SIGSEGV) — surface the signal so that's distinguishable from a
// non-zero exit / module-not-found.
const exit =
result.status !== null ? `status ${result.status}` : `signal ${result.signal ?? 'unknown'}`;
throw new Error(
`importing the scope-resolution registry failed (${exit}):\n` +
`stderr:\n${result.stderr}\nstdout:\n${result.stdout}`,
);
}
const newlyLoaded = JSON.parse(result.stdout) as string[];
// Non-vacuity guard: the registry's static-import closure MUST still reach
// the per-language query.ts modules (which is what makes "no optional
// binding loaded" meaningful). The REQUIRED grammars (python/typescript/…)
// still import their binding eagerly in their own query.ts, so at least one
// non-optional tree-sitter binding must appear. If a future refactor severs
// the registry→query.ts edge, this fails loudly instead of letting the
// optional-binding assertion pass green on a no-longer-exercised path.
const requiredLoaded = newlyLoaded.filter(
(p) => /tree-sitter-[a-z-]+[\\/]/.test(p) && !OPTIONAL_GRAMMAR_RE.test(p),
);
expect(
requiredLoaded.length,
`Expected the registry import closure to load at least one REQUIRED tree-sitter ` +
`binding (proving the chain still reaches the per-language query.ts modules). ` +
`Newly-loaded (${newlyLoaded.length}):\n${newlyLoaded.join('\n')}`,
).toBeGreaterThan(0);
// 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,
`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,110 @@
/**
* Pipeline-level regression for the optional-grammar exclusion (#2091, #2093).
*
* Locks the scope-resolution phase guard added alongside the lazy query.ts
* load: `scopeResolutionPhase` filters its `filesByLang` partition by
* `isLanguageAvailable`, so a file of an unavailable optional grammar never
* falls through to the main-thread re-extract in `run.ts` (which would throw
* "Unsupported language" — caught, but noisy, and it needlessly loads the
* grammar on the main thread).
*
* Drives the REAL pipeline over a mixed Python+Swift repo with the runtime
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out set (so Swift is treated as
* unavailable even though its binding is installed). This is the automated
* analog of the manual end-to-end verification: Python indexes, Swift is
* cleanly skipped, and the "scope extraction failed for …swift" noise never
* appears.
*
* `parser-loader` memoizes availability per process, so we `vi.resetModules()`
* BEFORE setting the env and dynamically import the pipeline + logger from the
* same fresh registry. That makes the first `isLanguageAvailable` call observe
* our env regardless of import order, and keeps the logger capture wired to the
* loader's logger instance (a static import would not survive resetModules).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { PipelineResult } from '../resolvers/helpers.js';
const ENV = 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS';
describe('optional-grammar pipeline exclusion (#2091/#2093)', () => {
let repoDir = '';
let result: PipelineResult;
let messages: string[] = [];
let prevEnv: string | undefined;
let getNodesByLabel: (r: PipelineResult, label: string) => string[];
beforeAll(async () => {
prevEnv = process.env[ENV];
vi.resetModules();
process.env[ENV] = 'swift';
const helpers = await import('../resolvers/helpers.js');
const loggerMod = await import('../../../src/core/logger.js');
getNodesByLabel = helpers.getNodesByLabel;
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'og-skip-pipeline-'));
fs.writeFileSync(
path.join(repoDir, 'app.py'),
'def greet(name):\n return f"hi {name}"\n\n\nclass Service:\n def run(self):\n return greet("world")\n',
);
fs.writeFileSync(
path.join(repoDir, 'Foo.swift'),
'struct Foo {\n func bar() -> Int { return 42 }\n}\n',
);
const cap = loggerMod._captureLogger();
try {
result = await helpers.runPipelineFromRepo(repoDir, () => {}, { skipGraphPhases: true });
messages = cap
.records()
.map((r) => (typeof r.msg === 'string' ? r.msg : ''))
.filter(Boolean);
} finally {
cap.restore();
}
}, 60_000);
afterAll(() => {
if (prevEnv === undefined) delete process.env[ENV];
else process.env[ENV] = prevEnv;
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('completes without crashing when an optional grammar is opted out', () => {
expect(result).toBeDefined();
});
it('skips the Swift file at the parse phase (non-vacuity: Swift was present)', () => {
expect(messages.some((m) => /Skipping 1 swift file\(s\)/.test(m))).toBe(true);
});
it('routes the opt-out message, not the missing-binding "npm rebuild" hint', () => {
// The "Skipping N swift file(s)" prefix is shared by BOTH the opt-out and
// the missing-binding branches — so assert the opt-out branch specifically:
// a message naming the env var, and NO "npm rebuild" hint anywhere. This is
// what proves the isGrammarRuntimeSkipped routing in parse-impl.ts fired.
expect(messages.some((m) => /GITNEXUS_SKIP_OPTIONAL_GRAMMARS/.test(m))).toBe(true);
expect(
messages.every((m) => !/npm rebuild/i.test(m)),
messages.join('\n'),
).toBe(true);
});
it('never falls through to the main-thread re-extract (no "scope extraction failed")', () => {
// This is the precise signal the scope-resolution phase guard eliminates.
// Without the `if (!isLanguageAvailable(fileLang)) continue;` in phase.ts
// the Swift file would reach run.ts's extractParsedFile and log this.
const offending = messages.filter((m) => /scope extraction failed/i.test(m));
expect(offending, offending.join('\n')).toEqual([]);
});
it('indexes the available Python language and excludes Swift symbols', () => {
// Python indexed (proves the pipeline actually ran end-to-end).
expect(getNodesByLabel(result, 'Class')).toContain('Service');
// Swift's struct must not be in the graph — it was excluded, not parsed.
expect(getNodesByLabel(result, 'Struct')).not.toContain('Foo');
});
});

View file

@ -1851,6 +1851,109 @@ describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-1
});
});
describe('C++ inheritance-lattice member lookup (#1891)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-member-lattice'), () => {});
}, 60000);
it('suppresses same-name members inherited from unrelated bases', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'ambiguousCall' && call.target === 'collide',
);
expect(calls).toHaveLength(0);
});
it('lets a derived declaration hide both base declarations', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'dominantCall' && call.target === 'collide',
);
expect(calls).toHaveLength(1);
expect(calls[0]?.targetFilePath).toBe('main.cpp');
});
it('merges a shared virtual base into one member subobject', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'virtualDiamondCall' && call.target === 'shared',
);
expect(calls).toHaveLength(1);
});
it('suppresses the same declaration reached through two non-virtual base subobjects', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'plainDiamondCall' && call.target === 'shared',
);
expect(calls).toHaveLength(0);
});
it('adds a member using-declaration to the derived overload set', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'usingCall' && call.target === 'select',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('records both conservative ambiguity suppressions', () => {
const outcomes = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'member-lookup-ambiguous',
);
const names = outcomes.map((outcome) => outcome.name);
expect(names).toContain('collide');
expect(names).toContain('overrideMember');
expect(names).toContain('shared');
});
it('keeps sibling non-virtual subobjects ambiguous when one branch overrides the member', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'nonVirtualOverrideCall' && call.target === 'overrideMember',
);
expect(calls).toHaveLength(0);
});
it('merges inherited using-declarations with methods declared by the same intermediate class', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'inheritedUsingCall' && call.target === 'inheritedUsing',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('uses qualified base identities when same-simple-name direct bases collide', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'qualifiedUsingCall' && call.target === 'qualified',
);
expect(calls).toHaveLength(1);
const target = result.graph.getNode(calls[0]!.rel.targetId);
expect(target?.properties.parameterTypes).toEqual(['int']);
});
it('normalizes every segment of a nested templated base name', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'nestedTemplateCall' && call.target === 'nestedTemplate',
);
expect(calls).toHaveLength(1);
});
it('applies lattice ambiguity suppression to explicit this receivers', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'callThis' && call.target === 'collide',
);
expect(calls).toHaveLength(0);
});
it('resolves inherited members across files', () => {
const calls = getRelationships(result, 'CALLS').filter(
(call) => call.source === 'crossFileCall' && call.target === 'crossFile',
);
expect(calls).toHaveLength(1);
expect(calls[0]?.targetFilePath).toBe('base.h');
});
});
// ---------------------------------------------------------------------------
// U1: `#include` must not leak class-owned methods as unqualified bindings
// ---------------------------------------------------------------------------

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

@ -232,4 +232,53 @@ describe('streamGraphNdjson', () => {
},
});
});
// Taint/PDG substrate (#2080): BasicBlock has no name/content columns, so its
// getNodeQuery projects none — mapGraphNodeRow must still yield a `string`
// name (NodeProperties.name contract) or the web layer derefs undefined.
it('emits a string name for BasicBlock nodes (no name column)', async () => {
lbugMocks.streamQuery.mockImplementation(
async (query: string, onRow: (row: any) => Promise<void>) => {
if (query.includes('MATCH (n:`BasicBlock`)')) {
expect(query).not.toContain('n.name'); // BasicBlock projects no name column
await onRow({
id: 'BasicBlock:src/a.ts:0',
filePath: 'src/a.ts',
startLine: 1,
endLine: 3,
text: 'const x = req.body;',
});
// a block with no text must still map to a string name, not undefined
await onRow({
id: 'BasicBlock:src/a.ts:1',
filePath: 'src/a.ts',
startLine: 4,
endLine: 4,
});
return 2;
}
return 0;
},
);
const writes: string[] = [];
const response = createMockResponse((chunk) => {
writes.push(chunk);
return true;
});
await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
const blocks = writes
.map((chunk) => JSON.parse(chunk))
.filter((r) => r.type === 'node' && r.data.label === 'BasicBlock');
expect(blocks).toHaveLength(2);
for (const b of blocks) {
expect(typeof b.data.properties.name).toBe('string'); // never undefined
}
// falls back to the block text when present, else the empty-string floor
expect(blocks[0].data.properties.name).toBe('const x = req.body;');
expect(blocks[0].data.properties.text).toBe('const x = req.body;');
expect(blocks[1].data.properties.name).toBe('');
});
});

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

@ -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

@ -1,5 +1,14 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, vi } from 'vitest';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
async function readRepoJson<T>(relativePath: string): Promise<T> {
return JSON.parse(await fs.readFile(path.join(REPO_ROOT, relativePath), 'utf8')) as T;
}
// Mock all the heavy imports before importing index
vi.mock('../../src/cli/analyze.js', () => ({
analyzeCommand: vi.fn(),
@ -20,6 +29,26 @@ describe('CLI commands', () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.version).toMatch(/^\d+\.\d+\.\d+/);
});
it('keeps Claude plugin manifests aligned with the gitnexus release version', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const pluginManifest = await readRepoJson<{ version: string }>(
'gitnexus-claude-plugin/.claude-plugin/plugin.json',
);
const marketplaceManifest = await readRepoJson<{
plugins?: Array<{ name: string; version: string }>;
}>('.claude-plugin/marketplace.json');
expect(Array.isArray(marketplaceManifest.plugins)).toBe(true);
const gitnexusEntries = (marketplaceManifest.plugins ?? []).filter(
(plugin) => plugin.name === 'gitnexus',
);
expect(gitnexusEntries).toHaveLength(1);
expect(pluginManifest.version).toBe(pkg.default.version);
expect(gitnexusEntries[0]?.version).toBe(pkg.default.version);
});
});
describe('package.json scripts', () => {
@ -55,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' },
@ -64,11 +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('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 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,76 @@
import { describe, it, expect } from 'vitest';
// Pure, side-effect-free classifier extracted from the FTS evict→reload bench.
// Importing it must NOT load @ladybugdb/core, build fixtures, or run the bench
// (the module has zero imports and zero module-scope side effects). If this
// import ever pulled in the bench's native-addon require, this test file would
// be slow / fail to load — so the import succeeding cheaply IS the no-side-effect
// guard (R3).
import { classifyVerdict, median, slopeMbPerCycle } from '../../scripts/bench/fts-rss-verdict.mjs';
// Build an exactly-linear series: start, start+slope, start+2·slope, …
const linear = (start: number, slope: number, n: number): number[] =>
Array.from({ length: n }, (_, i) => start + slope * i);
describe('classifyVerdict (FTS evict→reload bench)', () => {
it('pure helpers behave', () => {
expect(typeof classifyVerdict).toBe('function');
expect(median([3, 1, 2])).toBe(2);
// Least-squares slope of an exact linear series equals its step.
expect(slopeMbPerCycle(linear(100, 2, 10))).toBeCloseTo(2, 6);
});
it('truly flat run → PLATEAU (over-correction guard, R1)', () => {
// No leak, no warmup: first ≈ second ≈ 0. Must stay PLATEAU, NOT INCONCLUSIVE.
const series = [200, 200, 201, 200, 200, 201, 200, 200, 200, 201, 200, 200];
const r = classifyVerdict(series, 190);
expect(r.verdict).toBe('PLATEAU');
});
it('sustained sub-floor positive slope → INCONCLUSIVE (the headline fix, R1)', () => {
// ~0.4 MB/cycle, sustained (first ≈ second slope), below the absolute floor.
// The OLD logic labeled this PLATEAU ("no leak"); it must now be INCONCLUSIVE.
const series = linear(200, 0.4, 20); // peak ~207.6, baseline 190 → small WS → floor stays 0.5
const r = classifyVerdict(series, 190);
expect(r.secondHalfSlope).toBeGreaterThan(0.1); // above EPSILON
expect(r.secondHalfSlope).toBeLessThan(r.floor); // below the floor
expect(r.decelRatio).toBeGreaterThanOrEqual(0.6); // sustained, not decelerating
expect(r.verdict).toBe('INCONCLUSIVE');
});
it('decelerated-to-flat run → PLATEAU', () => {
// Climbs then flattens: tail slope ≈ 0 (below EPSILON).
const series = [200, 210, 218, 224, 228, 230, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231];
const r = classifyVerdict(series, 190);
expect(r.verdict).toBe('PLATEAU');
});
it('sustained linear above the floor → CLIMB', () => {
const series = linear(200, 3, 20); // 3 MB/cycle sustained
const r = classifyVerdict(series, 190);
expect(r.decelRatio).toBeGreaterThanOrEqual(0.6);
expect(r.secondHalfSlope).toBeGreaterThanOrEqual(r.floor);
expect(r.verdict).toBe('CLIMB');
});
it('step discontinuity → INCONCLUSIVE (existing guard preserved)', () => {
const series = [200, 201, 202, 203, 204, 205, 265, 266, 267, 268, 269, 270];
const r = classifyVerdict(series, 190);
expect(r.stepDiscontinuity).toBe(true);
expect(r.verdict).toBe('INCONCLUSIVE');
});
it('floor scales with working-set growth, not baseline RSS (R2)', () => {
// Identical 0.8 MB/cycle sustained tail, two different working sets.
const series = linear(600, 0.8, 12); // peak ~608.8
// Small working set (baseline near the series) → low floor → 0.8 clears it → CLIMB.
const small = classifyVerdict(series, 590); // peak-baseline ~18.8 → floor 0.5
expect(small.secondHalfSlope).toBeGreaterThanOrEqual(small.floor);
expect(small.verdict).toBe('CLIMB');
// Large working set (low baseline) → floor rises with arena size → 0.8 is now
// sub-floor → the sustained-but-small slope is unresolved, not a clean CLIMB.
const large = classifyVerdict(series, 0); // peak-baseline ~608 → floor ~1.5
expect(large.floor).toBeGreaterThan(small.floor);
expect(large.secondHalfSlope).toBeLessThan(large.floor);
expect(large.verdict).toBe('INCONCLUSIVE');
});
});

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,102 @@
import { describe, it, expect } from 'vitest';
import { PhaseRegistry } from '../../../src/core/ingestion/pipeline-phases/registry.js';
import type { PipelinePhase } from '../../../src/core/ingestion/pipeline-phases/types.js';
import { buildPhaseList } from '../../../src/core/ingestion/pipeline.js';
// ---------------------------------------------------------------------------
// PhaseRegistry — the issue #2080 phase-registry seam, tested in isolation
// with lightweight fake phases (no real pipeline dependencies).
// ---------------------------------------------------------------------------
const fakePhase = (name: string): PipelinePhase => ({
name,
deps: [],
execute: async () => ({}),
});
describe('PhaseRegistry', () => {
it('preserves registration order in build()', () => {
const list = new PhaseRegistry()
.register(fakePhase('a'))
.register(fakePhase('b'))
.register(fakePhase('c'))
.build({});
expect(list.map((p) => p.name)).toEqual(['a', 'b', 'c']);
});
it('includes a phase with no enabledWhen predicate unconditionally', () => {
const list = new PhaseRegistry<{ flag?: boolean }>()
.register(fakePhase('always'))
.build({ flag: true });
expect(list.map((p) => p.name)).toEqual(['always']);
});
it('excludes a phase whose enabledWhen returns false', () => {
const reg = new PhaseRegistry<{ skip?: boolean }>()
.register(fakePhase('core'))
.register(fakePhase('optional'), { enabledWhen: (o) => !o.skip });
expect(reg.build({ skip: true }).map((p) => p.name)).toEqual(['core']);
expect(reg.build({ skip: false }).map((p) => p.name)).toEqual(['core', 'optional']);
// empty (normalized) options → predicate sees a real object, phase enabled
expect(reg.build({}).map((p) => p.name)).toEqual(['core', 'optional']);
});
it('enabledWhen filtering does not reorder surviving phases', () => {
const list = new PhaseRegistry<{ drop?: boolean }>()
.register(fakePhase('first'))
.register(fakePhase('gated'), { enabledWhen: (o) => !o.drop })
.register(fakePhase('last'))
.build({ drop: true });
expect(list.map((p) => p.name)).toEqual(['first', 'last']);
});
});
// ---------------------------------------------------------------------------
// buildPhaseList parity — the registry refactor must produce a phase list
// byte-identical (names + order) to the legacy hand-maintained array for
// every options combination. This is the U6 characterization gate (R7/R8).
//
// Note: the second `skipGraphPhases` guard in runPipelineFromRepo (the
// result-extraction path) is intentionally NOT routed through the registry
// (KTD5); it remains keyed on the same option, so membership here stays
// consistent with output consumption there.
// ---------------------------------------------------------------------------
const FULL_ORDER = [
'scan',
'structure',
'markdown',
'cobol',
'parse',
'routes',
'tools',
'orm',
'crossFile',
'scopeResolution',
'pruneLocalSymbols',
'mro',
'communities',
'processes',
];
const WITHOUT_GRAPH_PHASES = FULL_ORDER.filter(
(n) => n !== 'mro' && n !== 'communities' && n !== 'processes',
);
describe('buildPhaseList parity (registry refactor, #2080)', () => {
it('default options → full phase list in legacy order', () => {
expect(buildPhaseList(undefined).map((p) => p.name)).toEqual(FULL_ORDER);
expect(buildPhaseList({}).map((p) => p.name)).toEqual(FULL_ORDER);
});
it('skipGraphPhases:false → full phase list (graph phases included)', () => {
expect(buildPhaseList({ skipGraphPhases: false }).map((p) => p.name)).toEqual(FULL_ORDER);
});
it('skipGraphPhases:true → omits exactly mro/communities/processes', () => {
expect(buildPhaseList({ skipGraphPhases: true }).map((p) => p.name)).toEqual(
WITHOUT_GRAPH_PHASES,
);
});
});

View file

@ -9,6 +9,11 @@ import { createTypeRegistry } from '../../../src/core/ingestion/model/type-regis
import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js';
import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js';
import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js';
import {
CLASS_TYPES_TUPLE,
FREE_CALLABLE_TUPLE,
} from '../../../src/core/ingestion/model/symbol-table.js';
import { EMBEDDABLE_LABELS } from '../../../src/core/embeddings/types.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { makeDef as makeBaseDef } from './helpers.js';
@ -101,6 +106,30 @@ describe('NodeLabel taxonomy coverage', () => {
});
});
// ---------------------------------------------------------------------------
// BasicBlock — taint/PDG substrate node (issue #2080). It is a control-flow
// node, never a symbol-resolution or embedding target (KTD4). These guards
// fail if a future change accidentally promotes it into a dispatch/callable
// tuple or the embeddable set.
// ---------------------------------------------------------------------------
describe('BasicBlock taint/PDG substrate label (issue #2080)', () => {
it('is classified inert — not a dispatch or callable resolution target', () => {
expect(INERT_LABELS.has('BasicBlock')).toBe(true);
expect(DISPATCH_LABELS.has('BasicBlock')).toBe(false);
expect(CALLABLE_ONLY_LABELS.has('BasicBlock')).toBe(false);
});
it('is excluded from the class-like and free-callable tuples', () => {
expect((CLASS_TYPES_TUPLE as readonly string[]).includes('BasicBlock')).toBe(false);
expect((FREE_CALLABLE_TUPLE as readonly string[]).includes('BasicBlock')).toBe(false);
});
it('is not embeddable', () => {
expect((EMBEDDABLE_LABELS as readonly string[]).includes('BasicBlock')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Behavior group coverage — every label in a behavior group routes to the
// group's registry write, regardless of how hooks are implemented (shared

View file

@ -0,0 +1,141 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
/**
* Runtime opt-out for optional grammars (#2091, #2093).
*
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` used to be an install-time-only env (the
* postinstall build scripts read it). `parser-loader` now also honors it at
* analyze time: when set, genuinely-optional grammars (swift/dart/kotlin)
* report unavailable so the ingestion pipeline skips their files, mirroring a
* genuinely-absent binding. Grammars that are required `dependencies` routed
* through the optional machinery for ABI safety (C — `severity: 'error'`) are
* NEVER skippable this way.
*
* `parser-loader` memoizes load results at module scope, so each case loads a
* fresh copy via `vi.resetModules()` after setting the env. These assertions
* are install-state-robust: they only assert the SKIP direction (skip → false)
* and that required grammars are unaffected (true) — never that an optional
* grammar is positively available, which depends on the install/platform.
*/
const ENV = 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS';
async function freshLoader(skipValue: string | undefined) {
vi.resetModules();
if (skipValue === undefined) delete process.env[ENV];
else process.env[ENV] = skipValue;
return import('../../src/core/tree-sitter/parser-loader.js');
}
afterEach(() => {
delete process.env[ENV];
vi.resetModules();
});
describe('parser-loader GITNEXUS_SKIP_OPTIONAL_GRAMMARS runtime gate', () => {
it('skip=1 reports every optional grammar as unavailable', async () => {
const { isLanguageAvailable } = await freshLoader('1');
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(false);
});
it('skip=all/true/* also skip every optional grammar', async () => {
for (const v of ['all', 'true', '*']) {
const { isLanguageAvailable } = await freshLoader(v);
expect(isLanguageAvailable(SupportedLanguages.Swift), `value=${v}`).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart), `value=${v}`).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Kotlin), `value=${v}`).toBe(false);
}
});
it('does NOT skip required grammars — skip=all is a no-op for C / Python', async () => {
// Compare availability WITH skip=all against the baseline (no skip). The
// runtime opt-out must never change a required grammar's availability:
// C is `optional: true` + `severity: 'error'` (a required dep routed
// through the optional machinery for ABI safety, #1242), and Python is a
// plain required dep. Asserting EQUALITY (not positive truth) keeps this
// install-state-robust — C's native binding is intentionally fallible, so
// a positive assertion could flake on an ABI-mismatched matrix.
const base = await freshLoader(undefined);
const cBase = base.isLanguageAvailable(SupportedLanguages.C);
const pyBase = base.isLanguageAvailable(SupportedLanguages.Python);
const skipped = await freshLoader('all');
expect(skipped.isLanguageAvailable(SupportedLanguages.C)).toBe(cBase);
expect(skipped.isLanguageAvailable(SupportedLanguages.Python)).toBe(pyBase);
});
it('a comma list skips ONLY the named grammars — un-named ones unaffected', async () => {
// Baseline (no skip) so the isolation check is install-state-robust.
const base = await freshLoader(undefined);
const dartBase = base.isLanguageAvailable(SupportedLanguages.Dart);
const kotlinBase = base.isLanguageAvailable(SupportedLanguages.Kotlin);
const { isLanguageAvailable } = await freshLoader('swift');
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(false);
// A prefix/union bug would skip these too — assert they match baseline.
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(dartBase);
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(kotlinBase);
});
it('accepts the tree-sitter-<lang> package spelling — others unaffected', async () => {
const base = await freshLoader(undefined);
const swiftBase = base.isLanguageAvailable(SupportedLanguages.Swift);
const kotlinBase = base.isLanguageAvailable(SupportedLanguages.Kotlin);
const { isLanguageAvailable } = await freshLoader('tree-sitter-dart');
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(swiftBase);
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(kotlinBase);
});
it('accepts a multi-entry list', async () => {
const { isLanguageAvailable } = await freshLoader('kotlin, dart');
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
});
it('getLanguageGrammar throws a clean "Unsupported language" for a skipped optional grammar', async () => {
const { getLanguageGrammar } = await freshLoader('all');
expect(() => getLanguageGrammar(SupportedLanguages.Swift)).toThrow(/Unsupported language/);
});
it('an empty / unset env does not skip (required grammars load)', async () => {
const { isLanguageAvailable } = await freshLoader(undefined);
expect(isLanguageAvailable(SupportedLanguages.Python)).toBe(true);
});
it('isGrammarRuntimeSkipped reflects the opt-out, never for required grammars', async () => {
const swiftOnly = await freshLoader('swift');
expect(swiftOnly.isGrammarRuntimeSkipped(SupportedLanguages.Swift)).toBe(true);
expect(swiftOnly.isGrammarRuntimeSkipped(SupportedLanguages.Dart)).toBe(false);
const all = await freshLoader('all');
expect(all.isGrammarRuntimeSkipped(SupportedLanguages.Swift)).toBe(true);
// C is not `userSkippable` (required dep via the optional machinery) — even
// `all` must not mark it runtime-skipped.
expect(all.isGrammarRuntimeSkipped(SupportedLanguages.C)).toBe(false);
});
it('logs an accurate runtime-skip note, not a missing-binding message', async () => {
// Import the logger AND parser-loader from the SAME fresh module registry so
// the capture intercepts the loader's logger instance.
vi.resetModules();
process.env[ENV] = 'swift';
const { _captureLogger } = await import('../../src/core/logger.js');
const { isLanguageAvailable } = await import('../../src/core/tree-sitter/parser-loader.js');
const cap = _captureLogger();
try {
isLanguageAvailable(SupportedLanguages.Swift); // triggers the one-time skip log
const msgs = cap
.records()
.map((r) => (typeof r.msg === 'string' ? r.msg : ''))
.filter(Boolean);
const skipMsg = msgs.find((m) => m.includes('GITNEXUS_SKIP_OPTIONAL_GRAMMARS'));
expect(skipMsg, `captured:\n${msgs.join('\n')}`).toBeTruthy();
// The opt-out note must NOT borrow the install/platform "missing binding"
// language — that would tell a deliberate opt-out to reinstall/rebuild.
expect(skipMsg).not.toMatch(/no prebuilt|failed to load|npm rebuild/i);
} finally {
cap.restore();
}
});
});

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

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the pool adapter (and its re-export shim) so executeParameterized is fully
// controllable — the proven seam from impact-batching-grouping.test.ts. This is a
// UNIT test: the integration suite runs the real executeParameterized against a
// real DB, so it cannot make ONE enrichment query throw while the rest succeed.
const executeParameterizedMock = vi.fn();
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/lbug/pool-adapter.js')>();
return {
...actual,
initLbug: vi.fn(),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/mcp/core/lbug-adapter.js')>();
return {
...actual,
initLbug: vi.fn(),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
import { LocalBackend } from '../../src/mcp/local/local-backend';
// A backend whose hybrid search yields exactly one matched symbol, so the
// enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized
// so we can exercise the FTS-missing + enrichment-degraded composition.
function makeBackend(ftsUsed = true): LocalBackend {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo1',
name: 'repo1',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
lbugPath: '/tmp/repo/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
(backend as any).repos.set(repoHandle.id, repoHandle);
(backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
const sym = {
nodeId: 'func:x',
name: 'x',
type: 'Function',
filePath: 'f.ts',
startLine: 1,
endLine: 2,
};
(backend as any).bm25Search = vi.fn().mockResolvedValue({ results: [sym], ftsUsed });
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
return { backend, repoHandle } as any;
}
const runQuery = (b: any, params: any = { query: 'x' }) =>
(b.backend as any).query(b.repoHandle, params);
describe('query: degraded-enrichment signal', () => {
beforeEach(() => vi.clearAllMocks());
it('a REAL enrichment failure surfaces warning + partial, and still returns the symbol', async () => {
const b = makeBackend(true);
executeParameterizedMock.mockImplementation(async (_repo: string, query: string) => {
if (query.includes('STEP_IN_PROCESS'))
throw new Error('Query execution timed out after 30000ms');
return []; // MEMBER_OF / content succeed (empty)
});
const result = await runQuery(b);
expect(result).not.toHaveProperty('error');
expect(result.partial).toBe(true);
expect(typeof result.warning).toBe('string');
expect(result.warning.toLowerCase()).toContain('enrichment');
// The matched symbol still comes back (degraded to definitions, not dropped).
expect(result.definitions.map((d: any) => d.id)).toContain('func:x');
});
it('a BENIGN missing-table error does NOT trip the signal', async () => {
const b = makeBackend(true);
executeParameterizedMock.mockImplementation(async (_repo: string, query: string) => {
// A repo analyzed without processes/communities: prepare fails because the
// table/label does not exist. This is normal, not degraded.
if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF'))
throw new Error('Binder exception: Table Process does not exist.');
return [];
});
const result = await runQuery(b);
expect(result).not.toHaveProperty('error');
expect(result.partial).toBeUndefined();
expect(result.warning).toBeUndefined(); // ftsUsed=true and no real failure
expect(result.definitions.map((d: any) => d.id)).toContain('func:x');
});
it('composes the FTS-missing warning with the enrichment-degraded message', async () => {
const b = makeBackend(false); // FTS unavailable
executeParameterizedMock.mockImplementation(async (_repo: string, query: string) => {
if (query.includes('STEP_IN_PROCESS'))
throw new Error('Query execution timed out after 30000ms');
return [];
});
const result = await runQuery(b);
expect(result.partial).toBe(true);
expect(typeof result.warning).toBe('string');
// Both messages present in the single composed warning — neither overwrites the other.
expect(result.warning).toMatch(/FTS indexes missing|repair-fts/i);
expect(result.warning.toLowerCase()).toContain('enrichment');
});
});

View file

@ -17,6 +17,7 @@ import {
CODE_ELEMENT_SCHEMA,
COMMUNITY_SCHEMA,
PROCESS_SCHEMA,
BASICBLOCK_SCHEMA,
RELATION_SCHEMA,
EMBEDDING_SCHEMA,
CREATE_VECTOR_INDEX_QUERY,
@ -68,9 +69,13 @@ describe('LadybugDB Schema', () => {
}
});
it('includes the BasicBlock taint/PDG substrate node (issue #2080)', () => {
expect(NODE_TABLES).toContain('BasicBlock');
});
it('has expected total count', () => {
// 9 core + 19 multi-language + Route + Tool = 31
expect(NODE_TABLES).toHaveLength(31);
// 9 core + 19 multi-language + Route + Tool + BasicBlock = 32
expect(NODE_TABLES).toHaveLength(32);
});
});
@ -90,6 +95,12 @@ describe('LadybugDB Schema', () => {
expect(REL_TYPES).toContain(t);
}
});
it('includes the taint/PDG substrate edge types (issue #2080)', () => {
for (const t of ['CFG', 'REACHING_DEF', 'TAINTED', 'SANITIZES', 'TAINT_PATH']) {
expect(REL_TYPES).toContain(t);
}
});
});
describe('node schema DDL', () => {
@ -123,6 +134,19 @@ describe('LadybugDB Schema', () => {
expect(PROPERTY_SCHEMA).toContain('declaredType STRING');
});
it('BasicBlock schema is wired into SCHEMA_QUERIES (issue #2080, F1 guard)', () => {
// Defining BASICBLOCK_SCHEMA is not enough — it must be appended to
// NODE_SCHEMA_QUERIES (→ SCHEMA_QUERIES) or initLbug never creates the
// table and the bulk-COPY round-trip fails with "table does not exist".
expect(SCHEMA_QUERIES).toContain(BASICBLOCK_SCHEMA);
expect(BASICBLOCK_SCHEMA).toContain('CREATE NODE TABLE BasicBlock');
expect(BASICBLOCK_SCHEMA).toContain('filePath STRING');
expect(BASICBLOCK_SCHEMA).toContain('startLine INT64');
expect(BASICBLOCK_SCHEMA).toContain('endLine INT64');
expect(BASICBLOCK_SCHEMA).toContain('text STRING');
expect(BASICBLOCK_SCHEMA).toContain('PRIMARY KEY (id)');
});
it('Community schema has heuristicLabel and cohesion', () => {
expect(COMMUNITY_SCHEMA).toContain('heuristicLabel STRING');
expect(COMMUNITY_SCHEMA).toContain('cohesion DOUBLE');
@ -164,6 +188,10 @@ describe('LadybugDB Schema', () => {
expect(RELATION_SCHEMA).toContain('FROM Method TO Process');
});
it('connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)', () => {
expect(RELATION_SCHEMA).toContain('FROM BasicBlock TO BasicBlock');
});
it('has all FROM/TO pairs needed for HAS_METHOD edges', () => {
// HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record
// HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY)
@ -208,7 +236,8 @@ describe('LadybugDB Schema', () => {
describe('schema query ordering', () => {
it('NODE_SCHEMA_QUERIES has correct count', () => {
expect(NODE_SCHEMA_QUERIES).toHaveLength(31);
// 31 + BasicBlock = 32
expect(NODE_SCHEMA_QUERIES).toHaveLength(32);
});
it('REL_SCHEMA_QUERIES has one relation table', () => {
@ -216,8 +245,8 @@ describe('LadybugDB Schema', () => {
});
it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => {
// 31 node + 1 rel + 1 embedding = 33
expect(SCHEMA_QUERIES).toHaveLength(33);
// 32 node + 1 rel + 1 embedding = 34
expect(SCHEMA_QUERIES).toHaveLength(34);
});
it('node schemas come before relation schemas in SCHEMA_QUERIES', () => {

View file

@ -14,9 +14,14 @@ import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helper
function parseNode(src: string, type: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
const child = tree.rootNode.namedChild(i);
if (child?.type === type) return child as SyntaxNode;
const stack: SyntaxNode[] = [tree.rootNode as SyntaxNode];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === type) return node;
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child as SyntaxNode);
}
}
return null;
}
@ -59,6 +64,12 @@ describe('C++ include decomposition (splitCppInclude)', () => {
// ── using declaration decomposition ─────────────────────────────────────────
describe('C++ using declaration decomposition (splitCppUsingDecl)', () => {
it('does not treat a class-scope member using-declaration as an import', () => {
const node = parseNode('struct Derived : Base { using Base::run; };', 'using_declaration');
expect(node).not.toBeNull();
expect(splitCppUsingDecl(node!)).toBeNull();
});
it('decomposes "using namespace std;" as wildcard import', () => {
const node = parseNode('using namespace std;', 'using_declaration');
expect(node).not.toBeNull();

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