mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into feat/private-repo-token-auth
This commit is contained in:
commit
83fe1b2428
175 changed files with 1359025 additions and 561 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
266
.github/scripts/update-vendored-grammars.mjs
vendored
Normal file
266
.github/scripts/update-vendored-grammars.mjs
vendored
Normal 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 };
|
||||
529
.github/workflows/build-tree-sitter-prebuilds.yml
vendored
Normal file
529
.github/workflows/build-tree-sitter-prebuilds.yml
vendored
Normal 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}`);
|
||||
5
.github/workflows/ci-tests.yml
vendored
5
.github/workflows/ci-tests.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
146
.github/workflows/grammar-update-monitor.yml
vendored
Normal file
146
.github/workflows/grammar-update-monitor.yml
vendored
Normal 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.');
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
126
gitnexus/package-lock.json
generated
126
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
173
gitnexus/scripts/assert-publish-grammar-coverage.cjs
Normal file
173
gitnexus/scripts/assert-publish-grammar-coverage.cjs
Normal 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,
|
||||
};
|
||||
374
gitnexus/scripts/bench/fts-evict-reload-rss.mjs
Normal file
374
gitnexus/scripts/bench/fts-evict-reload-rss.mjs
Normal 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);
|
||||
});
|
||||
105
gitnexus/scripts/bench/fts-rss-verdict.mjs
Normal file
105
gitnexus/scripts/bench/fts-rss-verdict.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
120
gitnexus/scripts/build-tree-sitter-grammars.cjs
Normal file
120
gitnexus/scripts/build-tree-sitter-grammars.cjs
Normal 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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
151
gitnexus/scripts/spikes/s1-reaching-def-index-bench.ts
Normal file
151
gitnexus/scripts/spikes/s1-reaching-def-index-bench.ts
Normal 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);
|
||||
});
|
||||
162
gitnexus/scripts/spikes/s2-postdom-prototype.ts
Normal file
162
gitnexus/scripts/spikes/s2-postdom-prototype.ts
Normal 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();
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
187
gitnexus/src/cli/editor-targets.ts
Normal file
187
gitnexus/src/cli/editor-targets.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '即使已是最新也强制完整重新生成',
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
518
gitnexus/src/cli/uninstall.ts
Normal file
518
gitnexus/src/cli/uninstall.ts
Normal 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('');
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
616
gitnexus/src/core/ingestion/languages/cpp/member-lookup.ts
Normal file
616
gitnexus/src/core/ingestion/languages/cpp/member-lookup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
gitnexus/src/core/ingestion/pipeline-phases/registry.ts
Normal file
73
gitnexus/src/core/ingestion/pipeline-phases/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
38
gitnexus/src/core/ingestion/taint/source-sink-config.ts
Normal file
38
gitnexus/src/core/ingestion/taint/source-sink-config.ts
Normal 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[];
|
||||
}
|
||||
42
gitnexus/src/core/ingestion/taint/source-sink-registry.ts
Normal file
42
gitnexus/src/core/ingestion/taint/source-sink-registry.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/base.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/base.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
struct CrossFileBase {
|
||||
void crossFile();
|
||||
};
|
||||
142
gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/main.cpp
vendored
Normal file
142
gitnexus/test/fixtures/lang-resolution/cpp-member-lattice/main.cpp
vendored
Normal 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();
|
||||
}
|
||||
6
gitnexus/test/fixtures/local-backend-seed.ts
vendored
6
gitnexus/test/fixtures/local-backend-seed.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
154
gitnexus/test/integration/basicblock-roundtrip.test.ts
Normal file
154
gitnexus/test/integration/basicblock-roundtrip.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
215
gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
Normal file
215
gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
83
gitnexus/test/unit/assert-publish-grammar-coverage.test.ts
Normal file
83
gitnexus/test/unit/assert-publish-grammar-coverage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
125
gitnexus/test/unit/build-tree-sitter-grammars-probe.test.ts
Normal file
125
gitnexus/test/unit/build-tree-sitter-grammars-probe.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
gitnexus/test/unit/fts-rss-verdict.test.ts
Normal file
76
gitnexus/test/unit/fts-rss-verdict.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
78
gitnexus/test/unit/grammar-update-monitor.test.ts
Normal file
78
gitnexus/test/unit/grammar-update-monitor.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
102
gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts
Normal file
102
gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
141
gitnexus/test/unit/parser-loader-skip-optional.test.ts
Normal file
141
gitnexus/test/unit/parser-loader-skip-optional.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
177
gitnexus/test/unit/prebuild-coverage.test.ts
Normal file
177
gitnexus/test/unit/prebuild-coverage.test.ts
Normal 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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
120
gitnexus/test/unit/query-degraded-signal.test.ts
Normal file
120
gitnexus/test/unit/query-degraded-signal.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue