Compare commits

...
21 Commits
Author SHA1 Message Date
gitnexus-release-bot[bot] f190e2fb01 release: v1.6.8-rc.7 2026-06-10 10:39:47 +00:00
Gergő MagyarandClaude Opus 4.8 ae5ec94fd9 fix: stop impact()/route_map under-reporting blast radius (#2129, #1858, #1589/#1852) (#2136)
* fix(query): stop impact()/context() under-reporting blast radius (#2129, #1858)

Two read-side fixes to the "run impact before editing" safety workflow, both
about the tools rendering "I could not give a single confident answer" as
"no impact" — the most dangerous failure mode for a refactor-safety tool.

#2129 — ambiguous resolution no longer hides a real caller behind a bare
`impactedCount: 0`. When a bare name collides with several symbols, the resolver
returns `ambiguous`; previously the payload carried a flat `impactedCount: 0`,
so the real caller (which calls a *different* same-name node) was invisible
unless the user already knew to disambiguate. The ambiguous branch now runs a
bounded, summary-only BFS per candidate (capped at 6) and surfaces each
candidate's true count plus the top-level `maxImpactedCount` / `maxRisk`, ranked
most-impactful-first. `risk` stays `UNKNOWN` (ambiguity must not read as "safe"),
`impactedCount` stays 0 (no single resolved symbol). The BFS and edge storage
are unchanged — an empirical repro confirmed they are correct; the bug was
purely in how the ambiguous case reported. Disambiguation by uid still returns
the exact result.

#1858 — impact()/context() now carry an additive `epistemic` field. When the
queried symbol sits on an interface / indirection boundary (it implements or
extends an interface, or is one) whose consumers bind via a DI container or
dynamic dispatch, those callers are not traced to the concrete symbol, so the
count is a lower bound. The result is annotated `epistemic: 'lower-bound'` with a
human-readable `boundaries[]` note; a fully resolved leaf stays
`epistemic: 'exact'`. Aligned to the surviving numeric confidence model (the
0.85 IMPACT_RELATION_CONFIDENCE heritage floor), not the long-deleted
TIER_CONFIDENCE enum. Purely additive — no existing field or count changes.

Tests: impact-ambiguous-blast-radius (per-candidate surfacing + uid
disambiguation) and impact-epistemic-lower-bound (interface boundary →
lower-bound, resolved leaf → exact, context parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(routes): configurable fetch wrappers + faster consumer scan (#1589/#1852)

Closes the residual gap behind the now-merged #1852 (which fixed #1589): the
fetch-wrapper consumer scan only traced wrappers the parse phase auto-detected
as calling the bare global `fetch()`. A wrapper built on axios / a custom
client, or one named outside the built-in convention, was invisible — route_map
silently returned `consumers: []` (the exact "named outside convention → silent
zero" hole #1858 calls out as needing a backstop).

- Configurable wrappers: `.gitnexusrc` gains a `fetchWrappers: [...]` list
  (validated as identifier/member names, de-duped, capped, regex-safe), threaded
  AnalyzeOptions → PipelineOptions → routes phase. Configured names are unioned
  with the auto-detected ones; configured names alone now trigger the scan even
  when nothing was auto-detected.
- Perf (F3 from #1852's review): the cross-file scan built one RegExp per
  (file × wrapper) — O(files × wrappers). It now builds a single alternation
  regex per file (O(files)) and reuses file contents already read for handler
  extraction instead of re-reading them.

Tests: configurable-fetch-wrapper (axios-based `doRequest` wrapper — invisible
without config, traced with it) + .gitnexusrc `fetchWrappers` validation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): harden the under-reporting fixes after adversarial review

Addresses findings from a reviewer-swarm pass over the two prior commits:

- CLI text false-safe (major): `formatImpactResult` (eval-server.ts) had no
  ambiguous branch, so `gitnexus impact <colliding-name>` printed "No
  dependencies found. This symbol appears isolated." for an ambiguous target —
  the exact false-safe #2129 exists to kill, defeating the JSON-layer fix at the
  text surface. Added an ambiguous branch (per-candidate blast radius +
  maxImpactedCount/maxRisk) and a lower-bound branch for both the zero-count and
  non-zero paths, mirroring the context formatter. Covered by new unit tests.
- Group fan-out dead work (major): impactByUid now passes skipEpistemic:true —
  the group cross-impact fan-out consumes only byDepth, so computing the #1858
  boundary per neighbor was wasted round-trips on the highest-volume path.
- Ambiguous all-UNKNOWN risk (minor): if every per-candidate probe fails, maxRisk
  now reports 'UNKNOWN' instead of falling to the 'LOW' seed (which would read as
  "safe").
- Candidate-probe cost (minor): the per-candidate summary BFS now sets
  skipEnrichment:true, bypassing the process/module aggregation passes it does
  not use.
- Epistemic latency (minor): computeEpistemicBoundary now runs concurrently with
  the impact BFS instead of as a trailing serial round-trip.
- Wrapper over-match (minor): the consumer-scan regex uses a `(?<![.\w$])`
  lookbehind instead of `\b`, so a bare configured name like `get` matches the
  free call `get('/x')` but not a member access `client.get(` (and `apiFetch`
  no longer matches `myApiFetch`).
- Boundary wording (nit): correct article ("a class" vs "an interface") and
  singular/plural ("1 implementation").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): drop unused describe import in new impact tests

The withTestLbugDB harness wraps describe internally, so the explicit
describe import was unused — unused-imports/no-unused-imports is an error
(not a warning) in the root eslint config, failing quality/lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag partialProbe when an ambiguous candidate probe fails (#2129 review F1)

The ambiguous-impact branch hoists maxRisk/maxImpactedCount so a colliding
name can't read as "isolated". But if a per-candidate BFS throws (e.g. DB
pool contention during the ≤6-way fan-out), it was recorded as
risk:'UNKNOWN', impactedCount:0 and silently masked by any benign sibling
success — maxRisk reduced to the benign tier and maxImpactedCount reflected
only successful probes. Track probeFailed and surface partialProbe:true
(additive, intentionally distinct from the traversal-interrupted `partial`
flag); formatImpactResult prints a lower-bound warning. Covered by a
formatter unit test (a natural in-harness probe throw is unreachable —
_runImpactBFS is fully self-catching under summaryOnly+skipEpistemic+
skipEnrichment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): report the full match count when ambiguous candidates are truncated (#2129 review F11)

The ambiguous candidate list is capped at AMBIGUOUS_MAX_CANDIDATES (6), but
the CLI headline read the truncated `candidates[]` length — so a name
matching 9 symbols printed "6 symbols share this name" while the JSON message
stated the true count. Add an additive `totalCandidates` field carrying the
full match count, include a "showing N of M" clause in the message when
truncated, and have formatImpactResult report the full count. Covered by
formatter unit tests for the truncated and non-truncated cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(query): run context() epistemic probe concurrently with methodMetadata (#1858 review F2)

impact() overlaps the #1858 boundary probe with its BFS, but _contextImpl
awaited computeEpistemicBoundary serially after every other query. Start the
probe right after `symKind` is known (the earliest point it can — symKind
depends on the incoming/outgoing round-trips) so it runs concurrently with the
methodMetadata fetch, and await it at result assembly. Output is unchanged
(covered by the existing epistemic context() tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag a leaf interface as lower-bound in context() (#1858 review F3)

context() passed `symKind` to computeEpistemicBoundary, but symKind collapses
a single-resolved Interface to 'Class' (resolvedLabel is '' on the
single-candidate path), so the `symType === 'Interface'` self-boundary branch
never fired and a directly-queried leaf interface (implements nothing, but
consumed) was under-reported as 'exact'. Pass an interface-preserving type
(`resolvedLabel || sym.type || symKind`) instead — enrichCandidateLabels runs
before the single-candidate early return and patches sym.type to 'Interface',
mirroring impact()'s derivation. impact() was already unaffected. Covered by a
new context()-on-a-leaf-interface test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): hoist epistemic relation-type lists + add USES to the allowlist (#1858/#2129 review F4, F5)

F4: promote computeEpistemicBoundary's function-local heritage/consumer
relation-type lists to module-level readonly constants
(EPISTEMIC_HERITAGE_RELATION_TYPES / EPISTEMIC_CONSUMER_RELATION_TYPES) next to
VALID_RELATION_TYPES / IMPACT_RELATION_CONFIDENCE, so a future heritage edge
type is visible to the probe. Kept as arrays (not Sets) because they bind as
Cypher params.

F5 (latent bug): USES is emitted (emit-references.ts) and already in the
default impact relTypes + context() queries, but was missing from
VALID_RELATION_TYPES — so impact({relationTypes:['USES']}) filtered to [] and
silently ran the full default traversal. Add it (0.5 confidence fallback,
matching FETCHES/WRAPS). Updates the security.test.ts allowlist assertions
(size 15→16, USES now valid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(query): document the _runImpactBFS enrichment skip-flag composition (#1858/#2129 review F6)

The three skip-flags (skipPerSymbolEnrichment / skipEpistemic / skipEnrichment)
suppress distinct sub-phases and compose implicitly. Add a JSDoc block at the
opts type listing what each suppresses, the three real call patterns, and the
key interaction (skipEnrichment makes skipPerSymbolEnrichment a no-op).
Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): genericize the shared string-array validation messages (#1589/#1852 review F7)

The shared `string-array` ValueKind hardcoded fetch-wrapper phrasing in three
messages (non-array, identifier-shape, empty-list). Since `source` already
names the config key, genericize all three so the shared normalizer carries no
fetchWrappers coupling — a future string-array config key gets sensible errors.
Test assertions updated to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): type the ambiguous candidate summary + epistemicPromise (#1858/#2129 review F8)

The ambiguous per-candidate summary was read through `any`, so a rename of
_runImpactBFS's return fields would silently zero candidate counts. Name the
read shape ({impactedCount, risk, summary?.direct}) at the narrowing site, and
type epistemicPromise as the optional-epistemic union (the skip case's `{}`
subtype) — keeping computeEpistemicBoundary's own return precise (epistemic
required). Type-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routes): trust validated fetchWrappers config, drop redundant re-filter (#1589/#1852 review F9)

`ctx.options.fetchWrappers` is already trimmed/shape-validated/de-duped/capped
in analyze-config.ts, so the routes-phase re-trim/re-typeof pre-pass was
redundant. Pass it straight through; the single Set-construction filter remains
to guard the auto-detected functionName values (which don't pass through
analyze-config). No behavior change — covered by the existing fetch-wrapper
route suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): make the wrapper-call boundary Unicode-aware (#1852 review F10)

The consumer-scan lookbehind used ASCII `\w`, so a configured bare wrapper name
preceded by a non-ASCII identifier character (`caféget('/x')`) satisfied the
boundary and produced a spurious FETCHES edge. Switch to the `u` flag with
Unicode property classes (`(?<![.\p{L}\p{N}_$])`). Covered by a fixture
consumer (`cafédoRequest('/api/things')`) asserting no spurious edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): count wrapper-scan line numbers incrementally (#1852 review F12)

The wrapper consumer scan computed each match's line number via
content.substring(0, match.index).split('\n').length — an O(matchIndex)
allocation per match. Matches arrive in ascending index, so accumulate
newlines with a running counter instead. 1-based line numbers are byte-identical
(covered by the existing fetch-wrapper route suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): keep the #1858 epistemic probe from skewing the impact-pagination mock

The impact-pagination mock counts every query containing `r.type IN` as a BFS
depth level. Once the #1858 epistemic boundary probe was parallelized with the
BFS (it fires `MATCH (x)-[r]->(iface) ... r.type IN $heritage` before the
frontier loop), that query was miscounted as depth-1, shifting the real depths
so multi-depth impactedCount read 50 instead of 200. Short-circuit the
epistemic queries (uniquely aliased `iface`) to empty in both mock setups so
only frontier queries count. Test-only; production is unaffected (the epistemic
query is a separate real query there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:25:48 +01:00
Gergő Magyar 7eaeb0a0c4 feat: multi-branch indexing and branch-scoped querying (#2106) (#2137)
* feat(git): add getCurrentBranch + resolveRefToCommit helpers (#2106)

* feat(storage): branch-scoped getStoragePaths + branchSlug + resolveBranchPlacement (#2106)

* feat(analyze): branch-aware indexing — per-branch slot, no overwrite (#2106)

* feat(registry): nest non-primary branches under one path entry (#2106)

* feat(mcp): optional branch scope on query tools + list_repos branches (#2106)

* feat(cli): --branch on analyze + query/context/impact/cypher/detect-changes (#2106)

* feat(cli): branch-aware list/status + per-branch staleness meta (#2106)

* fix(review): apply autofix feedback

- guard analyze against --branch != checked-out branch (prevents writing one
  branch's working tree into another branch's index slot)
- fix branch-handle pool reinit thrash (track observed indexedAt by lbugPath,
  since applyBranchScope returns fresh handles)
- remove dead resolveRefToCommit helper (staleness uses HEAD vs branch meta)
- RepoListing.branches -> Omit<BranchSummary,'stats'> for type cohesion
- add tests: branchSlug traversal containment, --branch mismatch reject,
  callTool branch threading, legacy-entry branch routing, status detached/stale

* fix(review): address tri-review findings (#2106)

- P1 data-loss: a detached-HEAD re-analyze (CI's actions/checkout default) no
  longer strips the primary's meta.branch stamp; preserve it so a later branch
  analyze cannot claim & overwrite the flat/primary index. +cascade integration test
- P2: capture validateBranchName's trimmed return for --branch so a
  whitespace-padded value no longer false-rejects on-branch or ghosts an index
- F1: on a lost/rebuilt registry, a branch run reconstructs the primary
  top-level entry from the flat meta, not the feature branch's meta

* fix(storage): only trust a non-empty-string flatMeta.branch (#2106 R5)

* fix(analyze): warn when the default branch is not the primary index (#2106 R8)

* fix(mcp): resolve --branch <primary> on a legacy unstamped flat index (#2106 R4)

* feat(cli): gitnexus clean --branch to remove a single branch index (#2106 R7)

* fix(mcp): evict orphaned branch pools on unregister/clean (#2106 R3)

* fix(analyze): union per-branch cache keys so a branch switch keeps shards (#2106 R6)

* fix(analyze): normalize the auto-detected branch label via sanitizeDetectedBranch (#2106 R1)

* fix(cli): skip AGENTS.md base_ref refresh for a non-primary branch fast path (#2106 R2)

* fix(storage): atomic writeRegistry + re-read-before-write to narrow the registry race (#2106 R9)

* refactor(storage): extract branch primitives to branch-index.ts (#2106 R10)
2026-06-10 10:24:40 +01:00
36ca096e75 feat(ingestion): Java Spring route annotation → Route node extraction (#2078)
* feat(ingestion): add Java Spring route annotation → Route node extraction

Previously, GitNexus only supported Route node generation for JS/TS
ecosystems (Express, Next.js, Fastify, etc.) and Python (FastAPI, Flask).
Java Spring's annotation-based routing (@RequestMapping, @GetMapping,
@PostMapping, etc.) was only supported at the group contract layer
(http-patterns/java.ts) for cross-repo matching, but NOT at the
ingestion layer for generating graph Route nodes.

This commit adds ingestion-layer support:

1. JAVA_QUERIES (tree-sitter-queries.ts):
   - Added method-level annotation captures (@GetMapping, @PostMapping,
     @PutMapping, @DeleteMapping, @PatchMapping) → @decorator captures
   - Added class-level @RequestMapping → @decorator capture (prefix)
   - Supports both positional ("/path") and named (path="/path",
     value="/path") annotation argument forms

2. parse-worker.ts:
   - Java class-level @RequestMapping is detected and stored as a prefix
     (not pushed as a standalone Route)
   - After per-file capture processing, the prefix is applied to all
     method-level routes in the same file via the existing
     ExtractedDecoratorRoute.prefix field
   - The routes phase (normalizeExtractedRoutePath) handles the prefix
     joining, producing final URLs like /api/users/list

3. Tests:
   - Unit test (worker-backed): 4 cases covering prefix joining,
     bare routes, class-level exclusion, multi-file isolation
   - Integration test (full pipeline): 6 cases covering end-to-end
     Route node + HANDLES_ROUTE edge generation

Closes the feature gap where `route_map`, `shape_check`, and
`api_impact` MCP tools returned empty results for Java Spring projects.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address review findings — extract spring.ts module, fix PatchMapping, multi-class support

Addresses all P2 findings from tri-review:

1. **Architecture**: Extracted Spring route logic from parse-worker.ts into
   a dedicated `route-extractors/spring.ts` module (matching the pattern
   of `laravel.ts` and `fastapi-router-bindings.ts`). parse-worker now
   has a single dispatch line — no language-specific logic inline.

2. **PatchMapping bug**: Added `'PatchMapping'` to `ROUTE_DECORATOR_NAMES`
   (was silently dropped before).

3. **Multi-class bug**: The new `extractSpringRoutes` walks each class
   declaration independently with its own prefix — no more single-scalar
   `javaClassPrefix` last-wins issue.

4. **Test hygiene**: Unit tests now import `extractSpringRoutes` directly
   (no dist build / worker pool dependency). Tests run in all tiers.

5. **Removed JAVA_QUERIES decorator patterns**: The Spring extractor does
   its own AST walk, so the tree-sitter query captures for Java annotations
   are no longer needed (avoids duplicate route emission).

Additional test coverage:
- Multi-class in one file with independent prefixes
- @PatchMapping support
- Named annotation args (path= and value=) on class-level @RequestMapping

* refactor: move Spring route extraction to LanguageProvider hook

Addresses the second review comment: instead of an inline
`if (language === SupportedLanguages.Java)` dispatch in parse-worker,
the Spring route extraction is now wired through a new optional
`extractDecoratorRoutes` hook on LanguageProviderConfig.

- Added `extractDecoratorRoutes` to LanguageProviderConfig interface
- Java provider registers `extractSpringRoutes` as its implementation
- parse-worker calls `provider.extractDecoratorRoutes?.()` generically
- Removed direct import of spring.ts from parse-worker

This keeps parse-worker fully language-agnostic — no language names
appear in the dispatch path for route extraction.

* refactor: rewrite spring.ts with tree-sitter captures, fix inline imports

Addresses all 4 inline review comments:

1. Rewrote spring.ts to use a single predicate-free Parser.Query
   (same pattern as group-layer JAVA_ROUTE_ANNOTATION_PATTERNS).
   Two-phase loop: first pass collects class prefixes by node.id,
   second pass resolves method routes via findEnclosingClass.
   No more manual DFS / recursion.

2-3. Moved inline import(...) type references in language-provider.ts
     to proper top-level imports (Parser, ExtractedDecoratorRoute).

4. Covered by #1 — recursive helpers removed entirely.

Added 3 extra test cases: non-route named args filtering,
prefix isolation across mixed classes, line number accuracy.

* refactor: extract shared Spring route primitives + add parity test

Addresses review follow-up on #2078:

- Extract the primitives shared by the ingestion (route-extractors/spring.ts)
  and group (http-patterns/java.ts) Spring extractors into a new
  route-extractors/spring-shared.ts: METHOD_ANNOTATION_TO_HTTP,
  findEnclosingClass, isRouteMemberKey, and a safe unquoteSpringLiteral.
  Both extractors now import from it (group -> ingestion, the layer-correct
  direction) so the shared semantics can't drift apart.

- Replace spring.ts's local unquote() with the safer unquoteSpringLiteral
  (returns null for non-string nodes instead of assuming a quoted string).

- Add test/unit/spring-route-extractor-parity.test.ts: runs one shared Spring
  fixture through both extractors and asserts they surface the same provider
  method/path combinations.

The broader HttpRouteExtractor source-scan optimization is tracked in #2138.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 09:44:56 +01:00
Gergő MagyarandClaude Opus 4.8 292f26ece3 fix(hooks): silence MCP-owned-DB augment skip for strict hook runners (#1913) (#2134)
* fix(hooks): silence MCP-owned-DB augment skip for strict hook runners

The PreToolUse augment-skip path wrote `[GitNexus] augment skipped: MCP
server owns DB` to stderr unconditionally on a normal (non-error) skip.
Strict hook runners that validate hook output (e.g. Codex `PreToolUse`)
treat that as noisy / "invalid pre-tool-use JSON output".

Gate the diagnostic behind GITNEXUS_DEBUG via a shared `isDebugEnabled()`
helper, so normal skips are silent by default (empty stdout AND stderr,
exit 0) and the reason stays recoverable with `GITNEXUS_DEBUG=1`. Applied
consistently to all three hand-maintained hook copies (claude,
antigravity, claude-plugin).

Tests:
- Unit (claude CJS + plugin): assert default-silent and debug-on behavior
  for the MCP-owned-DB skip and for the fail-closed (lsof ETIMEDOUT) skip
  that routes through the same gated line; the owner-detection tests run
  with GITNEXUS_DEBUG=1 so the skip discriminator stays observable.
- e2e (antigravity): the antigravity adapter shares the identical gated
  skip but only runs from its install dir, so cover it through the install
  pipeline with a faked DB-owner probe (strict empty-stdout/stderr +
  debug-on). Promote the fake-probe helpers (createHookToolDir / hookEnv,
  plus a module-private writeExecutable) into shared hook-test-helpers so
  unit + e2e reuse them.

Fixes #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): unify GITNEXUS_DEBUG gating in main() catch handlers

The main() catch-handler in all three hook copies still gated its crash
log on truthy `if (process.env.GITNEXUS_DEBUG)`, while the skip diagnostic
the #1913 fix added is gated on the strict `isDebugEnabled()` helper
(=== '1' || === 'true'). That split meant GITNEXUS_DEBUG=0 or =false
suppressed the skip line yet still enabled crash logging — two conflicting
contract signals in the same file.

Switch the three catch handlers to isDebugEnabled() so GITNEXUS_DEBUG has
one strict meaning everywhere: exactly '1' or 'true' enables all
diagnostics; everything else (incl. '0', 'false', empty, unset) is silent.

Add boundary tests asserting the MCP-owner skip stays silent with
GITNEXUS_DEBUG='0' and 'false' (CJS + Plugin), pinning the strict contract.

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): gate antigravity stale-index hint stderr behind GITNEXUS_DEBUG

The antigravity AfterTool handler mirrored the stale-index hint to stderr
unconditionally on a normal (non-error) success path — the last ungated
stderr write of the class issue #1913 targets, and a divergence from the
claude hook, which never mirrors this hint to stderr.

Gate the stderr mirror behind isDebugEnabled(). The hint still reaches the
agent via additionalContext (stdout JSON) — parts.push(hint) stays
unconditional — so there is no functional loss; only the by-default
terminal mirror moves behind GITNEXUS_DEBUG=1. This knowingly changes the
#1730 terminal-mirror behavior in favor of strict-runner cleanliness and
parity with the claude adapter.

Split the e2e assertion into a default-silent test (hint in
additionalContext, absent from stderr) and a GITNEXUS_DEBUG=1 test (hint
mirrored to stderr).

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(hooks): document GITNEXUS_DEBUG=1 for hook diagnostics

GITNEXUS_DEBUG was documented only in the cursor integration README, so
the diagnostic escape hatch for the Claude Code / Antigravity hooks was
undiscoverable. Operators hitting a silent hook skip (MCP server owns the
DB, fail-closed probe timeout, or an already-current index) had no
documented way to surface the reason.

Add a Troubleshooting subsection explaining that the hooks stay silent on
normal skip paths for strict runners, that GITNEXUS_DEBUG=1 surfaces the
reason on stderr, and that only '1'/'true' enable diagnostics (stdout JSON
the agent consumes is unaffected).

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): update setup-antigravity unit test for gated stale-index hint

U2 (7995e921) gated the antigravity stale-index hint stderr mirror behind
GITNEXUS_DEBUG, but a second test — setup-antigravity.test.ts's "AfterTool
emits stale-index hint" — also asserted the hint on stderr by default and
was missed (it lives outside the two files validated locally; the full CI
matrix caught it).

Update it to the U2 contract: assert the hint via additionalContext with
stderr silent by default, plus a GITNEXUS_DEBUG=1 run asserting the
terminal mirror reappears.

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:09:41 +01:00
Gergő MagyarandClaude Opus 4.8 4f9d595c73 fix(docker): ship runtime-needed published assets (hooks/, skills/) into the image (#2130) (#2132)
* fix(docker): copy hooks/ into Dockerfile.cli runtime stage (#2130)

`gitnexus analyze` inside the official image (akonlabs/gitnexus,
ghcr.io/abhigyanpatwari/gitnexus) crashed at startup with:

    Error: Cannot find module '../../hooks/claude/resolve-analyze-cmd.cjs'
    Require stack:
    - /app/gitnexus/dist/cli/resolve-invocation.js

`dist/cli/resolve-invocation.js` does
`createRequire(import.meta.url)('../../hooks/claude/resolve-analyze-cmd.cjs')`
at module load (it is the single source of truth for the npm-11 npx-crash
invocation decision, #1939), and `analyze.ts` statically imports it. The
Dockerfile.cli runtime stage copied dist/node_modules/package.json/the
duckdb script/vendor but never `hooks/`, so the require throws before the
command does any work. `hooks/` is in package.json `files`, so npm already
ships it — Docker was the only distribution dropping it.

Fix: copy `hooks/` into the runtime stage, mirroring what npm publishes.

Also add `test/unit/dockerfile-runtime-asset-parity.test.ts`: a regression
guard that derives every out-of-dist `require()`/`createRequire()` target
from source and asserts each is a runtime-stage `COPY`. Scoped to the
require family (not `fs.access`/`new URL`), so it locks the #2130 class
without false-flagging the intentionally-omitted, gracefully-degrading
`web/` and `skills/` assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(docker): also ship skills/ into the runtime image

Follow-up to the hooks/ fix: `skills/` is another published runtime asset
(in package.json `files`) the Docker image dropped. The CLI reads the
bundled SKILL.md templates from `<pkg>/skills/` for `gitnexus analyze
--skills` (ai-context skill generation) and `gitnexus setup`/`uninstall`
(installing skills into editor configs). Unlike the hooks/ require(), these
reads degrade SILENTLY when the dir is absent — `--skills` writes minimal
placeholder content (ai-context.ts), `setup` installs zero skills
(setup.ts readdir → []) — so the image looked fine but produced wrong
output. Copy `skills/` so the image is fully usable for all CLI tooling.

`web/` (also in `files`) is intentionally NOT shipped: this image never
builds gitnexus-web (the builder doesn't copy it, build.js logs "skipping
web UI"), so it is API-only by design — the UI is the separate
Dockerfile.web image / hosted app. The duckdb script is the only runtime
asset needed from scripts/, so that stays a single-file copy.

Extends the runtime-asset-parity guard with an explicit skills/ assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): correct stale docstring that listed skills/ as not copied

The 2nd commit on this branch added a skills/ COPY + an it('copies skills/…')
assertion, but the top-of-file docstring still grouped skills/ with web/ as
'intentionally not copied / out of scope'. Drop skills/ from that sentence and
note it is shipped (and covered by its own test). web/ remains the sole
fs-accessed-but-uncopied example. Documentation-only; assertions unchanged.

* fix(test): make runtime-stage detection case-insensitive on AS

Docker accepts a lowercase `as runtime`; the parity guard's stage-detection
regex was case-sensitive on `AS`, so a future Dockerfile reformat would empty
the parsed COPY set and trip the named assertions. Add the /i flag.

* fix(test): stop runtime-stage COPY parsing at the next FROM

runtimeStageCopiedSources scanned from the runtime FROM to EOF. Bound the scan
to the runtime stage (start after its FROM, break on the next FROM) so a build
stage added after runtime can't have its COPY lines misattributed. No-op today
(runtime is the last stage); the copied set is unchanged.

* fix(test): assert at least one runtime COPY is parsed (no vacuous pass)

If the runtime FROM or the /app/gitnexus/ source prefix ever stops matching,
the copied set goes empty and the parity assertion passes vacuously. Add an
explicit copied.length>0 guard so that failure mode is loud and named.

* fix(test): strip line comments before require-scanning

requiredExternalAssets() regex-scanned raw source, so a future doc-comment such
as a commented-out require('../../web/x') in a shallow src file would resolve
outside dist/ and spuriously fail the parity guard. Strip // line comments
first. Block comments are deliberately not stripped (a naive block strip mangles
slash-star inside string/glob literals). Verified the real-tree scanner output
is byte-identical with and without the strip, and resolve-invocation.ts's
multi-line createRequire is still detected. (Also swaps a stray non-ASCII glyph
in the prior commit's comment for ASCII.)

* fix(test): account for aliased + computed module-load requires (fail-closed)

The parity scanner only matched string-literal require/createRequire, so it
missed module-load requires via aliased createRequire bindings and computed
paths — and already failed to see community-processor.ts's
`_require(leidenPath)` -> vendor/leiden, making the "every out-of-dist asset"
claim untrue.

Broaden the scan:
- Discover per-file createRequire bindings (requireCJS, _require, …) and match
  their literal-arg calls; keep the createRequire(...)('…') IIFE form.
- Detect COMPUTED (non-literal) requires and gate them on MODULE-LOAD position
  (brace-depth 0), so the four in-function computed requires that target
  node_modules/package.json (optional-grammars, native-check, capabilities,
  parse-cache) are correctly out of charter and ignored. A module-load computed
  require must be vetted in KNOWN_COMPUTED_REQUIRES (seed: community-processor ->
  vendor/leiden) or the test FAILS CLOSED for manual review.
- Allowlist entries are coverage-checked via isCovered, never trusted: a new
  test removes the `vendor` COPY from a fixture and asserts leiden surfaces as
  uncovered (so deleting a COPY can't silently pass — the #2130 class).
- Exclude `<id>.resolve(...)` (a path lookup, not a load).
- Upgrade the comment stripper to a string-aware pass that removes line AND
  block comments without mangling slash-star inside string/glob literals — the
  computed branch needs JSDoc requires (e.g. javascript/index.ts) gone, and the
  literal scan output stays byte-identical.

Honest claim wording: the 4th test now says coverage = resolvable + vetted
module-load requires, unrecognized computed requires fail for review. Adds
unit tests for fail-closed, aliased-literal, and in-function-ignored paths.

* fix(test): also scan shipped .cjs/.mjs assets for sibling requires

The guard only scanned src/**/*.ts, so hand-written shipped runtime files were
invisible — and they DO require siblings: hooks/claude/gitnexus-hook.cjs and
hooks/antigravity/gitnexus-antigravity-hook.cjs each require('./hook-lock.cjs'),
'./hook-db-lock-probe.cjs', './resolve-analyze-cmd.cjs'. Add a second pass over
shipped .cjs/.mjs assets (the runtime COPY set minus dep/data roots), resolving
each relative require against the asset's OWN package-relative dir and checking
COPY coverage — by prefix, NOT on-disk existence: the antigravity hook's
'./hook-lock.cjs' resolves to hooks/antigravity/hook-lock.cjs (which doesn't
physically exist; hook-lock.cjs lives under hooks/claude) yet is covered by the
whole-hooks COPY. All 6 shipped sibling requires resolve under the hooks COPY.

* fix(docker): move hooks/skills COPYs past the DuckDB FTS RUN

The hooks/ and skills/ COPYs sat between the vendor COPY and the DuckDB
FTS-extension install RUN, so any edit to hook/skill content invalidated that
RUN's cache layer — which performs a one-time network INSTALL of the extension
(~tens of seconds per affected build). The COPYs have no input dependency on the
DuckDB step; relocate them to after it (before USER node) so stable
infrastructure layers are not rebuilt on hook/skill churn. Image contents are
unchanged. The runtime-asset-parity guard still detects both (its scan covers
the whole runtime stage), and the two are consolidated under one comment.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:38:37 +01:00
Gergő MagyarandClaude Opus 4.8 e46651e42c fix(embeddings): create VECTOR index via conn.query, not the prepared path (#2114)
`gitnexus analyze` silently failed to create the LadybugDB VECTOR/HNSW index because `CALL CREATE_VECTOR_INDEX(...)` was run through the prepared `conn.prepare()` path, which rejects multi-statement procedures — degrading semantic search to exact-scan. Route index creation through `conn.query()` via a new adapter-owned `createVectorIndex` (mirrors `createFTSIndex`), make the previously-swallowed error visible (`{ err }` logging), add an in-process idempotency cache, and add real-`@ladybugdb/core` regression coverage.

Fixes #2114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:59:37 +01:00
dependabot[bot]andGergő Magyar 43e6a3f688 chore(deps)(deps): bump js-yaml from 4.1.1 to 4.2.0 in /gitnexus (#2097)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/commits)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 06:33:39 +01:00
dependabot[bot] 581943bbe5 chore(deps)(deps-dev): bump @types/node in /gitnexus (#2128)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.1 to 25.9.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 06:32:57 +01:00
Gergő Magyar 1cf65b339c chore: release v1.6.7 (#2126)
Bump gitnexus 1.6.6 -> 1.6.7, add the 1.6.7 CHANGELOG section (14 PRs since v1.6.6), and sync the Claude plugin manifests (plugin.json + marketplace.json) to 1.6.7.
2026-06-09 21:37:33 +01:00
gitnexus-release-bot[bot]andgitnexus-release-bot[bot] d0452d26a1 chore(vendor): rebuild native prebuilds (tree-sitter-c,tree-sitter-dart,tree-sitter-proto,tree-sitter-kotlin,tree-sitter-swift) (#2125)
Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/27230510159

Co-authored-by: gitnexus-release-bot[bot] <gitnexus-release-bot[bot]@users.noreply.github.com>
2026-06-09 20:57:37 +01:00
Gergő Magyar 247ea431de fix(ci): make the prebuild PR push re-run-safe (--force-with-lease -> --force) (#2123) 2026-06-09 20:28:08 +01:00
Gergő MagyarandClaude Opus 4.8 4682a477d8 feat(mcp): paginate list_repos to avoid client token truncation (#2119) (#2120)
* feat(mcp): paginate list_repos to avoid client token truncation (#2119)

list_repos returned every indexed repository in one unpaginated array,
which large/LLM MCP clients truncate by token limit — so agents with
hundreds of indexed repos could not enumerate them all (the data
transmits fully; the consuming client drops it).

Add bounded limit/offset pagination to the list_repos tool:
- result changes from a bare array to
  { repositories, pagination: { total, limit, offset, returned,
  hasMore, nextOffset } }; default page 50, max 200 (shared constants)
- reject malformed limit/offset; clamp limit above the max
- deterministic order (lower-cased name, then path) over one registry
  snapshot per call, so paging never skips or duplicates an entry
- covers both stdio and remote /api/mcp (shared createMCPServer/callTool)

The internal listRepos() method (5 callers), GET /api/repos, and the
`gitnexus list` CLI are unchanged. The array->object tool-result shape
is a deliberate contract change, documented in CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): reject list_repos limit above the max instead of clamping (#2119)

parseListReposPagination silently clamped limit>max to the maximum while
throwing on every other out-of-bounds value (limit<1, offset<0, non-integer,
NaN). A client that advanced offset by its requested limit (rather than
pagination.nextOffset) then silently skipped repositories and saw
hasMore:false — defeating the "never skips" guarantee. Reject an over-max
limit too, so validation is symmetric and a caller never gets a smaller page
than it asked for without a clear error. Updates the schema/description, the
helper + ListReposPagination JSDoc, the guide note, and the two clamp tests.

Resolves the cross-engine-corroborated P2 (Codex + adversarial lane) and the
maintainability lane's clamp-vs-throw inconsistency from the PR #2120 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): name the list_repos return type and mark the parser @internal

Extract the inline listRepos() element shape into an exported RepoListing
interface and use it for both listRepos() and listReposPage().repositories,
replacing the opaque Awaited<ReturnType<LocalBackend['listRepos']>> expression
the maintainability review flagged. Tag parseListReposPagination @internal
(it is exported only for unit testing). Pure type/JSDoc change; no behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(eval-server): type formatListReposResult to the paginated shape

Narrow formatListReposResult's parameter from `any` to
{ repositories: RepoListing[]; pagination?: ListReposPagination } and drop the
dead bare-array branch — after #2119 callTool('list_repos') always returns the
paginated object, so the Array.isArray shim was unreachable. Add a list_repos
continuation hint to the eval-server's getNextStepHint (parity with the MCP
server), and cover the previously-untested non-empty + hasMore:false formatter
branch. Migrates the two bare-array formatter tests to the object shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): harden list_repos pagination coverage

- Exercise the #2054 sibling-clone guarantee through the real callTool tool
  path (in the #2054 describe, which has temp-dir cleanup), proving siblings
  and remoteUrl survive listReposPage's sort+slice — not only listRepos().
- Assert total + limit on the middle-page test (a total miscalculation at a
  non-zero offset would otherwise slip past it).
- Cover the benign boundaries: negative-zero offset (accepted as page 0) and a
  MAX_SAFE_INTEGER offset (empty page).
- Replace the integration test's '\n\n---' split with a string-aware brace
  scan, so a repo path containing braces can never truncate the JSON parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): sync the list_repos pagination example to the guide mirrors

The .claude and gitnexus-claude-plugin guide mirrors only carried the one-line
table note; add the full "Paginating list_repos" section (shape + multi-page
traversal example + notes) so all three guide copies are byte-consistent with
the canonical gitnexus/skills/gitnexus-guide.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop list_repos CHANGELOG entries from this PR

Restore gitnexus/CHANGELOG.md to match main so this PR contributes no
changelog change; the changelog is curated separately from feature PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:59:54 +01:00
Gergő Magyar bd90d5bf90 fix(ci): green the tree-sitter prebuild matrix (npm-bundled prebuilds + arm64 runtime) (#2122)
First real dispatch of build-tree-sitter-prebuilds failed every job from two
independent root causes:

1. `c` (kind:'npm'): tree-sitter-c's npm tarball bundles prebuilds/ for all 6
   tuples, so the post-build `find ... -print -quit` picked a non-host tuple
   (win32-x64 on a linux runner) and the "built X, expected Y" assertion failed.
   Clear $pkgdir/prebuilds before prebuildify so only the freshly-built host
   tuple remains. (kotlin is npm too but ships no prebuilds, so it dodged this.)

2. every linux-arm64: validate installed tree-sitter@0.21.1 with
   --ignore-scripts, but that tarball ships no linux-arm64 prebuild, so
   require("tree-sitter") threw "No native build was found ... arch=arm64". The
   grammar's own arm64 .node loaded fine. Drop --ignore-scripts and add node-gyp
   + node-addon-api so the runtime source-builds where upstream ships no prebuild;
   prebuild-covered tuples still use the prebuild. The grammar-vs-runtime ABI
   check still fires at setLanguage.
2026-06-09 19:39:10 +01:00
Gergő Magyar 6ab3f64443 fix(ci): drop the broken -t 22 from prebuildify (build-tree-sitter-prebuilds) (#2121)
The native build step ran `prebuildify --napi --strip -t 22`, but prebuildify
parses the bare `-t 22` as the NUMBER 22 and crashes in resolveTargets
(`TypeError: v.indexOf is not a function`) — so every matrix job (c/dart/proto/
kotlin × 6 tuples) failed on its first real run. N-API prebuilds are
Node-version-agnostic, so `-t <node-version>` is both wrong and the cause; drop
it. Verified locally: `prebuildify --napi --strip` builds the vendored c source
cleanly into prebuilds/<tuple>/tree-sitter-c.node and exports
napi_register_module_v1.
2026-06-09 19:02:06 +01:00
Gergő MagyarandClaude Opus 4.8 cef63dd044 feat(install): toolchain-free tree-sitter via vendored prebuilds (#2113)
* feat(install): toolchain-free tree-sitter via vendored GitNexus-built prebuilds

Eliminate the C/C++-toolchain requirement at install for the at-risk grammars
(dart, proto, kotlin) by generating + vendoring native prebuilds, mirroring the
existing vendored tree-sitter-swift. The 10 grammars that already ship 6 upstream
prebuilds stay npm dependencies (toolchain-free AND dependency-review-tracked).

- .github/workflows/build-tree-sitter-prebuilds.yml: a registry-parameterized
  workflow that builds {dart,proto,kotlin} x {linux,darwin,win32}-{x64,arm64}
  prebuilds natively, validates each loads + parses on its arch, and opens a PR
  vendoring them. A `guard` job gates the heavy matrix to run ONLY on dispatch
  or a real grammar-version change — ordinary code PRs cost zero matrix minutes.
- dart/proto: prefer a committed prebuild; fall back to today's source build
  when none matches (no behavior change until prebuilds are vendored).
- kotlin: vendor it (Swift parity) instead of compiling the third-party
  optionalDependency from source at the user's install — supersedes #2110's
  optionalDependency mechanism. The ~23 MB parser.c is NOT vendored (the
  workflow builds from the published package); only node-types + bindings +
  prebuilds are. Removed from optionalDependencies; lock regenerated; probe,
  parser-loader note, README/.devcontainer docs, and the #2110 tests updated.

DO NOT MERGE until vendor/tree-sitter-kotlin/prebuilds/ is populated by the
build-tree-sitter-prebuilds workflow: until then Kotlin is unavailable (vendored
with no source-build fallback). dart/proto remain fully functional throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(install): guard 6/6 N-API prebuild coverage for every grammar

Regression guard so a toolchain-less install can never silently lose a tree-sitter
language on a supported platform-arch:

- Vendored grammars (vendor/tree-sitter-*): every one MUST ship a loadable N-API
  prebuild for all 6 tuples {linux,darwin,win32}-{x64,arm64}. Asserts the
  napi_register_module_v1 entry symbol in each .node (cross-platform, no need to
  run the binary). Currently RED for dart/proto/kotlin until the
  build-tree-sitter-prebuilds workflow populates their prebuilds/ — this is the
  must-fill-before-merge gate (swift already passes 6/6).
- npm-dependency grammars: asserts upstream ships 6/6 N-API too, catching a
  future platform drop. tree-sitter-c is allow-listed at 4/6 (missing
  linux-arm64/win32-arm64) pending #2116; the guard also fails if that gap is
  silently closed (prompting allow-list removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(install): vendor tree-sitter-c at 0.21.4 with GitNexus-built prebuilds (#2116)

tree-sitter-c is the one grammar dependency upstream ships incomplete prebuilds
for (4/6 — no linux-arm64/win32-arm64), AND it is a REQUIRED grammar: its own
`install` (node-gyp-build) compiles from source when no prebuild matches and
exits non-zero, so on a toolchain-less ARM host `npm install gitnexus` HARD-FAILS
at the c step — during npm's dependency phase, before any GitNexus postinstall
runs (so a postinstall "supplement" can't help).

Fix: vendor c prebuild-only at the pinned 0.21.4 (Kotlin pattern), with all six
prebuilds GitNexus-cross-built, and drop it from `dependencies`:
- vendor/tree-sitter-c/ (bindings + node-types + manifest + prebuilds); build
  probe scripts/build-tree-sitter-c.cjs; added to the build workflow registry
  (kind 'npm' — built from c@0.21.4 source).
- materialize-vendor-grammars.cjs: c is REQUIRED, so it is always materialized,
  even under GITNEXUS_SKIP_OPTIONAL_GRAMMARS (it needs no toolchain).
- Removed from package.json dependencies + lockfile (nothing else needs npm c —
  tree-sitter-cpp's dep on c is dev-only and not installed). Preserves the #1242
  ABI pin: vendoring 0.21.4 keeps the good ABI while closing the ARM gap.
- parser-loader note + the prebuild-coverage guard + a cli-commands assertion
  updated; c moves from the npm-gap allow-list into the vendored 6/6 cohort.

Verified: tsc clean, 31 unit tests pass, c loads/parses; the guard is RED for
c/dart/proto/kotlin until the workflow populates prebuilds (the must-fill gate).
Closes the operational risk in #2116.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): source-build fallback for vendored c/kotlin so CI is healthy pre-prebuilds

The vendored prebuild-only grammars (c, kotlin) had empty prebuilds/ until the
build-tree-sitter-prebuilds workflow runs, so they could not load in CI — and
C is hard-required by cross-platform tests (tree-sitter-languages/parsing on
ubuntu+macos+windows), which I cannot pre-build for macos/windows locally. The
robust fix is a source-build fallback that works on every CI runner (all have a
toolchain), mirroring dart/proto:

- Vendor the grammar source (binding.gyp + src/) for c and kotlin; their build
  scripts now PREFER a committed prebuild (toolchain-free) and fall back to
  `node-gyp rebuild` from the vendored source when no prebuild matches. Verified
  both compile against the hoisted node-addon-api@^8 and the runtime loads.
- prebuild-coverage guard is now bootstrap-tolerant: a grammar that vendors its
  source (binding.gyp) may have an incomplete prebuild set (the workflow fills
  it); a prebuild-only grammar (swift) still must ship all six. Any present
  prebuild must still be N-API. Guard goes green; it re-tightens per-grammar as
  the workflow populates prebuilds.
- actionlint: silence a false-positive SC2016 (JS template literals inside the
  single-quoted `node -e` validate block).

Note: kotlin's generated parser.c is large (~23 MB on disk; compresses heavily
in git). Once the workflow populates all six kotlin prebuilds, the source serves
only as the fallback and could be slimmed if desired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(docker): re-materialize+rebuild vendored grammars after npm prune

`npm prune --omit=dev` in the gitnexus CLI image drops anything not in
package.json's dependency tree — including the VENDORED tree-sitter grammars
(materialized by postinstall, not declared deps) and their built bindings. The
`serve` image analyzes/parses repos at runtime, so re-run the grammar postinstall
after the prune (in the toolchain-equipped builder) to restore them. Load-bearing
for tree-sitter-c, a core REQUIRED grammar now vendored (#2116): as a former
dependency it survived prune; vendored, it would not. Also restores
swift/dart/proto/kotlin, which were silently pruned from the image before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(grammars): unify tree-sitter-swift with the vendored-source build pipeline

Swift was the last grammar handled differently — it shipped only upstream
prebuilds, while c/dart/proto/kotlin vendor their grammar source and use a
prefer-prebuild -> source-build-fallback activation script. Vendor swift's
source so all five are handled identically (one uniform build path).

- vendor/tree-sitter-swift: add binding.gyp (win-hardened), bindings/node/
  binding.cc, src/parser.c (ABI-14 default, ~18 MB), src/scanner.c, and
  src/tree_sitter/ headers. The 6/6 prebuilds are retained. The legacy
  parser_abi13.c alternate is intentionally not vendored.
- build-tree-sitter-swift.cjs: rewrite the prebuild probe into the dart-style
  prefer-prebuild then source-build fallback (keeps the GITNEXUS_SKIP gate and
  the never-exit-non-zero postinstall invariant).
- build-tree-sitter-prebuilds.yml: register swift (kind 'vendored'); add its
  package.json to the version-gated pull_request paths and a validate snippet.
- prebuild-coverage guard auto-moves swift into the source-fallback cohort
  (binding.gyp now present); refresh the stale "swift is prebuild-only" comments.
- tests: add build-tree-sitter-swift-probe.test.ts; fix the pre-existing
  build-tree-sitter-kotlin-probe.test.ts breakage (it still asserted the old
  probe strings after kotlin's dart-style conversion); assert swift's vendored
  source in cli-commands.test.ts.
- docs: README / .devcontainer / kotlin vendor README — swift's prebuilds are
  now GitNexus-cross-built from vendored source like the rest, not upstream-only.

Verified: swift source-builds against node-addon-api@8 -> N-API binary -> loads
against the pinned tree-sitter@0.21.1 (ABI 14) -> parses cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(publish): gate a lean prebuilds-only npm tarball behind a coverage guard

Vendoring grammar source (parser.c) alongside the prebuilds means the npm
tarball now carries ~50 MB of generated source it almost never compiles (every
supported platform-arch has a prebuild). Prepare to drop it from the published
package once all prebuilds exist — safely.

- .npmignore: add a GATED, commented-out "lean publish" block that excludes the
  source-build inputs (parser.c/scanner.c/tree_sitter/binding.gyp/binding.cc) but
  keeps prebuilds/ + the runtime files. Uncommenting ships prebuilds-only.
- scripts/assert-publish-grammar-coverage.cjs: a prepack guard that refuses to
  pack/publish if the source exclusion is active while any vendored grammar still
  lacks 6/6 prebuilds (which would ship a grammar with no loadable binding). Wired
  into `prepack` (runs on npm pack + publish, incl. the publish.yml dry-run) and
  exposed as `npm run assert-publish-coverage`.
- test: pure-core decision cases + a real-repo publish-safety check that fails CI
  if .npmignore is activated prematurely.

Net: the prebuilds already publish today (files: ["vendor"]); this makes the
future switch to a prebuilds-only tarball a one-line uncomment that can't ship a
dead grammar. The guard currently reports "source + prebuilds" (only swift has
6/6 prebuilds so far) and passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(grammars): consolidate the 5 build-tree-sitter-*.cjs into one

The per-grammar activation scripts (c/dart/proto/swift/kotlin) were ~95%
identical — same prefer-prebuild → source-build → never-fail flow, differing only
in name, target_name, required-vs-optional, and the display label in warnings.

- scripts/build-tree-sitter-grammars.cjs: one registry-driven script. Bare call
  builds all (postinstall); `... <name>` builds only the named grammars (so the
  probe test can isolate one). c is `required: true` (ignores the opt-out gate);
  the rest honor GITNEXUS_SKIP_OPTIONAL_GRAMMARS. Per-grammar try/catch + a final
  process.exit(0) preserve the postinstall never-exit-non-zero invariant.
- package.json: postinstall is now `materialize && build-tree-sitter-grammars.cjs`
  (was five chained `build-tree-sitter-<name>.cjs` calls).
- tests: replace the two near-identical *-probe.test.ts files with one
  parameterized build-tree-sitter-grammars-probe.test.ts that also covers the
  required-vs-optional opt-out split and an unknown-grammar arg.
- update cli-commands.test.ts postinstall assertions + the vendor c/kotlin/swift
  README + swift provenance to reference the consolidated script.

Behavior is preserved (warnings normalized to one consistent format). Removes 5
scripts + 1 test file; adds 1 script + 1 test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): lazy-load tree-sitter-c to prevent module-load crash

tree-sitter-c is now vendored prebuild-only (#2116) with 0/6 committed
prebuilds, so on a toolchain-less or `--ignore-scripts` install C has no native
binding. Three modules loaded it via a hard top-level `import C from
'tree-sitter-c'`, which throws ERR_MODULE_NOT_FOUND at module-load — crashing
`analyze` before parser-loader's optional/severity:error degradation can run.
This is the #2091/#2093 bug class (previously fixed for swift/dart/kotlin); C was
left static because it used to be an always-present npm dependency.

- languages/c/query.ts: load via the lazy guarded getLanguageGrammar(C), mirroring
  swift/query.ts; the main-thread isLanguageAvailable filter ensures the getters
  are reached only when C is present.
- workers/parse-worker.ts: guarded `_require('tree-sitter-c')` + conditional
  languageMap spread, like swift/dart/kotlin.
- group/extractors/include-extractor.ts: guarded `_require`; getLanguageForFile
  returns null for .c/.h when absent, so C include-extraction degrades to a no-op
  (C++ unaffected).
- extend the registry-import-closure regression test (#2091/#2093) to assert C
  also loads lazily at registry static-import time.

* fix(ci): repin attest-build-provenance to the real v2.4.0 SHA

The workflow pinned actions/attest-build-provenance@bd77c077… commented
`# v2.4.0`, but v2.4.0 is e8998f94… (verified via the GitHub API); bd77c077…
is an untagged mid-stream commit, so the SLSA-attestation step ran unvetted
action code and the comment misrepresented what runs. Repin to the real
v2.4.0 commit and drop the `# PLACEHOLDER-PIN` markers on both this line and
the setup-python pin (a26af69b… is already the correct v5.6.0 — only its
comment was stale). Update the header NOTE accordingly.

* fix(ci): skip the prebuild-PR aggregate when release App secrets are absent

The aggregate job mints a GitHub App token as its first step; with
RELEASE_APP_ID/RELEASE_APP_PRIVATE_KEY unset it hard-failed AFTER a full
(up-to-6-runner) native build. Since the `secrets` context isn't available in
a job-level `if:`, the guard job now computes a `release_app` boolean output
(a step can read secrets) and emits an actionable `::notice::`; aggregate
gates on it and skips cleanly, while the build job's artifacts still upload
(run with open_pr=false for artifacts-only).

* chore(ci): drop package-lock.json from the prebuild paths filter; widen build timeout

`gitnexus/package-lock.json` changes on nearly every dependency PR, so it
fired the prebuild workflow's guard job on unrelated churn (the matrix stayed
correctly skipped — `gitnexus/package.json` already covers the transition-window
pin, so removing the lock only drops guard noise). Also bump the native build
job timeout 30 -> 45 min for headroom compiling the 23 MB kotlin / 18 MB swift
parser.c, especially under arm emulation.

* fix(ci): event-gate the aggregate open-PR condition explicitly

`inputs.open_pr` is null on pull_request events, and the prior
`inputs.open_pr != false` leg relied on GHA's direction-ambiguous null
coercion (Codex F4) to decide whether to open the prebuild PR. Gate
explicitly on the event: a non-fork pull_request that bumped a grammar
version opens the prebuild PR (the documented flow), and `open_pr` is only
consulted on workflow_dispatch — so a manual run with open_pr=false stays
artifacts-only and no event's behavior rests on coercion.

* fix(publish): validate the effective npm-pack contents in the coverage guard

The publish guard inferred "is source shipped?" from a single .npmignore toggle
line, which a partial/out-of-order edit could defeat (exclude binding.gyp but
leave parser.c → unbuildable yet "source-shipping"). It now inspects the
EFFECTIVE tarball via `npm pack --dry-run --ignore-scripts --json` (the
--ignore-scripts avoids re-entering this guard through prepack): a grammar
"ships source" only when EVERY on-disk source-build input (binding.gyp +
binding.cc + parser.c + scanner.c when present + a tree_sitter header) is
actually in the packed file list.

This also surfaced that the gated lean-publish .npmignore block was inert:
package.json's `files: ["vendor"]` allow-list overrides .npmignore for the
vendored subtree, so those exclusion lines never dropped anything. Replace the
dead toggle with documentation of the real mechanism (narrow the `files` field)
and note the guard enforces safety on the effective pack regardless of how the
slim is done.

* test(prebuild): hard-gate declared-fully-prebuilt grammars on 6/6 coverage

The strict 6/6 prebuild assertion was dormant whenever a grammar vendors source
(binding.gyp) — which is every grammar — so a dropped prebuild passed CI
silently. Add a FULLY_PREBUILT allowlist of grammars GitNexus has committed 6/6
for (today: swift); those must keep all six even with a source fallback, so
losing one now fails CI. Grammars graduate into the set as the
build-tree-sitter-prebuilds workflow lands their binaries. (The static-import
degradation smoke is covered by the registry-import-closure regression test
extended in the C lazy-load commit.)

* chore(deps): promote node-gyp-build/node-addon-api to regular dependencies

Every vendored grammar's index.js does `require("node-gyp-build")` at runtime
to load even a prebuilt .node, so node-gyp-build is runtime-load-critical (and
node-addon-api is needed for the source-build fallback). They were
optionalDependencies, surviving `--omit=optional` only via the required
tree-sitter's transitive edge — correct today but fragile. Promote both to
regular dependencies so the contract is explicit (optionalDependencies is now
empty and removed). Lock the contract with a cli-commands assertion.

* chore(vendor): add Windows cflags parity block to tree-sitter-c/binding.gyp

c's binding.gyp used an unconditional `cflags_c: ["-std=c11"]`, while
kotlin/swift gate MSVC flags behind an `OS=='win'` condition (/std:c11 /utf-8).
Inert today (no non-ASCII bytes in c's parser.c, and node-gyp ignores cflags_c
on MSVC anyway), but align the three so a future source-build fallback on
Windows behaves consistently.

* docs(agents): correct stale optional-grammar / postinstall notes

AGENTS.md still said postinstall "patches tree-sitter-swift, builds
tree-sitter-proto" and that only kotlin/swift are "optional". Update to the
vendored-uniform model: postinstall materializes the vendored grammars and
prefers a committed prebuild (source-build only when none matches); c is
required while dart/proto/swift/kotlin are optional + skippable via
GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, with non-fatal warnings only on a
toolchain-less host with no matching prebuild.

* fix(install): preserve the backup and warn loudly on a failed materialize rollback

If renameSync(partial, dest) failed AND the rollback renameSync(backup, dest)
also failed, the grammar was left unmaterialized (node_modules/<name> missing)
with only a generic "could not materialize" warning — the recoverable backup at
<dest>.materialize-bak was unmentioned. Emit a CRITICAL warning naming the
backup path and the recovery command on that double-failure, and document that
the fail-soft catch removes only the scratch `partial`, never the `backup`
(which may be the sole recoverable copy). Never-throw / exit-0 contract intact.

* fix(publish): make the coverage guard's npm-pack inspection script-safe

The prepack guard shelled out to `npm pack --dry-run --ignore-scripts --json`,
but the `--ignore-scripts` flag is not reliably honored by npm pack's
prepare/prepack lifecycle on the CI npm — so build.js ran, polluted the --json
stdout with `[build] …`, and the guard's JSON.parse threw. That broke every
`npm pack` (packaged-install-smoke on ubuntu+windows) and failed the guard's own
real-repo unit test (the only coverage-job failure). Force script-skipping via
the reliable `npm_config_ignore_scripts` env config (also removes the prepack
re-entry/recursion risk) and parse defensively from the JSON-array start.

* fix(publish): make the coverage guard deterministic — read `files`, not `npm pack`

The npm-pack-based guard timed out in CI: `npm pack`'s prepare/prepack lifecycle
is not skipped by `--ignore-scripts` (flag or env config) on the CI npm, so the
inner pack ran the full build (~20s+) — fine for the slow smoke job, but it blew
past vitest's 30s test timeout in the coverage job (and risked re-entering this
prepack guard).

Replace it with a deterministic, fast (~0.1s) check that needs no subprocess:
since `files: ["vendor"]` OVERRIDES `.npmignore` for the vendored subtree (so
`.npmignore` can never drop vendored source — verified), the ONLY lever that can
exclude source is narrowing the package.json `files` field. The guard now reads
`files` directly: a grammar "ships source" iff `files` includes the vendor
subtree AND the grammar carries a buildable source set on disk. A lean publish
that narrows `files` while a grammar lacks 6/6 prebuilds still fails the gate.

* feat(ci): vendored tree-sitter grammar update monitor

Adds a weekly (+ dispatchable) workflow that checks each vendored grammar against
its source-of-origin (npm for swift/kotlin, the GitHub default branch for
dart/proto; c is excluded — held at 0.21.4 for ABI safety) and opens a PR
re-vendoring any update that is ABI-COMPATIBLE with the pinned tree-sitter@0.21.1
(LANGUAGE_VERSION 13-14).

ABI awareness is the point: most upstreams have moved to ABI 15 (newer
tree-sitter), so a blind "bump to latest" would open PRs that can't build. The
monitor fetches the candidate source, reads its parser.c LANGUAGE_VERSION, and
only re-vendors 13/14 — incompatible updates are reported (notice + job summary),
never applied. (Confirmed live: dart/proto upstreams are ABI 15 today and are
correctly held; swift/kotlin are current.)

The re-vendor refreshes only the source-build inputs + runtime entrypoints,
preserving the GitNexus-hardened binding.gyp / README / prebuilds; the version
bump then triggers build-tree-sitter-prebuilds.yml, whose ABI-validation is the
final safety net so a subtly-wrong re-vendor can't silently ship. PR creation is
gated on the RELEASE_APP secret (skips with a notice if absent), mirroring the
build aggregate. Unit test locks the ABI gate; the script is import-safe.

* feat(ci): monitor tree-sitter-c too (report-only, ABI-pinned)

c was excluded from the update monitor, so an upstream c update went unnoticed.
Include it, but as report-only via a `hold`: c 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 + surfaced (notice + job summary) but never
auto-PR'd — even if it were ABI-13/14. `--apply c` refuses defensively. (Live:
upstream c is 0.24.1 / ABI 15 today, so c is doubly held — reported, not applied.)

* fix(ci): drop the shell in the grammar monitor's github fetch (CodeQL)

CodeQL flagged the GitHub-tarball fetch — it used `bash -c "gh api …/tarball/$ref
> src.tgz && tar xzf src.tgz"`, interpolating the API-derived ref into a shell
command (the shell-command-injection family: "this shell command depends on an
uncontrolled file name"). Replace it with a shell-free path: capture `gh api`'s
binary tarball as a Buffer via execFileSync, write it to a fixed file, and
extract with execFileSync('tar', …). No shell, no injection surface. Verified the
dart/proto fetch + ABI read still work.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:16:24 +01:00
Nilotpal KashyapandGergő Magyar 1716bf7c1e feat(cli): add gitnexus uninstall to reverse setup (#2060) (#2062)
* feat(cli): add `gitnexus uninstall` to reverse setup (#2060)

`gitnexus uninstall` was documented in #168 but never implemented, so the
CLI rejected it with "error: unknown command 'uninstall'" (#2060).

Add an `uninstall` command that reverses `gitnexus setup` target-by-target:
removes the GitNexus MCP server entries (Cursor, Claude Code, Antigravity,
OpenCode, Codex), the installed skill directories, and the Claude Code /
Antigravity hook entries plus their bundled hook scripts. Edits are surgical
and idempotent — only gitnexus-owned keys/entries/dirs are touched, and JSONC
comments/indentation are preserved. Defaults to a dry-run preview; `--force`
applies. Per-repo indexes and the global npm package are left alone with
printed hints, since both are destructive in ways setup never caused.

Adds i18n entries (en + zh-CN), help wiring, README/CHANGELOG docs, and unit
tests covering MCP/hook/skill/Codex-TOML removal, dry-run, corrupt-file
safety, and the no-op case.

* changelog changes

* changelog changes

* fix(cli): harden uninstall against data-loss edge cases (review #2062)

Address review findings on the uninstall command:

- Empty derived skill name no longer wipes the whole skills dir: a bare
  '.md' source file would make basename() return '', resolving to the
  skills dir itself. Skip empty names in derivation and reject
  empty/'.'/'..'/separator names in removeSkillsFrom.
- Corrupt settings.json no longer orphans the hook: gate the hook-script
  dir removal on status !== 'corrupt' so we don't delete a script while a
  still-registered entry points at it (Claude + Antigravity blocks).
- Hook removal is now element-granular: delete only the gitnexus command
  inside an entry's hooks[], removing the whole entry only when it becomes
  empty. Preserves a user command co-located in the same entry.
- Fallback TOML stripper: also remove descendant sub-tables
  ([mcp_servers.gitnexus.env]), track multiline strings so a bracketed
  line inside a value isn't treated as a header, and stop reflowing
  unrelated blank lines.
- Set process.exitCode=1 on partial failure; add a 10s timeout to
  'codex mcp remove'.

Tests expanded 7 -> 17: empty-skill guard, corrupt-settings hook
preservation, shared-entry hook removal, OpenCode MCP keyPath,
Antigravity MCP + AfterTool hooks, codex-remove success path, TOML
sub-table + multiline-string cases, dry-run for hooks/skills, and the
directory-layout skill branch.

* refactor(cli): share setup/uninstall target map + harden TOML fallback (review #2062)

Maintainer review follow-ups:

- Extract editor target identities into editor-targets.ts (MCP paths/keyPaths,
  Codex TOML section, skill dirs, hook settings/events/needles/script dirs,
  shared detectIndentation). Both setup.ts and uninstall.ts consume it, so a
  target change updates both sides — killing the silent drift hazard.
- Add a setup -> uninstall round-trip integration test that iterates
  getEditorTargets(): setup writes every target, uninstall removes all of them,
  and a co-located user MCP server + user hook survive. Drift tripwire in both
  directions.
- Preview now prints the exact paths it would remove; command output + README
  state skills are matched by bundled gitnexus skill name. (Provenance marker
  deferred to a tracked follow-up.)

Hardening of the hand-rolled Codex TOML fallback (found in code review):
- Strip a section header that has a trailing inline comment (was matched as a
  header but failed the exact classify check -> section left behind while
  reported removed).
- Preserve CRLF line endings instead of rewriting the whole file to LF.
- Fix multiline-string scan: a line with an odd count of BOTH """ and '''
  no longer mis-picks the delimiter and desyncs the scanner (left->right scan).
- removeSkillsFrom guard also rejects absolute names.

Regression tests added for each. Full setup/uninstall suite green.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-09 10:50:18 +01:00
Gergő MagyarandClaude Opus 4.8 f1151660b9 fix(install): graceful Kotlin optional-grammar install + accurate toolchain docs (#2110)
* fix(install): document Kotlin optional-grammar toolchain behavior + graceful install probe

tree-sitter-kotlin is a third-party npm optionalDependency that ships
source-only (no upstream prebuilds) and compiles its native binding via
node-gyp at install. It was the only optional grammar without a GitNexus
install-time probe, and the README's GITNEXUS_SKIP_OPTIONAL_GRAMMARS
"no toolchain needed" note omitted Kotlin entirely. This adds a fail-soft
probe (mirroring the Swift one) that warns clearly and always exits 0 so
install never breaks, wires it into postinstall, and corrects the
optional-grammar docs in README.md and .devcontainer/README.md. Shipping
prebuilt .node binaries (the literal request) needs an upstream/CI build
matrix and is intentionally left as follow-up.

Refs #2107

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address PR #2110 tri-review findings (Kotlin optional-grammar install)

Addresses the four P2 findings from the PR #2110 tri-review:

- F1: docs no longer imply GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 skips Kotlin's
  toolchain. npm compiles tree-sitter-kotlin via its own node-gyp-build step
  regardless of that variable; point to `npm install --omit=optional` as the
  real lever (README.md + .devcontainer/README.md).
- F2: the install probe now surfaces its "Kotlin unavailable" guidance on the
  dir-absent branch — the dominant toolchain-less case, where npm prunes the
  failed optional dependency so the package dir is gone at postinstall. Gated on
  npm_config_omit so a deliberate `--omit=optional` stays silent. Still never
  throws or exits non-zero.
- F3: add a behavioral test that executes the probe across its skip /
  dir-absent-warn / dir-absent-omit-silent paths and asserts exit code 0
  (guards the postinstall "never exit non-zero" invariant a static assertion
  cannot).
- F4: reframe prebuilt Kotlin as deferred Swift-parity follow-up — GitNexus
  already vendors its own self-built Swift prebuilds and could do the same for
  Kotlin — tracked in #2107, not an upstream-only blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:04:01 +01:00
Gergő MagyarandClaude Opus 4.8 288b96f3e5 fix: batch query enrichment, bake FTS extension into CLI image, add FTS memory repro (#2108)
* perf(query): batch per-symbol process/cohesion/content lookups (N+1 -> 2-3)

Port of the local-backend query-batching from gitnexus-enterprise PR #222
into the OSS local MCP backend. The query tool traced each matched symbol
to its processes + cohesion (+ content) with up to 3N sequential pool
round-trips; batch them into 2-3 'WHERE n.id IN $nodeIds' queries keyed
back to each symbol by a prepended 'n.id AS nodeId' column. Output is
identical: the aggregation loop is unchanged, iterates merged in the same
order, and reads pre-fetched maps instead of issuing a query per symbol.

Adaptations over a blind cherry-pick (would otherwise change output):
- per-nodeId first-row community pick replaces the per-symbol LIMIT 1, so
  each symbol keeps its own community (not one for the whole batch);
- batched rows regrouped to the originating merged item by nodeId so the
  JS-side RRF item.score still drives process ranking;
- positional fallbacks shift +1 (process row[1..6], cohesion [1]/[2],
  content [1]); CodeRelation{type:...} relation form kept; IN-list chunked
  at 100 like the impact path.

Adds a regression test asserting per-node community/content association
(func:login keeps comm:auth; func:validate inherits no community).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(docker): bake LadybugDB FTS extension into the CLI/serve image

The container runs `serve` under the default `load-only` extension policy
(the read pool pins {policy:'load-only'}), so a runtime LOAD EXTENSION fts
never INSTALLs. Dockerfile.cli copied the extension installer but never ran
it, so the runtime user's HOME had no FTS extension: keyword search
silently degraded (no FTS indexes written, ranking falls back to
vector-only with only a warning field). Same class of footgun fixed for
the Hub image in gitnexus-enterprise PR #222.

Run install-duckdb-extension.mjs as the `node` user with the runtime HOME
so INSTALL fts materializes the extension under $HOME/.lbdb/extension where
the runtime LOAD resolves it offline. Pin ENV HOME=/home/node because
Docker does not derive HOME from USER — without it the build-install and
runtime-load would resolve different paths. Verified locally: INSTALL lands
in $HOME/.lbdb/extension/0.17.0 and a fresh offline load-only
`LOAD EXTENSION fts` resolves it. Dockerfile.web is unaffected (static
frontend, no @ladybugdb backend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(lbug): FTS evict->reload RSS repro + inert pool RSS tracing

Settles the gitnexus-enterprise PR #222 root-cause hypothesis for OSS:
does re-running LOAD EXTENSION fts on every pool evict->reload strand the
native FTS arena (unbounded RSS growth in long-lived MCP serve), or does
db.close() reclaim it (bounded by MAX_POOL_SIZE)? Static read could not
decide — the native lbugjs.node binary documents no close->extension-unload
contract.

Adds gitnexus/scripts/bench/fts-evict-reload-rss.mjs: a NATIVE mode that
reproduces the exact native sequence doInitLbug()+closeOne() perform
(open Database -> Connection -> LOAD EXTENSION fts -> QUERY_FTS_INDEX ->
close) across K self-built FTS fixtures, and a --via-pool mode that drives
the real compiled pool (initLbug/executeParameterized/closeLbug) against an
existing analyzed repo. Plus a behavior-neutral GITNEXUS_POOL_RSS_TRACE=1
stderr trace on pool init/close (stdout reserved for MCP JSON-RPC; single
env read when disabled).

RESULT (native, 24 and 40 cycles x 6 fixtures, --expose-gc): PLATEAU. RSS
warms up to ~400 MB then flattens (40-cycle: +36 MB over cycles 1-10, +3 MB
over 30-40; decelerating), not the linear climb a per-reload arena leak
would produce (240 reloads x stranded arena = multi-GB). db.close()
reclaims the FTS arena. The unbounded-leak hypothesis is NOT reproduced for
the OSS path: the pool's LRU eviction + close-on-evict BOUNDS the footprint,
which is exactly the protection the enterprise Hub supervisor lacked (it
opened bridge DBs in-process without eviction -> 15 GB). => plan U4
(worker/process isolation) is NOT justified by this evidence; U1 + U2 are
the only OSS-shared changes. Caveat: small fixtures + awaited close; a
--via-pool run against a large analyzed repo over a long session is the
production-faithful follow-up (instrumentation is in place for it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): apply ce-code-review autofix feedback (#222 migration)

Adversarial review found the U3 bench PLATEAU->no-leak conclusion was
over-claimed from a 600-row fixture: a size-proportional FTS-arena leak
would be sub-threshold at that scale. Strengthen the bench and make its
verdict honest:
- scale the fixture (--rows, UNWIND batch insert), probe ALL 5 FTS indexes
  in --via-pool (not 2 of 5), add a --no-await-close variant (the pool
  fire-and-forget close shape), and replace the absolute-delta gate with a
  SLOPE-DECELERATION 3-way verdict (PLATEAU / CLIMB / INCONCLUSIVE) plus
  step-discontinuity detection. At production-representative scale the
  synthetic runs are noisy/INCONCLUSIVE (deceleration argues against an
  UNBOUNDED leak but does not prove bounded), so plan U4 stays GATED on a
  --via-pool run against a real large analyzed repo -- not closed.
- Dockerfile.cli: source the scratch-DB size from ENV GITNEXUS_LBUG_MAX_DB_SIZE
  (single source of truth) and add a build-time verify-only LOAD gate
  that fails the build on a HOME/extension-dir mismatch instead of silently
  degrading runtime keyword search.
- install-duckdb-extension.mjs: additive verify-only mode (LOAD-only in a
  fresh process) + robust size parse; back-compatible with the runtime
  positional-size caller (validated).
- tests: wire func:validate into a second process (proc:beta-flow) so the
  batched STEP_IN_PROCESS row[1..6] positional shift is exercised by a
  genuine multi-process symbol, and assert process ranking. No blast radius
  (75 seed-consuming tests pass).
- pool-adapter.ts: trim the traceRss narrated-code comment (DoD 2.3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bench): classify a sustained sub-floor RSS slope as INCONCLUSIVE, not PLATEAU

Tri-review P2: the FTS evict->reload verdict short-circuited to PLATEAU
whenever secondHalfSlope < SUSTAIN_FLOOR, BEFORE the deceleration check —
so a sustained (non-decelerating) linear leak below 0.5 MB/cycle was
labeled PLATEAU ("no leak"), the label that would wrongly close plan U4.

Extract median/slopeMbPerCycle/classifyVerdict into a pure, side-effect-free
fts-rss-verdict.mjs (zero imports) so it is unit-testable without loading the
native addon or running the bench, and fix the classifier:
- epsilon-first gate: a truly flat tail (< 0.1 MB/cycle) is PLATEAU regardless
  of decelRatio (guards against over-correcting a real negative into
  INCONCLUSIVE);
- a sustained sub-floor positive slope (>= epsilon, < floor, decelRatio >= 0.6)
  is INCONCLUSIVE — a slow creep RSS cannot distinguish from noise at this
  scale, so the honest label is "not resolved", never a clean PLATEAU;
- the noise floor now scales with the WORKING-SET growth (peak-baseline), not
  the pre-DB baseline RSS (which is interpreter/addon overhead, larger in
  --via-pool mode, and would inflate the floor and HIDE leaks).

Reconcile the stale "per-row-relative delta floor" docstring; add floor +
decelRatio to the MACHINE line. New fts-rss-verdict.test.ts pins all label
boundaries (flat->PLATEAU, sustained-sub-floor->INCONCLUSIVE,
decelerated->PLATEAU, sustained-linear->CLIMB, step->INCONCLUSIVE,
working-set floor, no import side effects). U1 does NOT add detection power
for sub-floor leaks (RSS cannot attribute that magnitude) — it stops the
false PLATEAU and routes that regime to the --via-pool run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): signal partial/warning on a real enrichment failure (not benign missing-table)

Tri-review P2: when a batched enrichment query (process/cohesion/content)
threw, it was caught + logged and the chunk's symbols silently fell back to
`definitions` with no signal — the caller could not tell "genuinely
standalone" from "enrichment failed".

Track an `enrichmentDegraded` flag in the three enrichment catch blocks and,
at response build, compose a single `warning` (FTS-missing and/or the
enrichment message, so neither overwrites the other) plus `partial: true`.
Both fields are omitted on the clean path, so the success-path response shape
is byte-identical.

Crucially, the flag fires ONLY for a REAL failure (timeout / lock / native
fault), NOT the benign "no Process/Community table" prepare error — a repo
analyzed without processes/communities is a normal config, and firing
`partial` on every such query would desensitize callers
(isBenignMissingTableError gates it).

New unit test test/unit/query-degraded-signal.test.ts (vi.mock pool-adapter,
override hybrid search to feed one matched symbol, route STEP_IN_PROCESS ->
throw): real failure -> warning+partial+symbol still returned; benign
missing-table -> no signal; FTS-missing + enrichment failure -> both messages
in one warning. Plus a success-path no-warning/no-partial assertion in the
calltool integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:46:46 +01:00
azizur100389andGergő Magyar 3a4247ec36 feat(cpp): resolve inheritance-lattice member lookup (#2077)
* feat(cpp): resolve inheritance-lattice member lookup

* fix(cpp): harden inheritance-lattice lookup

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-09 06:53:11 +01:00
dependabot[bot] 774cd4d568 chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#2098) 2026-06-09 05:59:57 +01:00
196 changed files with 1362094 additions and 771 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.6",
"version": "1.6.7",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}
@@ -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
+2 -2
View File
@@ -310,8 +310,8 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.
@@ -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}`);
+3 -2
View File
@@ -94,8 +94,9 @@ jobs:
# 1. Static, offline: assert every grammar's compiled ABI loads on the
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
# static check can't introspect) fails on the platform it ships to.
# ABI-incompatible committed vendor prebuilt (e.g. Swift's — the static
# check introspects source, not the shipped .node) fails on the platform
# it ships to.
abi-assert:
name: tree-sitter ABI (${{ matrix.os }})
strategy:
@@ -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.');
}
+2 -2
View File
@@ -173,6 +173,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
### Gotchas
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
+49
View File
@@ -36,6 +36,17 @@ RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
# `npm prune` removes anything not in package.json's dependency tree — which
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
# postinstall, but not declared as deps) and their freshly-built native bindings.
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
# must survive into the runtime layer. Re-run the grammar postinstall here in the
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
# as a former `dependency` it used to survive prune; vendored, it would not.
RUN npm run postinstall --prefix gitnexus
# -- Runtime -----------------------------------------------------------
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
@@ -67,6 +78,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.
+8 -3
View File
@@ -117,7 +117,9 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. See the `tree-sitter-kotlin` note below.
>
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream `tree-sitter-kotlin` ships **source only** (no prebuilt binaries), so GitNexus builds the Kotlin platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline now used for Dart, Proto, and Swift (Swift's prebuilds were originally copied from upstream; they're now GitNexus-cross-built too). `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
### MCP Setup
@@ -223,6 +225,7 @@ args = ["-y", "gitnexus@latest", "mcp"]
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@@ -259,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Embeddings node limit
@@ -328,7 +333,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
#### Publishing to understand-quickly (opt-in)
@@ -342,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| Tool | What It Does | `repo` Param |
| ----------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@@ -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"
},
+18 -3
View File
@@ -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
+20
View File
@@ -13,6 +13,26 @@ node_modules/
vendor/**/node_modules
vendor/**/build
# ── Lean publish (FUTURE optimization — NOT done here) ─────────────────────────
# Once the build-tree-sitter-prebuilds workflow has committed 6/6 prebuilds for
# EVERY vendored grammar (c, dart, proto, kotlin, swift), the ~50 MB of generated
# source (parser.c etc.) can be dropped from the tarball — node-gyp-build never
# needs the source when a prebuild matches.
#
# IMPORTANT: this CANNOT be done from this file. package.json's `files: ["vendor"]`
# allow-list OVERRIDES .npmignore for the vendor/ subtree (verified: an active
# `vendor/**/src/parser.c` line here does NOT exclude it from `npm pack`). To slim
# the tarball, narrow the `files` field instead — replace the blanket "vendor"
# with the non-source subpaths only (vendor/**/prebuilds/**,
# vendor/**/bindings/node/index.*, vendor/**/src/node-types.json,
# vendor/**/package.json, vendor/**/LICENSE, vendor/**/README.md).
#
# Whatever the mechanism, the prepack guard
# (scripts/assert-publish-grammar-coverage.cjs, also `npm run
# assert-publish-coverage`) inspects the EFFECTIVE `npm pack` file list and FAILS
# the publish whenever a grammar with <6 prebuilds loses a source-build input — so
# the slim can never silently ship a dead grammar. Do not bypass it.
# Package lock (consumers use their own)
package-lock.json
+26 -1
View File
@@ -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
View File
@@ -126,7 +126,7 @@ Your AI agent gets these tools automatically:
| Tool | What It Does | `repo` Param |
| ---------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@@ -159,6 +159,7 @@ Your AI agent gets these tools automatically:
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
@@ -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
+2 -2
View File
@@ -18,11 +18,11 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "fd3d3768cdebbb4767d7cf18b8d2df19d61de969c816d7f4d6b599f947811356",
"fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
@@ -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));
}
}
+18 -3
View File
@@ -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));
}
}
+43 -83
View File
@@ -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",
+6 -9
View File
@@ -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);
});
+105
View File
@@ -0,0 +1,105 @@
// Pure, side-effect-free verdict classifier for the FTS evict→reload RSS bench
// (fts-evict-reload-rss.mjs). Extracted so it can be unit-tested WITHOUT importing
// the native LadybugDB addon or running the bench — this module has zero imports
// and zero module-scope side effects. Do not add imports or top-level statements.
//
// The discriminant between a real leak and allocator warmup is SLOPE DECELERATION,
// not total delta. A true per-reload leak (stranded FTS arena) rises ~linearly:
// the second-half slope stays ≈ the first-half slope. Allocator working-set warmup
// rises then flattens: the second-half slope decays to a fraction of the first.
//
// Thresholds:
// EPSILON (~0.1 MB/cycle) — below this the tail is effectively flat (no leak).
// SUSTAIN_FLOOR (0.5 MB/cycle) — the base noise floor.
// The floor SCALES with the working-set growth (peak − baseline), NOT the pre-DB
// `baseline` RSS: baseline is interpreter/addon overhead (and is LARGER in
// --via-pool mode), so a baseline-keyed floor would inflate and HIDE leaks. A
// bigger fixture has a bigger arena and bigger per-cycle noise, so the floor
// rises with the working set: floor = SUSTAIN_FLOOR · max(1, (peak−baseline)/REF).
export const EPSILON_MB_PER_CYCLE = 0.1;
export const SUSTAIN_FLOOR = 0.5;
// Reference working-set (MB) at which the floor equals SUSTAIN_FLOOR; the floor
// scales up linearly for larger arenas. ~200 MB ≈ a small FTS fixture's footprint.
export const FLOOR_REF_WORKINGSET_MB = 200;
export function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
}
export function slopeMbPerCycle(series) {
// Least-squares slope of rss vs cycle index.
const n = series.length;
if (n < 2) return 0;
const xs = series.map((_, i) => i);
const xMean = xs.reduce((a, b) => a + b, 0) / n;
const yMean = series.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (series[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
return den === 0 ? 0 : num / den;
}
/**
* Classify an RSS-per-cycle series into PLATEAU / CLIMB / INCONCLUSIVE.
* Pure: no I/O, no globals. `baseline` is the pre-DB RSS; `peak` defaults to the
* series max. Returns the label plus the diagnostics the bench prints.
*/
export function classifyVerdict(series, baseline, peak = Math.max(...series)) {
const cycles = series.length;
const half = Math.max(1, Math.floor(cycles / 2));
const firstHalfSlope = slopeMbPerCycle(series.slice(0, half));
const secondHalfSlope = slopeMbPerCycle(series.slice(-half));
const decelRatio = secondHalfSlope / Math.max(firstHalfSlope, 1e-9);
// Step discontinuity: a single cycle-to-cycle jump far larger than the typical
// per-cycle delta — a one-time allocator/arena reservation (then flat), not a
// per-reload leak, but a noisy run we won't claim a clean result on.
const deltas = series.slice(1).map((v, i) => v - series[i]);
const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b);
const medAbsDelta = absDeltas.length ? absDeltas[Math.floor(absDeltas.length / 2)] : 0;
const maxJump = deltas.length ? Math.max(...deltas) : 0;
const stepDiscontinuity = maxJump > Math.max(30, 5 * Math.max(medAbsDelta, 1));
// Working-set-scaled floor (see header). Guard against a negative working set.
const workingSet = Math.max(0, peak - baseline);
const floor = SUSTAIN_FLOOR * Math.max(1, workingSet / FLOOR_REF_WORKINGSET_MB);
const SUSTAINED = 0.6; // decelRatio at/above which the tail is "not decaying"
let verdict;
if (stepDiscontinuity) {
verdict = 'INCONCLUSIVE';
} else if (secondHalfSlope < EPSILON_MB_PER_CYCLE) {
// Effectively flat — no leak, regardless of decelRatio (a flat-from-start run
// has decelRatio ≈ 1 but is still PLATEAU). This gate is what keeps a true
// negative from being over-corrected into INCONCLUSIVE.
verdict = 'PLATEAU';
} else if (secondHalfSlope >= floor) {
// Tail is still substantial: sustained → real leak; decelerating → unresolved.
verdict = decelRatio >= SUSTAINED ? 'CLIMB' : 'INCONCLUSIVE';
} else if (decelRatio < SUSTAINED) {
// Below the floor AND decelerating — warmup converged toward flat → PLATEAU.
verdict = 'PLATEAU';
} else {
// Below the floor but SUSTAINED — a slow steady creep RSS can't distinguish
// from noise at this scale. The honest label is "not resolved", NEVER a clean
// PLATEAU ("no leak"). This is the headline tri-review fix.
verdict = 'INCONCLUSIVE';
}
return {
verdict,
firstHalfSlope,
secondHalfSlope,
decelRatio,
floor,
stepDiscontinuity,
maxJump,
peak,
};
}
@@ -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);
}
+22 -6
View File
@@ -14,7 +14,7 @@ function parseLbugMaxDbSize(raw) {
return Math.floor(parsed);
}
async function installDuckDbExtension(extensionName) {
async function installDuckDbExtension(extensionName, verifyOnly = false) {
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
}
@@ -22,9 +22,11 @@ async function installDuckDbExtension(extensionName) {
const require = createRequire(import.meta.url);
const lbugModule = require('@ladybugdb/core');
const lbug = lbugModule.default ?? lbugModule;
const lbugMaxDbSize = parseLbugMaxDbSize(
process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
);
// argv[3] is the optional positional size; ignore it when it is actually a
// flag token (e.g. `--verify-only`) and fall back to the env default.
const sizeArg =
process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
const dbPath = path.join(tmpDir, 'install.lbug');
@@ -34,7 +36,18 @@ async function installDuckDbExtension(extensionName) {
try {
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
await conn.query(`INSTALL ${extensionName}`);
if (verifyOnly) {
// Prove a previously-baked extension is resolvable by a FRESH process
// under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
// no network. Used as a Docker build-time gate so a HOME/extension-dir
// mismatch fails the build instead of silently degrading search at runtime.
await conn.query(`LOAD EXTENSION ${extensionName}`);
console.log(
`[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
);
} else {
await conn.query(`INSTALL ${extensionName}`);
}
} finally {
if (conn) await conn.close().catch(() => {});
if (db) await db.close().catch(() => {});
@@ -42,7 +55,10 @@ async function installDuckDbExtension(extensionName) {
}
}
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
installDuckDbExtension(
process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
process.argv.includes('--verify-only'),
).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});
@@ -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(
+32 -1
View File
@@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference
+42
View File
@@ -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
+51 -7
View File
@@ -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);
+45
View File
@@ -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);
+187
View File
@@ -0,0 +1,187 @@
/**
* Editor targets — the single source of truth for *where* GitNexus writes its
* per-editor configuration and *how* its entries are identified.
*
* `setup` (writes these) and `uninstall` (removes them) both consume this
* module so the two stay structurally in lock-step: add or change a target
* here and both sides follow. This is declarative metadata only — file
* locations, JSON key paths, hook event names, command needles, and script
* directories, plus the shared `detectIndentation` formatting helper. The
* format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
* command array, Gemini's hook schema) deliberately stays in setup.ts /
* uninstall.ts.
*
* The `setup → uninstall` round-trip integration test verifies the two
* implementations remain behaviourally symmetrical on top of this shared
* structure.
*/
import os from 'os';
import path from 'path';
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's MCP config file. */
file: string;
/**
* JSON path of the gitnexus server entry within that file. Typed as
* `string[]` (all our keys are object keys) so it satisfies both setup's
* `mergeJsoncFile(string[])` and uninstall's `removeJsoncKey(JSONPath)`
* without either side needing a cast.
*/
keyPath: string[];
}
/** Codex stores MCP config as a TOML table, not JSONC. */
export interface CodexMcpTarget {
id: 'codex';
label: string;
/** Absolute path to ~/.codex/config.toml. */
configFile: string;
/** The TOML table header (without brackets) setup writes / uninstall strips. */
tomlSection: string;
}
export interface SkillTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's skills directory. */
dir: string;
}
export interface HookTarget {
id: EditorId;
label: string;
/** Absolute path to the editor's settings file (JSONC). */
settingsFile: string;
/** Hook event arrays that may hold a gitnexus entry. */
events: string[];
/** Substring identifying the gitnexus command within a hook entry. */
needle: string;
/** Absolute path to the bundled hook-script directory setup writes. */
scriptDir: string;
}
export interface EditorTargets {
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
mcpJsonc: McpJsoncTarget[];
/** Codex MCP (TOML). */
codex: CodexMcpTarget;
/** Skill install directories, one per editor that supports skills. */
skills: SkillTarget[];
/** Hook registrations + their bundled script directories. */
hooks: HookTarget[];
}
/**
* Resolve all editor targets for the given home directory. Defaults to
* `os.homedir()`; call sites pass it through so tests can point HOME at a temp
* dir. Paths are computed at call time (not module load) so a test setting
* `process.env.HOME` before invoking sees the right locations.
*/
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
const mcpJsonc: McpJsoncTarget[] = [
{
id: 'cursor',
label: 'Cursor',
file: path.join(home, '.cursor', 'mcp.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'claude',
label: 'Claude Code',
file: path.join(home, '.claude.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'antigravity',
label: 'Antigravity',
file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'opencode',
label: 'OpenCode',
file: path.join(home, '.config', 'opencode', 'opencode.json'),
// OpenCode nests servers under `mcp`, not `mcpServers`.
keyPath: ['mcp', 'gitnexus'],
},
];
const codex: CodexMcpTarget = {
id: 'codex',
label: 'Codex',
configFile: path.join(home, '.codex', 'config.toml'),
tomlSection: 'mcp_servers.gitnexus',
};
const skills: SkillTarget[] = [
{ id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
{
id: 'antigravity',
label: 'Antigravity',
dir: path.join(home, '.gemini', 'antigravity', 'skills'),
},
{ id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
{ id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
];
const hooks: HookTarget[] = [
{
id: 'claude',
label: 'Claude Code',
settingsFile: path.join(home, '.claude', 'settings.json'),
events: ['PreToolUse', 'PostToolUse'],
needle: 'gitnexus-hook',
scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
},
{
id: 'antigravity',
label: 'Antigravity',
settingsFile: path.join(home, '.gemini', 'settings.json'),
events: ['AfterTool'],
needle: 'gitnexus-antigravity-hook',
scriptDir: path.join(home, '.gemini', 'config', 'hooks', 'gitnexus'),
},
];
return { mcpJsonc, codex, skills, hooks };
}
/** Look up a single JSONC MCP target by editor id (throws if unknown). */
export function mcpTarget(id: EditorId, home?: string): McpJsoncTarget {
const t = getEditorTargets(home).mcpJsonc.find((m) => m.id === id);
if (!t) throw new Error(`No JSONC MCP target for editor "${id}"`);
return t;
}
/** Look up a single skill target by editor id (throws if unknown). */
export function skillTarget(id: EditorId, home?: string): SkillTarget {
const t = getEditorTargets(home).skills.find((s) => s.id === id);
if (!t) throw new Error(`No skill target for editor "${id}"`);
return t;
}
/** Look up a single hook target by editor id (throws if unknown). */
export function hookTarget(id: EditorId, home?: string): HookTarget {
const t = getEditorTargets(home).hooks.find((h) => h.id === id);
if (!t) throw new Error(`No hook target for editor "${id}"`);
return t;
}
/**
* Detect indentation style from file content so JSONC edits preserve the file's
* existing formatting. Shared by setup (writes) and uninstall (removes).
*/
export function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}
+76 -5
View File
@@ -32,7 +32,11 @@
import http from 'http';
import { isIPv4, isIPv6 } from 'node:net';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import {
LocalBackend,
type RepoListing,
type ListReposPagination,
} from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn, cliError } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
@@ -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 '';
}
+8
View File
@@ -12,6 +12,7 @@ const TITLE_KEYS = {
const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
@@ -69,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',
+15
View File
@@ -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':
+15
View File
@@ -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)',
+20
View File
@@ -23,6 +23,14 @@ program
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('uninstall')
.description(
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
)
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('analyze [path]')
.description('Index a repository (full analysis)')
@@ -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) ─────────────────
+13
View File
@@ -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
View File
@@ -15,6 +15,13 @@ import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
getEditorTargets,
mcpTarget,
skillTarget,
hookTarget,
detectIndentation,
} from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -162,17 +169,6 @@ function getOpenCodeMcpEntry() {
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
}
/**
* Detect indentation style from file content.
* Returns formatting options matching the file's existing style.
*/
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
const firstIndented = raw.match(/^( +|\t)/m);
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
return { tabSize: firstIndented[1].length, insertSpaces: true };
}
/**
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
@@ -233,9 +229,9 @@ async function setupCursor(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(cursorDir, 'mcp.json');
const { file: mcpPath, keyPath } = mcpTarget('cursor');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Cursor');
} else {
@@ -254,9 +250,9 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
}
// Claude Code stores MCP config in ~/.claude.json
const mcpPath = path.join(os.homedir(), '.claude.json');
const { file: mcpPath, keyPath } = mcpTarget('claude');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Claude Code');
} else {
@@ -276,7 +272,7 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const skillsDir = path.join(claudeDir, 'skills');
const skillsDir = skillTarget('claude').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@@ -422,13 +418,14 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
const settingsPath = path.join(claudeDir, 'settings.json');
const claudeHook = hookTarget('claude');
const settingsPath = claudeHook.settingsFile;
// Source hooks bundled within the gitnexus package (hooks/claude/)
const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
// Copy unified hook script to ~/.claude/hooks/gitnexus/
const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
const destHooksDir = claudeHook.scriptDir;
try {
await fs.mkdir(destHooksDir, { recursive: true });
@@ -494,7 +491,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
// Session context is delivered via CLAUDE.md / skills instead.
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PreToolUse',
value: {
@@ -510,7 +507,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
},
});
}
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) {
hookEntries.push({
eventName: 'PostToolUse',
value: {
@@ -566,9 +563,9 @@ async function setupAntigravity(result: SetupResult): Promise<void> {
return;
}
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
const { file: mcpPath, keyPath } = mcpTarget('antigravity');
try {
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
result.configured.push('Antigravity');
} else {
@@ -590,7 +587,7 @@ async function installAntigravitySkills(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const skillsDir = path.join(antigravityDir, 'skills');
const skillsDir = skillTarget('antigravity').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@@ -618,9 +615,9 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
if (!(await dirExists(antigravityDir))) return;
const geminiDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiDir, 'settings.json');
const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
const antigravityHook = hookTarget('antigravity');
const settingsPath = antigravityHook.settingsFile;
const destHooksDir = antigravityHook.scriptDir;
// The antigravity adapter shares its lock/probe helpers with the claude
// adapter — same DB, same concurrency rules — so we reuse those CJS files
@@ -694,7 +691,7 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', antigravityHook.needle)) {
// Matcher follows the Gemini CLI built-in tool naming (snake_case).
// search_file_content / glob cover content + filename search; run_shell_command
// catches rg/grep invocations and the git commit family for stale-index hints.
@@ -742,9 +739,9 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
return;
}
const configPath = path.join(opencodeDir, 'opencode.json');
const { file: configPath, keyPath } = mcpTarget('opencode');
try {
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
const ok = await mergeJsoncFile(configPath, keyPath, getOpenCodeMcpEntry());
if (ok) {
result.configured.push('OpenCode');
} else {
@@ -764,7 +761,7 @@ function getCodexMcpTomlSection(): string {
const entry = getMcpEntry();
const command = JSON.stringify(entry.command);
const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
return `[${getEditorTargets().codex.tomlSection}]\ncommand = ${command}\nargs = ${args}\n`;
}
/**
@@ -778,7 +775,7 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
existing = '';
}
if (existing.includes('[mcp_servers.gitnexus]')) {
if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
return;
}
@@ -809,7 +806,7 @@ async function setupCodex(result: SetupResult): Promise<void> {
}
try {
const configPath = path.join(codexDir, 'config.toml');
const configPath = getEditorTargets().codex.configFile;
await upsertCodexConfigToml(configPath);
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
} catch (err: any) {
@@ -920,7 +917,7 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
if (!(await dirExists(cursorDir))) return;
const skillsDir = path.join(cursorDir, 'skills');
const skillsDir = skillTarget('cursor').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@@ -938,7 +935,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
if (!(await dirExists(opencodeDir))) return;
const skillsDir = path.join(opencodeDir, 'skills');
const skillsDir = skillTarget('opencode').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
@@ -958,7 +955,7 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
if (!(await dirExists(codexDir))) return;
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
const skillsDir = skillTarget('codex').dir;
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
+29 -5
View File
@@ -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')}`);
};
+10
View File
@@ -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));
}
+518
View File
@@ -0,0 +1,518 @@
/**
* Uninstall Command
*
* Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
* skills, and hooks that setup writes into each detected AI editor's
* global configuration. The set of targets (paths, key paths, hook events,
* needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
* two stay in lock-step.
*
* Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
* removed. Unrelated user config (other MCP servers, other hooks, JSONC
* comments, indentation) is preserved. Files that are absent or that
* never contained a gitnexus entry are left untouched.
*
* Ownership is by name: skill directories are matched by the bundled gitnexus
* skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
* needle. There is no per-install provenance marker yet (a user dir that
* happens to share a bundled skill name, or files a user added inside an
* installed skill dir, are matched purely by name) — which is why uninstall is
* a dry-run preview by default and prints the exact paths it will remove.
* Richer provenance tracking is a tracked follow-up.
*
* Intentionally NOT done here (printed as hints instead, since both are
* destructive in ways setup never caused):
* - per-repo indexes → `gitnexus clean --all`
* - the global npm package → `npm uninstall -g gitnexus`
*
* Default is a dry-run preview; pass --force to apply.
*/
import fs from 'fs/promises';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import {
parseTree,
modify,
applyEdits,
findNodeAtLocation,
parse as parseJsonc,
type ParseError,
type JSONPath,
} from 'jsonc-parser';
import { getEditorTargets, detectIndentation } from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execFileAsync = promisify(execFile);
interface UninstallResult {
removed: string[];
skipped: string[];
errors: string[];
}
type RemovalStatus = 'removed' | 'absent' | 'corrupt' | 'missing';
/**
* Remove a single key (by JSON path) from a JSONC file, preserving the
* surrounding comments and formatting. Returns:
* - 'missing': file does not exist
* - 'absent': file exists but the key isn't there (nothing to do)
* - 'corrupt': file isn't valid JSONC — left untouched on purpose
* - 'removed': the key was present (and removed unless dryRun)
*/
async function removeJsoncKey(
filePath: string,
keyPath: JSONPath,
dryRun: boolean,
): Promise<RemovalStatus> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return 'missing';
}
if (raw.trim().length === 0) return 'absent';
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) return 'corrupt';
if (!findNodeAtLocation(tree, keyPath)) return 'absent';
if (!dryRun) {
const formattingOptions = detectIndentation(raw);
const edits = modify(raw, keyPath, undefined, { formattingOptions });
await fs.writeFile(filePath, applyEdits(raw, edits), 'utf-8');
}
return 'removed';
}
/**
* Remove the gitnexus hook command(s) — those whose command string contains
* `commandNeedle` — from the given `eventNames` arrays in a JSONC settings
* file. Mirrors the idempotency probes in setup.ts (hasGitnexusHook /
* geminiHasGitnexusHook). Returns how many event entries contained a gitnexus
* command.
*
* Removal is element-granular to honor the "other hooks are preserved"
* contract: only the matching command object inside an entry's `hooks[]` is
* deleted. The surrounding matcher entry is removed only when it becomes
* empty (i.e. it held nothing but gitnexus commands — which is exactly what
* setup creates). A user who hand-added their own command alongside ours
* keeps it. Edits are applied highest-index-first so earlier indices stay
* valid across edits.
*/
async function removeHookEntries(
filePath: string,
eventNames: string[],
commandNeedle: string,
dryRun: boolean,
): Promise<{ status: RemovalStatus; count: number }> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return { status: 'missing', count: 0 };
}
if (raw.trim().length === 0) return { status: 'absent', count: 0 };
const parseErrors: ParseError[] = [];
const tree = parseTree(raw, parseErrors);
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
return { status: 'corrupt', count: 0 };
}
const parsed = parseJsonc(raw);
const formattingOptions = detectIndentation(raw);
let current = raw;
let total = 0;
const isGitnexusHook = (hh: any): boolean =>
typeof hh?.command === 'string' && hh.command.includes(commandNeedle);
for (const eventName of eventNames) {
const entries = parsed?.hooks?.[eventName];
if (!Array.isArray(entries)) continue;
// Walk entries high → low so removing a later one never shifts the
// index of an earlier one.
for (let entryIdx = entries.length - 1; entryIdx >= 0; entryIdx--) {
const entry = entries[entryIdx];
if (!Array.isArray(entry?.hooks)) continue;
const hookIdxs: number[] = [];
entry.hooks.forEach((hh: any, hi: number) => {
if (isGitnexusHook(hh)) hookIdxs.push(hi);
});
if (hookIdxs.length === 0) continue;
total += 1;
if (dryRun) continue;
if (hookIdxs.length === entry.hooks.length) {
// The entry held only gitnexus command(s) — drop the whole entry.
const edits = modify(current, ['hooks', eventName, entryIdx], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
} else {
// The entry also holds user command(s) — delete only ours, keep
// the rest. Highest hook index first to keep lower indices valid.
for (const hi of hookIdxs.reverse()) {
const edits = modify(current, ['hooks', eventName, entryIdx, 'hooks', hi], undefined, {
formattingOptions,
});
current = applyEdits(current, edits);
}
}
}
}
if (total === 0) return { status: 'absent', count: 0 };
if (!dryRun) await fs.writeFile(filePath, current, 'utf-8');
return { status: 'removed', count: total };
}
/**
* Remove a directory tree if it exists. Returns true when something was
* (or would be) removed.
*/
async function removeDir(dirPath: string, dryRun: boolean): Promise<boolean> {
try {
await fs.access(dirPath);
} catch {
return false;
}
if (!dryRun) await fs.rm(dirPath, { recursive: true, force: true });
return true;
}
/**
* The exact set of skill directory names setup installs, derived from the
* bundled `skills/` source the same way installSkillsTo does (flat
* `{name}.md` and `{name}/SKILL.md` layouts). Deriving the set — rather
* than globbing `gitnexus-*` — ensures we never delete a user's own
* similarly-named skill folder.
*/
async function listGitnexusSkillNames(): Promise<string[]> {
const skillsRoot =
process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
const names = new Set<string>();
try {
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
// Guard against a bare `.md` file: basename('.md', '.md') === '',
// which would later resolve to the skills dir itself and wipe it.
const base = path.basename(entry.name, '.md');
if (base) names.add(base);
} else if (entry.isDirectory()) {
try {
await fs.access(path.join(skillsRoot, entry.name, 'SKILL.md'));
names.add(entry.name);
} catch {
// Not a skill directory — skip.
}
}
}
} catch {
return [];
}
return [...names];
}
/**
* Remove the gitnexus skill directories from a target skills folder. Returns
* the absolute paths that were removed (or would be removed in dryRun) so the
* caller can show the user exactly what is affected.
*/
async function removeSkillsFrom(
targetDir: string,
skillNames: string[],
dryRun: boolean,
): Promise<string[]> {
const removed: string[] = [];
for (const name of skillNames) {
// Defense in depth: an empty/relative/absolute name would resolve back to
// targetDir (or escape it) and wipe unrelated content. Only act on a
// plain child directory name.
if (
!name ||
name.includes('/') ||
name.includes('\\') ||
name === '.' ||
name === '..' ||
path.isAbsolute(name)
) {
continue;
}
const dir = path.join(targetDir, name);
if (await removeDir(dir, dryRun)) removed.push(dir);
}
return removed;
}
/**
* Remove the `[mcp_servers.gitnexus]` table — and any of its descendant
* sub-tables (`[mcp_servers.gitnexus.env]`, `[[mcp_servers.gitnexus.x]]`) —
* from Codex's config.toml. Used only as a fallback when the `codex` binary
* isn't on PATH; the CLI's `codex mcp remove` is preferred.
*
* Hand-rolled (no TOML dependency), but careful about the cases a naive
* line-scan gets wrong:
* - descendant sub-tables of the section are also removed (else they'd be
* left dangling, referencing a server that no longer exists);
* - `[...]`-shaped lines inside a multiline string (`"""`/`'''`) are NOT
* treated as table headers;
* - unrelated whitespace/formatting elsewhere in the file is left intact
* (no global blank-line reflow). Only a single blank separator line
* directly above the removed section is dropped.
*/
function stripTomlSection(raw: string, sectionName: string): string {
const header = `[${sectionName}]`;
const childTable = `[${sectionName}.`;
const childArray = `[[${sectionName}.`;
// Capture group 1 is the bracket token only, so a trailing inline comment
// (`[mcp_servers.gitnexus] # note`) is stripped before classification —
// otherwise an exact `=== header` check fails and the section is left behind.
const headerRe = /^(\[\[?[^[\]]+\]\]?)\s*(#.*)?$/;
const isSectionHeader = (token: string): boolean =>
token === header || token.startsWith(childTable) || token.startsWith(childArray);
// Return the multiline-string delimiter still OPEN at the end of `line`,
// given the state at its start (null = outside any multiline string). Scans
// left→right so the delimiter that actually opens first wins — a line with an
// odd count of BOTH `"""` and `'''` (e.g. `x = '''has """ inside`) no longer
// mis-picks the wrong delimiter and desyncs the scanner.
const multilineStateAfter = (line: string, startState: string | null): string | null => {
let state = startState;
let i = 0;
while (i < line.length) {
if (state) {
const close = line.indexOf(state, i);
if (close === -1) return state; // still open at end of line
i = close + state.length;
state = null;
} else {
const a = line.indexOf('"""', i);
const b = line.indexOf("'''", i);
if (a === -1 && b === -1) return null;
const useA = b === -1 || (a !== -1 && a < b);
state = useA ? '"""' : "'''";
i = (useA ? a : b) + 3;
}
}
return state;
};
const lines = raw.split(/\r?\n/);
const out: string[] = [];
let skipping = false;
let mlDelim: string | null = null;
for (const line of lines) {
if (mlDelim) {
// Inside a multiline string: brackets here are data, not headers.
mlDelim = multilineStateAfter(line, mlDelim);
if (!skipping) out.push(line);
continue;
}
const trimmed = line.trim();
const headerMatch = trimmed.match(headerRe);
if (headerMatch) {
if (isSectionHeader(headerMatch[1])) {
// Drop a single blank separator line immediately above the section.
if (!skipping && out.length > 0 && out[out.length - 1].trim() === '') out.pop();
skipping = true;
continue;
}
// A non-descendant header ends the section.
skipping = false;
out.push(line);
continue;
}
// Track whether this (non-header) line opens a multiline string so a
// bracketed line inside it isn't mistaken for a header.
mlDelim = multilineStateAfter(line, null);
if (!skipping) out.push(line);
}
// Preserve the file's line endings: a CRLF (Windows) config.toml should not
// be silently rewritten to LF. Rejoin with the dominant EOL of the input.
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
let result = out.join(eol);
if (!result.endsWith(eol)) result += eol;
return result;
}
async function uninstallCodex(
result: UninstallResult,
dryRun: boolean,
configPath: string,
tomlSection: string,
): Promise<void> {
let raw: string;
try {
raw = await fs.readFile(configPath, 'utf-8');
} catch {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (!raw.includes(`[${tomlSection}]`)) {
result.skipped.push('Codex MCP (not configured)');
return;
}
if (dryRun) {
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
return;
}
// Prefer the official CLI (mirrors setup's `codex mcp add`); fall back
// to editing config.toml directly when the binary isn't on PATH.
try {
await execFileAsync('codex', ['mcp', 'remove', 'gitnexus'], {
shell: process.platform === 'win32',
windowsHide: true,
timeout: 10000,
});
result.removed.push("Codex MCP server — via 'codex mcp remove gitnexus'");
return;
} catch {
// Fall through to manual edit.
}
try {
await fs.writeFile(configPath, stripTomlSection(raw, tomlSection), 'utf-8');
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
} catch (err: any) {
result.errors.push(`Codex: ${err.message}`);
}
}
// ─── Main command ──────────────────────────────────────────────────
export const uninstallCommand = async (options?: { force?: boolean }) => {
const dryRun = !options?.force;
const targets = getEditorTargets();
console.log('');
console.log(' GitNexus Uninstall');
console.log(' ==================');
console.log('');
if (dryRun) {
console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
console.log('');
}
const result: UninstallResult = { removed: [], skipped: [], errors: [] };
// ─── MCP server entries (JSONC editors) ──────────────────────────
for (const target of targets.mcpJsonc) {
try {
const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
if (status === 'removed')
result.removed.push(
`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`,
);
else if (status === 'corrupt')
result.errors.push(
`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`,
);
else result.skipped.push(`${target.label} MCP (not configured)`);
} catch (err: any) {
result.errors.push(`${target.label}: ${err.message}`);
}
}
await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
// ─── Hooks ───────────────────────────────────────────────────────
for (const hook of targets.hooks) {
try {
const { status, count } = await removeHookEntries(
hook.settingsFile,
hook.events,
hook.needle,
dryRun,
);
if (status === 'removed')
result.removed.push(`${hook.label} hooks (${count}) — ${hook.settingsFile}`);
else if (status === 'corrupt')
result.errors.push(
`${hook.label} hooks: ${path.basename(hook.settingsFile)} is corrupt — left untouched`,
);
// Don't delete the hook script while a registered entry may still point
// at it (corrupt = we couldn't parse/remove the entry) — that would
// leave the editor invoking a missing script on every matched tool call.
if (status !== 'corrupt' && (await removeDir(hook.scriptDir, dryRun)))
result.removed.push(`${hook.label} hook scripts — ${hook.scriptDir}`);
} catch (err: any) {
result.errors.push(`${hook.label} hooks: ${err.message}`);
}
}
// ─── Skills ──────────────────────────────────────────────────────
// Skill directories are identified by the bundled gitnexus skill names; the
// exact paths are listed below so the user can see what will be removed.
const skillNames = await listGitnexusSkillNames();
for (const target of targets.skills) {
try {
const removedDirs = await removeSkillsFrom(target.dir, skillNames, dryRun);
for (const dir of removedDirs) result.removed.push(`${target.label} skill — ${dir}`);
} catch (err: any) {
result.errors.push(`${target.label} skills: ${err.message}`);
}
}
// ─── Report ──────────────────────────────────────────────────────
const verb = dryRun ? 'Would remove' : 'Removed';
if (result.removed.length > 0) {
console.log(` ${verb}:`);
for (const name of result.removed) console.log(` - ${name}`);
} else {
console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
}
if (result.skipped.length > 0) {
console.log('');
console.log(' Skipped:');
for (const name of result.skipped) console.log(` - ${name}`);
}
if (result.errors.length > 0) {
console.log('');
console.log(' Errors:');
for (const err of result.errors) console.log(` ! ${err}`);
// Signal partial failure to callers/CI without aborting the remaining
// cleanup (which has already run by this point).
process.exitCode = 1;
}
console.log('');
console.log(' Note: skill directories are matched by bundled gitnexus skill name. If you');
console.log(' customized files inside an installed skill dir, back them up before --force.');
console.log('');
console.log(' Not removed automatically:');
console.log(' - Per-repo indexes — run: gitnexus clean --all');
console.log(' - The global npm package — run: npm uninstall -g gitnexus');
if (dryRun && result.removed.length > 0) {
console.log('');
console.log(' Re-run with --force to apply the changes above.');
}
console.log('');
};
@@ -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);
}
}
}
+9
View File
@@ -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);
+54
View File
@@ -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.
*
+17
View File
@@ -103,6 +103,19 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
// Behavior-neutral RSS tracing for the FTS evict→reload memory repro
// (gitnexus/scripts/bench/fts-evict-reload-rss.mjs). Two invariants keep it safe
// in the pool init/close hot path: it writes ONLY to stderr (stdout is the MCP
// JSON-RPC channel), and the GITNEXUS_POOL_RSS_TRACE gate makes it a no-op — one
// env-var compare per call, nothing else — unless a harness explicitly enables it.
function traceRss(event: 'init' | 'close', repoId: string): void {
if (process.env.GITNEXUS_POOL_RSS_TRACE !== '1') return;
const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
process.stderr.write(
`[pool-rss] ${event} repo=${repoId} pool=${pool.size} dbCache=${dbCache.size} rssMB=${rssMb}\n`,
);
}
let idleTimer: ReturnType<typeof setInterval> | null = null;
// Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
@@ -240,6 +253,8 @@ function closeOne(repoId: string): void {
// Isolate listener failures — teardown must complete.
}
}
traceRss('close', repoId);
}
/**
@@ -611,6 +626,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
closed: false,
});
ensureIdleTimer();
traceRss('init', repoId);
}
/**
@@ -673,6 +689,7 @@ export async function initLbugWithDb(
closed: false,
});
ensureIdleTimer();
traceRss('init', repoId);
}
/**
+210 -28
View File
@@ -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
+19 -16
View File
@@ -121,25 +121,26 @@ const SOURCES: Record<string, GrammarSource> = {
'Vue parsing piggybacks on `tree-sitter-typescript`. Check the install and native binding.',
},
// tree-sitter-c is a required dependency, but its native binding has
// historically been ABI-incompatible with the bundled tree-sitter@0.21.1
// runtime on some platforms (#1242, #858). Loading it through the
// optional machinery turns a would-be segfault into a clean degradation
// while preserving every other language's analysis. Severity is pinned
// to `error` because the package is in `dependencies`: a failure here
// is always an install/platform problem the user needs to see, never an
// expected "user opted out" condition like Swift/Dart/Kotlin.
// tree-sitter-c is a core grammar, vendored prebuild-only (under
// gitnexus/vendor/tree-sitter-c) with GitNexus-built prebuilds for every
// supported platform-arch — upstream ships only 4/6 (#2116) and C is a
// required grammar whose source build hard-fails install on a toolchain-less
// ARM host. Loading through the optional machinery turns a would-be ABI
// segfault (#1242, #858) into a clean degradation while preserving every
// other language's analysis. Severity stays `error` because C is not a
// user-opt-out grammar like Swift/Dart/Kotlin: a failure here is always an
// install/platform problem the user needs to see.
[SupportedLanguages.C]: {
load: () => _require('tree-sitter-c'),
optional: true,
severity: 'error',
unavailableNote:
'C parsing disabled: `tree-sitter-c` could not be loaded. ' +
'This package is in `dependencies` and prebuilds ship for all supported ' +
'platforms (win32/darwin/linux x64+arm64, Node 18/20/22), so this ' +
'usually indicates a corrupted install, an unsupported Node version, ' +
'or a native ABI mismatch with the bundled tree-sitter runtime. ' +
'Try `npm rebuild tree-sitter-c` or reinstalling, then re-run analyze. ' +
'C parsing disabled: vendored `tree-sitter-c` (under ' +
'`gitnexus/vendor/tree-sitter-c`) could not be loaded. GitNexus ships ' +
'prebuilt binaries for all supported platforms (win32/darwin/linux ' +
'x64+arm64, N-API), so this usually indicates a corrupted install or a ' +
'native ABI mismatch with the bundled tree-sitter@0.21.1 runtime. ' +
'Try reinstalling, then re-run analyze. ' +
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
},
@@ -170,8 +171,10 @@ const SOURCES: Record<string, GrammarSource> = {
optional: true,
userSkippable: true,
unavailableNote:
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
'and is not installed (or its native binding failed to build).',
'Kotlin parsing disabled: vendored `tree-sitter-kotlin` (under ' +
'`gitnexus/vendor/tree-sitter-kotlin`) failed to load. ' +
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
`See ${ISSUES_URL}/2107.`,
},
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -44,7 +44,7 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
switch (toolName) {
case 'list_repos':
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness.`;
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness. If pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset to fetch the rest.`;
case 'query':
return `\n\n---\n**Next:** To understand a specific symbol in depth, use context({name: "<symbol_name>"${repoParam}}) to see categorized refs and process participation.`;
+64 -3
View File
@@ -51,12 +51,25 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
openWorldHint: false,
};
/**
* Pagination bounds for the `list_repos` tool. Exported so the backend
* validation (`local-backend.ts`) and the schema below stay a single source of
* truth. `list_repos` is paginated to keep its response under MCP/LLM token
* truncation limits when many repos are indexed (#2119); the default page is
* small enough to render safely, and `LIST_REPOS_MAX_LIMIT` caps how much a
* caller can pull in one request.
*/
export const LIST_REPOS_DEFAULT_LIMIT = 50;
export const LIST_REPOS_MAX_LIMIT = 200;
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
description: `List all indexed repositories available to GitNexus.
description: `List indexed repositories available to GitNexus (paginated).
Returns each repo's name, path, indexed date, last commit, and stats.
Returns a page of repositories — each with name, path, indexed date, last commit, and stats — plus a "pagination" object: { total, limit, offset, returned, hasMore, nextOffset }.
PAGINATION: Results are paginated so a large registry is not truncated by MCP/LLM token limits. "limit" sets the page size (default ${LIST_REPOS_DEFAULT_LIMIT}, max ${LIST_REPOS_MAX_LIMIT}; values above the max are rejected, not capped). "offset" selects the start. To enumerate EVERY repository: when pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset, and repeat until hasMore is false. Repositories are returned in a stable order, so paging never skips or duplicates an entry while the registry is unchanged.
WHEN TO USE: First step when multiple repos are indexed, or to discover available repos.
AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work with.
@@ -66,7 +79,22 @@ on other tools (query, context, impact, etc.) to target the correct one.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
properties: {
limit: {
type: 'integer',
description: `Max repositories to return in this page (default: ${LIST_REPOS_DEFAULT_LIMIT}, min: 1, max: ${LIST_REPOS_MAX_LIMIT}). Values outside [1, ${LIST_REPOS_MAX_LIMIT}] are rejected.`,
default: LIST_REPOS_DEFAULT_LIMIT,
minimum: 1,
maximum: LIST_REPOS_MAX_LIMIT,
},
offset: {
type: 'integer',
description:
'Number of repositories to skip before this page (default: 0). Pass pagination.nextOffset from the previous response to fetch the next page.',
default: 0,
minimum: 0,
},
},
required: [],
},
},
@@ -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.',
};
}
+84
View File
@@ -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 };
};
+27
View File
@@ -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.
+170 -18
View File
@@ -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,
@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
export async function GET() {
const things = [{ id: 1, name: 'Widget' }];
return NextResponse.json(things);
}
@@ -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);
}
@@ -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;
}
@@ -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();
}
+6
View File
@@ -35,6 +35,12 @@ export const LOCAL_BACKEND_SEED_DATA = [
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
// func:validate is the terminalId of proc:beta-flow too — wiring its second
// STEP_IN_PROCESS edge makes it a genuine MULTI-process symbol, which the
// batched-query test uses to exercise the full row[1..6] positional shift
// (a single-process symbol can't expose an off-by-one in those fallbacks).
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:beta-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 3}]->(p)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:alpha' AND t.id = 'Tool:alpha'
CREATE (h)-[:CodeRelation {type: 'HANDLES_TOOL', confidence: 1.0, reason: 'tool-definition', step: 0}]->(t)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:beta' AND t.id = 'Tool:beta'
@@ -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";
}
}
@@ -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";
}
}
@@ -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 "{}";
}
}
@@ -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);
});
});
+22
View File
@@ -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