Compare commits
21
Commits
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.7",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}`);
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,44 @@ 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"
|
||||
|
||||
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
|
||||
# FTS-extension RUN above so editing hook/skill content does not invalidate that
|
||||
# network-fetching cache layer; they have no input dependency on it.
|
||||
# `hooks/`: dist/cli/resolve-invocation.js does
|
||||
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
|
||||
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
|
||||
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
|
||||
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
|
||||
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
|
||||
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
|
||||
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
|
||||
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
|
||||
# the UI is the separate Dockerfile.web image / hosted app.)
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
|
||||
@@ -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.6.6",
|
||||
"version": "1.6.7",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
|
||||
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -267,7 +277,12 @@ function handlePreToolUse(input) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,7 +381,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+26
-1
@@ -4,9 +4,34 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.7] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Taint/PDG substrate (M0)** — foundational schema + seams for reliable taint analysis on a PDG-expandable substrate (#2080, Epic #2087). Adds the `BasicBlock` node label and `CFG` / `REACHING_DEF` / `TAINTED` / `SANITIZES` / `TAINT_PATH` relationship types to the graph schema (round-trip through the bulk-COPY path), a phase-registry seam (`registerPhase` / `enabledWhen`) generalising the graph-phase opt-in guard, and a per-language source/sink/sanitizer config registry seam. All additive and inert — no phase emits the new nodes/edges yet, and a default `analyze` run is byte-identical to before. De-risking spikes (LadybugDB rel-property indexing, post-dominator feasibility) recorded on the issue.
|
||||
- **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
|
||||
|
||||
|
||||
+24
-1
@@ -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:
|
||||
@@ -433,6 +436,26 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi
|
||||
|
||||
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
|
||||
|
||||
### Hook augmentation/notifications are silently skipped
|
||||
|
||||
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
|
||||
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
|
||||
output. A search may not be augmented — or a stale-index reminder may not appear
|
||||
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
|
||||
times out and fails closed, or when the index is already current.
|
||||
|
||||
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
|
||||
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
|
||||
stale-index hint to its stderr:
|
||||
|
||||
```bash
|
||||
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
|
||||
```
|
||||
|
||||
Only `GITNEXUS_DEBUG=1` and `GITNEXUS_DEBUG=true` enable diagnostics; every other
|
||||
value (including `0` and `false`) is treated as off. Diagnostics go to stderr
|
||||
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -91,10 +91,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners validate hook output, so normal, non-error skip paths must stay
|
||||
* silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
|
||||
* See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -258,8 +268,14 @@ function buildAfterToolContext(input) {
|
||||
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
|
||||
const hint = buildStaleIndexHint(gitNexusDir, cwd);
|
||||
if (hint) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
// The hint always reaches the agent via additionalContext (parts). Mirror
|
||||
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
|
||||
// hook runners see no unexpected output on this normal path (#1913). The
|
||||
// claude hook never mirrored this to stderr — this aligns the two adapters.
|
||||
parts.push(hint);
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +285,11 @@ function buildAfterToolContext(input) {
|
||||
|
||||
function runAugment(gitNexusDir, cwd, pattern) {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
// Normal skip path: the MCP server owns the DB. Stay silent for strict
|
||||
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
@@ -338,7 +358,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
|
||||
@@ -250,7 +260,12 @@ function handlePreToolUse(input) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -361,7 +376,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+43
-83
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.8-rc.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.8-rc.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.8-rc.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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ type ValueKind =
|
||||
| 'boolean'
|
||||
| 'boolean-negate'
|
||||
| 'string'
|
||||
| 'string-array'
|
||||
| 'numeric-string'
|
||||
| 'embeddings'
|
||||
| 'branch';
|
||||
@@ -99,6 +100,12 @@ const KEY_SPECS: Record<string, KeySpec> = {
|
||||
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
|
||||
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
|
||||
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
|
||||
// #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
|
||||
// consumers. The auto-detector only flags functions that call the bare global
|
||||
// `fetch()`; a wrapper built on axios / a custom client, or named outside the
|
||||
// built-in convention set, is otherwise invisible to route_map consumers.
|
||||
// Listing it here adds it to the cross-file consumer scan.
|
||||
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
|
||||
};
|
||||
|
||||
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
|
||||
@@ -230,6 +237,41 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
case 'string-array': {
|
||||
// Generic shared validator — `source` already names the config key, so
|
||||
// messages here stay key-agnostic (no fetch-wrapper coupling in the
|
||||
// shared normalizer; #1589/#1852 review F7).
|
||||
if (!Array.isArray(value)) {
|
||||
throw new GitNexusRcError(`${source} must be an array of strings.`);
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new GitNexusRcError(`${source} entries must all be strings.`);
|
||||
}
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source} entries must not be empty.`);
|
||||
}
|
||||
assertNoHiddenChars(trimmed, source);
|
||||
// Values may be interpolated into a RegExp downstream. Restrict to
|
||||
// identifier / member-access shapes so a config value can never smuggle
|
||||
// regex metacharacters into a consumer.
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
|
||||
throw new GitNexusRcError(
|
||||
`${source} entry "${trimmed}" must be an identifier or member name ` +
|
||||
`(letters, digits, _, $, . — e.g. "client.get").`,
|
||||
);
|
||||
}
|
||||
names.push(trimmed);
|
||||
}
|
||||
if (names.length === 0) {
|
||||
throw new GitNexusRcError(`${source} must list at least one string.`);
|
||||
}
|
||||
// De-duplicate and cap to a sane bound so a pathological config cannot
|
||||
// blow up the consumer scan's alternation.
|
||||
return Array.from(new Set(names)).slice(0, 100);
|
||||
}
|
||||
case 'numeric-string': {
|
||||
// Mirror Commander's contract: these options reach the existing CLI
|
||||
// validation as strings. Accept a JSON number or a string; normalize to a
|
||||
|
||||
@@ -620,6 +620,14 @@ export interface AnalyzeOptions {
|
||||
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
|
||||
*/
|
||||
defaultBranch?: string;
|
||||
/**
|
||||
* Index-branch selector (#2106). From `--branch`. Distinct from
|
||||
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
|
||||
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
|
||||
* alias for `defaultBranch` and must not change index placement. Defaults to
|
||||
* the checked-out branch inside `runFullAnalysis` when omitted.
|
||||
*/
|
||||
branch?: string;
|
||||
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
||||
indexOnly?: boolean;
|
||||
/** Index the folder even when no .git directory is present. */
|
||||
@@ -655,6 +663,14 @@ export interface AnalyzeOptions {
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
embeddingDevice?: string;
|
||||
/**
|
||||
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
|
||||
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
|
||||
* the routes phase, where the cross-file consumer scan unions them with the
|
||||
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
|
||||
* outside the built-in convention still produces `route_map` consumers.
|
||||
*/
|
||||
fetchWrappers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -762,6 +778,21 @@ const analyzeCommandImpl = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the index-branch selector (#2106) the same way, so a malformed
|
||||
// `--branch` exits before any expensive analysis starts. Capture the TRIMMED
|
||||
// return so a whitespace-padded value (e.g. " feature" from shell completion)
|
||||
// normalizes before the checked-out-branch mismatch guard and slug — otherwise
|
||||
// it would false-reject on-branch or create a ghost index when detached.
|
||||
if (cliOptions?.branch !== undefined) {
|
||||
try {
|
||||
cliOptions.branch = validateBranchName(cliOptions.branch, '--branch');
|
||||
} catch (err) {
|
||||
cliError(` ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
|
||||
// Parse/validate before the progress bar so a malformed config produces an
|
||||
// actionable error and exits before any expensive analysis starts.
|
||||
@@ -1094,6 +1125,10 @@ const analyzeCommandImpl = async (
|
||||
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
|
||||
// threaded into the generated regression-compare example (#243).
|
||||
defaultBranch: resolvedDefaultBranch,
|
||||
// Index-branch selector (#2106). Read straight from the CLI flag (not
|
||||
// the .gitnexusrc-merged options) so the cosmetic defaultBranch config
|
||||
// can never change index placement. Undefined → auto-detect in pipeline.
|
||||
branch: cliOptions?.branch,
|
||||
// commander.js `.option('--no-stats', …)` registers the flag as
|
||||
// `options.stats` (boolean, default true; `false` when the user
|
||||
// passed --no-stats). Reading `options.noStats` here returns
|
||||
@@ -1110,6 +1145,9 @@ const analyzeCommandImpl = async (
|
||||
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
||||
// env / auto-formula fallback inside the pipeline.
|
||||
workerPoolSize,
|
||||
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
|
||||
// forwarded to the routes phase consumer scan.
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
},
|
||||
{
|
||||
onProgress: (_phase, percent, message) => {
|
||||
@@ -1131,14 +1169,20 @@ const analyzeCommandImpl = async (
|
||||
// preserving the rest of the block (incl. --skills community rows). No-op
|
||||
// when the value already matches, so a routine up-to-date run is silent
|
||||
// (#1996 tri-review P2).
|
||||
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
|
||||
// PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
|
||||
// analyze must not churn the committed AGENTS.md — this mirrors the
|
||||
// in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
|
||||
let baseRefRefreshed: string[] = [];
|
||||
try {
|
||||
const { refreshBaseRefLine } = await import('./ai-context.js');
|
||||
baseRefRefreshed = (
|
||||
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
|
||||
).files;
|
||||
} catch {
|
||||
/* best-effort — never fail the fast path over a context refresh */
|
||||
if (result.isPrimaryBranch !== false) {
|
||||
try {
|
||||
const { refreshBaseRefLine } = await import('./ai-context.js');
|
||||
baseRefRefreshed = (
|
||||
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
|
||||
).files;
|
||||
} catch {
|
||||
/* best-effort — never fail the fast path over a context refresh */
|
||||
}
|
||||
}
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
unregisterRepo,
|
||||
listRegisteredRepos,
|
||||
assertSafeStoragePath,
|
||||
getStoragePaths,
|
||||
removeBranchIndex,
|
||||
UnsafeStoragePathError,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
@@ -26,7 +28,50 @@ export const cleanCommand = async (options?: {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
lbugSidecars?: boolean;
|
||||
branch?: string;
|
||||
}) => {
|
||||
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
|
||||
// Resolve against the RECORDED branches[] summary (never by slugging the
|
||||
// user's raw input, which can disagree with the index-time-sanitized label).
|
||||
if (options?.branch) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
|
||||
const summary = entry?.branches?.find((b) => b.branch === options.branch);
|
||||
if (!summary) {
|
||||
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
|
||||
return;
|
||||
}
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.repoPath, summary.branch);
|
||||
const branchDir = path.dirname(lbugPath);
|
||||
// Safety guard: the target MUST live under <repo>/.gitnexus/branches/.
|
||||
// assertSafeStoragePath only validates the flat `<repo>/.gitnexus`, so this
|
||||
// is a dedicated branches-sub-dir check before any destructive fs.rm.
|
||||
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
|
||||
if (!branchDir.startsWith(branchesRoot)) {
|
||||
logger.error(`Refusing to clean branch index outside .gitnexus/branches: ${branchDir}`);
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(t('clean.deleteBranch', { branch: summary.branch, path: branchDir }));
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.rm(branchDir, { recursive: true, force: true });
|
||||
await removeBranchIndex(repo.repoPath, summary.branch);
|
||||
console.log(t('clean.deletedBranch', { branch: summary.branch }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete branch index:');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.lbugSidecars) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
|
||||
@@ -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';
|
||||
@@ -185,7 +189,47 @@ export function formatImpactResult(result: any): string {
|
||||
const byDepth = result.byDepth || {};
|
||||
const total = result.impactedCount || 0;
|
||||
|
||||
// #2129 — an ambiguous bare name must not print the "isolated / safe to
|
||||
// refactor" headline. Surface the per-candidate blast radius + the maximum,
|
||||
// mirroring formatContextResult, so the real impact under whichever symbol the
|
||||
// caller meant is visible on the text surface, not just in the JSON.
|
||||
if (result.status === 'ambiguous') {
|
||||
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
|
||||
// truncated `candidates[]` length; note when the candidate list is capped.
|
||||
const shown = result.candidates?.length ?? 0;
|
||||
const total = result.totalCandidates ?? shown;
|
||||
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
|
||||
const lines = [
|
||||
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
|
||||
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
|
||||
`Disambiguate with --uid for one authoritative result:`,
|
||||
];
|
||||
for (const c of result.candidates || []) {
|
||||
lines.push(
|
||||
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
|
||||
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`,
|
||||
);
|
||||
}
|
||||
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
|
||||
if (result.partialProbe) {
|
||||
lines.push(
|
||||
' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
// #1858 — "isolated" is a confident claim. If an interface / indirection
|
||||
// boundary is on the path, the true count is a lower bound, not zero;
|
||||
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
|
||||
if (result.epistemic === 'lower-bound') {
|
||||
const lines = [
|
||||
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
|
||||
];
|
||||
for (const b of result.boundaries || []) lines.push(` • ${b}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
|
||||
}
|
||||
|
||||
@@ -198,6 +242,14 @@ export function formatImpactResult(result: any): string {
|
||||
if (result.partial) {
|
||||
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
|
||||
}
|
||||
// #1858 — an interface / indirection boundary on the path makes this a lower
|
||||
// bound; surface it so the count is not read as exhaustive.
|
||||
if (result.epistemic === 'lower-bound') {
|
||||
lines.push(
|
||||
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
|
||||
);
|
||||
for (const b of result.boundaries || []) lines.push(` • ${b}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const depthLabels: Record<number, string> = {
|
||||
@@ -265,13 +317,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 +340,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 +393,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,8 +70,10 @@ 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|--branch <name>': 'help.option.clean.branch',
|
||||
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
|
||||
'remove|-f, --force': 'help.option.force.confirmation',
|
||||
'wiki|-f, --force': 'help.option.wiki.force',
|
||||
@@ -91,16 +94,19 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'publish|--id <owner/repo>': 'help.option.publish.id',
|
||||
'publish|--skip-git': 'help.option.skipGit',
|
||||
'query|-r, --repo <name>': 'help.option.repo.targetOmitOne',
|
||||
'query|--branch <name>': 'help.option.branch',
|
||||
'query|-c, --context <text>': 'help.option.query.context',
|
||||
'query|-g, --goal <text>': 'help.option.query.goal',
|
||||
'query|-l, --limit <n>': 'help.option.query.limit',
|
||||
'query|--content': 'help.option.content',
|
||||
'context|-r, --repo <name>': 'help.option.repo.target',
|
||||
'context|--branch <name>': 'help.option.branch',
|
||||
'context|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'context|-f, --file <path>': 'help.option.context.file',
|
||||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|--branch <name>': 'help.option.branch',
|
||||
'impact|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'impact|-f, --file <path>': 'help.option.context.file',
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
@@ -110,9 +116,11 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'impact|--offset <n>': 'help.option.impact.offset',
|
||||
'impact|--summary-only': 'help.option.impact.summaryOnly',
|
||||
'cypher|-r, --repo <name>': 'help.option.repo.target',
|
||||
'cypher|--branch <name>': 'help.option.branch',
|
||||
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
|
||||
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
||||
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
|
||||
'detect-changes|--branch <name>': 'help.option.branch',
|
||||
'eval-server|-p, --port <port>': 'help.option.port',
|
||||
'eval-server|--host <host>': 'help.option.evalServer.host',
|
||||
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const en = {
|
||||
'list.title': 'Indexed Repositories ({{count}})',
|
||||
'list.indexed': 'Indexed',
|
||||
'list.commit': 'Commit',
|
||||
'list.branch': 'Branch',
|
||||
'list.branchIndexes': 'Branch indexes',
|
||||
'list.branchLine': '{{branch}} ({{commit}}, {{indexed}})',
|
||||
'list.stats': 'Stats',
|
||||
'list.statsValue': '{{files}} files, {{symbols}} symbols, {{edges}} edges',
|
||||
'list.clusters': 'Clusters',
|
||||
@@ -23,6 +26,10 @@ export const en = {
|
||||
'status.indexed': 'Indexed',
|
||||
'status.indexedCommit': 'Indexed commit',
|
||||
'status.currentCommit': 'Current commit',
|
||||
'status.branch': 'Branch',
|
||||
'status.detached': '(detached HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)",
|
||||
'status.status': 'Status',
|
||||
'status.upToDate': '✅ up-to-date',
|
||||
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
||||
@@ -30,6 +37,9 @@ export const en = {
|
||||
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
|
||||
'clean.notFoundHere': 'No indexed repository found in this directory.',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
|
||||
'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
|
||||
'clean.deletedBranch': 'Deleted branch index: {{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
|
||||
'clean.lbugSidecars.none': 'No quarantined LadybugDB missing-shadow WAL sidecars found.',
|
||||
'clean.lbugSidecars.preview':
|
||||
@@ -106,6 +116,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,7 +197,9 @@ 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.branch': 'Delete only the named branch index (not the primary)',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
@@ -214,6 +228,7 @@ export const en = {
|
||||
'help.option.query.limit': 'Max processes to return (default: 5)',
|
||||
'help.option.content': 'Include full symbol source code',
|
||||
'help.option.repo.target': 'Target repository',
|
||||
'help.option.branch': 'Scope to a specific branch index (multi-branch repos)',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.impact.kind':
|
||||
|
||||
@@ -14,6 +14,9 @@ export const zhCN = {
|
||||
'list.title': '已索引仓库({{count}})',
|
||||
'list.indexed': '索引时间',
|
||||
'list.commit': '提交',
|
||||
'list.branch': '分支',
|
||||
'list.branchIndexes': '分支索引',
|
||||
'list.branchLine': '{{branch}}({{commit}},{{indexed}})',
|
||||
'list.stats': '统计',
|
||||
'list.statsValue': '{{files}} 个文件,{{symbols}} 个符号,{{edges}} 条边',
|
||||
'list.clusters': '聚类',
|
||||
@@ -27,6 +30,10 @@ export const zhCN = {
|
||||
'status.indexed': '索引时间',
|
||||
'status.indexedCommit': '索引提交',
|
||||
'status.currentCommit': '当前提交',
|
||||
'status.branch': '分支',
|
||||
'status.detached': '(分离 HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ 当前分支未索引(主索引对应 '{{primary}}';请运行 gitnexus analyze)",
|
||||
'status.status': '状态',
|
||||
'status.upToDate': '✅ 已是最新',
|
||||
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
||||
@@ -34,6 +41,9 @@ export const zhCN = {
|
||||
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
|
||||
'clean.notFoundHere': '当前目录未找到已索引仓库。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。',
|
||||
'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}',
|
||||
'clean.deletedBranch': '已删除分支索引:{{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
|
||||
'clean.lbugSidecars.none': '未找到已隔离的 LadybugDB missing-shadow WAL sidecar。',
|
||||
'clean.lbugSidecars.preview':
|
||||
@@ -108,6 +118,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,7 +186,9 @@ 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.branch': '仅删除指定分支的索引(不影响主索引)',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
@@ -200,6 +214,7 @@ export const zhCN = {
|
||||
'help.option.query.limit': '最多返回的流程数(默认:5)',
|
||||
'help.option.content': '包含完整符号源码',
|
||||
'help.option.repo.target': '目标仓库',
|
||||
'help.option.branch': '将查询限定到指定分支的索引(多分支仓库)',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
|
||||
@@ -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)')
|
||||
@@ -49,6 +57,12 @@ program
|
||||
'Default branch used in the generated regression-compare example (base_ref). ' +
|
||||
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".',
|
||||
)
|
||||
.option(
|
||||
'--branch <name>',
|
||||
'Index the working tree under a specific branch slot (multi-branch indexing). ' +
|
||||
'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
|
||||
'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).',
|
||||
)
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-skills',
|
||||
@@ -137,6 +151,7 @@ program
|
||||
.description('Delete GitNexus index for current repo')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the primary)')
|
||||
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
|
||||
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
||||
|
||||
@@ -208,6 +223,7 @@ program
|
||||
.command('query <search_query>')
|
||||
.description('Search the knowledge graph for execution flows related to a concept')
|
||||
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-c, --context <text>', 'Task context to improve ranking')
|
||||
.option('-g, --goal <text>', 'What you want to find')
|
||||
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
||||
@@ -218,6 +234,7 @@ program
|
||||
.command('context [name]')
|
||||
.description('360-degree view of a code symbol: callers, callees, processes')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
@@ -228,6 +245,7 @@ program
|
||||
.description('Blast radius analysis: what breaks if you change a symbol')
|
||||
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option(
|
||||
@@ -245,6 +263,7 @@ program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
program
|
||||
@@ -254,6 +273,7 @@ program
|
||||
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
|
||||
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
@@ -38,6 +38,7 @@ export const listCommand = async () => {
|
||||
console.log(` ${t('common.path')}: ${entry.path}`);
|
||||
console.log(` ${t('list.indexed')}: ${indexedDate}`);
|
||||
console.log(` ${t('list.commit')}: ${commitShort}`);
|
||||
if (entry.branch) console.log(` ${t('list.branch')}: ${entry.branch}`);
|
||||
console.log(
|
||||
` ${t('list.stats')}: ${t('list.statsValue', {
|
||||
files: stats.files ?? 0,
|
||||
@@ -47,6 +48,18 @@ export const listCommand = async () => {
|
||||
);
|
||||
if (stats.communities) console.log(` ${t('list.clusters')}: ${stats.communities}`);
|
||||
if (stats.processes) console.log(` ${t('list.processes')}: ${stats.processes}`);
|
||||
// Per-branch indexes (#2106). Only rendered when extra branches were
|
||||
// indexed for this path, so single-branch output is unchanged.
|
||||
if (entry.branches && entry.branches.length > 0) {
|
||||
console.log(` ${t('list.branchIndexes')}:`);
|
||||
for (const b of entry.branches) {
|
||||
const bCommit = b.lastCommit?.slice(0, 7) || t('list.unknown');
|
||||
const bIndexed = new Date(b.indexedAt).toLocaleString();
|
||||
console.log(
|
||||
` ${t('list.branchLine', { branch: b.branch, commit: bCommit, indexed: bIndexed })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
};
|
||||
|
||||
+32
-35
@@ -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) {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Shows the indexing status of the current repository.
|
||||
*/
|
||||
|
||||
import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import path from 'path';
|
||||
import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export const statusCommand = async () => {
|
||||
@@ -32,11 +33,34 @@ export const statusCommand = async () => {
|
||||
}
|
||||
|
||||
const currentCommit = getCurrentCommit(repo.repoPath);
|
||||
const isUpToDate = currentCommit === repo.meta.lastCommit;
|
||||
const currentBranch = getCurrentBranch(repo.repoPath);
|
||||
|
||||
// Pick the index matching the checked-out branch (#2106). The flat index
|
||||
// belongs to the primary branch (repo.meta.branch); when the current branch
|
||||
// differs and has its own index, report that one. Legacy/no-branch metas and
|
||||
// detached HEAD fall through to the flat index (unchanged behavior).
|
||||
let activeMeta = repo.meta;
|
||||
let currentBranchIndexed = true;
|
||||
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
||||
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
|
||||
const branchMeta = await loadMeta(path.dirname(metaPath));
|
||||
if (branchMeta) activeMeta = branchMeta;
|
||||
else currentBranchIndexed = false;
|
||||
}
|
||||
|
||||
console.log(`${t('status.repository')}: ${repo.repoPath}`);
|
||||
console.log(`${t('status.indexed')}: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${repo.meta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
|
||||
|
||||
if (!currentBranchIndexed) {
|
||||
console.log(
|
||||
`${t('status.status')}: ${t('status.branchNotIndexed', { primary: repo.meta.branch ?? '' })}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpToDate = currentCommit === activeMeta.lastCommit;
|
||||
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function queryCommand(
|
||||
queryText: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
context?: string;
|
||||
goal?: string;
|
||||
limit?: string;
|
||||
@@ -81,6 +82,7 @@ export async function queryCommand(
|
||||
limit: options?.limit ? parseInt(options.limit) : undefined,
|
||||
include_content: options?.content ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -89,6 +91,7 @@ export async function contextCommand(
|
||||
name: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
file?: string;
|
||||
uid?: string;
|
||||
content?: boolean;
|
||||
@@ -111,6 +114,7 @@ export async function contextCommand(
|
||||
file_path: options?.file,
|
||||
include_content: options?.content ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -120,6 +124,7 @@ export async function impactCommand(
|
||||
options?: {
|
||||
direction?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
uid?: string;
|
||||
file?: string;
|
||||
kind?: string;
|
||||
@@ -165,6 +170,7 @@ export async function impactCommand(
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
limit: parsedLimit,
|
||||
offset: parsedOffset,
|
||||
summaryOnly: options?.summaryOnly ?? undefined,
|
||||
@@ -188,6 +194,7 @@ export async function cypherCommand(
|
||||
query: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!query?.trim()) {
|
||||
@@ -199,6 +206,7 @@ export async function cypherCommand(
|
||||
const result = await backend.callTool('cypher', {
|
||||
query,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -207,12 +215,14 @@ export async function detectChangesCommand(options?: {
|
||||
scope?: string;
|
||||
baseRef?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
}): Promise<void> {
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('detect_changes', {
|
||||
scope: options?.scope || 'unstaged',
|
||||
base_ref: options?.baseRef,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(formatDetectChangesResult(result));
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
};
|
||||
@@ -36,13 +36,8 @@ import {
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
CREATE_VECTOR_INDEX_QUERY,
|
||||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, STALE_HASH_SENTINEL } from '../lbug/schema.js';
|
||||
import { loadVectorExtension, createVectorIndex } from '../lbug/lbug-adapter.js';
|
||||
import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
import { logger } from '../logger.js';
|
||||
@@ -215,24 +210,36 @@ export const batchInsertEmbeddings = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the vector index for semantic search
|
||||
|
||||
* Now indexes the separate CodeEmbedding table.
|
||||
* Delegates extension loading to lbug-adapter's loadVectorExtension(),
|
||||
* which owns the VECTOR extension lifecycle and state tracking.
|
||||
|
||||
* Create the vector index for semantic search (indexes the CodeEmbedding table).
|
||||
*
|
||||
* Keeps the embedding-specific extension-install policy gate here
|
||||
* (ensureVectorExtensionAvailable → resolveEmbeddingInstallPolicy, default
|
||||
* `auto` for the analyze write path), then delegates the actual
|
||||
* `CALL CREATE_VECTOR_INDEX(...)` to the adapter, which runs it through the
|
||||
* unprepared `conn.query()` path. It must NOT go through the injected
|
||||
* `executeQuery` (prepared `conn.prepare()`): LadybugDB cannot prepare that
|
||||
* procedure and fails with "We do not support prepare multiple statements" —
|
||||
* the silent degrade in #2114.
|
||||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<boolean> => {
|
||||
const buildVectorIndex = async (): Promise<boolean> => {
|
||||
// This pre-check applies the embedding-specific install policy
|
||||
// (resolveEmbeddingInstallPolicy, default `auto` for analyze) before reaching
|
||||
// the adapter. The adapter's createVectorIndex() calls loadVectorExtension()
|
||||
// again, but that's a no-op here: once this gate loads VECTOR the module-level
|
||||
// `vectorExtensionLoaded` flag is set, so the adapter's second call
|
||||
// short-circuits without re-resolving the policy — no double install.
|
||||
if (!(await ensureVectorExtensionAvailable())) return false;
|
||||
try {
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
return true;
|
||||
return await createVectorIndex();
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
logger.warn({ error }, 'Vector index creation warning:');
|
||||
}
|
||||
// Surface this even outside dev: it silently downgrades a user-requested
|
||||
// feature (semantic search) to exact scan. Log under `err` so pino's
|
||||
// standard serializer captures the message/stack — logging under `error`
|
||||
// serialized an Error to `{}` (the empty `{"error":{}}` reported in #2114).
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Vector index creation failed; semantic search will use exact-scan fallback',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -383,7 +390,7 @@ export const runEmbeddingPipeline = async (
|
||||
// Ensure the vector index exists even when no new nodes need embedding.
|
||||
// A prior crash or first-time incremental run may have left CodeEmbedding
|
||||
// rows without ever reaching index creation.
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await buildVectorIndex();
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
@@ -544,7 +551,7 @@ export const runEmbeddingPipeline = async (
|
||||
logger.info('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await buildVectorIndex();
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
unquoteLiteral,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
import type {
|
||||
HttpDetection,
|
||||
HttpFileDetections,
|
||||
@@ -33,14 +38,6 @@ import type {
|
||||
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
|
||||
*/
|
||||
|
||||
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
GetMapping: 'GET',
|
||||
PostMapping: 'POST',
|
||||
PutMapping: 'PUT',
|
||||
DeleteMapping: 'DELETE',
|
||||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
// Each route-defining annotation has two AST shapes — a positional argument
|
||||
// and a named one — that must both be matched:
|
||||
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
||||
@@ -361,19 +358,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing class/interface declaration ancestor for
|
||||
* a node, or null if the node is top-level. Tree-sitter's
|
||||
* SyntaxNode.parent walks one level at a time.
|
||||
* Find the nearest enclosing interface declaration ancestor for a node, or
|
||||
* null if the node is top-level.
|
||||
*/
|
||||
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
if (cur.type === 'class_declaration') return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
@@ -439,18 +426,6 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named annotation argument contributes a route only when its member key is
|
||||
* `path` or `value`; a positional argument (no key node) always qualifies.
|
||||
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
|
||||
* drops Spring's non-route string attributes (`produces`, `consumes`,
|
||||
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
|
||||
*/
|
||||
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
|
||||
if (!keyNode) return true;
|
||||
return keyNode.text === 'path' || keyNode.text === 'value';
|
||||
}
|
||||
|
||||
interface MethodRouteAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -36,6 +36,8 @@ import type { VariableExtractor } from './variable-types.js';
|
||||
import type { ImportResolverFn } from './import-resolvers/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
||||
// ── Shared type aliases ────────────────────────────────────────────────────
|
||||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
@@ -236,6 +238,22 @@ interface LanguageProviderConfig {
|
||||
* Default: undefined (no route files). */
|
||||
readonly isRouteFile?: (filePath: string) => boolean;
|
||||
|
||||
/**
|
||||
* Extract decorator-style route annotations from a parsed file.
|
||||
*
|
||||
* When defined, the parse worker calls this after per-file capture processing
|
||||
* to extract framework route definitions that require AST-level analysis beyond
|
||||
* generic `@decorator` captures (e.g., Java Spring class-level prefix joining,
|
||||
* multi-class handling). The returned routes are appended to `decoratorRoutes`.
|
||||
*
|
||||
* Default: undefined (no language-specific decorator route extraction).
|
||||
*/
|
||||
readonly extractDecoratorRoutes?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset: number,
|
||||
) => ExtractedDecoratorRoute[];
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -13,6 +13,7 @@ import { javaClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { extractSpringRoutes } from '../route-extractors/spring.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
@@ -126,4 +127,7 @@ export const javaProvider = defineLanguage({
|
||||
arityCompatibility: javaArityCompatibility,
|
||||
resolveImportTarget: resolveJavaImportTarget,
|
||||
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
|
||||
|
||||
// ── Route extraction ──
|
||||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
});
|
||||
|
||||
@@ -367,25 +367,77 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
||||
// scan JS/TS consumer files for calls to those wrapper functions with
|
||||
// URL-like string arguments and add them to allFetchCalls so
|
||||
// processNextjsFetchRoutes can create FETCHES edges.
|
||||
if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
|
||||
const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
|
||||
// Wrapper names come from two sources: functions the parse phase
|
||||
// auto-detected as calling the bare global `fetch()`, plus any names the
|
||||
// user declared in `.gitnexusrc` `fetchWrappers` (#1589/#1852 residual).
|
||||
// Config names let an axios/custom-client wrapper — or one named outside the
|
||||
// built-in convention — still produce route_map consumers; without them it
|
||||
// silently falls back to `consumers: []`. Configured names alone are enough
|
||||
// to run the scan even when nothing was auto-detected.
|
||||
// Configured names are already validated/trimmed/de-duped/capped by
|
||||
// analyze-config.ts — trusted as-is (#1589/#1852 review F9, dropped the
|
||||
// redundant re-trim/re-filter). The single filter below guards only the
|
||||
// auto-detected `functionName`s, which have no shape guarantee.
|
||||
const configuredWrappers = ctx.options?.fetchWrappers ?? [];
|
||||
const wrapperNames = new Set<string>(
|
||||
[...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter(
|
||||
(n): n is string => typeof n === 'string' && n.trim().length > 0,
|
||||
),
|
||||
);
|
||||
if (wrapperNames.size > 0 && routeRegistry.size > 0) {
|
||||
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
|
||||
if (jsFiles.length > 0 && wrapperNames.size > 0) {
|
||||
const jsContents = await readFileContents(ctx.repoPath, jsFiles);
|
||||
for (const [filePath, content] of jsContents) {
|
||||
for (const name of wrapperNames) {
|
||||
const regex = new RegExp(
|
||||
`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
|
||||
'g',
|
||||
);
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
allFetchCalls.push({
|
||||
filePath,
|
||||
fetchURL: match[1],
|
||||
lineNumber: content.substring(0, match.index).split('\n').length,
|
||||
});
|
||||
if (jsFiles.length > 0) {
|
||||
// Reuse contents already read for handler extraction; only read the
|
||||
// remainder (mirrors the Expo block above). Avoids a second full read of
|
||||
// files we already have in memory.
|
||||
const unreadJsFiles = jsFiles.filter((p) => !handlerContents?.has(p));
|
||||
const extraContents =
|
||||
unreadJsFiles.length > 0
|
||||
? await readFileContents(ctx.repoPath, unreadJsFiles)
|
||||
: new Map<string, string>();
|
||||
// One alternation regex over every wrapper name per file — O(files), not
|
||||
// O(files × wrappers) (#1852 review F3). Names are escaped and grouped
|
||||
// non-capturing so capture group 1 stays the URL. The left boundary is a
|
||||
// negative lookbehind, not `\b`: a bare configured name like `get` must
|
||||
// match the free call `get('/x')` but NOT a member access `client.get(`
|
||||
// (a `.get(` on an unrelated object), and `apiFetch` must not match
|
||||
// `myApiFetch`. Member-style wrappers are configured with the dot
|
||||
// (`client.get`), where the `.` is part of the pattern. The `u` flag +
|
||||
// Unicode property classes make the boundary cover non-ASCII identifier
|
||||
// characters too — ASCII `\w` would let `caféget('/x')` match `get`
|
||||
// (#1852 review F10).
|
||||
const alternation = [...wrapperNames].map(escapeRegex).join('|');
|
||||
const wrapperCallRegex = new RegExp(
|
||||
`(?<![.\\p{L}\\p{N}_$])(?:${alternation})\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
|
||||
'gu',
|
||||
);
|
||||
const scanContent = (filePath: string, content: string): void => {
|
||||
wrapperCallRegex.lastIndex = 0;
|
||||
// 1-based line number via a running newline counter: matches arrive in
|
||||
// ascending index, so accumulate newlines incrementally instead of
|
||||
// re-allocating `content.substring(0, match.index).split('\n')` on
|
||||
// every match (#1852 review F12). Output is identical.
|
||||
let line = 1;
|
||||
let scanned = 0;
|
||||
let match;
|
||||
while ((match = wrapperCallRegex.exec(content)) !== null) {
|
||||
for (; scanned < match.index; scanned++) {
|
||||
if (content.charCodeAt(scanned) === 10 /* '\n' */) line++;
|
||||
}
|
||||
allFetchCalls.push({
|
||||
filePath,
|
||||
fetchURL: match[1],
|
||||
lineNumber: line,
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const [filePath, content] of extraContents) scanContent(filePath, content);
|
||||
// Also scan already-read JS/TS handler files (a handler can itself
|
||||
// consume another route through a wrapper).
|
||||
if (handlerContents) {
|
||||
for (const p of jsFiles) {
|
||||
const cached = handlerContents.get(p);
|
||||
if (cached !== undefined) scanContent(p, cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,15 @@ export interface PipelineOptions {
|
||||
* `process.env` state across invocations. When undefined, the env var decides.
|
||||
*/
|
||||
keepLocalValueSymbols?: boolean;
|
||||
/**
|
||||
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
|
||||
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
|
||||
* residual). The routes phase unions these with the auto-detected `fetch()`
|
||||
* wrappers when scanning for `route_map` consumers, so a wrapper named outside
|
||||
* the built-in convention (or built on axios / a custom client) is still
|
||||
* traced. Empty/undefined leaves behavior unchanged.
|
||||
*/
|
||||
fetchWrappers?: readonly string[];
|
||||
}
|
||||
|
||||
// ── Phase registry ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared Spring route-annotation primitives.
|
||||
*
|
||||
* These are the low-level building blocks the two Spring route extractors —
|
||||
* the ingestion-layer `route-extractors/spring.ts` (produces graph `Route`
|
||||
* nodes) and the group-layer `group/extractors/http-patterns/java.ts`
|
||||
* (produces cross-repo HTTP contracts) — would otherwise each maintain
|
||||
* independently. Centralising the annotation→method map, the enclosing-class
|
||||
* lookup, and the route-key filter keeps those semantics in one place so the
|
||||
* two extractors can't drift apart.
|
||||
*
|
||||
* This module lives in `ingestion/` (the lower layer); the group layer imports
|
||||
* from it, matching the existing `group → ingestion` dependency direction
|
||||
* (e.g. `group/extractors/include-extractor.ts` already imports
|
||||
* `ingestion/import-resolvers/utils.ts`). It MUST NOT import anything from
|
||||
* `group/` to avoid a dependency cycle.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
/**
|
||||
* Spring shortcut method-annotation → HTTP verb.
|
||||
*
|
||||
* `@RequestMapping` is intentionally absent: on a method it carries no implicit
|
||||
* verb (the verb lives in its `method = RequestMethod.X` attribute), and on a
|
||||
* class it is a URL prefix rather than a route. Callers handle `@RequestMapping`
|
||||
* separately.
|
||||
*/
|
||||
export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
GetMapping: 'GET',
|
||||
PostMapping: 'POST',
|
||||
PutMapping: 'PUT',
|
||||
DeleteMapping: 'DELETE',
|
||||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* A named annotation argument contributes a route only when its member key is
|
||||
* `path` or `value`; a positional argument (no key node) always qualifies.
|
||||
* Drops Spring's non-route string attributes (`produces`, `consumes`,
|
||||
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
|
||||
*/
|
||||
export function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
|
||||
if (!keyNode) return true;
|
||||
return keyNode.text === 'path' || keyNode.text === 'value';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing `class_declaration` ancestor for a node, or null
|
||||
* if the node is top-level. Tree-sitter's `SyntaxNode.parent` walks one level
|
||||
* at a time.
|
||||
*/
|
||||
export function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
if (cur.type === 'class_declaration') return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip enclosing quotes from a tree-sitter string-literal node's text.
|
||||
* Handles single / double / template (backtick) quotes and triple-quoted
|
||||
* strings. Mirrors the safer semantics of the group layer's `unquoteLiteral`:
|
||||
* returns `null` for empty / nullish input so callers can uniformly skip
|
||||
* captures whose value is missing, and returns the text unchanged when it
|
||||
* carries no recognisable surrounding quotes (some grammars expose string
|
||||
* content without quotes already).
|
||||
*/
|
||||
export function unquoteSpringLiteral(raw: string): string | null {
|
||||
if (!raw) return null;
|
||||
|
||||
if (
|
||||
(raw.startsWith('"""') && raw.endsWith('"""')) ||
|
||||
(raw.startsWith("'''") && raw.endsWith("'''"))
|
||||
) {
|
||||
return raw.slice(3, -3);
|
||||
}
|
||||
|
||||
const first = raw[0];
|
||||
const last = raw[raw.length - 1];
|
||||
if ((first === '"' || first === "'" || first === '`') && last === first && raw.length >= 2) {
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Spring route annotation extractor for the ingestion pipeline.
|
||||
*
|
||||
* Extracts `@GetMapping`, `@PostMapping`, `@PutMapping`, `@DeleteMapping`,
|
||||
* `@PatchMapping`, and `@RequestMapping` annotations from Java source files
|
||||
* and returns `ExtractedDecoratorRoute[]` with class-level `@RequestMapping`
|
||||
* prefixes already resolved per-class.
|
||||
*
|
||||
* This module is the ingestion-layer counterpart of
|
||||
* `group/extractors/http-patterns/java.ts` (which extracts HTTP contracts
|
||||
* for cross-repo matching). It uses the same tree-sitter capture approach:
|
||||
* a single predicate-free query matches all route annotations generically,
|
||||
* then a for-loop discriminates class-level prefixes from method-level routes
|
||||
* by reading `@node.type` and the annotation name.
|
||||
*
|
||||
* The query is predicate-free to avoid the tree-sitter 0.21.x hazard where
|
||||
* `#match?` / `#eq?` predicates in a top-level `[...]` alternation silently
|
||||
* drop sibling-branch matches (see group-layer `JAVA_ROUTE_ANNOTATION_PATTERNS`
|
||||
* header comment for details).
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
unquoteSpringLiteral,
|
||||
} from './spring-shared.js';
|
||||
|
||||
/**
|
||||
* Single predicate-free tree-sitter query that captures all route annotations
|
||||
* on classes and methods. Discrimination by annotation name and node type
|
||||
* happens in the loop below.
|
||||
*
|
||||
* Captures:
|
||||
* @ann → annotation name identifier (RequestMapping, GetMapping, etc.)
|
||||
* @node → enclosing declaration (class_declaration | method_declaration)
|
||||
* @value → the string-literal argument
|
||||
* @key → the named-argument member key (absent for positional form)
|
||||
*/
|
||||
const ROUTE_ANNOTATION_QUERY = new Parser.Query(
|
||||
Java,
|
||||
`
|
||||
[
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
]
|
||||
`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract Spring route annotations from a parsed Java file.
|
||||
*
|
||||
* Uses a single tree-sitter query pass to capture all annotations, then
|
||||
* discriminates class-level prefixes from method-level routes in a loop.
|
||||
* Handles multiple classes per file, each with its own prefix.
|
||||
*
|
||||
* @param tree - tree-sitter parse tree
|
||||
* @param filePath - relative file path (for `ExtractedDecoratorRoute.filePath`)
|
||||
* @param lineOffset - line offset for pre-processing (usually 0)
|
||||
* @returns Decorator routes with prefix already set per-class
|
||||
*/
|
||||
export function extractSpringRoutes(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset = 0,
|
||||
): ExtractedDecoratorRoute[] {
|
||||
const matches = ROUTE_ANNOTATION_QUERY.matches(tree.rootNode);
|
||||
|
||||
// Phase 1: collect class-level @RequestMapping prefixes keyed by node id
|
||||
const prefixByClassId = new Map<number, string>();
|
||||
|
||||
for (const match of matches) {
|
||||
const caps: Record<string, Parser.SyntaxNode> = {};
|
||||
for (const { name, node } of match.captures) {
|
||||
caps[name] = node;
|
||||
}
|
||||
const annNode = caps['ann'];
|
||||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
|
||||
if (node.type === 'class_declaration' && annNode.text === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const prefix = unquoteSpringLiteral(valueNode.text);
|
||||
if (prefix !== null) prefixByClassId.set(node.id, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: collect method-level routes and resolve their class prefix
|
||||
const routes: ExtractedDecoratorRoute[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
const caps: Record<string, Parser.SyntaxNode> = {};
|
||||
for (const { name, node } of match.captures) {
|
||||
caps[name] = node;
|
||||
}
|
||||
const annNode = caps['ann'];
|
||||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
|
||||
if (node.type !== 'method_declaration') continue;
|
||||
|
||||
const ann = annNode.text;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
|
||||
if (!httpMethod) continue; // skip @RequestMapping on methods (ambiguous verb)
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
|
||||
const routePath = unquoteSpringLiteral(valueNode.text);
|
||||
if (routePath === null) continue;
|
||||
const enclosingClass = findEnclosingClass(node);
|
||||
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
|
||||
|
||||
routes.push({
|
||||
filePath,
|
||||
routePath,
|
||||
httpMethod,
|
||||
decoratorName: ann,
|
||||
lineNumber: annNode.startPosition.row + lineOffset,
|
||||
...(classPrefix ? { prefix: classPrefix } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import Python from 'tree-sitter-python';
|
||||
import Java from 'tree-sitter-java';
|
||||
import C from 'tree-sitter-c';
|
||||
import CPP from 'tree-sitter-cpp';
|
||||
// Explicit subpath import — see parser-loader.ts for rationale (#1013).
|
||||
import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js';
|
||||
@@ -67,6 +66,16 @@ let Kotlin: TreeSitterLanguage | null = null;
|
||||
try {
|
||||
Kotlin = _require('tree-sitter-kotlin');
|
||||
} catch {}
|
||||
|
||||
// tree-sitter-c is now vendored prebuild-only (#2116) and may be absent on a
|
||||
// toolchain-less / `--ignore-scripts` install. Guard it like Swift/Dart/Kotlin so
|
||||
// a missing binding cannot crash the worker at module-load (#2091/#2093); the
|
||||
// main-thread `isLanguageAvailable` filter keeps C files from being dispatched
|
||||
// here when the entry is absent.
|
||||
let C: TreeSitterLanguage | null = null;
|
||||
try {
|
||||
C = _require('tree-sitter-c');
|
||||
} catch {}
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import {
|
||||
buildConcreteTypedefDefinitionRanges,
|
||||
@@ -404,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,
|
||||
@@ -1026,6 +1035,7 @@ const ROUTE_DECORATOR_NAMES = new Set([
|
||||
'PostMapping',
|
||||
'PutMapping',
|
||||
'DeleteMapping',
|
||||
'PatchMapping',
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
@@ -2272,6 +2282,15 @@ const processFileGroup = (
|
||||
);
|
||||
}
|
||||
|
||||
// Language-specific decorator route extraction via provider hook.
|
||||
// The provider's extractDecoratorRoutes walks the AST for framework-specific
|
||||
// route patterns (e.g., Java Spring class-level prefix joining). Routes are
|
||||
// appended to decoratorRoutes for the routes phase to emit as Route nodes.
|
||||
if (provider.extractDecoratorRoutes) {
|
||||
const frameworkRoutes = provider.extractDecoratorRoutes(tree, file.path, lineOffset);
|
||||
for (const r of frameworkRoutes) result.decoratorRoutes.push(r);
|
||||
}
|
||||
|
||||
// Vue: emit CALLS edges for components used in <template>
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
REL_TABLE_NAME,
|
||||
SCHEMA_QUERIES,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
CREATE_VECTOR_INDEX_QUERY,
|
||||
STALE_HASH_SENTINEL,
|
||||
NodeTableName,
|
||||
} from './schema.js';
|
||||
@@ -171,6 +172,11 @@ let currentDbPath: string | null = null;
|
||||
let currentDbReadOnly = false;
|
||||
let ftsLoaded = false;
|
||||
let vectorExtensionLoaded = false;
|
||||
// In-process guard so a repeated createVectorIndex() within one connection
|
||||
// lifetime skips the DB round-trip (mirrors ensuredFTSIndexes). Reset wherever
|
||||
// vectorExtensionLoaded resets, so it can never stay true against a swapped or
|
||||
// closed connection.
|
||||
let vectorIndexEnsured = false;
|
||||
|
||||
/**
|
||||
* In-process cache of FTS indexes observed against the current singleton
|
||||
@@ -603,6 +609,7 @@ const resetOpenConnectionState = (): void => {
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
vectorIndexEnsured = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
};
|
||||
|
||||
@@ -690,6 +697,7 @@ export const withLbugDb = async <T>(
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
vectorIndexEnsured = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
});
|
||||
// Sleep outside the lock — no need to block others while waiting
|
||||
@@ -716,6 +724,7 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
vectorIndexEnsured = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
}
|
||||
|
||||
@@ -1671,6 +1680,7 @@ export const closeLbug = async (): Promise<void> => {
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
vectorIndexEnsured = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
};
|
||||
|
||||
@@ -1938,6 +1948,50 @@ export const createFTSIndex = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the HNSW vector index on the CodeEmbedding table.
|
||||
*
|
||||
* MUST run via `conn.query()` (here through `queryAndDrain`), NOT through the
|
||||
* prepared `executeQuery`/`conn.prepare()` path: `CALL CREATE_VECTOR_INDEX(...)`
|
||||
* compiles to multiple statements, which LadybugDB cannot prepare — it fails
|
||||
* with "Connection Exception: We do not support prepare multiple statements."
|
||||
* Routing index creation through `executeQuery` (prepared) is exactly what
|
||||
* broke vector-index creation during `analyze` (#2114; the singleton
|
||||
* `executeQuery` was switched to the prepared path in #1655 while FTS index
|
||||
* creation kept using `conn.query()`, which is why FTS survived and VECTOR did
|
||||
* not). Mirrors `createFTSIndex` above.
|
||||
*
|
||||
* Returns `true` on success (or when the index already exists — idempotent so
|
||||
* incremental re-runs don't spuriously downgrade to exact scan), `false` when
|
||||
* the VECTOR extension is unavailable or the connection is read-only. Any other
|
||||
* failure propagates so the caller can log it.
|
||||
*/
|
||||
export const createVectorIndex = async (): Promise<boolean> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
// Already built on this connection — skip the round-trip (mirrors createFTSIndex).
|
||||
if (vectorIndexEnsured) return true;
|
||||
if (!(await loadVectorExtension())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await queryAndDrain(conn, CREATE_VECTOR_INDEX_QUERY);
|
||||
vectorIndexEnsured = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Idempotent: a prior analyze already built the HNSW index.
|
||||
if (msg.includes('already exists')) {
|
||||
vectorIndexEnsured = true;
|
||||
return true;
|
||||
}
|
||||
// Read-only DB (e.g. the MCP query pool): writable analyze owns creation.
|
||||
if (isReadOnlyDbError(e)) return false;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazy-create an FTS index, caching the fact in-process.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from './lbug/wal-checkpoint-driver.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
resolveBranchPlacement,
|
||||
saveMeta,
|
||||
loadMeta,
|
||||
ensureGitNexusIgnored,
|
||||
@@ -60,6 +61,8 @@ import {
|
||||
} from '../storage/parsedfile-store.js';
|
||||
import {
|
||||
getCurrentCommit,
|
||||
getCurrentBranch,
|
||||
getDefaultBranch,
|
||||
getRemoteUrl,
|
||||
hasGitDir,
|
||||
getInferredRepoName,
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
} from '../storage/git.js';
|
||||
import type { CachedEmbedding } from './embeddings/types.js';
|
||||
import { generateAIContextFiles } from '../cli/ai-context.js';
|
||||
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
||||
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
|
||||
import { STALE_HASH_SENTINEL } from './lbug/schema.js';
|
||||
|
||||
@@ -122,6 +126,16 @@ export interface AnalyzeOptions {
|
||||
* "main" fallback for non-CLI callers (e.g. the server analyze worker).
|
||||
*/
|
||||
defaultBranch?: string;
|
||||
/**
|
||||
* Index-branch selector (#2106). Distinct from `defaultBranch` (which only
|
||||
* affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this run is
|
||||
* labelled as that branch and routed to a per-branch index slot unless it is
|
||||
* the primary branch. When `undefined`, the branch is auto-detected from the
|
||||
* checked-out HEAD (the flat/primary slot for the first-indexed branch, a
|
||||
* `branches/<slug>/` sub-directory otherwise). Detached HEAD / non-git always
|
||||
* maps to the flat slot.
|
||||
*/
|
||||
branch?: string;
|
||||
/**
|
||||
* User-provided alias for the registry `name` (#829). When set,
|
||||
* forwarded to `registerRepo` so the indexed repo is stored under
|
||||
@@ -145,6 +159,13 @@ export interface AnalyzeOptions {
|
||||
* removed); `undefined` defers to the env / auto-formula fallback.
|
||||
*/
|
||||
workerPoolSize?: number;
|
||||
/**
|
||||
* Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to
|
||||
* `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI
|
||||
* `.gitnexusrc` `fetchWrappers` list. `undefined`/empty leaves the route
|
||||
* consumer scan unchanged.
|
||||
*/
|
||||
fetchWrappers?: string[];
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
@@ -170,6 +191,13 @@ export interface AnalyzeResult {
|
||||
* the persisted meta surface the degraded state instead of reporting healthy.
|
||||
*/
|
||||
ftsSkipped?: boolean;
|
||||
/**
|
||||
* True when the index this run produced/validated is the primary/flat slot
|
||||
* (#2106 R2). `false` for a non-primary branch index. Lets the CLI skip
|
||||
* repo-root AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a
|
||||
* branch analyze, mirroring the in-pipeline `if (!placement.branch)` gate.
|
||||
*/
|
||||
isPrimaryBranch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,6 +253,66 @@ export const PHASE_LABELS: Record<string, string> = {
|
||||
* the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
|
||||
* directly and never calls `process.exit()`.
|
||||
*/
|
||||
/**
|
||||
* Build the primary-inversion warning (#2106 R8), or `undefined` when there is
|
||||
* nothing to warn about. Pure + exported for testing. Both inputs are trimmed
|
||||
* (a diagnostic — a missed warning is low-harm; a false warning is the thing to
|
||||
* avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
|
||||
* e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
|
||||
*/
|
||||
export const primaryInversionWarning = (
|
||||
defaultBranch: string | null | undefined,
|
||||
flatOwner: string | null | undefined,
|
||||
): string | undefined => {
|
||||
const norm = (s: string | null | undefined): string | undefined => s?.trim() || undefined;
|
||||
const d = norm(defaultBranch);
|
||||
const o = norm(flatOwner);
|
||||
if (!d || !o || d === o) return undefined;
|
||||
return (
|
||||
`Warning: the default branch "${d}" is not the primary index — "${o}" owns the flat slot. ` +
|
||||
`Run \`gitnexus clean --branch ${o}\` then re-index on "${d}", or query it explicitly with \`--branch ${d}\`.`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect the recorded parse-cache chunk keys across the flat + every branch
|
||||
* meta under a flat `.gitnexus` storage, EXCLUDING `excludeDir` (the current
|
||||
* run's own meta dir) so a single-branch repo collects nothing and its prune
|
||||
* stays byte-identical to today (#2106 R6). `complete` is false when a sibling
|
||||
* meta.json exists but fails to parse — callers then retain the whole shared
|
||||
* cache rather than over-evict another branch's still-live shards. Exported for
|
||||
* testing.
|
||||
*/
|
||||
export const collectBranchCacheKeys = async (
|
||||
storagePath: string,
|
||||
excludeDir?: string,
|
||||
): Promise<{ keys: Set<string>; complete: boolean }> => {
|
||||
const keys = new Set<string>();
|
||||
let complete = true;
|
||||
const metaDirs = [storagePath];
|
||||
const branchesDir = path.join(storagePath, 'branches');
|
||||
const slugs = await fs.readdir(branchesDir).catch(() => [] as string[]);
|
||||
for (const slug of slugs) metaDirs.push(path.join(branchesDir, slug));
|
||||
for (const dir of metaDirs) {
|
||||
if (excludeDir && path.resolve(dir) === path.resolve(excludeDir)) continue;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(dir, 'meta.json'), 'utf-8');
|
||||
} catch {
|
||||
continue; // no meta here — not a branch index, not a failure
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { cacheKeys?: unknown };
|
||||
if (Array.isArray(parsed.cacheKeys)) {
|
||||
for (const k of parsed.cacheKeys) if (typeof k === 'string') keys.add(k);
|
||||
}
|
||||
} catch {
|
||||
complete = false; // present but corrupt → fail-safe toward retention
|
||||
}
|
||||
}
|
||||
return { keys, complete };
|
||||
};
|
||||
|
||||
export async function runFullAnalysis(
|
||||
repoPath: string,
|
||||
options: AnalyzeOptions,
|
||||
@@ -242,7 +330,10 @@ export async function runFullAnalysis(
|
||||
// worker-side reset is needed (see safe-parse.ts ParseTimeoutError contract).
|
||||
resetDegradedParseCounter();
|
||||
|
||||
const { storagePath, lbugPath } = getStoragePaths(repoPath);
|
||||
// `storagePath` is ALWAYS the flat `.gitnexus` — content-addressed caches
|
||||
// (parse-cache, parsedfile-store) and the kuzu-migration cleanup live there
|
||||
// and are shared across branches (#2106 KTD7).
|
||||
const { storagePath } = getStoragePaths(repoPath);
|
||||
|
||||
// Clean up stale KuzuDB files from before the LadybugDB migration.
|
||||
const kuzuResult = await cleanupOldKuzuFiles(storagePath);
|
||||
@@ -252,7 +343,57 @@ export async function runFullAnalysis(
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
// ── #2106: resolve which branch slot this run writes to ───────────────
|
||||
// `branchLabel` is the branch identity recorded in meta.json (incl. the
|
||||
// primary). `placement.branch` is undefined for the flat/primary slot (the
|
||||
// lbug/meta paths stay byte-identical to single-branch behavior) and set for
|
||||
// a `branches/<slug>/` sub-directory. Explicit `--branch` is always honored;
|
||||
// otherwise auto-detect the checked-out branch (null for detached HEAD /
|
||||
// non-git → flat slot).
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
// validated (#2106 R1): a git ref the branch-name rules forbid (backtick,
|
||||
// `~ ^ : ? *`, leading `-`, `..`) becomes `null` → the flat slot, matching
|
||||
// that a later `--branch <that-ref>` query would also be rejected. A normal
|
||||
// ref passes through unchanged so index-time and query-time labels round-trip.
|
||||
const checkedOutBranch = repoHasGit
|
||||
? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null)
|
||||
: null;
|
||||
// Analyze indexes the working tree, not an arbitrary ref. An explicit
|
||||
// `--branch X` while a DIFFERENT branch Y is checked out would write Y's
|
||||
// content (and Y's commit) into X's index slot, corrupting X (#2106). Refuse
|
||||
// the mismatch. Detached HEAD / non-git (checkedOutBranch === null) still
|
||||
// allow an explicit label so CI checkouts can name their snapshot.
|
||||
if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) {
|
||||
throw new Error(
|
||||
`--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` +
|
||||
`Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`,
|
||||
);
|
||||
}
|
||||
const branchLabel = options.branch ?? checkedOutBranch;
|
||||
const placement = await resolveBranchPlacement(repoPath, branchLabel);
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
||||
// Directory that owns this run's meta.json (flat `.gitnexus` for the primary
|
||||
// slot, `branches/<slug>/` otherwise). loadMeta/saveMeta operate on it so
|
||||
// each branch keeps its own lastCommit / fileHashes / incremental dirty flag.
|
||||
const metaDir = path.dirname(metaPath);
|
||||
|
||||
const existingMeta = await loadMeta(metaDir);
|
||||
|
||||
// ── #2106 (R8): warn when the repo's default branch is not the primary ──
|
||||
// A non-default branch can own the flat slot (it was indexed first). That
|
||||
// index is still fully queryable via `--branch`, so this is an ergonomics
|
||||
// wart, not data loss — we only warn (no risky relocation of a live DB).
|
||||
if (repoHasGit) {
|
||||
// Who owns the flat slot after this run? For a flat/primary run it is this
|
||||
// run's resolved label (carrying an existing stamp forward); for a branch
|
||||
// run the flat owner is unchanged, so read the flat meta.
|
||||
const flatOwner = placement.branch
|
||||
? (await loadMeta(storagePath))?.branch
|
||||
: (branchLabel ?? existingMeta?.branch);
|
||||
const warning = primaryInversionWarning(getDefaultBranch(repoPath), flatOwner);
|
||||
if (warning) log(warning);
|
||||
}
|
||||
|
||||
// ── FTS-only repair path ────────────────────────────────────────────
|
||||
if (options.repairFts) {
|
||||
@@ -400,6 +541,7 @@ export async function runFullAnalysis(
|
||||
repoPath,
|
||||
stats: existingMeta.stats ?? {},
|
||||
alreadyUpToDate: true,
|
||||
isPrimaryBranch: !placement.branch,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -506,6 +648,7 @@ export async function runFullAnalysis(
|
||||
{
|
||||
parseCache,
|
||||
workerPoolSize: options.workerPoolSize,
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -553,8 +696,8 @@ export async function runFullAnalysis(
|
||||
} unchanged file rows preserved)`,
|
||||
);
|
||||
// Set the dirty flag BEFORE any destructive DB mutation. Cleared on
|
||||
// success at the meta-save step.
|
||||
await saveMeta(storagePath, {
|
||||
// success at the meta-save step. Scoped to this branch's meta.json.
|
||||
await saveMeta(metaDir, {
|
||||
...existingMeta!,
|
||||
incrementalInProgress: {
|
||||
startedAt: Date.now(),
|
||||
@@ -938,6 +1081,14 @@ export async function runFullAnalysis(
|
||||
repoPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
// Branch identity this index represents (#2106). Recorded for the flat
|
||||
// slot too (so resolveBranchPlacement knows which branch owns it). When
|
||||
// the label is null (detached HEAD / non-git re-analyze) we PRESERVE an
|
||||
// existing stamp rather than stripping it — otherwise a detached re-index
|
||||
// of the primary (e.g. CI's `actions/checkout` default) would un-claim the
|
||||
// flat slot and let the next branch analyze overwrite the primary index.
|
||||
// Stays absent only when never stamped (fresh detached/non-git repo).
|
||||
branch: branchLabel ?? existingMeta?.branch,
|
||||
// Captured here (not at registration) so it travels with the
|
||||
// on-disk meta.json — sibling-clone fingerprinting works for
|
||||
// out-of-tree consumers (group-status, future tooling) without
|
||||
@@ -976,9 +1127,14 @@ export async function runFullAnalysis(
|
||||
// dirty flag (full and incremental success paths converge here).
|
||||
schemaVersion: hasGitDir(repoPath) ? INCREMENTAL_SCHEMA_VERSION : undefined,
|
||||
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
|
||||
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
|
||||
// chunk hash touched in this scan — cache HITS included (see parse-impl
|
||||
// usedKeys.add) — so it's complete even on an incremental run. Persisted
|
||||
// so a sibling branch's prune can union it and not evict our shards.
|
||||
cacheKeys: [...parseCache.usedKeys],
|
||||
incrementalInProgress: undefined as { startedAt: number; toWriteCount: number } | undefined,
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await saveMeta(metaDir, meta);
|
||||
|
||||
// Persist the incremental parse cache for the next run. Wraps in
|
||||
// try/catch so a cache-write failure never breaks an otherwise
|
||||
@@ -988,6 +1144,22 @@ export async function runFullAnalysis(
|
||||
// dead weight; the parse phase populates `usedKeys` as it processes
|
||||
// chunks).
|
||||
try {
|
||||
// #2106 R6: the parse cache + durable store are shared across branches.
|
||||
// Before pruning to this run's keys, fold in the OTHER branches' recorded
|
||||
// chunk keys so a branch switch doesn't evict their still-live shards.
|
||||
// Adding to usedKeys makes them survive pruneCache AND land in the saved
|
||||
// index (saveParseCache builds the index from usedKeys). Excludes this
|
||||
// run's own meta dir, so a single-branch repo folds in nothing → prune
|
||||
// set byte-identical to today.
|
||||
const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
|
||||
if (complete) {
|
||||
for (const k of siblingKeys) parseCache.usedKeys.add(k);
|
||||
} else {
|
||||
// Fail-safe toward retention: a sibling meta was unreadable, so keep
|
||||
// everything currently loaded rather than evict on incomplete info.
|
||||
log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).');
|
||||
for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k);
|
||||
}
|
||||
const pruned = pruneCache(parseCache, parseCache.usedKeys);
|
||||
if (pruned > 0) {
|
||||
log(`Parse cache: pruned ${pruned} stale chunk entries`);
|
||||
@@ -1021,6 +1193,10 @@ export async function runFullAnalysis(
|
||||
const projectName = await registerRepo(repoPath, meta, {
|
||||
name: options.registryName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
// Non-primary branch runs upsert into the entry's branches[]; the
|
||||
// primary/flat run (placement.branch === undefined) refreshes the
|
||||
// top-level fields (#2106).
|
||||
branch: placement.branch,
|
||||
});
|
||||
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
@@ -1037,29 +1213,34 @@ export async function runFullAnalysis(
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length;
|
||||
}
|
||||
|
||||
try {
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
storagePath,
|
||||
projectName,
|
||||
{
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: options.skipAgentsMd,
|
||||
skipSkills: options.skipSkills,
|
||||
noStats: options.noStats,
|
||||
defaultBranch: options.defaultBranch,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — don't fail the entire analysis for context file issues
|
||||
// Only (re)generate the repo-root AI context files (AGENTS.md / CLAUDE.md /
|
||||
// skills) for the primary/flat index (#2106). A non-primary branch analyze
|
||||
// must not churn the repo's committed AGENTS.md with branch-specific stats.
|
||||
if (!placement.branch) {
|
||||
try {
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
storagePath,
|
||||
projectName,
|
||||
{
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: options.skipAgentsMd,
|
||||
skipSkills: options.skipSkills,
|
||||
noStats: options.noStats,
|
||||
defaultBranch: options.defaultBranch,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — don't fail the entire analysis for context file issues
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close LadybugDB ──────────────────────────────────────────────
|
||||
@@ -1076,6 +1257,7 @@ export async function runFullAnalysis(
|
||||
stats: meta.stats,
|
||||
pipelineResult,
|
||||
ftsSkipped: !ftsAvailable,
|
||||
isPrimaryBranch: !placement.branch,
|
||||
};
|
||||
} catch (err) {
|
||||
// Ensure LadybugDB is closed even on error. Stop the driver first
|
||||
|
||||
@@ -121,25 +121,26 @@ const SOURCES: Record<string, GrammarSource> = {
|
||||
'Vue parsing piggybacks on `tree-sitter-typescript`. Check the install and native binding.',
|
||||
},
|
||||
|
||||
// tree-sitter-c is a required dependency, but its native binding has
|
||||
// historically been ABI-incompatible with the bundled tree-sitter@0.21.1
|
||||
// runtime on some platforms (#1242, #858). Loading it through the
|
||||
// optional machinery turns a would-be segfault into a clean degradation
|
||||
// while preserving every other language's analysis. Severity is pinned
|
||||
// to `error` because the package is in `dependencies`: a failure here
|
||||
// is always an install/platform problem the user needs to see, never an
|
||||
// expected "user opted out" condition like Swift/Dart/Kotlin.
|
||||
// tree-sitter-c is a core grammar, vendored prebuild-only (under
|
||||
// gitnexus/vendor/tree-sitter-c) with GitNexus-built prebuilds for every
|
||||
// supported platform-arch — upstream ships only 4/6 (#2116) and C is a
|
||||
// required grammar whose source build hard-fails install on a toolchain-less
|
||||
// ARM host. Loading through the optional machinery turns a would-be ABI
|
||||
// segfault (#1242, #858) into a clean degradation while preserving every
|
||||
// other language's analysis. Severity stays `error` because C is not a
|
||||
// user-opt-out grammar like Swift/Dart/Kotlin: a failure here is always an
|
||||
// install/platform problem the user needs to see.
|
||||
[SupportedLanguages.C]: {
|
||||
load: () => _require('tree-sitter-c'),
|
||||
optional: true,
|
||||
severity: 'error',
|
||||
unavailableNote:
|
||||
'C parsing disabled: `tree-sitter-c` could not be loaded. ' +
|
||||
'This package is in `dependencies` and prebuilds ship for all supported ' +
|
||||
'platforms (win32/darwin/linux x64+arm64, Node 18/20/22), so this ' +
|
||||
'usually indicates a corrupted install, an unsupported Node version, ' +
|
||||
'or a native ABI mismatch with the bundled tree-sitter runtime. ' +
|
||||
'Try `npm rebuild tree-sitter-c` or reinstalling, then re-run analyze. ' +
|
||||
'C parsing disabled: vendored `tree-sitter-c` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-c`) could not be loaded. GitNexus ships ' +
|
||||
'prebuilt binaries for all supported platforms (win32/darwin/linux ' +
|
||||
'x64+arm64, N-API), so this usually indicates a corrupted install or a ' +
|
||||
'native ABI mismatch with the bundled tree-sitter@0.21.1 runtime. ' +
|
||||
'Try reinstalling, then re-run analyze. ' +
|
||||
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
|
||||
},
|
||||
|
||||
@@ -170,8 +171,10 @@ const SOURCES: Record<string, GrammarSource> = {
|
||||
optional: true,
|
||||
userSkippable: true,
|
||||
unavailableNote:
|
||||
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
|
||||
'and is not installed (or its native binding failed to build).',
|
||||
'Kotlin parsing disabled: vendored `tree-sitter-kotlin` (under ' +
|
||||
'`gitnexus/vendor/tree-sitter-kotlin`) failed to load. ' +
|
||||
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
|
||||
`See ${ISSUES_URL}/2107.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
},
|
||||
},
|
||||
@@ -583,3 +611,36 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-repo tools that accept an optional `branch` scope (#2106). Single source
|
||||
* of truth: the schema property is injected here so it cannot drift from the
|
||||
* server-side default in `local-backend.ts` (`resolveRepo(repo, branch)`).
|
||||
* `list_repos` and the `group_*` tools are intentionally excluded — they are
|
||||
* not single-repo, single-branch operations.
|
||||
*/
|
||||
const BRANCH_SCOPED_TOOLS = new Set([
|
||||
'query',
|
||||
'cypher',
|
||||
'context',
|
||||
'detect_changes',
|
||||
'impact',
|
||||
'rename',
|
||||
'route_map',
|
||||
'tool_map',
|
||||
'shape_check',
|
||||
'api_impact',
|
||||
]);
|
||||
|
||||
for (const tool of GITNEXUS_TOOLS) {
|
||||
if (!BRANCH_SCOPED_TOOLS.has(tool.name)) continue;
|
||||
if (tool.inputSchema.properties.branch) continue;
|
||||
// Optional — `required` is left unchanged so omitting `branch` keeps today's
|
||||
// default/primary-branch behavior. Ignored in group mode (repo starts "@").
|
||||
tool.inputSchema.properties.branch = {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional: scope to a specific branch index (multi-branch repos, #2106). ' +
|
||||
'Omit for the default/primary branch. Ignored in group mode.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Branch-index primitives (#2106).
|
||||
*
|
||||
* Extracted from `repo-manager.ts` to keep the multi-branch slug/placement
|
||||
* logic in one focused module. `getStoragePaths`, `loadMeta`, and the registry
|
||||
* I/O stay in `repo-manager.ts`; this module imports the two it needs at
|
||||
* call-time only (no module-load cross-calls), so the repo-manager ⇄
|
||||
* branch-index import cycle is ESM-safe. `repo-manager.ts` re-exports these so
|
||||
* existing import sites keep working unchanged.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { sanitizeRepoName } from './git.js';
|
||||
import { getStoragePaths, loadMeta, type RepoMeta } from './repo-manager.js';
|
||||
|
||||
/**
|
||||
* Per-branch index summary nested under a registry entry (#2106). Records
|
||||
* non-primary branches indexed for the same repo path so `list`, `status`, and
|
||||
* `list_repos` can surface them without a second registry entry.
|
||||
*/
|
||||
export interface BranchSummary {
|
||||
/** Git branch name this sub-index represents. */
|
||||
branch: string;
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
stats?: RepoMeta['stats'];
|
||||
}
|
||||
|
||||
/** Branch-index sub-directory name, relative to the flat `.gitnexus` storage. */
|
||||
export const BRANCHES_DIR = 'branches';
|
||||
|
||||
/**
|
||||
* Filesystem-safe slug for a git branch ref (#2106).
|
||||
*
|
||||
* `sanitizeRepoName` alone is lossy — it maps `/`→`_`, so `feature/x` and
|
||||
* `feature_x` would collide into the same directory. We append a short sha256
|
||||
* of the RAW ref (mirroring `assignRepoId`'s digest fallback) so two distinct
|
||||
* refs can never share a branch directory, while keeping the human prefix
|
||||
* readable.
|
||||
*/
|
||||
export const branchSlug = (rawRef: string): string => {
|
||||
const safe = sanitizeRepoName(rawRef);
|
||||
const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
|
||||
return `${safe}-${hash}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
|
||||
* or a per-branch sub-directory (#2106 KTD2).
|
||||
*
|
||||
* Returns `{}` for the flat/primary placement (byte-identical layout) or
|
||||
* `{ branch }` for a `branches/<slug>/` sub-directory. The flat slot is owned by
|
||||
* the FIRST branch indexed, recorded as `branch` in the flat `meta.json`; a
|
||||
* different checked-out branch then auto-routes to its own sub-directory so it
|
||||
* never overwrites the primary index.
|
||||
*
|
||||
* `label` is the resolved index-branch (explicit `--branch`, else the
|
||||
* checked-out branch, else `null`). A `null` label — detached HEAD, non-git
|
||||
* folder, or CI checkout — always maps to the flat slot.
|
||||
*/
|
||||
export const resolveBranchPlacement = async (
|
||||
repoPath: string,
|
||||
label: string | null,
|
||||
): Promise<{ branch?: string }> => {
|
||||
// Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
|
||||
if (!label) return {};
|
||||
const { storagePath } = getStoragePaths(repoPath);
|
||||
const flatMeta = await loadMeta(storagePath);
|
||||
// The flat slot's owner is authoritative ONLY when it is a non-empty string.
|
||||
// A corrupt/hand-edited meta (empty string, or a non-string value that slips
|
||||
// past JSON typing) must not be trusted to route the real primary into a
|
||||
// sub-directory (#2106 review R5).
|
||||
const owner =
|
||||
flatMeta && typeof flatMeta.branch === 'string' && flatMeta.branch.length > 0
|
||||
? flatMeta.branch
|
||||
: undefined;
|
||||
// Fresh repo (no flat index) or legacy/unstamped flat index (no recorded
|
||||
// owner): the current label claims/adopts the flat slot. The legacy case
|
||||
// preserves today's overwrite-in-place behavior until the slot is stamped.
|
||||
if (!owner) return {};
|
||||
// Flat slot is owned. Same branch → flat; otherwise this branch gets its own
|
||||
// sub-directory.
|
||||
return owner === label ? {} : { branch: label };
|
||||
};
|
||||
@@ -292,6 +292,33 @@ export const getDefaultBranch = (repoPath: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Name of the currently checked-out branch, or `null` when HEAD is detached
|
||||
* (CI checkouts, `git checkout <sha>`), the directory is not a git worktree, or
|
||||
* git is unavailable.
|
||||
*
|
||||
* `git rev-parse --abbrev-ref HEAD` prints the literal `HEAD` for a detached
|
||||
* checkout. We map that (and empty output) to `null` so callers fall back to the
|
||||
* flat/default index rather than ever creating a branch literally named
|
||||
* "HEAD" (#2106).
|
||||
*/
|
||||
export const getCurrentBranch = (repoPath: string): string | null => {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: repoPath,
|
||||
// Suppress stderr -- see getCurrentCommit comment and #1172.
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
if (!branch || branch === 'HEAD') return null;
|
||||
return branch;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a repository name to prevent argument injection and ensure
|
||||
* cross-platform filesystem compatibility.
|
||||
|
||||
@@ -12,6 +12,17 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import {
|
||||
branchSlug,
|
||||
BRANCHES_DIR,
|
||||
resolveBranchPlacement,
|
||||
type BranchSummary,
|
||||
} from './branch-index.js';
|
||||
|
||||
// Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
|
||||
// existing `repo-manager` import sites and tests keep working unchanged.
|
||||
export { branchSlug, resolveBranchPlacement };
|
||||
export type { BranchSummary };
|
||||
|
||||
/**
|
||||
* Normalise a repo path for registry comparison across platforms
|
||||
@@ -99,6 +110,23 @@ export interface RepoMeta {
|
||||
/** Number of files in the writable set, for diagnostic logs. */
|
||||
toWriteCount: number;
|
||||
};
|
||||
/**
|
||||
* Name of the git branch this index represents (#2106). Absent for the
|
||||
* default/legacy single-branch case so the flat `meta.json` stays
|
||||
* byte-identical to pre-multi-branch output. When present in the FLAT
|
||||
* `meta.json`, it records which branch "owns" the flat slot (the first
|
||||
* branch indexed); per-branch indexes under `branches/<slug>/` always carry
|
||||
* their own `branch`.
|
||||
*/
|
||||
branch?: string;
|
||||
/**
|
||||
* The parse-cache chunk keys this branch's index needs (#2106 R6). The
|
||||
* parse-cache and durable parsedfile store live ONCE at the repo root and are
|
||||
* shared across branches; recording each branch's live chunk keys lets the
|
||||
* prune step union them so re-analyzing one branch doesn't evict another
|
||||
* branch's still-live shards. Additive/optional; absent in legacy metas.
|
||||
*/
|
||||
cacheKeys?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +154,18 @@ export interface RegistryEntry {
|
||||
/** See {@link RepoMeta.remoteUrl}. Mirrored from meta at register time. */
|
||||
remoteUrl?: string;
|
||||
stats?: RepoMeta['stats'];
|
||||
/**
|
||||
* Branch name owning the flat/primary index (#2106). Mirrors the flat
|
||||
* `meta.branch`. Absent for legacy single-branch entries and non-git repos —
|
||||
* additive and backward compatible.
|
||||
*/
|
||||
branch?: string;
|
||||
/**
|
||||
* Non-primary branch indexes for this same path (#2106). Absent when only the
|
||||
* primary branch is indexed, preserving the one-entry-per-path model and the
|
||||
* legacy registry shape.
|
||||
*/
|
||||
branches?: BranchSummary[];
|
||||
}
|
||||
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
@@ -141,14 +181,21 @@ export const getStoragePath = (repoPath: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get paths to key storage files
|
||||
* Get paths to key storage files.
|
||||
*
|
||||
* `storagePath` is ALWAYS the flat `<repo>/.gitnexus` — content-addressed
|
||||
* caches (`parse-cache/`, `parsedfile-store/`) live there and are shared
|
||||
* across branches (#2106 KTD7). When `branch` is provided, only `lbugPath` and
|
||||
* `metaPath` are scoped under `branches/<slug>/`; the flat call (no `branch`)
|
||||
* returns byte-identical paths to the pre-multi-branch behavior.
|
||||
*/
|
||||
export const getStoragePaths = (repoPath: string) => {
|
||||
export const getStoragePaths = (repoPath: string, branch?: string) => {
|
||||
const storagePath = getStoragePath(repoPath);
|
||||
const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
|
||||
return {
|
||||
storagePath,
|
||||
lbugPath: path.join(storagePath, 'lbug'),
|
||||
metaPath: path.join(storagePath, 'meta.json'),
|
||||
lbugPath: path.join(baseDir, 'lbug'),
|
||||
metaPath: path.join(baseDir, 'meta.json'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -399,7 +446,13 @@ export const readRegistry = async (): Promise<RegistryEntry[]> => {
|
||||
const writeRegistry = async (entries: RegistryEntry[]): Promise<void> => {
|
||||
const dir = getGlobalDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
|
||||
// Atomic tmp+rename (mirrors saveMeta): a crash mid-write can never leave a
|
||||
// truncated/half-written registry.json that the next load would treat as
|
||||
// empty and silently drop every registered repo (#2106 R9).
|
||||
const target = getGlobalRegistryPath();
|
||||
const tmp = `${target}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8');
|
||||
await fs.rename(tmp, target);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -432,6 +485,14 @@ export interface RegisterRepoOptions {
|
||||
* re-run the full pipeline.
|
||||
*/
|
||||
allowDuplicateName?: boolean;
|
||||
/**
|
||||
* Non-primary branch this run indexed (#2106). When set, the branch's
|
||||
* summary is upserted into the entry's `branches[]` and the primary
|
||||
* top-level fields are left untouched. When `undefined`, this is a
|
||||
* primary/flat run that refreshes the top-level fields (and preserves any
|
||||
* existing branch summaries).
|
||||
*/
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -597,23 +658,87 @@ export const registerRepo = async (
|
||||
}
|
||||
}
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
name,
|
||||
path: resolved,
|
||||
storagePath,
|
||||
indexedAt: meta.indexedAt,
|
||||
lastCommit: meta.lastCommit,
|
||||
remoteUrl: meta.remoteUrl,
|
||||
stats: meta.stats,
|
||||
};
|
||||
// This run's branch summary (non-primary runs only); hoisted so the
|
||||
// re-read-before-write merge below can re-apply it against a fresh snapshot.
|
||||
const summary: BranchSummary | null = opts?.branch
|
||||
? {
|
||||
branch: opts.branch,
|
||||
indexedAt: meta.indexedAt,
|
||||
lastCommit: meta.lastCommit,
|
||||
stats: meta.stats,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
entries[existingIdx] = entry;
|
||||
let entry: RegistryEntry;
|
||||
if (summary) {
|
||||
// Non-primary branch run (#2106): keep the primary's top-level fields and
|
||||
// upsert this branch into branches[]. One entry per path is preserved.
|
||||
// When the registry entry is missing (lost/rebuilt registry.json), rebuild
|
||||
// the primary top-level from the FLAT meta.json rather than this branch's
|
||||
// meta, so `--branch <primary>` can still resolve (#2106 review).
|
||||
const flatMeta = existing ? null : await loadMeta(storagePath);
|
||||
const base: RegistryEntry = existing ?? {
|
||||
name,
|
||||
path: resolved,
|
||||
storagePath,
|
||||
indexedAt: flatMeta?.indexedAt ?? meta.indexedAt,
|
||||
lastCommit: flatMeta?.lastCommit ?? meta.lastCommit,
|
||||
remoteUrl: flatMeta?.remoteUrl ?? meta.remoteUrl,
|
||||
stats: flatMeta?.stats ?? meta.stats,
|
||||
...(flatMeta?.branch ? { branch: flatMeta.branch } : {}),
|
||||
};
|
||||
const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
|
||||
branches.push(summary);
|
||||
entry = { ...base, name, branches };
|
||||
} else {
|
||||
entries.push(entry);
|
||||
// Primary/flat run: refresh top-level fields, preserve any branch summaries
|
||||
// already recorded for this path so a primary re-analyze does not drop them.
|
||||
entry = {
|
||||
name,
|
||||
path: resolved,
|
||||
storagePath,
|
||||
indexedAt: meta.indexedAt,
|
||||
lastCommit: meta.lastCommit,
|
||||
remoteUrl: meta.remoteUrl,
|
||||
stats: meta.stats,
|
||||
...(meta.branch ? { branch: meta.branch } : {}),
|
||||
...(existing?.branches ? { branches: existing.branches } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
await writeRegistry(entries);
|
||||
// Re-read immediately before writing to narrow the lost-update window (#2106
|
||||
// R9): re-derive THIS run's delta against the FRESHEST snapshot so a
|
||||
// concurrent change to the OTHER axis (a branch upsert vs a primary refresh)
|
||||
// survives instead of being clobbered by a stale entry-time view.
|
||||
const fresh = await readRegistry();
|
||||
const freshIdx = fresh.findIndex((e) => {
|
||||
const a = canonicalizePath(e.path);
|
||||
return process.platform === 'win32'
|
||||
? a.toLowerCase() === canonicalInput.toLowerCase()
|
||||
: a === canonicalInput;
|
||||
});
|
||||
const freshExisting = freshIdx >= 0 ? fresh[freshIdx] : null;
|
||||
let merged: RegistryEntry;
|
||||
if (summary) {
|
||||
// Branch run: keep the FRESH top-level + branches, just upsert our summary.
|
||||
const base = freshExisting ?? entry;
|
||||
const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
|
||||
branches.push(summary);
|
||||
merged = { ...base, name, branches };
|
||||
} else {
|
||||
// Primary run: apply our refreshed top-level, but defer to the FRESH
|
||||
// branches[] (a concurrent branch upsert or `clean --branch` wins).
|
||||
merged = { ...entry };
|
||||
if (freshExisting?.branches) merged.branches = freshExisting.branches;
|
||||
else delete merged.branches;
|
||||
}
|
||||
if (freshIdx >= 0) {
|
||||
fresh[freshIdx] = merged;
|
||||
} else {
|
||||
fresh.push(merged);
|
||||
}
|
||||
|
||||
await writeRegistry(fresh);
|
||||
return name;
|
||||
};
|
||||
|
||||
@@ -635,6 +760,33 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
||||
await writeRegistry(filtered);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a single non-primary branch's summary from a repo's registry entry
|
||||
* (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
|
||||
* matching `branches[]` summary was found and removed; `false` otherwise (so
|
||||
* the CLI can report "no such indexed branch" without crashing). The top-level
|
||||
* primary entry is left intact; an empty `branches[]` is dropped to keep the
|
||||
* registry shape legacy-clean.
|
||||
*/
|
||||
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> => {
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const matches = (a: string, b: string) =>
|
||||
process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
const entries = await readRegistry();
|
||||
const idx = entries.findIndex((e) => matches(canonicalizePath(e.path), resolved));
|
||||
if (idx < 0) return false;
|
||||
const entry = entries[idx];
|
||||
const before = entry.branches?.length ?? 0;
|
||||
if (!entry.branches || before === 0) return false;
|
||||
const remaining = entry.branches.filter((b) => b.branch !== branch);
|
||||
if (remaining.length === before) return false; // branch not recorded
|
||||
if (remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
||||
* the caller's target string (by alias, basename, remote-inferred name,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const things = [{ id: 1, name: 'Widget' }];
|
||||
return NextResponse.json(things);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = process.env.API_BASE || '';
|
||||
|
||||
// A custom HTTP wrapper built on axios — it never calls the bare global
|
||||
// `fetch()`, so the parse-phase auto-detector cannot flag it. Listing
|
||||
// "doRequest" in `.gitnexusrc` `fetchWrappers` is what lets the routes phase
|
||||
// trace its consumers (#1589/#1852 residual).
|
||||
export async function doRequest(path: string, opts?: Record<string, unknown>) {
|
||||
return axios.get(`${API_BASE}${path}`, opts);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// `cafédoRequest` is a DIFFERENT function whose name ends in the configured
|
||||
// wrapper `doRequest`, preceded by a non-ASCII letter. The consumer scan's
|
||||
// left boundary must treat `é` as an identifier character (Unicode-aware) and
|
||||
// NOT match `doRequest` here — otherwise this produces a spurious FETCHES edge
|
||||
// to /api/things (#1852 review F10).
|
||||
declare function cafédoRequest(path: string): Promise<unknown>;
|
||||
|
||||
export default function Accented() {
|
||||
const load = async () => {
|
||||
const res = await cafédoRequest('/api/things');
|
||||
return res;
|
||||
};
|
||||
return null;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { doRequest } from '../lib/http';
|
||||
|
||||
export default function ThingsList() {
|
||||
const loadThings = async () => {
|
||||
const res = await doRequest('/api/things');
|
||||
return res.data;
|
||||
};
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
struct CrossFileBase {
|
||||
void crossFile();
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package com.example.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Controller without class-level @RequestMapping — routes should be bare paths.
|
||||
*/
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public String health() {
|
||||
return "OK";
|
||||
}
|
||||
|
||||
@GetMapping("/ready")
|
||||
public String ready() {
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
package com.example.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Two controllers in one file — tests that class prefixes don't bleed.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
class AdminController {
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public String dashboard() {
|
||||
return "admin";
|
||||
}
|
||||
|
||||
@PatchMapping("/settings")
|
||||
public String updateSettings() {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/public")
|
||||
class PublicController {
|
||||
|
||||
@GetMapping("/info")
|
||||
public String info() {
|
||||
return "public";
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
package com.example.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderController {
|
||||
|
||||
@GetMapping("/list")
|
||||
public String listOrders() {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
public String submitOrder() {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
package com.example.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
@GetMapping("/list")
|
||||
public List<User> listUsers() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
public User createUser() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@DeleteMapping(path = "/delete")
|
||||
public void deleteUser() {}
|
||||
|
||||
@PutMapping(value = "/update")
|
||||
public void updateUser() {}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
runHook,
|
||||
parseHookOutput,
|
||||
createGitNexusPathEntry,
|
||||
createHookToolDir,
|
||||
hookEnv,
|
||||
envWithPath,
|
||||
} from '../utils/hook-test-helpers.js';
|
||||
import { setupCommand } from '../../src/cli/setup.js';
|
||||
@@ -101,7 +103,10 @@ afterAll(async () => {
|
||||
|
||||
describe('antigravity hook adapter e2e', () => {
|
||||
describe('AfterTool — stale-index hint after git mutations', () => {
|
||||
it('emits the hint via both additionalContext and stderr after a successful git commit', () => {
|
||||
// #1913: by default the hint reaches the agent via additionalContext (stdout
|
||||
// JSON) but is NOT mirrored to stderr, so strict hook runners see no
|
||||
// unexpected output on this normal (non-error) path.
|
||||
it('emits the hint via additionalContext and stays silent on stderr by default', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
|
||||
@@ -117,7 +122,7 @@ describe('antigravity hook adapter e2e', () => {
|
||||
cwd: tmpDir,
|
||||
},
|
||||
tmpDir,
|
||||
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
|
||||
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx', GITNEXUS_DEBUG: '' } },
|
||||
);
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
@@ -125,9 +130,33 @@ describe('antigravity hook adapter e2e', () => {
|
||||
expect(output!.hookEventName).toBe('AfterTool');
|
||||
expect(output!.additionalContext).toContain('index is stale');
|
||||
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze');
|
||||
// Strict-runner contract: the hint is NOT mirrored to stderr by default.
|
||||
expect(result.stderr).not.toContain('[GitNexus] index is stale');
|
||||
});
|
||||
|
||||
// Mirror to stderr so terminal users see the hint even when the agent
|
||||
// discards additionalContext
|
||||
// #1913: the terminal-mirror remains available for operators who opt in.
|
||||
it('mirrors the hint to stderr for terminal users only under GITNEXUS_DEBUG=1', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(
|
||||
installedHook,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_response: { llmContent: '[committed]' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
tmpDir,
|
||||
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx', GITNEXUS_DEBUG: '1' } },
|
||||
);
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('index is stale');
|
||||
expect(result.stderr).toContain('[GitNexus] index is stale');
|
||||
});
|
||||
|
||||
@@ -359,6 +388,91 @@ describe('antigravity hook adapter e2e', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #1913: when a GitNexus MCP server owns the repo DB, runAugment() must
|
||||
// SKIP — silently by default so strict hook runners never see unexpected
|
||||
// output, and surface the reason only under GITNEXUS_DEBUG=1. The Claude/Plugin
|
||||
// copies are covered in test/unit/hooks.test.ts; the antigravity adapter shares
|
||||
// the identical gated skip and is exercised here through the install pipeline
|
||||
// (its lock/probe helpers only resolve from the install dir). A faked lsof/ps +
|
||||
// an empty `lbug` lock force hasGitNexusServerOwner() => true; a marker-writing
|
||||
// fake CLI proves augment never ran.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'AfterTool — augment skipped when MCP server owns the DB (#1913)',
|
||||
() => {
|
||||
const OWNER_PROBE = {
|
||||
lsofOutput: '12345\n',
|
||||
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
|
||||
};
|
||||
|
||||
it('stays SILENT by default (no augment ran, no stderr noise, exit 0)', () => {
|
||||
const markerPath = path.join(os.tmpdir(), `antigravity-skip-silent-${process.pid}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({ ...OWNER_PROBE, gitnexusMarkerPath: markerPath });
|
||||
try {
|
||||
const result = runHook(
|
||||
installedHook,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
tmpDir,
|
||||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
// Strict-runner contract: completely silent — empty stdout AND stderr
|
||||
// (matches the unit suite's assertion strength for the claude/plugin copies).
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.stderr.trim()).toBe('');
|
||||
// Marker absent ⇒ the CLI never ran (augment short-circuited at the owner
|
||||
// check). The paired GITNEXUS_DEBUG=1 test below positively proves the skip
|
||||
// was the owner path (it asserts the owner-skip diagnostic on stderr).
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(lbugPath, { force: true });
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces the skip reason on stderr only under GITNEXUS_DEBUG=1', () => {
|
||||
const markerPath = path.join(os.tmpdir(), `antigravity-skip-debug-${process.pid}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({ ...OWNER_PROBE, gitnexusMarkerPath: markerPath });
|
||||
try {
|
||||
const result = runHook(
|
||||
installedHook,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
tmpDir,
|
||||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped: MCP server owns DB');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(lbugPath, { force: true });
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('cwd validation', () => {
|
||||
it('rejects relative cwd silently', () => {
|
||||
const result = runHook(installedHook, {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Integration test: impact() ambiguous-resolution blast radius (#2129)
|
||||
*
|
||||
* Reproduces the issue's graph shape: a small helper name (`classifyCard`)
|
||||
* exists in two files. The "real" one is called by `syncContent` (+ another
|
||||
* caller); a coincidental same-name helper elsewhere is called by `renderCard`.
|
||||
*
|
||||
* Before fix: impact("classifyCard", upstream) resolves the ambiguous bare name
|
||||
* to `impactedCount: 0` with a flat candidate list — the real caller
|
||||
* (`syncContent`) is silently dropped because it calls the *other* same-name
|
||||
* node. After fix: the ambiguous response runs a bounded summary-only BFS per
|
||||
* candidate, surfacing each one's true count + the maximum, so no real caller
|
||||
* hides behind a bare zero. The BFS / edge storage are unchanged — disambiguation
|
||||
* by uid still returns the exact caller.
|
||||
*/
|
||||
import { it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const SYNC_LOGIC_ID = 'Function:src/sync-logic.ts:classifyCard';
|
||||
const UI_HELPERS_ID = 'Function:src/ui-helpers.ts:classifyCard';
|
||||
|
||||
const SEED = [
|
||||
// Two distinct functions named `classifyCard` in different files.
|
||||
`CREATE (cc1:Function {id: '${SYNC_LOGIC_ID}', name: 'classifyCard', filePath: 'src/sync-logic.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (cc2:Function {id: '${UI_HELPERS_ID}', name: 'classifyCard', filePath: 'src/ui-helpers.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
|
||||
|
||||
// Real callers of the sync-logic classifyCard (the blast radius that was lost).
|
||||
`CREATE (sc:Function {id: 'Function:src/actions.ts:syncContent', name: 'syncContent', filePath: 'src/actions.ts', startLine: 10, endLine: 120, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (ss:Function {id: 'Function:src/actions.ts:scheduleSync', name: 'scheduleSync', filePath: 'src/actions.ts', startLine: 130, endLine: 160, isExported: true, content: '', description: ''})`,
|
||||
// Caller of the coincidental ui-helpers classifyCard.
|
||||
`CREATE (rc:Function {id: 'Function:src/ui-helpers.ts:renderCard', name: 'renderCard', filePath: 'src/ui-helpers.ts', startLine: 20, endLine: 40, isExported: true, content: '', description: ''})`,
|
||||
|
||||
`MATCH (a:Function {id:'Function:src/actions.ts:syncContent'}), (b:Function {id:'${SYNC_LOGIC_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
|
||||
`MATCH (a:Function {id:'Function:src/actions.ts:scheduleSync'}), (b:Function {id:'${SYNC_LOGIC_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
|
||||
`MATCH (a:Function {id:'Function:src/ui-helpers.ts:renderCard'}), (b:Function {id:'${UI_HELPERS_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'impact-ambiguous-blast-radius',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
beforeAll(() => {
|
||||
backend = (handle as any)._backend;
|
||||
});
|
||||
|
||||
it('surfaces per-candidate blast radius instead of a bare impactedCount:0', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'classifyCard',
|
||||
direction: 'upstream',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ambiguous');
|
||||
expect(Array.isArray(result.candidates)).toBe(true);
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
|
||||
// The fix: the maximum real blast radius is hoisted to the top level so
|
||||
// the response can never be misread as "safe to refactor".
|
||||
expect(result.maxImpactedCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each candidate carries its own true count — the dropped caller is no
|
||||
// longer hidden behind the ambiguous zero.
|
||||
const syncLogic = result.candidates.find((c: any) =>
|
||||
String(c.filePath).includes('sync-logic'),
|
||||
);
|
||||
const uiHelpers = result.candidates.find((c: any) =>
|
||||
String(c.filePath).includes('ui-helpers'),
|
||||
);
|
||||
expect(syncLogic).toBeDefined();
|
||||
expect(uiHelpers).toBeDefined();
|
||||
expect(syncLogic.impactedCount).toBeGreaterThanOrEqual(2);
|
||||
expect(uiHelpers.impactedCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Candidates are ranked by blast radius (most-impactful interpretation
|
||||
// first) so the dangerous one leads.
|
||||
expect(result.candidates[0].impactedCount).toBeGreaterThanOrEqual(
|
||||
result.candidates[1].impactedCount,
|
||||
);
|
||||
});
|
||||
|
||||
it('disambiguation by uid returns the exact dropped caller (BFS unchanged)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'classifyCard',
|
||||
target_uid: SYNC_LOGIC_ID,
|
||||
direction: 'upstream',
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe('ambiguous');
|
||||
expect(result.impactedCount).toBeGreaterThanOrEqual(2);
|
||||
const names = Object.values(result.byDepth as Record<string, any[]>)
|
||||
.flat()
|
||||
.map((d: any) => d.name);
|
||||
expect(names).toContain('syncContent');
|
||||
expect(names).toContain('scheduleSync');
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 5, nodes: 6, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Integration test: epistemic lower-bound flag (#1858)
|
||||
*
|
||||
* When a symbol sits behind an interface / indirection boundary, callers that
|
||||
* bind via a DI container or dynamic dispatch are not traced to the concrete
|
||||
* symbol — so impact()/context() report a *lower bound*, not an exact figure.
|
||||
* Instead of a silent confident zero, the result is annotated
|
||||
* `epistemic: 'lower-bound'` with a human-readable boundary note. A fully
|
||||
* resolved leaf with no indirection stays `epistemic: 'exact'`.
|
||||
*
|
||||
* Graph shape (the canonical Symfony/DI case from #1858 / the #1589 comment):
|
||||
* SignupController --CALLS--> Logger (interface)
|
||||
* EmailLogger --IMPLEMENTS--> Logger
|
||||
* FileLogger --IMPLEMENTS--> Logger
|
||||
* The controller binds to the *interface*; the concrete impl is wired by the
|
||||
* container, so impact("EmailLogger", upstream) finds no direct caller — but
|
||||
* must flag that the true blast radius is higher.
|
||||
*/
|
||||
import { it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const SEED = [
|
||||
// Interface + two implementations + an interface-level consumer.
|
||||
`CREATE (iface:Interface {id: 'Interface:src/Logger.ts:Logger', name: 'Logger', filePath: 'src/Logger.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (email:Class {id: 'Class:src/EmailLogger.ts:EmailLogger', name: 'EmailLogger', filePath: 'src/EmailLogger.ts', startLine: 1, endLine: 20, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (file:Class {id: 'Class:src/FileLogger.ts:FileLogger', name: 'FileLogger', filePath: 'src/FileLogger.ts', startLine: 1, endLine: 20, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (ctrl:Class {id: 'Class:src/SignupController.ts:SignupController', name: 'SignupController', filePath: 'src/SignupController.ts', startLine: 1, endLine: 30, isExported: true, content: '', description: ''})`,
|
||||
|
||||
`MATCH (a:Class {id:'Class:src/EmailLogger.ts:EmailLogger'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'IMPLEMENTS', confidence:0.85, reason:'implements', step:0}]->(b)`,
|
||||
`MATCH (a:Class {id:'Class:src/FileLogger.ts:FileLogger'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'IMPLEMENTS', confidence:0.85, reason:'implements', step:0}]->(b)`,
|
||||
`MATCH (a:Class {id:'Class:src/SignupController.ts:SignupController'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'interface-call', step:0}]->(b)`,
|
||||
|
||||
// A fully-resolved leaf with no indirection — must stay `exact`.
|
||||
`CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
|
||||
`CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
|
||||
`MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'impact-epistemic-lower-bound',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
beforeAll(() => {
|
||||
backend = (handle as any)._backend;
|
||||
});
|
||||
|
||||
it('flags impact() on a concrete impl behind an interface as lower-bound', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'EmailLogger',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
expect(Array.isArray(result.boundaries)).toBe(true);
|
||||
expect(result.boundaries.join(' ')).toContain('Logger');
|
||||
});
|
||||
|
||||
it('flags impact() on the interface itself as lower-bound', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'Logger',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
});
|
||||
|
||||
it('keeps a fully-resolved leaf exact (no false boundary)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'formatDate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result.epistemic).toBe('exact');
|
||||
expect(result.boundaries).toBeUndefined();
|
||||
// The real caller is still reported — the flag is additive, not lossy.
|
||||
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('context() carries the same epistemic signal', async () => {
|
||||
const result = await backend.callTool('context', {
|
||||
name: 'EmailLogger',
|
||||
file_path: 'src/EmailLogger.ts',
|
||||
});
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
});
|
||||
|
||||
it('context() on a leaf interface itself is lower-bound (#1858 review F3)', async () => {
|
||||
// Logger is a leaf interface — it implements/extends nothing, so the only
|
||||
// boundary signal is computeEpistemicBoundary's symType==='Interface'
|
||||
// self-branch. Before the F3 fix, context() collapsed symKind to 'Class'
|
||||
// and this returned 'exact'.
|
||||
const result = await backend.callTool('context', { name: 'Logger' });
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.epistemic).toBe('lower-bound');
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 6, nodes: 6, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -7,7 +7,7 @@
|
||||
* Follows existing lbug integration test patterns (lbug-core-adapter,
|
||||
* lbug-lock-retry).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
withTestLbugDB('vector-extension', (handle) => {
|
||||
@@ -73,3 +73,119 @@ withTestLbugDB('vector-extension', (handle) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: VECTOR/HNSW index creation during analyze (#2114).
|
||||
*
|
||||
* `CALL CREATE_VECTOR_INDEX(...)` compiles to multiple statements, which
|
||||
* LadybugDB cannot run through `conn.prepare()`. Routing it through the
|
||||
* prepared `executeQuery` path (as #1655 inadvertently did when it switched the
|
||||
* singleton `executeQuery` from `conn.query()` to `conn.prepare()`) makes it
|
||||
* throw "We do not support prepare multiple statements", which `analyze`
|
||||
* swallowed and silently downgraded to exact-scan. The fix gives the adapter a
|
||||
* `createVectorIndex()` that runs the procedure via `conn.query()` (like
|
||||
* `createFTSIndex`). These tests exercise the real adapter against a real
|
||||
* LadybugDB so a revert to the prepared path fails loudly.
|
||||
*/
|
||||
withTestLbugDB('vector-index-creation', () => {
|
||||
// VECTOR is platform-sensitive (skipped on win32 / unsupported platforms,
|
||||
// and when it cannot be installed offline). Probe once, skip the suite if
|
||||
// unavailable — mirrors the FTS-skip convention in withTestLbugDB.
|
||||
let vectorAvailable = false;
|
||||
let skipWarned = false;
|
||||
beforeAll(async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { resolveAnalyzeInstallPolicy } = await import('../../src/core/lbug/extension-loader.js');
|
||||
// Mirror the analyze write path (`auto`: LOAD-first, then one bounded
|
||||
// INSTALL) so this suite runs wherever analyze would have vector support.
|
||||
vectorAvailable = await adapter.loadVectorExtension(undefined, {
|
||||
policy: resolveAnalyzeInstallPolicy(),
|
||||
});
|
||||
});
|
||||
beforeEach((ctx) => {
|
||||
if (!vectorAvailable) {
|
||||
if (!skipWarned) {
|
||||
skipWarned = true;
|
||||
console.warn(
|
||||
'[withTestLbugDB(vector-index-creation)] Skipping — the LadybugDB VECTOR ' +
|
||||
'extension is unavailable (unsupported platform or could not be installed).',
|
||||
);
|
||||
}
|
||||
ctx.skip();
|
||||
}
|
||||
});
|
||||
|
||||
describe('createVectorIndex', () => {
|
||||
it('creates the HNSW index via conn.query (the prepared path cannot)', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
const created = await adapter.createVectorIndex();
|
||||
expect(created).toBe(true);
|
||||
|
||||
const rows = await adapter.executeQuery('CALL SHOW_INDEXES() RETURN *');
|
||||
const idx = rows.find((r: any) => r.index_name === 'code_embedding_idx');
|
||||
expect(idx).toBeDefined();
|
||||
expect(idx.index_type).toBe('HNSW');
|
||||
});
|
||||
|
||||
it('is idempotent — a second call returns true so incremental re-runs do not downgrade to exact scan', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
await adapter.createVectorIndex();
|
||||
await expect(adapter.createVectorIndex()).resolves.toBe(true);
|
||||
|
||||
// No duplicate index created by the repeat call.
|
||||
const rows = await adapter.executeQuery('CALL SHOW_INDEXES() RETURN *');
|
||||
const matches = rows.filter((r: any) => r.index_name === 'code_embedding_idx');
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression for the #2114 root cause: the prepared `executeQuery` path cannot
|
||||
* create the index. This lives in its OWN suite (a fresh, index-free DB) on
|
||||
* purpose — in the `vector-index-creation` suite above the index already exists
|
||||
* by the time this would run, so `conn.prepare()` fails with "index already
|
||||
* exists" instead of the multi-statement rejection we want to pin. With no index
|
||||
* present, `CALL CREATE_VECTOR_INDEX(...)` (which compiles to multiple
|
||||
* statements) is rejected by `conn.prepare()` with "We do not support prepare
|
||||
* multiple statements" — the exact failure that silently downgraded analyze to
|
||||
* exact-scan, and why `createVectorIndex` must use `conn.query()` instead.
|
||||
*/
|
||||
withTestLbugDB('vector-index-prepare-rejects', () => {
|
||||
let vectorAvailable = false;
|
||||
let skipWarned = false;
|
||||
beforeAll(async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { resolveAnalyzeInstallPolicy } = await import('../../src/core/lbug/extension-loader.js');
|
||||
vectorAvailable = await adapter.loadVectorExtension(undefined, {
|
||||
policy: resolveAnalyzeInstallPolicy(),
|
||||
});
|
||||
});
|
||||
beforeEach((ctx) => {
|
||||
if (!vectorAvailable) {
|
||||
if (!skipWarned) {
|
||||
skipWarned = true;
|
||||
console.warn(
|
||||
'[withTestLbugDB(vector-index-prepare-rejects)] Skipping — the LadybugDB VECTOR ' +
|
||||
'extension is unavailable (unsupported platform or could not be installed).',
|
||||
);
|
||||
}
|
||||
ctx.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it('the prepared executeQuery path rejects CREATE_VECTOR_INDEX (#2114 root cause)', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { CREATE_VECTOR_INDEX_QUERY } = await import('../../src/core/lbug/schema.js');
|
||||
|
||||
// executeQuery -> executePrepared -> conn.prepare(): the multi-statement
|
||||
// CREATE_VECTOR_INDEX procedure cannot be prepared. Anchored to the specific
|
||||
// error so the test can only pass for the #2114 reason — not for an
|
||||
// unrelated throw (e.g. a missing table or an already-existing index).
|
||||
await expect(adapter.executeQuery(CREATE_VECTOR_INDEX_QUERY)).rejects.toThrow(
|
||||
/prepare multiple statements/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,177 @@
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #2106 — multi-branch indexing end-to-end. Proves that analyzing a second
|
||||
* branch creates its own index under `.gitnexus/branches/<slug>/` and does NOT
|
||||
* overwrite the primary (flat) index, and that the primary single-branch
|
||||
* layout stays at `.gitnexus/{lbug,meta.json}`.
|
||||
*/
|
||||
const git = (args: string[], cwd: string): string =>
|
||||
execSync(['git', ...args].join(' '), { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commit = (cwd: string, message: string): void => {
|
||||
git(['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', message], cwd);
|
||||
};
|
||||
|
||||
describe('multi-branch analyze (#2106)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Isolate the global registry so the full analyze runs below don't write
|
||||
// to the developer's real ~/.gitnexus/registry.json.
|
||||
tmpHome = await createTempDir('gitnexus-multibranch-home-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('indexes a second branch without overwriting the first', async () => {
|
||||
const tmp = await createTempDir('gitnexus-multibranch-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
git(['init'], repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'a');
|
||||
// Normalise the branch name across git defaults (master vs main).
|
||||
git(['branch', '-M', 'main'], repo);
|
||||
const mainCommit = git(['rev-parse', 'HEAD'], repo);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
// Primary branch lands in the flat slot, byte-identical layout.
|
||||
const flat = getStoragePaths(repo);
|
||||
expect(path.dirname(flat.lbugPath)).toBe(flat.storagePath);
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('main');
|
||||
expect(flatMeta?.lastCommit).toBe(mainCommit);
|
||||
// main records its live chunk keys so a later branch prune can keep them.
|
||||
const mainCacheKeys = flatMeta?.cacheKeys ?? [];
|
||||
expect(mainCacheKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to a feature branch with different content and re-analyze.
|
||||
git(['checkout', '-b', 'feature/x'], repo);
|
||||
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'b');
|
||||
const featureCommit = git(['rev-parse', 'HEAD'], repo);
|
||||
expect(featureCommit).not.toBe(mainCommit);
|
||||
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
// The flat (main) index is untouched — NOT overwritten by the feature run.
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
const flatMetaAfter = await loadMeta(flat.storagePath);
|
||||
expect(flatMetaAfter?.branch).toBe('main');
|
||||
expect(flatMetaAfter?.lastCommit).toBe(mainCommit);
|
||||
|
||||
// The feature index is a separate DB under branches/<slug>/.
|
||||
const branchPaths = getStoragePaths(repo, 'feature/x');
|
||||
const branchDir = path.dirname(branchPaths.lbugPath);
|
||||
expect(branchDir.includes(path.join('.gitnexus', 'branches'))).toBe(true);
|
||||
expect(existsSync(branchPaths.lbugPath)).toBe(true);
|
||||
const branchMeta = await loadMeta(branchDir);
|
||||
expect(branchMeta?.branch).toBe('feature/x');
|
||||
expect(branchMeta?.lastCommit).toBe(featureCommit);
|
||||
|
||||
// #2106 R6: the feature analyze must NOT have evicted main's chunks from
|
||||
// the SHARED parse cache (they were unioned in via main's recorded keys).
|
||||
const { loadParseCache } = await import('../../src/storage/parse-cache.js');
|
||||
const sharedCache = await loadParseCache(flat.storagePath);
|
||||
const onDisk = sharedCache.onDiskKeys ?? new Set<string>();
|
||||
for (const k of mainCacheKeys) {
|
||||
expect(onDisk.has(k), `main chunk ${k} survives the feature prune`).toBe(true);
|
||||
}
|
||||
|
||||
// The global registry keeps one entry per path: primary at top level,
|
||||
// the feature branch nested under branches[] (#2106 U4).
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.branch).toBe('main');
|
||||
expect(entry?.lastCommit).toBe(mainCommit);
|
||||
expect(entry?.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('a detached-HEAD re-analyze preserves the primary stamp (no later overwrite)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-multibranch-detached-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
git(['init'], repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'a');
|
||||
git(['branch', '-M', 'main'], repo);
|
||||
const mainCommit = git(['rev-parse', 'HEAD'], repo);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
const flat = getStoragePaths(repo);
|
||||
expect((await loadMeta(flat.storagePath))?.branch).toBe('main');
|
||||
|
||||
// Detach HEAD (what CI's actions/checkout does) and force a rebuild of the
|
||||
// flat/primary index. The primary stamp must NOT be stripped.
|
||||
git(['checkout', mainCommit], repo); // detached
|
||||
await runFullAnalysis(repo, { force: true }, { onProgress: () => {} });
|
||||
expect((await loadMeta(flat.storagePath))?.branch).toBe('main');
|
||||
|
||||
// Now a feature analyze must still route to a sub-dir (the stamp survived),
|
||||
// leaving the primary index intact rather than claiming the flat slot.
|
||||
git(['checkout', '-b', 'feature/y'], repo);
|
||||
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'b');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('main');
|
||||
expect(flatMeta?.lastCommit).toBe(mainCommit); // primary NOT overwritten
|
||||
expect(existsSync(getStoragePaths(repo, 'feature/y').lbugPath)).toBe(true);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('an auto-detected branch the rules forbid lands on the flat slot (#2106 R1)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-multibranch-r1-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
git(['init'], repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'a');
|
||||
// A backtick is valid in a git ref but rejected by validateBranchName.
|
||||
// execFileSync (no shell) so the backtick is not interpreted.
|
||||
execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
// The forbidden ref was normalized to null → flat slot, no branch field,
|
||||
// and no branches/ sub-directory created for an unqueryable slug.
|
||||
const flat = getStoragePaths(repo);
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
expect((await loadMeta(flat.storagePath))?.branch).toBeUndefined();
|
||||
expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -4,15 +4,17 @@
|
||||
* The scope-resolution registry (`scope-resolution/pipeline/registry.ts`) and
|
||||
* the language-provider index statically import all 16 language providers. Each
|
||||
* per-language `query.ts` used to do a top-level `import X from 'tree-sitter-Y'`.
|
||||
* For the OPTIONAL grammars (swift/dart/kotlin) that import resolved — and on a
|
||||
* default install where the vendored/optional binding is absent, THREW
|
||||
* `ERR_MODULE_NOT_FOUND` — at module-load on the main thread, before any runtime
|
||||
* gate, crashing `gitnexus analyze` regardless of the repo's actual languages.
|
||||
* For the prebuild-only / optional grammars (swift/dart/kotlin, and — since
|
||||
* #2116 — vendored-prebuild-only C) that import resolved — and on a default
|
||||
* install where the binding is absent, THREW `ERR_MODULE_NOT_FOUND` — at
|
||||
* module-load on the main thread, before any runtime gate, crashing
|
||||
* `gitnexus analyze` regardless of the repo's actual languages.
|
||||
*
|
||||
* The fix routes those three `query.ts` modules through the lazy, guarded
|
||||
* The fix routes those `query.ts` modules through the lazy, guarded
|
||||
* `parser-loader.getLanguageGrammar()` so the grammar binding is only required
|
||||
* at first use (inside the worker, for a file of that language) — never at
|
||||
* module-load.
|
||||
* module-load. (C joined this set when it became vendored prebuild-only; it used
|
||||
* to be an always-present npm dependency.)
|
||||
*
|
||||
* This test locks the fix in WITHOUT needing to simulate a missing grammar:
|
||||
* spawn a child Node process, import the built scope-resolution `registry.js`
|
||||
@@ -57,10 +59,13 @@ const PROBE = `
|
||||
process.stdout.write(JSON.stringify([...after].filter((k) => !before.has(k))));
|
||||
`;
|
||||
|
||||
const OPTIONAL_GRAMMAR_RE = /tree-sitter-(swift|dart|kotlin)[\\/]/;
|
||||
// `tree-sitter-c[\\/]` matches only the exact `tree-sitter-c/` package — NOT
|
||||
// `tree-sitter-cpp/` or `tree-sitter-c-sharp/` (those need a non-separator after
|
||||
// the `c`), so the required C++/C# eager loads are unaffected.
|
||||
const OPTIONAL_GRAMMAR_RE = /tree-sitter-(swift|dart|kotlin|c)[\\/]/;
|
||||
|
||||
describe('optional-grammar static-import closure (#2091/#2093)', () => {
|
||||
it('importing the scope-resolution registry loads NO optional grammar binding', () => {
|
||||
describe('optional-grammar static-import closure (#2091/#2093, #2116)', () => {
|
||||
it('importing the scope-resolution registry loads NO lazy grammar binding (swift/dart/kotlin/c)', () => {
|
||||
if (!fs.existsSync(DIST_REGISTRY)) {
|
||||
throw new Error(
|
||||
`${DIST_REGISTRY} missing — run \`npm run build\` first (or \`npm run test:integration\`, ` +
|
||||
@@ -117,13 +122,13 @@ describe('optional-grammar static-import closure (#2091/#2093)', () => {
|
||||
`Newly-loaded (${newlyLoaded.length}):\n${newlyLoaded.join('\n')}`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// Headline assertion: no OPTIONAL grammar binding (swift/dart/kotlin) is
|
||||
// Headline assertion: no lazy grammar binding (swift/dart/kotlin/c) is
|
||||
// loaded at registry static-import time — they must load lazily.
|
||||
const optionalLoaded = newlyLoaded.filter((p) => OPTIONAL_GRAMMAR_RE.test(p));
|
||||
expect(
|
||||
optionalLoaded,
|
||||
`Optional tree-sitter grammar binding(s) loaded at registry static-import time. ` +
|
||||
`query.ts must load swift/dart/kotlin lazily via parser-loader, not via a ` +
|
||||
`Lazy tree-sitter grammar binding(s) loaded at registry static-import time. ` +
|
||||
`query.ts must load swift/dart/kotlin/c lazily via parser-loader, not via a ` +
|
||||
`top-level \`import\`. Offending paths:\n${optionalLoaded.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Integration test: configurable fetch wrappers (#1589/#1852 residual)
|
||||
*
|
||||
* The parse-phase auto-detector only flags functions that call the bare global
|
||||
* `fetch()`. A wrapper built on axios / a custom client — like `doRequest` in
|
||||
* this fixture — is invisible to it, so `route_map` silently reports
|
||||
* `consumers: []` (exactly the "named outside convention" hole #1858 calls out).
|
||||
*
|
||||
* Declaring the wrapper name in `.gitnexusrc` `fetchWrappers` (threaded here via
|
||||
* PipelineOptions) lets the routes-phase consumer scan trace it, producing the
|
||||
* FETCHES edge. The control run (no config) proves the gap; the configured run
|
||||
* proves the fix.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
|
||||
const FIXTURE = path.join(FIXTURES, 'configurable-fetch-wrapper');
|
||||
|
||||
describe('Configurable fetch wrapper consumer extraction', () => {
|
||||
let withConfig: PipelineResult;
|
||||
let withoutConfig: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
withoutConfig = await runPipelineFromRepo(FIXTURE, () => {});
|
||||
withConfig = await runPipelineFromRepo(FIXTURE, () => {}, {
|
||||
fetchWrappers: ['doRequest'],
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
it('does NOT trace an axios-based wrapper without configuration (the gap)', () => {
|
||||
const edges = getRelationships(withoutConfig, 'FETCHES');
|
||||
const thingsEdge = edges.find((e) => e.target === '/api/things');
|
||||
expect(thingsEdge).toBeUndefined();
|
||||
});
|
||||
|
||||
it('traces the configured wrapper as a route consumer', () => {
|
||||
const edges = getRelationships(withConfig, 'FETCHES');
|
||||
const thingsEdge = edges.find(
|
||||
(e) => e.sourceFilePath.includes('ThingsList') && e.target === '/api/things',
|
||||
);
|
||||
expect(thingsEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT match a configured bare name inside a longer non-ASCII identifier (#1852 review F10)', () => {
|
||||
// Accented.tsx calls `cafédoRequest('/api/things')` — `doRequest` preceded by
|
||||
// the non-ASCII letter `é`. The Unicode-aware lookbehind must reject it.
|
||||
const edges = getRelationships(withConfig, 'FETCHES');
|
||||
const spurious = edges.find(
|
||||
(e) => e.sourceFilePath.includes('Accented') && e.target === '/api/things',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* End-to-end coverage of Spring @RequestMapping / @GetMapping route ingestion.
|
||||
*
|
||||
* This test verifies that the ingestion pipeline correctly:
|
||||
* 1. Extracts method-level route annotations (@GetMapping, @PostMapping, etc.)
|
||||
* 2. Joins class-level @RequestMapping prefix with method-level paths
|
||||
* 3. Handles both positional and named annotation arguments (path = "...", value = "...")
|
||||
* 4. Generates correct Route nodes without a class prefix when none exists
|
||||
*
|
||||
* The fixture lives at `test/fixtures/spring-route-app/`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../types/pipeline.js';
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-route-app');
|
||||
|
||||
describe('Spring @RequestMapping route ingestion pipeline', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
|
||||
}, 60_000);
|
||||
|
||||
function routeNames(): string[] {
|
||||
const out: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Route') out.push(String(n.properties.name));
|
||||
});
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
it('joins class-level @RequestMapping prefix with method-level @GetMapping/@PostMapping', () => {
|
||||
const names = routeNames();
|
||||
// UserController: @RequestMapping("/api/users") + @GetMapping("/list")
|
||||
expect(names).toContain('/api/users/list');
|
||||
// UserController: @RequestMapping("/api/users") + @PostMapping("/create")
|
||||
expect(names).toContain('/api/users/create');
|
||||
});
|
||||
|
||||
it('handles named annotation arguments (path = "..." and value = "...")', () => {
|
||||
const names = routeNames();
|
||||
// UserController: @DeleteMapping(path = "/delete")
|
||||
expect(names).toContain('/api/users/delete');
|
||||
// UserController: @PutMapping(value = "/update")
|
||||
expect(names).toContain('/api/users/update');
|
||||
});
|
||||
|
||||
it('joins prefix for OrderController routes', () => {
|
||||
const names = routeNames();
|
||||
// OrderController: @RequestMapping("/api/orders") + @GetMapping("/list")
|
||||
expect(names).toContain('/api/orders/list');
|
||||
// OrderController: @RequestMapping("/api/orders") + @PostMapping("/submit")
|
||||
expect(names).toContain('/api/orders/submit');
|
||||
});
|
||||
|
||||
it('emits bare paths when no class-level @RequestMapping exists', () => {
|
||||
const names = routeNames();
|
||||
// HealthController: no class prefix, @GetMapping("/health") and @GetMapping("/ready")
|
||||
expect(names).toContain('/health');
|
||||
expect(names).toContain('/ready');
|
||||
});
|
||||
|
||||
it('does NOT emit class-level @RequestMapping as a standalone Route', () => {
|
||||
const names = routeNames();
|
||||
// The prefix "/api/users" alone must not become a Route node
|
||||
expect(names).not.toContain('/api/users');
|
||||
expect(names).not.toContain('/api/orders');
|
||||
});
|
||||
|
||||
it('handles multiple classes in one file with independent prefixes', () => {
|
||||
const names = routeNames();
|
||||
// MultiController.java: AdminController @RequestMapping("/api/admin") + @GetMapping("/dashboard")
|
||||
expect(names).toContain('/api/admin/dashboard');
|
||||
// MultiController.java: PublicController @RequestMapping("/api/public") + @GetMapping("/info")
|
||||
expect(names).toContain('/api/public/info');
|
||||
// Prefixes should not bleed between classes
|
||||
expect(names).not.toContain('/api/public/dashboard');
|
||||
expect(names).not.toContain('/api/admin/info');
|
||||
});
|
||||
|
||||
it('supports @PatchMapping', () => {
|
||||
const names = routeNames();
|
||||
// MultiController.java: AdminController @PatchMapping("/settings")
|
||||
expect(names).toContain('/api/admin/settings');
|
||||
});
|
||||
|
||||
it('emits HANDLES_ROUTE edges linking Route nodes to their handler files', () => {
|
||||
const handlesRouteEdges: Array<{ routeName: string; filePath: string }> = [];
|
||||
result.graph.forEachRelationship((r) => {
|
||||
if (r.type !== 'HANDLES_ROUTE') return;
|
||||
const targetNode = result.graph.getNode(r.targetId);
|
||||
const sourceNode = result.graph.getNode(r.sourceId);
|
||||
if (targetNode?.label === 'Route' && sourceNode?.label === 'File') {
|
||||
handlesRouteEdges.push({
|
||||
routeName: String(targetNode.properties.name),
|
||||
filePath: String(sourceNode.properties.name),
|
||||
});
|
||||
}
|
||||
});
|
||||
// At least one route should be linked to the UserController file
|
||||
const userRoutes = handlesRouteEdges.filter((e) => e.filePath.includes('UserController.java'));
|
||||
expect(userRoutes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,28 @@ describe('analyze-config (.gitnexusrc support, #243)', () => {
|
||||
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
|
||||
});
|
||||
|
||||
// ── fetchWrappers (#1589/#1852 residual) ───────────────────────────
|
||||
|
||||
it('normalizes a fetchWrappers string array (de-duped)', async () => {
|
||||
await writeRc(JSON.stringify({ fetchWrappers: ['doRequest', 'apiClient.get', 'doRequest'] }));
|
||||
expect(loadAnalyzeConfig(dir)).toEqual({ fetchWrappers: ['doRequest', 'apiClient.get'] });
|
||||
});
|
||||
|
||||
it('rejects a non-array fetchWrappers value', async () => {
|
||||
await writeRc(JSON.stringify({ fetchWrappers: 'doRequest' }));
|
||||
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an array of strings/);
|
||||
});
|
||||
|
||||
it('rejects a fetchWrappers entry with regex / non-identifier characters', async () => {
|
||||
await writeRc(JSON.stringify({ fetchWrappers: ['do(.*)Request'] }));
|
||||
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an identifier or member name/);
|
||||
});
|
||||
|
||||
it('rejects an empty fetchWrappers array', async () => {
|
||||
await writeRc(JSON.stringify({ fetchWrappers: [] }));
|
||||
expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/);
|
||||
});
|
||||
|
||||
// ── validateBranchName ─────────────────────────────────────────────
|
||||
|
||||
it('validateBranchName trims and accepts normal branch names', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user