Compare commits

...
Author SHA1 Message Date
github-actions[bot] 6a528c7a12 release: v1.6.4-rc.48 2026-05-03 13:15:57 +00:00
DuduPhuduandCursor 36ff15151f fix(typescript): name HOC-wrapped const declarations (forwardRef / memo / useCallback / useMemo / observer) (#1261)
* fix(typescript): name HOC-wrapped const declarations (forwardRef / memo / useCallback / useMemo / observer / debounce)

Follow-up to issue #1166 / PR #1175. After fixing HOF callbacks (Promise
fan-out, queryFn pair-arrows, multi-action Zustand stores) and JSX-as-call,
the dominant residual 0%-capture pattern in real React UI codebases was
the HOC-wrapped variable declaration:

  const Button = React.forwardRef((props, ref) => { ... })
  const Card = memo((props) => { ... })
  const handleClick = useCallback(() => { ... }, [])
  const computed = useMemo(() => { ... }, [])
  const debouncedSearch = debounce((q) => { ... }, 250)

All share the AST shape `lexical_declaration > variable_declarator >
call_expression > arguments > arrow_function`. Pre-fix, neither the
registry-primary `query.ts` nor the legacy `tree-sitter-queries.ts` had
a `@declaration.function` pattern matching this shape, and the legacy
DAG's `tsExtractFunctionName` only walked `variable_declarator` and
`pair` parents — `arguments` parents fell through with `funcName = null`.

Result: every shadcn/Radix component, every memoised React component,
and every `useCallback` / `useMemo` callback bound to a const registered
as anonymous; calls inside attributed to the file. Sourcerer-fe audit:
~296 declarations affected (~57 forwardRef + ~21 memo + ~161 useCallback
+ ~57 useMemo).

Fix:
  - 4 new tree-sitter patterns in `languages/typescript/query.ts`
    (registry-primary), anchored on the inner arrow_function /
    function_expression — same anchor discipline as the existing
    `lexical_declaration` and `pair` patterns from PR #1175.
  - 8 mirrored patterns in `tree-sitter-queries.ts` (4 in
    TYPESCRIPT_QUERIES, 4 in JAVASCRIPT_QUERIES) for the legacy DAG
    and the CI parity gate.
  - New `arguments`-parent branch in `tsExtractFunctionName` that
    walks `arguments → call_expression → variable_declarator` and
    returns the const's name. Three guards keep it strictly scoped
    to HOC-wrapped declarations; bare statement-level HOC calls fall
    through anonymous.

Tests:
  - 11 integration tests + 9 minimal TS/TSX fixtures exercising
    forwardRef / memo / useCallback / useMemo / observer / debounce,
    with positive (named-Function + correct CALLS edge), negative
    (no phantom Functions for unbound HOCs, no phantom self-loops,
    no first-sibling-wins leakage), and cross-pollination assertions.
  - 8 new unit tests in `call-attribution-issue-1166.test.ts`
    pinning the legacy-DAG path: 6 attribution tests + 2
    @definition.function capture tests.

Trade-off documented inline: chained array-method declarations
(`const x = arr.find((y) => p(y))`) match the same shape and produce
a mostly-harmless phantom `Function:x` with one outgoing edge. The
false-positive cost is negligible vs. the React UI coverage gain.

Verification: - 11/11 typescript-hoc-wrapped (registry-primary)
  - 26/26 call-attribution-issue-1166 (8 new + 18 pre-existing)
  - 266/266 across all 4 typescript resolver test files (registry)
  - 236/236 typescript.test.ts on legacy DAG (CI parity gate)
  - 1693/1693 across all non-Kotlin/Swift resolver test files
  - tsc --noEmit clean; prettier clean; eslint clean (no new warnings)
Co-authored-by: Cursor <cursoragent@cursor.com>

* test(typescript): pin documented HOC trade-offs and close var-form parity gap

Addresses the four findings on PR #1261 (Claude bot review for #1261).
All findings flagged missing assertion tests for behaviour already documented
in code comments — none reported a real bug. The verdict was
"production-ready with minor follow-ups"; these tests strengthen the
documentation-to-test contract.

[medium #1] Array-method false-positive
  Pin `const found = items.find((item) => predicate(item))` →
  `predicate.attributedTo === 'found'` as an accepted FP. The const is a
  value, never invoked, so no incoming CALLS edge ever points at it; the
  outgoing edge is a minor mis-attribution we accept rather than maintain
  a HOC allowlist.

[medium #2] Nested HOCs (`memo(forwardRef(...))`) — no phantom Function:Wrapped
  Two integration tests in `typescript-hoc-wrapped.test.ts`:
    1. `Wrapped` is NOT a Function node (the outer call's first arg is a
       call_expression, not an arrow — no @declaration.function pattern
       matches the outer shape).
    2. The deepest arrow's `helper()` call is NOT attributed to
       Function:Wrapped (the deepest arrow is anonymous because
       call_expression.parent is `arguments`, not `variable_declarator`),
       and no Function-sourced CALLS originate from `nested.tsx`.

[medium #3] Multi-arrow argument dedup
  Pin `const x = call(() => first(), () => second())` — both arrows share
  the same `arguments → call_expression → variable_declarator` ancestor
  chain on the legacy DAG, so both attribute to "x". Documents the
  registry-primary dedup story alongside.

[low #4] `var X = HOC(...)` parity gap
  Registry-primary `query.ts` had `(variable_declaration ...)` HOC patterns
  but legacy `tree-sitter-queries.ts` (TS + JS) did not. Closes the gap by
  mirroring two `(variable_declaration ...)` HOC patterns into both legacy
  sections so the parity gate stays tight even if a codebase mixes
  `var X = HOC(...)` with `const X = HOC(...)`.

Validation
  - Targeted: 41/41 (28 unit + 13 integration) on registry-primary.
  - Broader TS suite: 60/60 across 4 resolver test files.
  - CI parity gate (`typescript.test.ts`): 236/236 on legacy DAG and 236/236
    on registry-primary.
  - Prettier clean. ESLint clean (5 pre-existing non-null-assertion
    warnings in the test file, unrelated). tsc --noEmit clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 13:58:09 +01:00
Christian C. BerclazandGergo Magyar 7f8b01d506 refactor(ingestion): consolidate per-language patterns into LanguageProvider (#1279)
* refactor(ingestion): consolidate per-language patterns into LanguageProvider

Move entry-point name patterns and AST framework detection patterns from
shared maps in entry-point-scoring.ts and framework-detection.ts into each
LanguageProvider. The shared files now build their lookup tables dynamically
from the provider registry at module load.

This aligns with the architecture principle that shared pipeline code must
not name languages. Adding a new language no longer requires modifying
entry-point-scoring.ts or framework-detection.ts — the provider file is
the single source of truth for all language-specific data.

New LanguageProvider fields:
  - entryPointPatterns: RegExp[] (default: [])
  - astFrameworkPatterns: AstFrameworkPatternConfig[] (default: [])

* test(ingestion): add provider-registry, multiplier/reason, and Kotlin/Dart/Ruby entry-point coverage

Addresses review feedback on the per-language pattern consolidation:

- Runtime guard that providers map covers every SupportedLanguages member,
  catching enum/registry drift that the compile-time `satisfies` cannot.
- Multiplier/reason parity assertions for nestjs (3.2/nestjs-decorator),
  spring (3.2/spring-annotation), and fastapi (3.0/fastapi-decorator) so a
  silent value change during future relocations would fail loudly.
- Entry-point pattern coverage for Kotlin (Android lifecycle, ViewModel,
  Service), Dart (Flutter widget lifecycle), and Ruby (call/perform/execute)
  — the three providers whose patterns moved without representative tests.

* refactor(ingestion): apply satisfies AstFrameworkPatternConfig[] to remaining providers

The c-cpp, dart, php, ruby, and swift providers imported AstFrameworkPatternConfig
but never used it, which the root ESLint config flagged as a hard error in the
quality / lint CI gate.

Use the type the same way csharp/go/java/kotlin/python/rust/typescript already do —
as a satisfies assertion on the astFrameworkPatterns array. This both clears the
unused-import error and gives every provider compile-time validation of pattern
shape, narrowing the gap that the original review flagged about lost exhaustiveness
on the optional astFrameworkPatterns field.

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-03 10:34:00 +01:00
Gergő MagyarandCursor 114d5304d9 fix(mcp): avoid git from non-repo cwd in sibling cwd match (#1138) (#1293)
* fix(mcp): avoid git shellout from non-repo cwd for sibling match

checkCwdMatch used getGitRoot(cwd), which runs git rev-parse from the
launch cwd (often \C:\Users\gergo in MCP stdio). Resolve the cwd git root via
ancestor .git checks first, then keep existing remote-based sibling
logic.

Fixes #1138

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(mcp): address PR #1293 review follow-ups

Three test gaps flagged by review on the #1138 fix:

- sibling-clone-drift.test.ts: the existing "non-git cwd" test only
  asserted match=none, which the pre-fix code also returned (by
  silently failing the spawn). Wrap child_process / node:child_process
  with passthrough vi.fn() spies and assert no execSync/execFileSync
  call is recorded when checkCwdMatch runs against a non-git cwd, so a
  regression that re-introduces the spawn fails loudly.
- git.test.ts: add coverage for findGitRootByDotGit's three untested
  inputs — a `.git` FILE (linked worktree / submodule), a path that
  does not exist, and a file path inside a repo (must walk from the
  parent dir). Each asserts no subprocess was spawned.

No production code changes. Test additions only.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 09:17:12 +01:00
TemirkhanandGergo Magyar 1fe3bf9399 feat(mcp): add tool safety annotations (#1127)
* feat(mcp): add tool safety annotations

* test(mcp): address PR #1127 review follow-ups

- Replace private `_requestHandlers` SDK access in server.test.ts with
  `Client` + `InMemoryTransport.createLinkedPair()` for the tools/list
  annotation propagation test. The new path uses supported public APIs
  and surfaces SDK changes loudly instead of silently degrading.
- Extract `OPEN_WORLD_READ_ONLY_TOOLS` set in tools.test.ts so future
  read-only open-world tools can be added without rewriting the
  invariant; preserves the current "only `query` is open-world" guard.
- Add inline rationale on `group_sync` annotations explaining the
  conservative `idempotentHint: false` (writes contracts.json on every
  call even when output is deterministic).

No runtime behavior change. Annotations themselves and tools/list shape
are unchanged.

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-03 09:06:41 +01:00
azizur100389 db22a89021 fix(embeddings): bridge HF_ENDPOINT env var to transformers.js env.remoteHost (#1205) (#1252) 2026-05-03 07:37:05 +01:00
Christian C. Berclaz b9a17f553d feat(group): auto-discover Rust workspace cross-crate contracts (#1256) 2026-05-03 01:43:22 +01:00
Christian C. Berclaz bc722b9d8f fix(group): resolve custom manifest links against graph symbols (#1254) 2026-05-03 01:40:29 +01:00
jelsco 368049576b fix(python): make multi-segment suffix fallback deterministic (#1253) 2026-05-02 11:36:28 +01:00
Gergő Magyar 55d504284f fix(ci): consolidate Claude review workflow (#1258) 2026-05-02 08:15:46 +01:00
Gergő Magyar 0418cbb347 fix(cli): keep GitNexus ignores inside .gitnexus (#1248)
* fix(cli): keep GitNexus ignores inside .gitnexus

Avoid mutating analyzed repositories' root .gitignore while keeping generated GitNexus state untracked via .gitnexus/.gitignore.

Made-with: Cursor

* fix(cli): also use git info exclude for GitNexus storage

When an analyzed repo has a real .git directory, add .gitnexus/ to .git/info/exclude so local Git metadata ignores generated storage without touching root .gitignore.

Made-with: Cursor

* fix(cli): keep skip-git subdir indexes ignored

Ensure full analyze always writes the internal GitNexus ignore file so parent Git repositories stay clean for --skip-git subdirectory indexes.

Made-with: Cursor
2026-05-01 16:46:05 +01:00
azizur100389 4be4abe8e4 fix(group): contract extractors honour .gitnexusignore via shared IgnoreService (#1185) (#1247)
* fix(group): contract extractors honour .gitnexusignore via shared IgnoreService (#1185)

The HTTP, gRPC, and topic contract extractors each globbed the repo
with a hardcoded `ignore: ['**/node_modules/**', '**/.git/**',
'**/dist/**', '**/build/**', '**/vendor/**']` array, bypassing the
shared `IgnoreService` that the rest of the ingestion pipeline uses
for `.gitnexusignore` and `.gitignore` parsing. Result: a vendored
Python venv (`mentor_env/`), generated stubs, or any user-defined
exclusion silently produced false-positive contracts.

Replace each hardcoded array with `createIgnoreFilter(repoPath)`,
mirroring the canonical pattern in `filesystem-walker.ts`. The 5
hardcoded names are all in `DEFAULT_IGNORE_LIST`, so default
behaviour is preserved; users now also get `.gitnexusignore`
patterns, the rest of the hardcoded list (e.g. `__pycache__`,
`.pytest_cache`), and the `.gitnexusignore` negation semantics
introduced in #771.

The topic extractor additionally filters Go `*_test.go` at the glob
level. That filter is preserved via a small wrapper around
`createIgnoreFilter` that short-circuits before delegating, so
glob-level pruning still applies and the existing `_test.go` skip
test (with new content asserting the pruning is real) still passes.

Tests added to all three `*-extractor.test.ts` files exercising
`.gitnexusignore` honouring end-to-end via real temp directories.

* test(group): exercise gRPC source-scan ignore + add .gitignore-only coverage (#1185)

Addresses two findings from the @claude review on PR #1247:

[medium] The gRPC ignore test claimed to cover both proto-context and
source-scan paths but only wrote a .proto file under mentor_env/.
Added a Python `_pb2_grpc.<Name>Stub(channel)` consumer file under the
same ignored dir (mirroring the canonical pattern from
`test_extract_python_stub_returns_consumer`); without the
`.gitnexusignore` filter that file would emit a consumer contract.
The test now exercises both `createIgnoreFilter` calls inside the gRPC
extractor (`buildProtoContext` + `extract`) in a single run, with both
defence-in-depth path-prefix assertions and a specific
`role: consumer` LeakedService assertion.

[low] Added one shared .gitignore-only test on the HTTP extractor.
`createIgnoreFilter` reads both `.gitignore` and `.gitnexusignore` via
`loadIgnoreRules`, but no extractor-level test exercised the
`.gitignore` path. One shared test is sufficient because all three
extractors consume the same filter object — verified at
`IgnoreService` level already.

The remaining [low] finding — "negation semantics (!pattern) not
tested at extractor level" — is deferred deliberately, not skipped.
Three reasons:

  1. The negation logic (introduced in #771) lives entirely inside
     `createIgnoreFilter`'s `hasExplicitUnignore` ancestor-walk in
     `ignore-service.ts`. The extractors only consume the returned
     filter object — they never inspect patterns, never call
     `hasExplicitUnignore` directly, and have no code path that could
     diverge from the IgnoreService's negation behaviour.

  2. Negation is already locked in by 8 dedicated unit tests in
     `test/unit/ignore-service.test.ts` (the #771 suite), plus the
     `!parent/` + `parent/child/` last-match-wins regression test
     added in PR #1046. An extractor-level negation test would
     re-prove the same code path and would not catch any failure mode
     the existing tests don't already catch.

  3. The bot itself flagged the gap as "Acceptable to leave as
     follow-up referencing existing IgnoreService negation tests" —
     the deferral matches its own recommendation.

If a future change inserts an extractor-side wrapper around the filter
(as topic-extractor.ts already does for `*_test.go`) that could
plausibly affect negation, an extractor-level negation test should be
added at that point — not pre-emptively here.
2026-05-01 16:42:21 +01:00
jelsco 59acfb2261 fix(python): walk ancestors for multi-segment dotted imports (#1241)
* fix(python): walk ancestors for multi-segment dotted imports (#1240)

Single-segment Python imports (`from middleware import X`) already get an
ancestor-directory walk in `resolvePythonImportInternal`, so they resolve
correctly when the importer and the imported module share a parent
directory (e.g. both under `backend/`).

Multi-segment dotted imports (`from services.sync import X`) were only
resolved against the workspace root. In a `backend/`-prefixed repo,
`from services.sync import X` from `backend/routers/cron.py` would not
resolve because `services/sync.py` does not exist at the workspace root —
only `backend/services/sync.py` does. The IMPORTS edge was dropped, the
imported names were never bound, and downstream CALLS edges to those
names were silently lost.

The fix mirrors the single-segment ancestor walk for multi-segment paths
in `resolveAbsoluteFromFiles`, and widens `hasRepoCandidate` to accept
nested `/segment/` matches so it does not bail before the walk runs.

Includes a new fixture and 5 integration tests covering:
- IMPORTS resolution for `from services.sync`, `from services.alerts`,
  `from routers.alerts` from `backend/routers/cron.py`.
- CALLS edge counts for every multi-segment-imported callee.
- Regression check: single-segment ancestor walk
  (`from auth_utils import …`) still resolves correctly.

The django-app-imports regression suite (which prevents `accounts.apps`
from spuriously matching a local `apps.py`) continues to pass — the new
nested-namespace check in `hasRepoCandidate` is bounded by an explicit
`/segment/` substring, and the workspace-root candidate check still
runs first.

* fix(python): scope hasRepoCandidate widening to importer ancestors + tighten ancestor-walk loop

Address review findings from PR #1241:

1. hasRepoCandidate's nested check now requires the matching directory to
   sit on an ancestor of the importer. Previously any nested /SEGMENT/
   path satisfied the gate, which would let a vendored copy of an
   external package (e.g. vendor/django/urls.py) gate-pass an external
   import like 'from django.urls import path' issued from app/main.py.

2. Loop bound in resolveAbsoluteFromFiles tightened from 'i >= 0' to
   'i > 0' to skip a redundant root-candidate recheck (the workspace-root
   direct check above already covers that case).

3. Doc-comment in resolveAbsoluteFromFiles now states the precedence
   order explicitly: workspace root > closest ancestor > suffix fallback.

Tests added:
- Vendored-external false-positive guard (vendor/django/urls.py must not
  resolve from app/main.py).
- Workspace-root vs ancestor precedence (root services/sync.py wins over
  backend/services/sync.py for a backend/routers/cron.py importer).

215/215 python integration tests pass (+4 from this change). tsc --noEmit
green.
2026-05-01 16:27:07 +01:00
Gergo Magyar 26b39560ce fix(ci): configure e2e GitNexus home at runtime
Avoid workflow planning failures by deriving the e2e GitNexus home from RUNNER_TEMP inside a shell step instead of using runner context in job-level env.

Made-with: Cursor
2026-05-01 12:39:02 +01:00
Gergő Magyar 2a1546a8b6 Revert "Revert "chore(deps)(deps): bump uuid from 13.0.0 to 14.0.0 in /gitnex…" (#1250)
This reverts commit d57f15f18d.
2026-05-01 12:33:46 +01:00
Gergő Magyar ec07467601 fix(ci): seed e2e with a small fixture repo (#1249)
Avoid crashing the e2e job during setup by indexing a tiny temp fixture instead of the full monorepo before Playwright starts.

Made-with: Cursor
2026-05-01 12:29:53 +01:00
dependabot[bot] e07a95dffb chore(deps)(deps): bump react-zoom-pan-pinch in /gitnexus-web (#1214)
Bumps [react-zoom-pan-pinch](https://github.com/prc5/react-zoom-pan-pinch) from 3.7.0 to 4.0.3.
- [Release notes](https://github.com/prc5/react-zoom-pan-pinch/releases)
- [Changelog](https://github.com/BetterTyped/react-zoom-pan-pinch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prc5/react-zoom-pan-pinch/compare/v3.7.0...v4.0.3)

---
updated-dependencies:
- dependency-name: react-zoom-pan-pinch
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 10:32:03 +01:00
Copilot 6372b0bfeb fix(cli): --skip-git treats cwd as index root instead of walking up to parent git repo (#1245) 2026-05-01 09:49:48 +01:00
dependabot[bot]andGergo Magyar c90ffdd2e1 chore(deps)(deps): bump react and @types/react in /gitnexus-web (#1210)
* chore(deps)(deps): bump react and @types/react in /gitnexus-web

Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 18.3.1 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `@types/react` from 18.3.27 to 19.2.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-major
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(web): complete React 19 upgrade

Align react-dom and @types/react-dom with React 19 so npm can resolve the web dependency graph without peer conflicts. React 19 types now correctly model refs initialized with null, so widen the affected hook return contracts instead of asserting non-null DOM nodes before mount.

Validation: cd gitnexus-web && npm ci; npx tsc -b --noEmit; npm test; npm run build; npx prettier --check package.json package-lock.json src/hooks/useAutoScroll.ts src/hooks/useSigma.ts; npx eslint . (warnings only, existing baseline); npm run test:e2e (11 passed, 17 skipped).
Made-with: Cursor

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-01 08:42:26 +01:00
Gergő Magyar 71e1e8a3f0 fix(deps): pin tree-sitter-c/cpp to fix Windows segfault (#1242) (#1243)
* fix(deps): pin tree-sitter-c/cpp to fix Windows segfault (#1242)

`tree-sitter-c@0.23.2` ships native prebuilds compiled against tree-sitter
ABI 14 (tree-sitter-cli >=0.24), while GitNexus is pinned to the
tree-sitter@0.21.1 JS runtime. On Windows the JS runtime hits
`Cannot read properties of undefined (reading '161')` inside
`unmarshalNode` and a native segfault in the parse-worker pipeline on
real C codebases (e.g. STM32 headers from the issue reporter).

Two coordinated registry pins fix the root cause without any override
gymnastics or vendoring:

- `tree-sitter-c` -> `0.21.4` (last release built against the
  tree-sitter@0.21 ABI; declared peer `^0.21.0`).
- `tree-sitter-cpp` -> `0.23.2` (last 0.23.x release before
  tree-sitter-cpp added a runtime dep on the broken-ABI
  `tree-sitter-c@^0.23.1`; pinning here lets us drop the previous
  global override entirely).

`npm ls tree-sitter-c` is now clean: single deduped 0.21.4, no
`overridden` annotations, no nested copy.

Parser loader collapsed to one declarative table:

- One `SOURCES` map with `{ load, unavailableNote, optional? }` rows
  for every grammar including TSX. Adding/removing a grammar is one
  entry; `unavailableNote` is mandatory and the type checker enforces
  it, so failures are never silent and never generic.
- Single `loadGrammar(key)` does lazy require + cache + per-failure
  classification. Required failures `console.error` the note and
  rethrow the original (preserves stack); optional failures
  `console.warn` and report the language as Unsupported. One
  warn-once `Set` deduplicates per language key.
- The previous bespoke `warnCUnavailable` + `cWarningEmitted` state
  and 4 conditional spreads in the language map are gone.

Per-grammar `unavailableNote` strings name the package, list the most
likely failure mode for that grammar, and link the relevant tracking
issue (#1013, #1125, #1130, #1242) where applicable.

Tests: new `C parser ABI compatibility (#1242)` block under
parser-loader.test.ts exercises the actual failure paths
(non-trivial parse + tree walk + Query.captures + TreeCursor
descent). The original report's `unmarshalNode` crash sits on
exactly the traversal hot path these tests now cover.

Validation:
- npx tsc --noEmit: clean
- npx vitest run test/unit: 4808 passed, 10 skipped
- npx vitest run test/integration/resolvers/cpp.test.ts: 133/133
- minimal C parse + walk + query + cursor verified manually under
  tree-sitter@0.21.1 + tree-sitter-c@0.21.4 on Win11 x64 / Node 22

Closes #1242. Does not unblock the broader tree-sitter@0.25 upgrade
tracked in #858.

Made-with: Cursor

* chore(ci): redesign tree-sitter upgrade-readiness report (#858)

The daily script that owns the body of #858 used to dump one giant
matrix and leave a human to figure out which grammars are actually
ready to bump. After pinning `tree-sitter-c@0.21.4` and
`tree-sitter-cpp@0.23.2` for #1242, several rows in that matrix now
look like regressions when in fact they are deliberate. The report
now classifies each grammar instead of just listing them.

What changed in `check-tree-sitter-upgrade-readiness.py`:

- New `INTENTIONAL_PINS` table documents grammars deliberately held
  below `npm latest`, with a one-line rationale and a tracking issue
  per row (#1242 for C and C++, #1013 for C#). The script reads pins
  straight from `gitnexus/package.json` so a future bump cannot
  drift away from this report.
- New `_classify_grammar(...)` produces one primary disposition per
  grammar: Ready for 0.25 / Intentionally pinned / Waiting on
  upstream npm release / Blocked on upstream / Could not check.
  The dispositions drive the report layout.
- New `vendored_drift_summary(...)` covers all three vendored
  parsers (`tree-sitter-proto`, `tree-sitter-dart`,
  `tree-sitter-swift`) uniformly: ABI from `parser.c` when present,
  upstream npm + GitHub status, and the rationale extracted from
  each vendor's `_vendoredBy` field. Prebuilt-only vendors
  (Swift today) report `ABI 'prebuilt'` instead of `None`.
- Report layout: top-of-page TL;DR + counts, an actionable
  "What you can do today" section, then one section per
  disposition bucket, then a dedicated "Vendored parsers"
  section. The original raw matrix is preserved inside a
  collapsible `<details>` block so the row-diff bot that watches
  this issue still has stable input.
- `sys.stdout.reconfigure(encoding="utf-8")` so the workflow no
  longer crashes on Windows when the report contains arrows or
  em-dashes.

No workflow / cron changes; the daily job posts the new body the
next time it runs. #858 itself was updated by hand in the meantime
to keep the tracker readable.

Made-with: Cursor

* fix(parser-loader): log C grammar load failures at error severity (#1242)

Addresses review feedback on #1243.

`tree-sitter-c` is in `dependencies` (not `optionalDependencies`) so a
load failure on a supported platform always indicates a real install
problem the user needs to see — corrupted node_modules, unsupported
Node version, or an ABI mismatch with the bundled runtime. Previously
the optional-grammar machinery downgraded that to `console.warn`,
which can be missed in long log streams and silently drops C analysis
for an entire repo.

Decouples log severity from throw behavior:

- `GrammarSource.severity?: 'warn' | 'error'` is a new optional field
  that overrides the default log level for a load failure. Default is
  `error` for required grammars and `warn` for optional ones, matching
  the prior behavior for every existing row.
- `LoadResult` carries the resolved severity through `loadGrammar` so
  `logFailure` no longer derives it from `fatal`.
- `tree-sitter-c` row sets `optional: true, severity: 'error'`. The
  pipeline still degrades gracefully (callers see Unsupported instead
  of a thrown error), but the diagnostic is loud and the
  `unavailableNote` now spells out what to try first
  (`npm rebuild tree-sitter-c`, reinstall) and links the tracker.

No test changes needed: `parser-loader.test.ts` exercises behavior on
the success path and on optional-failure dispatch; severity is a
display-only concern routed through `console.error` vs `console.warn`,
which the existing tests don't assert on.

Made-with: Cursor

* fix(ci): treat intentional pins as 0.25 blockers in readiness report

Addresses review feedback on #1243.

`_classify_grammar` returned bucket `intentional` before checking
`target_compat`, and the per-grammar status loop only added a row to
`blockers` when npm-latest was incompatible with the target runtime.
The combination meant: if every other grammar resolved tomorrow but we
were still holding `tree-sitter-c@0.21.4` and `tree-sitter-cpp@0.23.2`
(both incompatible with `tree-sitter@0.25.x`), the script would emit
"**Ready** — all grammars are 0.25-compatible" and mislead maintainers
into thinking the runtime upgrade was unblocked.

Fix:

- The status loop now adds an entry to `blockers` whenever a grammar
  is in `INTENTIONAL_PINS`, regardless of npm-latest's peer dep. The
  blocker message names the pinned spec, embeds the rationale from
  `INTENTIONAL_PINS`, and tells the reader the pin must be lifted
  before the target runtime upgrade. When the pin is removed (entry
  deleted from `INTENTIONAL_PINS`), the grammar resumes standard
  classification on the next run.
- `bump_now` now excludes intentional pins so they never show up in
  the "What you can do today" section. Bumping an intentional pin
  requires a deliberate edit to both `INTENTIONAL_PINS` and
  `package.json`, not a one-line dependency bump.

Verified locally: TL;DR now reports 8 blockers (6 upstream + 2
intentional) where it previously reported 6, and the verdict
correctly remains **Blocked** even in the hypothetical future where
all upstream blockers clear.

Made-with: Cursor
2026-05-01 08:02:16 +01:00
dependabot[bot] b278926bb6 chore(deps)(deps): bump onnxruntime-node in /gitnexus (#1213)
Bumps [onnxruntime-node](https://github.com/Microsoft/onnxruntime) from 1.24.3 to 1.25.1.
- [Release notes](https://github.com/Microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseManagement.md)
- [Commits](https://github.com/Microsoft/onnxruntime/compare/v1.24.3...v1.25.1)

---
updated-dependencies:
- dependency-name: onnxruntime-node
  dependency-version: 1.25.1
  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>
2026-05-01 07:22:44 +01:00
dependabot[bot] 275f09d6c0 chore(deps)(deps-dev): bump @types/node in /gitnexus-web (#1212)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.9 to 25.6.0.
- [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.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 07:22:32 +01:00
dependabot[bot] b4f9d56f33 chore(deps)(deps): bump mnemonist from 0.40.3 to 0.40.4 in /gitnexus (#1239)
Bumps [mnemonist](https://github.com/yomguithereal/mnemonist) from 0.40.3 to 0.40.4.
- [Release notes](https://github.com/yomguithereal/mnemonist/releases)
- [Changelog](https://github.com/Yomguithereal/mnemonist/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yomguithereal/mnemonist/compare/0.40.3...0.40.4)

---
updated-dependencies:
- dependency-name: mnemonist
  dependency-version: 0.40.4
  dependency-type: direct:production
  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-04-30 22:14:55 +01:00
Gergő Magyar 6f42253dfd fix(cli): surface silent finalize-skips so analyze cannot exit 0 without persisting (#1169) (#1237)
* fix(cli): surface silent finalize-skips so analyze cannot exit 0 without persisting (#1169)

Closes #1169.

On Windows, `gitnexus analyze .` was observed to exit with code 0 after
printing only the "GitNexus Analyzer" banner. `.gitnexus/lbug.wal` was
written but `meta.json` was never persisted and the repo was not added
to `~/.gitnexus/registry.json`, so `gitnexus list` / `status` reported
no indexed repository. The reporter confirmed the same shape on both
LadybugDB (1.6.x) and the pre-LadybugDB KuzuDB build (1.4.1), so the
silent finalize-skip is upstream of the DB engine and indistinguishable
from a healthy index from the user's perspective.

This change makes that state a hard, actionable failure regardless of
the upstream root cause.

Behaviour change

- New `assertAnalysisFinalized()` invariant in `repo-manager.ts` checks
  that meta.json exists at `<repo>/.gitnexus/meta.json` AND that the
  global registry has a canonical-path-matching entry. Throws
  `AnalysisNotFinalizedError` (kind: "AnalysisNotFinalizedError") with a
  diagnostic that names the missing artifact and the storage path the
  user should inspect.
- `analyzeCommand` invokes the invariant on the rebuild path (skipped
  on `alreadyUpToDate`), so a future silent finalize-skip surfaces with
  exit code 1 and a recoverable error instead of a silent exit 0.
- `analyzeCommand` installs idempotent `unhandledRejection` and
  `uncaughtException` handlers that bypass the progress bar's console
  redirection by writing to a stderr handle captured at module load.
  This addresses the secondary symptom where the `barLog` redirection
  visually erased stack traces with `\x1b[2K\r` and stripped them via
  `String(err)`.
- The catch block also writes the failing error's full stack via the
  captured stderr, so failure diagnostics survive any downstream
  monkey-patching of `process.stdout`/`stderr`.

Tests

- `test/unit/repo-manager-finalize-invariant.test.ts` (4 tests): cover
  both `missing="meta"` and `missing="registry-entry"`, the happy path,
  and Windows case-insensitive registry path matching.
- `test/integration/cli-e2e.test.ts` adds a regression test that runs
  the real CLI on a fresh repo copy, asserts exit 0, AND verifies
  `meta.json` plus the matching registry entry are both written —
  catches any future regression of the wiring.

Validation

- `npx tsc --noEmit` passes.
- `npx vitest run --project default` passes for all my touched files
  (89 tests across 4 files). The full default suite reports 7188 pass
  with the known native LadybugDB Windows-worker flake unrelated to
  this change.
- `npx prettier --check` clean on the diff.
- `npx eslint` reports only pre-existing `any` warnings on the file;
  no new warnings introduced.
- Live repro on the issue's two-file Python fixture reproduces a
  successful index after the change: meta.json present (742 B), exit 0,
  `gitnexus list` shows the repo.

Rollback

Strictly additive — the success path is unchanged when `meta.json` is
written and the registry is updated. Reverting the four-file diff is
safe; the previous silent-finalize behaviour returns. No persisted
schema or registry shape changes.

DoD

- [x] Runtime wiring is complete on the affected CLI path.
- [x] Requested behavior is correct and existing contracts are preserved.
- [x] Smallest correct solution — one invariant, one helper, two
      handlers; no speculative abstraction.
- [x] Tests prove the changed behavior at unit AND integration level.
- [x] Required validation for `gitnexus/` was run.
- [x] Repo boundaries respected; no language-specific code, no shared
      ingestion changes, no new injection surfaces.
- [x] Diff contains only the intended change — no unrelated churn.

Made-with: Cursor

* fix(cli): enforce analyze finalization on fast path (#1169)

Address PR review feedback by checking finalization even when analyze reports already up to date, and by making the #1169 E2E guard fail on timeout instead of passing silently.

Made-with: Cursor

* test(cli): fix #1169 regression coverage on CI

Normalize macOS temp paths in the registry assertion and update the analyze worker timeout test mock for the new finalization invariant exports.

Made-with: Cursor
2026-04-30 21:36:28 +01:00
Gergo Magyar b5316c2df1 test(ci): isolate native LadybugDB and CLI e2e flakes
createFTSIndex now short-circuits on the in-process cache before issuing
the native CALL CREATE_FTS_INDEX, so a prior writable session cannot
trigger the macOS WAL/checkpoint duplicate-create path observed on main.
The cache is also primed on the "already exists" recovery and cleared on
re-init/close/drop, keeping ensureFTSIndex semantics identical for
read-only fallbacks.

The lbug-core-adapter close+reopen test moves to the end of the suite so
its native handle churn cannot corrupt later assertions in the same
fixture.

skills-e2e moves into its own sequential vitest project so the heavy
spawnSync-driven CLI fixtures stop competing with the parallel default
project on Windows runners, fixing the C-fixture beforeAll timeout.

Made-with: Cursor
2026-04-30 19:45:26 +01:00
aa7c273093 fix(ingestion): index Python repos with empty __init__.py and >32 KB files (#1163)
* fix(ingestion): index Python repos with empty __init__.py and >32 KB files

Two defensive fixes that let `gitnexus analyze` complete on Python
codebases that previously failed.

scope-extractor: synthesize an empty Module scope when the provider
emits zero captures. Previously threw "no Module scope found", which
fired for any 0-byte `__init__.py` package marker if the bridge's
empty-source guard was bypassed.

python/captures: wrap the parser.parse() and getPythonScopeQuery()
.matches() calls in try/catch. node-tree-sitter throws "Invalid
argument" for sources that overrun internal buffers (observed at the
~32 KB threshold on Windows). Degrade gracefully with a clear
"skipping scope extraction for this file" warning instead of the
opaque "Invalid argument" surfacing through the bridge.

Verified by indexing whittlem/pycryptobot (which has 7 empty
__init__.py and 11 Python files between 34 KB and 158 KB):
2,367 nodes / 4,973 edges, no segfault, queries resolve symbols
inside the 158 KB controllers/PyCryptoBot.py.

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

* fix(ingestion): harden Python scope extraction fallbacks

Keep failed Python scope extraction on the bridge skip path and build synthetic module scopes before extractor indexes are derived.

Made-with: Cursor

---------

Co-authored-by: Vijay Gali <vgali@vexcelco.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-30 19:24:04 +01:00
Gergo Magyar 78e965bf62 ci: avoid duplicate main push checks
Let release-candidate.yml be the single main-push entry point that reuses CI before publishing, while keeping CI as the direct pull-request gate.

Made-with: Cursor
2026-04-30 19:06:21 +01:00
sburdges-engandGergo Magyar b79278705a fix(hook): resolve canonical repo root + guard read-only FTS ensure (#1226)
* fix(hook): resolve canonical repo root + guard read-only FTS ensure (#1224)

Two bugs in the Claude Code hook + query layer integration:

1. `findGitNexusDir` (in `gitnexus/hooks/claude/gitnexus-hook.cjs` and
   `gitnexus-claude-plugin/hooks/gitnexus-hook.js`) walked upward from
   cwd looking for a non-registry `.gitnexus/`. In linked git worktrees
   created via `git worktree add`, the canonical repo's `.gitnexus/`
   never sits above the worktree path, so the walk silently fails and
   neither augmentation nor staleness notifications fire.

   Fix: keep the cwd-walk as the fast path, then fall back to
   `git rev-parse --git-common-dir` to resolve the shared `.git/`
   directory (which lives inside the canonical repo across all linked
   worktrees) and walk up from its parent. Returns null cleanly when
   `git` isn't on PATH or cwd isn't inside any working tree.

2. `ensureFTSIndex` in the LadybugDB adapter rethrew when the active
   connection is read-only (e.g. the MCP query pool, which opens DBs
   read-only by design). Defensive callers used to surface five
   "Cannot execute write operations in a read-only database" warnings
   per query.

   Fix: extract `isReadOnlyDbError` (mirroring the existing
   `isDbBusyError` discriminator) and have `ensureFTSIndex` catch the
   read-only error, cache the key, and return silently. Index creation
   is owned by `gitnexus analyze` on a writable connection — the
   ensure call is safely a no-op on the read pool. Lock / busy /
   "already exists" / schema errors continue to propagate.

Tests:
- `test/unit/hooks.test.ts`: new "Linked git worktree resolution"
  block exercises both hooks against a real linked worktree to confirm
  PostToolUse stale notifications fire, plus a negative case when the
  canonical repo has no `.gitnexus/`.
- `test/unit/lbug-readonly-error.test.ts`: new file unit-tests the
  `isReadOnlyDbError` discriminator (positive matches, case
  insensitivity, non-Error inputs, and unrelated errors that must
  still surface — lock contention, "already exists", schema misses).
- `test/integration/lbug-core-adapter.test.ts`: extends the existing
  FTS coverage with an idempotency assertion for `ensureFTSIndex` to
  pin the read-only guard's success-path contract.

Verified with `npx tsc --noEmit` and `vitest run` on the affected
files (hooks + readonly + lbug-core-adapter + bm25-search +
lbug-extension-loader + lbug-embedding-hashes — 136 tests pass).
Build: `npm run build` succeeds.

Closes #1224

* fix(local-backend): cover supported vector path

Add the supported-platform regression assertion for QUERY_VECTOR_INDEX and align the unsupported VECTOR diagnostic wording with platform policy.

Made-with: Cursor

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-30 18:12:03 +01:00
Gergő Magyarandmagyargergo 3f0c74fea0 fix(deps): upgrade @ladybugdb/core to 0.16.0 to resolve native segfaults (#1235)
* fix(deps): upgrade @ladybugdb/core to 0.16.0 to resolve native segfaults

Resolves the SIGSEGV / access-violation (0xC0000005) / exit-139 crashes that
have been reported widely since 1.6.3. The native crashes originate in
@ladybugdb/core 0.15.x — primarily during FTS index creation, VECTOR
extension load, and concurrent query teardown — and are reproducible on
Linux, macOS and Windows. The maintainer-confirmed fix is to bump the
runtime to 0.16.0, which ships nodejs async + memory-management fixes,
extension ABI bump, and macOS Intel binaries.

Adopting 0.16.0 cleanly required three supporting changes; without them
the upgrade itself regresses other paths:

1. maxDBSize must be passed explicitly. 0.16.0 keeps the upstream JSDoc
   note that the default 0 is "introduced temporarily for now to get
   around with the default 8 TB mmap address space limit some
   environment". Constrained CI runners and laptops cannot reserve 8 TB
   and crash with "Buffer manager exception: Mmap for size
   8796093022208 failed." A new gitnexus/src/core/lbug/lbug-config.ts
   centralises a 16 GiB default (overridable via
   GITNEXUS_LBUG_MAX_DB_SIZE) and every Database() construction site
   now passes it.

2. enableCompression default flipped from false to true in 0.16.0. Every
   Database() call site is updated to pass false explicitly so existing
   GitNexus indexes keep the same wire format.

3. Bridge DB sidecar files (.wal, .shadow). 0.16.0 enforces a database-id
   check on .wal / .shadow sidecars and rejects opens whose sidecars
   belong to a different base name. writeBridge now (a) cleans the full
   sidecar set when removing the tmp slot, (b) renames .wal / .shadow
   alongside the main file during the atomic .tmp -> .lbug swap, and
   (c) wraps openBridgeDbReadOnly in a bounded retry on transient
   Win32-Error-33 lock errors. Eager db.init() / conn.init() forces the
   lazy native handle to surface lock contention at the retry site.

Known limitation (not a regression): on Windows the 0.16.0 native binary
does not release the OS file lock until the process exits, so the
close-then-reopen-same-process pattern raises Error 33 after the first
close. Production paths (analyze / serve / mcp each open the DB exactly
once per process) are unaffected, but eight tests that exercise the
pattern are guarded with a process.platform === 'win32' skip; CI's
Linux + macOS shards exercise them as before. Tracking upstream:
kuzudb/kuzu#3872 / #3883 / #4730.

Closes #1136 #1154 #1160 #1162 #1178 #1195 #1196 #1199 #1204 #1206
Refs #1209 (supersedes — Dependabot bump without the supporting fixes)

Made-with: Cursor

* fix(test): isolate LadybugDB native test state

Use per-suite LadybugDB databases in integration helpers so test forks do not reopen a database created by Vitest global setup, and centralize Windows-tolerant native temp cleanup for bridge tests.

* fix(lbug): avoid bridge existence reopen

Reuse the built LadybugDB config in the extension installer and avoid native close/reopen cycles when checking bridge existence on Windows.

Made-with: Cursor

* chore(docs): exclude local lbug plan

Keep the refactor planning note out of the PR while leaving the ignored local copy on disk.

Made-with: Cursor

* refactor(lbug): centralize database construction

Route LadybugDB opens through shared helpers so native constructor defaults stay consistent across core, pool, bridge, and extension install paths.

Made-with: Cursor

---------

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-30 17:40:39 +01:00
Abhigyan Patwari 883091f3e0 Merge pull request #1175 from ReidenXerx/fix/typescript-hof-callbacks-and-jsx-as-call
fix(typescript): capture missed CALLS edges from HOF callbacks and JSX
2026-04-30 21:55:27 +05:30
ReidenXerx 66ad5c4980 fix(typescript): anchor pair-with-arrow @declaration.function on inner arrow
Addresses the medium-severity finding in @abhigyanpatwari's review of #1175:
the four `pair`-with-arrow patterns in `query.ts` anchored
`@declaration.function` on the outer `pair` node instead of the inner
`arrow_function` / `function_expression`. For multi-action object literals
like Zustand's

    persist((set) => ({
      addItem:    (item) => doA(item),
      removeItem: (item) => doB(item),
      fetchData:  ()     => doC(),
    }))

`pass2AttachDeclarations.atPosition(pair.startLine, pair.startCol)`
resolved to the *parent* `(set) => ({...})` callback's scope (because the
pair node starts at the property-key token, before the inner arrow's
`@scope.function` range). All three pair-function defs landed in the
same parent's `ownedDefs`, and `resolveCallerGraphId.ownedDefs.find(...)`
returned the FIRST one — `addItem` — for every walk-up. Calls inside
`removeItem` and `fetchData` mis-attributed to `addItem`; those two
functions had zero outgoing CALLS edges in the registry-primary path.

Single-pair fixtures (`bump` in `store.ts`, `queryFn` in `query-hook.ts`)
masked the defect because there is no ambiguity when only one
Function-like def lives in the parent's `ownedDefs` — `find()` is
deterministic over a single-element set.

Fix: move the `@declaration.function` anchor from the outer `pair` to
the inner `arrow_function` / `function_expression`, mirroring the
`lexical_declaration` patterns above (`const fn = () => {}`). The def
then lands in the arrow's own scope's `ownedDefs`, the
`rangesEqual(anchor.range, innermost.range)` auto-hoist promotes the
binding to the parent scope (so importers + lookups still find the name
in the surrounding scope), and each pair-arrow becomes an independent
caller anchor in the walk.

Tests:

  * Updated `useFeature → fetchData` expectation to `queryFn → fetchData`
    in `typescript-hof-callbacks.test.ts`. The new attribution is
    structurally correct: `fetchData()` is called from inside the named
    pair-arrow `queryFn: () => fetchData()`. The pre-fix expectation
    only worked because the pair-pattern bug rerouted the walk past the
    syntactic owner.

  * Added `multi-action-store.ts` fixture with three pair-arrows
    (`addItem` / `removeItem` / `fetchData`) plus three top-level call
    targets (`doA` / `doB` / `doC`). Four new tests pin per-action
    attribution: positive (each action calls its own target), negative
    (no sibling leakage), exact-set (the full pair set is what we
    expect), and the regression fingerprint (`addItem → doB` MUST be
    empty).

Validation:

  * `REGISTRY_PRIMARY_TYPESCRIPT=1 vitest run` on
    typescript-hof-callbacks (12 tests, +4 new), typescript-jsx-as-call
    (7), typescript (236), typescript-finalize, typescript-cross-file-imports,
    call-attribution-issue-1166 (18), all scope-resolution unit suites:
    886/886 pass on registry-primary AND legacy DAG paths.
  * Legacy DAG attribution was already correct via @abhigyanpatwari's
    `tsExtractFunctionName` pair-parent handling (#1179, merged into this
    PR earlier); this fix brings the registry-primary path to the same
    behavior, restoring parity for multi-action objects.
  * `npx prettier --check .`, `tsc --noEmit`, and `eslint` clean on the
    three modified/added files.

Made-with: Cursor
2026-04-30 18:26:51 +03:00
ReidenXerx ca3d520293 style(typescript): fix prettier formatting in tsExtractFunctionName
Single line-length fix in `gitnexus/src/core/ingestion/languages/typescript.ts`
flagged by `quality / format` CI on commit ef96603f. The unformatted block came
from the merge of upstream PR #1179 (`fix/issue-1166-calls-edges`) where the
`pair`-with-arrow / `pair`-with-string-key handling was added; prettier wanted
the `.find` callback inlined onto a single line.

No behavior change. Pre-commit hook would have caught this locally if the
husky postinstall step had been able to write `.git/config` on this dev machine.

Made-with: Cursor
2026-04-30 14:50:56 +03:00
Morieity 9cd8c3663f fix(local-backend): (#1178)skip vector index query on unsupported platforms (#1181) 2026-04-30 07:57:05 +01:00
Gergő Magyar d57f15f18d Revert "chore(deps)(deps): bump uuid from 13.0.0 to 14.0.0 in /gitnexus-web (…" (#1222)
This reverts commit d31156288f.
2026-04-30 06:33:39 +01:00
dependabot[bot] d31156288f chore(deps)(deps): bump uuid from 13.0.0 to 14.0.0 in /gitnexus-web (#1211) 2026-04-30 05:26:15 +01:00
dependabot[bot] 02ed335ff6 chore(deps): bump release-drafter/release-drafter from 7.2.0 to 7.2.1 (#1208) 2026-04-30 05:02:15 +01:00
dependabot[bot] 52febea560 chore(deps)(deps): bump @langchain/openai in /gitnexus-web (#1215) 2026-04-30 05:01:33 +01:00
ReidenXerx ef96603fed feat(package): add gitnexus commands for analysis
Introduced new scripts in package.json for GitNexus analysis:
- `gitnexus:refresh`: analyzes with embeddings and skills.
- `gitnexus:full`: forces analysis with embeddings and skills.

No production behavior changes. This enhances the development workflow for GitNexus users.
2026-04-29 16:37:06 +03:00
ReidenXerx bb6aa0c855 Merge remote-tracking branch 'origin/fix/issue-1166-calls-edges' into fix/typescript-hof-callbacks-and-jsx-as-call 2026-04-29 15:56:24 +03:00
ReidenXerx 851d2ab749 fix(typescript): address review findings — formatting + tighter test assertions
Addresses the automated review findings on PR #1175:

- prettier --write the 3 files flagged by `quality / format` CI check
  (query.ts, typescript-hof-callbacks.test.ts, typescript-jsx-as-call.test.ts).

- [medium] typescript-jsx-as-call.test.ts: tighten the combined HOF+JSX
  assertion from `toBeGreaterThan(0)` to `toHaveLength(1)`. A single
  `<Foo />` is one logical invocation; the bounds-only assertion would
  have masked a duplicate-CALLS-edge regression (e.g. if both
  `jsx_self_closing_element` and a generic call pattern matched the
  same site).

- [medium] typescript-hof-callbacks.test.ts: replace the vacuously-true
  `for (c of calls) expect(...)` Zustand assertion with a structural
  one. Old form passed unconditionally when `calls` was empty (any
  change that silenced ALL CALLS edges from store.ts would have
  slipped through). New form asserts both: (a) at least one File-rooted
  edge exists (proving the `isCallerAnchorLabel` fallback fires), and
  (b) no edge sources from anything else (proving the fallback fires
  exclusively).

- [low] finalize-algorithm.ts (`findExportByName`): rephrase the
  comment to make the language-agnostic nature of the tie-break rule
  explicit. The implementation was already correct for all migrated
  languages; only the comment overplayed the TypeScript specificity.

- [low] captures.ts (arity synthesis): add a comment explaining why
  JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element`)
  intentionally don't synthesize `@reference.arity`. Name-only
  resolution is correct for React (components aren't overloaded in the
  current graph model); a JSX-aware synthesizer counting jsx_attribute
  children would be needed if that ever changes.

No production behavior change. All 8/8 HOF + 7/7 JSX + 236/236
typescript + 11/11 api-deep-flow integration tests still pass.
gitnexus and gitnexus-shared typechecks clean.

Made-with: Cursor
2026-04-29 15:51:01 +03:00
Bennett Tai 07629a39d1 fix(docker): use HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182) 2026-04-29 06:58:17 +01:00
abhigyanpatwariandClaude Opus 4.7 93a0be2310 fix(ts): attribute calls inside HOF/callback patterns to the right function
Two roots in `findEnclosingFunctionId` (parse-worker) and the parallel
`findEnclosingFunction` (call-processor):

A. `genericFuncName` scanned `arrow_function` / `function_expression`
   children for the first identifier and returned it. For unparenthesized
   arrows like `file => processFile(file)` the first identifier is the
   parameter `file`, so calls inside got attributed to a phantom
   `Function file` ID and emitted dangling CALLS edges that never showed
   up in `(:Function)-[:CALLS]->()` queries.

B. `tsExtractFunctionName` only named arrows whose parent was
   `variable_declarator`. Object-property arrows like
   `addItem: (item) => set(...)` (Zustand stores, TanStack queryFn,
   React Context providers, config objects) live under a `pair`, so they
   were treated as anonymous. With no named ancestor up to the file,
   every call inside fell back to the File and became invisible to
   `context()` / `impact()`.

Fix:
- `genericFuncName` returns null for anonymous JS/TS function-likes —
  the language hook is authoritative.
- `tsExtractFunctionName` resolves names from `pair` parents
  (property_identifier / string keys; computed keys stay anonymous).
- Mirror the new shape in `TYPESCRIPT_QUERIES` / `JAVASCRIPT_QUERIES` /
  the scope-resolution query so pair-with-arrow becomes a Function
  declaration node — call sourceIds resolve to a real graph node.

Adds 18 unit tests pinning attribution and definition behaviour for
plain helpers, `arr.map(x => fn(x))`, Promise constructor callbacks,
Zustand-style nested HOFs, TanStack query factories, string-keyed
pairs, and computed-key anonymity.

Fixes #1166

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 05:33:40 +05:30
ReidenXerx 7be595d317 fix(typescript): capture missed CALLS edges from HOF callbacks and JSX
Two distinct gaps in the TypeScript scope-resolution path were silently
dropping call edges in real-world React + TanStack + Zustand codebases.
On the bug reporter's repo (Sourcerer-fe, 1185 src/ functions), 504
missing Function->Function CALLS edges are now captured (+61.6%) and
the no-outgoing-CALLS orphan rate drops from 73.2% to 60.3%.

HOF / arrow-callback caller-attribution (3 cooperating fixes):
  - typescript/query.ts: @declaration.function anchor moved from the
    wrapping lexical_declaration to the inner arrow_function /
    function_expression, so anchor.range aligns with @scope.function and
    pass2AttachDeclarations lands the def on the arrow's own scope.
  - finalize-algorithm.ts: findExportByName prefers callable / class-
    like defs over Variable when localDefs contains both for the same
    name (TS emits two defs per `const fn = () => {}`).
  - graph-bridge/ids.ts: resolveCallerGraphId's walk-up class-fallback
    now uses isCallerAnchorLabel restricted to Function / Method /
    Constructor / Class / Interface / Struct / Enum, so module-level
    calls fall through to the File node instead of mis-attributing to
    sibling Variable defs (the Zustand `create()(devtools(...))`
    phantom-self-loop regression).

JSX as a CALLS edge (2 cooperating fixes):
  - typescript/query.ts: new TSX_JSX_QUERY_SUFFIX (TSX-grammar only)
    captures jsx_self_closing_element / jsx_opening_element as
    @reference.call.free / @reference.call.member. PascalCase predicate
    filters native HTML elements (<div>, <span>) so they don't emit
    edges to nonexistent targets.
  - typescript/captures.ts: shouldEmitReadMember extended with
    jsx_self_closing_element / jsx_opening_element parent cases to
    suppress phantom ACCESSES edges on member-form JSX names.

Tests: 8 HOF assertions + 7 JSX assertions across two new integration
test files plus 13 minimal fixtures. typescript.test.ts (236),
api-deep-flow.test.ts (11), and scope-resolution / scope-extractor unit
tests (613) pass with no regressions.

Made-with: Cursor
2026-04-28 20:52:07 +03:00
Abhigyan Patwari dafda284bc Merge pull request #1159 from abhigyanpatwari/fix/issue-1110-readme-web-ui
docs(readme): fix misleading Web UI section
2026-04-28 17:33:06 +05:30
abhigyanpatwariandClaude Opus 4.7 2ff3e64f71 docs(readme): fix misleading Web UI section (#1110)
The "Web UI (browser-based)" section described an old client-side
architecture. Today gitnexus.vercel.app is a thin frontend that
auto-connects to a local `gitnexus serve` backend — there is no
ZIP drag-and-drop and no fully self-contained mode.

- Drop "No server, no install" claim
- Replace "drag & drop a ZIP" tagline with the actual onboarding step
- Add the missing `gitnexus serve` step to the local-dev block

Closes #1110

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:15:03 +05:30
Gergő Magyar 2a0c97c178 fix: add platform-aware semantic fallback (#1150)
* fix: add platform-aware semantic fallback

Make VECTOR an optional capability so Windows analysis remains stable while semantic embeddings can fall back to exact scan when native vector indexing is unavailable.

Made-with: Cursor

* fix: remove stale vector pool import

Keep the merge with main lint-clean after VECTOR loading moved out of the read pool.
2026-04-28 12:21:25 +01:00
Gergő Magyar 1f6df5fdbb fix(swift): use official prebuilt parser runtime (#1130)
* fix(swift): use official prebuilt parser runtime

Vendor the official tree-sitter-swift 0.7.1 runtime package so Swift parsing works without source-building, while keeping the repo on the current tree-sitter runtime until the broader upgrade is ready. Also preserves Swift resolver correctness for overloaded owned functions and extension-backed type duplicates now that Swift is available by default.

Made-with: Cursor

* fix(swift): move duplicate type ordering into provider

Keep Swift extension candidate ordering behind the LanguageProvider contract and cover the Swift 0.7 init scanner path so parser runtime changes do not leak language-specific logic into shared resolution.

Made-with: Cursor

* fix(swift): address parser runtime review

Add explicit Swift prebuild checks and vendor guidance so parser runtime packaging remains observable and maintainable.
2026-04-28 09:57:42 +01:00
CauchYoungandlaplace young 86abc01445 fix(hooks): ignore global registry during staleness checks (#1141)
* fix(hooks): ignore global registry during staleness checks

* test(hooks): cover indexed repos under global registry

---------

Co-authored-by: laplace young <yangqk12@whu.edu.cn>
2026-04-28 09:39:18 +01:00
Ivan Uzun 46586a8319 fix(group): add configurable cross-link path exclusions to reduce false positives (#1093)
* fix(group): add configurable cross-link path exclusions to reduce false positives

Add matching.exclude_links_paths and matching.exclude_links_param_only_paths
to group.yaml config. These filter out noisy HTTP contracts (health checks,
param-only catch-all routes) from cross-link matching while preserving them
in the contract registry for documentation purposes.

Defaults are empty/false for backward compatibility — no behavior change
unless the operator explicitly configures exclusions.

* fix(group): address review findings — filter unmatched, normalize trailing slash, add tests

- Excluded contracts no longer inflate SyncResult.unmatched (isNoisy guard)
- pathPart in buildNoisyContractFilter strips trailing slashes before comparison
- 8 new unit tests for buildNoisyContractFilter covering all code paths
- Config-parser test asserts defaults for new matching fields

* fix(group): normalize configured exclusion paths and add root-path test

- Strip trailing slashes from configured exclude_links_paths at Set-build
  time so root path '/' (which normalizes to '') matches correctly
- Add test: exclude_links_paths: ['/'] suppresses http::GET::/ contracts
- Add new matching fields as commented examples in fixture group.yaml (DoD §2.4)

* docs(group): document exclude_links_paths and exclude_links_param_only_paths config fields

Add JSDoc to MatchingConfig interface, update the microservices guide
YAML example and field notes, and scaffold the new fields (commented out)
in the group create template.
2026-04-28 08:22:14 +01:00
Gergő Magyar ffa0510f9a fix(lbug): prevent DuckDB extension install hangs (#1129)
* fix(lbug): bound DuckDB extension install via ExtensionManager (closes #1128)

`gitnexus analyze` could hang indefinitely (60% / 85% on Windows) when
DuckDB's `INSTALL fts` or `INSTALL VECTOR` was unable to reach
`extensions.duckdb.org`. The DuckDB driver's INSTALL is a synchronous
network call, so any blocked egress would block the Node event loop
forever.

Replace the ad-hoc, in-process INSTALL/LOAD scattered across
`lbug-adapter.ts` and `pool-adapter.ts` with a single
`ExtensionManager` that owns the lifecycle of optional DuckDB
extensions:

* `LOAD` is always tried first — per-connection, idempotent, no network.
* If `LOAD` fails and policy permits, INSTALL runs in a short-lived
  child Node process bounded by `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS`
  (default 15s). The parent loop keeps spinning; on timeout the child is
  killed with SIGKILL and the capability is flagged unavailable.
* Capabilities and install attempts are cached per process, so a single
  bounded install per extension covers every subsequent call.

Install policy is now an explicit, per-context decision:

* `auto` (default for analyze) — try LOAD, fall back to bounded INSTALL.
* `load-only` — used by `pool-adapter` (serve / MCP read paths) so user
  queries never block on a network install.
* `never` — operator escape hatch for offline / airgapped environments.

`createFTSIndex` and `createVectorIndex` now check the boolean return
value before issuing the index DDL, so missing extensions degrade BM25
and semantic search gracefully without ever throwing during analyze.

Tests:
- New unit suite for `ExtensionManager` covering LOAD-first behavior,
  all three policies, install caching, observability, and warn dedup.
- Existing vector-extension integration tests pass against the new
  boolean return type.
- Existing embedding-pipeline mocks updated to return `true`.

Docs: `gitnexus/README.md` documents `GITNEXUS_LBUG_EXTENSION_INSTALL`
and `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` with examples for
offline and slow-network environments.

Made-with: Cursor

* fix(lbug): move DuckDB extension install child into script

Keep the bounded out-of-process INSTALL behavior, but replace the inline child code with a stable packaged ESM script. This makes the child process directly runnable and gives debuggable stack traces without source-vs-dist branching or a runtime transpiler.

Made-with: Cursor
2026-04-27 23:09:17 +01:00
Prashant Pandey 038f2b33ec Fix typo: capitalize Goliath in README (#1126) 2026-04-27 22:18:26 +01:00
Gergő Magyar 780ee83d52 fix(install): vendor tree-sitter-dart source (#1125)
Avoid remote git/SSH downloads for the Dart grammar during Docker and npm installs by resolving tree-sitter-dart from vendored source and building it during postinstall.

Made-with: Cursor
2026-04-27 21:15:37 +01:00
Gergő MagyarandGitNexus Maintainer 38ccf7ceb1 fix: recover worker parse stalls (#1121)
* fix(ingestion): recover worker parse stalls

Made-with: Cursor

* test(ingestion): cover worker timeout controls

Made-with: Cursor

* docs: document analyze worker timeout controls

Made-with: Cursor

* fix(ingestion): fail fast after worker pool hard failure

Made-with: Cursor

* test(ingestion): stabilize worker stall recovery tests

Made-with: Cursor

---------

Co-authored-by: GitNexus Maintainer <maintainer@gitnexus.local>
2026-04-27 20:07:03 +01:00
Gergő Magyar aa7bacd48b fix(search): load FTS during core DB init (#1123)
* fix(search): load FTS during core DB init

Made-with: Cursor

* test(lbug): rely on core init for FTS extension loading

Made-with: Cursor
2026-04-27 19:43:09 +01:00
Brandy Goodandgenoshide 2a799ae369 fix: start MCP bridge correctly when using npx (#1114)
* fix: start MCP bridge correctly when using npx

* test: add MCPBridge command discovery and spawn tests; fix stdin isolation

---------

Co-authored-by: genoshide <genoshide@users.noreply.github.com>
2026-04-27 18:19:02 +01:00
Gergő Magyar 2727a8ca2a fix(mcp): project tool_map flows from handlers (#1113) 2026-04-27 17:13:02 +01:00
Ali Hamza 77844acb6a Revert "fix: correct OpenCode skills directory from 'skill' to 'skills'" (#1104)
installOpenCodeSkills() was writing to ~/.config/opencode/skill/gitnexus/
but OpenCode only discovers skills from ~/.config/opencode/skills/*/SKILL.md.
Skills installed by `gitnexus setup` were silently ignored by OpenCode.

- Line 590: path.join(opencodeDir, 'skill') → 'skills'
- Line 587: updated JSDoc comment to match
2026-04-27 14:27:44 +01:00
Ikko Eltociear Ashimine 8d7beaf1cf docs: update README.md (#1115)
minor fix
2026-04-27 14:25:20 +01:00
Tom Hale 94a4365e6d fix(serve): serve web UI at root path instead of 404 (#1048)
* fix(serve): serve web UI at root path instead of 404

gitnexus serve returned Cannot GET / because no route handler existed
for the root path. Now serves the built gitnexus-web dist at / with
SPA fallback for client-side routing. Falls back to a helpful landing
page with API links when the web UI hasn't been built yet.

Also updates the build script to build and copy gitnexus-web into
gitnexus/web/ for the published npm package.

* fix(serve): address Copilot review feedback

- Use regex SPA fallback that excludes /api paths (avoids serving
  index.html for unknown API routes)
- Add rel="noopener noreferrer" to external link (reverse-tabnabbing)
- Move build "done" log after web UI step

* fix(build): use npm run build for web UI, add npm install guard

The build script ran `npx tsc -b && npx vite build` in gitnexus-web/,
but CI only installs node_modules for gitnexus/ — not gitnexus-web/.
npx then resolved the wrong `tsc` package (a trojan on npm), causing
all CI jobs to fail.

Fix: add an npm install guard when node_modules is missing, and use
`npm run build` (which runs the local typescript) instead of npx.

* feat(serve): styled fallback page, asset 404s, build script safety

- Add landingPageHtml() with gitnexus-web design tokens (void bg,
  surface cards, accent color, terminal-style build command block).
- Add resolveWebDistDir() helper with non-ENOENT error logging.
- Register express.static with Cache-Control headers (no-cache HTML,
  immutable assets) and SPA fallback route.
- Replace wildcard SPA fallback with regex that excludes /api/* AND
  asset-like file extensions (.js, .css, .ico, .woff2, .map, etc.).
- Add ordering comment warning about SPA fallback route placement.

scripts/build.js:
- Change npm install to npm ci.
- Add timeout: 120_000 to all execSync calls.

Test coverage:
- 26 new unit tests for design tokens, terminal block, external links,
  SPA regex acceptance/exclusion, cache headers, and fs.access edge
  cases.

Closes #1048 (review feedback)

* fix: format, lint, and add GITNEXUS_WEB_DIST env var

- Remove unused fsType import from web-ui-serving.test.ts (lint error)
- Run prettier on fallback-page-screenshot.html and test file
- Add GITNEXUS_WEB_DIST env var as primary override in resolveWebDistDir
- Add tests for env var: prefer when set, fallback when dir missing

* fix: use cross-platform path matching in env var tests

Path.includes('/env/dist') fails on Windows where path.join
produces backslashed paths. Normalize via path.sep replacement
before matching.

* fix(serve): address PR #1048 review findings

- Add uncaughtException/unhandledRejection crash guards to HTTP serve path
- Export SPA_FALLBACK_REGEX so tests use the production constant (no drift)
- Export staticCacheControlSetHeaders so tests verify the real production function
- Add real Express dispatch tests for API 404 and asset 404 isolation
- Delete committed debug artifact fallback-page-screenshot.html
2026-04-27 13:17:40 +01:00
Gergő Magyar c4999b02b0 fix(scope-resolution): avoid variadic reference site aggregation (#1112)
Materialize finalized reference sites without spreading large arrays into push so large repositories do not overflow the JS argument stack.
2026-04-27 12:44:21 +01:00
ManniX-ITA 1e80285c47 fix(scope-resolution): allow same-range Module-as-parent for top-level scopes (closes #1086) (#1087)
* fix(scope-resolution): allow same-range Module-as-parent for top-level scopes (closes #1086)

When a C# file consists of a single top-level `namespace_declaration` that
ends exactly at EOF (no trailing newline, no leading content outside the
namespace's `{}` body), tree-sitter-c-sharp 0.23.1 reports identical byte
ranges for `compilation_unit` and `namespace_declaration`. Pre-fix the
scope-extractor parent-finder relied on strict containment, so the Module
was popped off the stack and the Namespace ended up with `parent === null`
→ `ScopeTreeInvariantError: non-module-requires-parent` →
`extractParsedFile` swallowed the throw and the whole file was dropped
from the registry-primary path. Cross-file IMPORTS / CALLS edges
originating in or terminating at that file vanished.

Hit on three real-world `*.Designer.cs` files in PersistentWindows
(`HotKeyWindow.Designer.cs`, `LaunchProcess.Designer.cs`,
`DbKeySelect.Designer.cs`) — all have the byte signature
`<BOM><CRLF>namespace ... { ... }<EOF>` (last hex = `... 7D 0D 0A 7D`).

The fix is a single carve-out in the parent-validity contract: a `Module`
may parent a same-range non-`Module` child. The relationship stays
acyclic because the carve-out is direction-asymmetric — only Module-as-
outer parents a same-range non-Module, never the reverse.

Two coordinated changes:

* `gitnexus/src/core/ingestion/scope-extractor.ts` — `pass1BuildScopes`
  now consults a new `canParentScope` helper instead of
  `rangeStrictlyContains` directly. Sort tie-breaker added so a same-
  range Module always sorts before a non-Module candidate, ensuring the
  Module lands on the parent-stack first regardless of tree-sitter
  capture iteration order.

* `gitnexus-shared/src/scope-resolution/scope-tree.ts` — `buildScopeTree`'s
  `parent-must-contain-child` check now uses the same `canParentScope`
  carve-out so the validator agrees with the extractor on what a
  well-formed parent edge looks like. Error message updated to spell
  out the new contract.

`rangeStrictlyContains` keeps its strict semantics in both files —
position-index lookups, hook-side range comparisons, and other call
sites are unchanged.

* `gitnexus/test/fixtures/lang-resolution/csharp-namespace-as-root-no-trailing-newline/`
  — minimal regression fixture mirroring the PersistentWindows shape:
  both `Models/User.cs` and `App/Program.cs` end exactly on the closing
  `}` of their namespace with no trailing newline. The trigger is shape-
  driven, not size-driven, so the fixture stays small (~250 bytes total).
* New `csharp.test.ts` describe block: scope extraction completes for
  both files, and the cross-file `IMPORTS` edge resolves through the
  scope-resolution path with `reason: 'csharp-scope: using'`.
* `scope-tree.test.ts`: replaced the prior "rejects child ranges
  identical to the parent" case with three new ones — non-Module parent
  still rejected at equal range; Module-as-parent of a same-range non-
  Module accepted (the #1086 carve-out); Module-as-parent of another
  Module still rejected (the asymmetry guard).

* `npx vitest run test/unit/scope-resolution test/integration/resolvers`
  → 2514 passed / 77 skipped / 0 failed (52 test files).
* `npx tsc --noEmit` clean in both `gitnexus/` and `gitnexus-shared/`.
* End-to-end on PersistentWindows (after rebuilding the Docker image
  with this branch): 3 prior `scope extraction failed for *.Designer.cs`
  warnings → 0. Pre-fix index numbers will be re-checked here once the
  branch is built and indexed; the existing post-#1082 baseline is
  1113 nodes / 2987 edges / 39 clusters / 97 flows.

`canParentScope` is language-agnostic. Other languages whose query emits
`(compilation_unit) @scope.module` plus a single same-range top-level
scope can naturally hit the same byte shape on minimal files; this fix
applies to all of them uniformly.

Refs: #1086 (issue with full root-cause analysis + 4-case empirical
repro through `extractParsedFile`).

* refactor(scope-resolution): export canParentScope from gitnexus-shared

Addresses #1087 review (medium): the helper was previously duplicated
byte-for-byte in `scope-extractor.ts` and `scope-tree.ts`. Per DoD
"single source of truth in shared", the contract piece belongs in
gitnexus-shared (Ring 2 SHARED #912) and the consuming layer should
import it. Eliminates the silent-drift surface where a future edit
to one copy would produce extractor/validator disagreement on what
a well-formed parent edge looks like.

Changes:
- gitnexus-shared/src/scope-resolution/scope-tree.ts: add `export`
  to `canParentScope`.
- gitnexus-shared/src/index.ts: re-export `canParentScope`.
- gitnexus/src/core/ingestion/scope-extractor.ts: remove the local
  `canParentScope` definition (and its now-unused local copy of
  `rangeStrictlyContains`), import from `gitnexus-shared`. The local
  `rangesEqual` stays — it's still used in capture-anchor logic at
  two unrelated sites.

Validation (per DoD §4.4 — both CLI and web consumers verified):
- npx tsc --noEmit clean in gitnexus/ and gitnexus-shared/
- cd gitnexus-web && npx tsc -b --noEmit clean
- gitnexus-shared `npm run build` clean
- Targeted: vitest run test/unit/scope-resolution test/integration/resolvers
  → 2522 passed / 0 failed / 77 skipped (54 files)
- Full suite: vitest run → 7238 passed / 1 failed / 97 skipped.
  The single failure is `test/unit/ignore-service.test.ts > warns
  on EACCES but does not throw`, which cannot run when uid=0 (root
  bypasses POSIX permission checks). Pre-existing on this branch
  before the refactor; unrelated to scope-resolution.
2026-04-27 11:06:54 +01:00
ManniX-ITA 8fbbb35718 test(ignore-service): skip EACCES test under uid=0 (root bypasses chmod) (#1108)
The `loadIgnoreRules — error handling > warns on EACCES but does not
throw` test relies on `chmod 000` denying read access to a temporary
.gitignore file. On Linux, root bypasses POSIX read-permission checks,
so chmod 000 does NOT trigger EACCES under uid=0 — fs.readFile reads
the file anyway and loadIgnoreRules returns parsed rules instead of
the `null` the test expects.

Symptom under root: assertion fails with `Ignore { _rules: [...] }
to be null`, surfaced as a single test failure in any privileged
test environment (rootful Docker container, CI runners configured to
run tests as root, etc.).

Fix: extend the existing `skipIf(process.platform === 'win32')` guard
with `process.getuid?.() === 0`. The non-root code path still
exercises the real EACCES branch — root just can't reproduce the
failure mode the test asserts on, so skipping there is the correct
posture (matches the win32 skip's reasoning: the OS-level mechanism
the test depends on isn't available there).

Optional chaining (`getuid?.()`) keeps Windows compatibility — Node
on Windows doesn't expose `process.getuid` at all.
2026-04-27 11:06:16 +01:00
Gergő Magyar 5c434ff313 fix(search): create FTS indexes during analyze (#1107)
Keep query-time LadybugDB access read-only by materializing BM25 indexes in the writable analyze phase.
2026-04-27 11:00:32 +01:00
Gergő Magyarandgergo 7c3fa5853f fix(ingestion): classify Python class methods as Method (#1102)
* fix(ingestion): classify Python class methods as Method

* fix(test): align Python large-buffer assertion with Method labels

---------

Co-authored-by: gergo <gergo@Galahad.localdomain>
2026-04-27 09:04:50 +01:00
Gergő Magyar 09d78cadec fix(ingestion): skip empty scope extraction (#1100) 2026-04-27 07:29:37 +01:00
244 changed files with 270861 additions and 2460 deletions
@@ -36,7 +36,6 @@ import urllib.request
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto"
# ── Upgrade target ──────────────────────────────────────────────────────
# The runtime version we want to upgrade TO. Update this when the goal
@@ -73,18 +72,37 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
# Vendored parsers — kept here so the upstream coords for drift
# detection are co-located with every other grammar's coords.
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
}
UPSTREAM_PROTO_OWNER = "coder3101"
UPSTREAM_PROTO_REPO = "tree-sitter-proto"
UPSTREAM_PROTO_BRANCH = "main"
# Grammars deliberately held below npm latest. The readiness report surfaces
# these so reviewers can tell intentional pins apart from drift, and so the
# context for each pin (which issue motivated it) is visible at a glance.
# Add an entry whenever you pin a grammar below npm latest.
INTENTIONAL_PINS: dict[str, str] = {
"tree-sitter-c": (
"#1242 — last release built against the tree-sitter@0.21 ABI; "
"tree-sitter-c@0.23.x prebuilds segfault on Windows under tree-sitter@0.21.1"
),
"tree-sitter-cpp": (
"#1242 — last 0.23.x release before tree-sitter-cpp added a runtime "
"dep on the broken-ABI tree-sitter-c@^0.23.1; pinning here removes "
"the need for a transitive override"
),
}
# ── Helpers ─────────────────────────────────────────────────────────────
def _load_package_json() -> dict:
return json.loads((GITNEXUS_DIR / "package.json").read_text())
def read_current_runtime() -> str:
"""Return the tree-sitter runtime version pinned in package.json (e.g. '0.21')."""
pkg = json.loads((GITNEXUS_DIR / "package.json").read_text())
pkg = _load_package_json()
raw = pkg["dependencies"]["tree-sitter"]
match = re.search(r"(\d+)\.(\d+)", raw)
if not match:
@@ -92,6 +110,22 @@ def read_current_runtime() -> str:
return f"{match.group(1)}.{match.group(2)}"
def read_pinned_grammar_versions() -> dict[str, str]:
"""Return the grammar version range pinned in gitnexus/package.json.
Looks at both runtime and optional dependencies. Returns the raw range
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
expose how flexible each pin is.
"""
pkg = _load_package_json()
pinned: dict[str, str] = {}
for section in ("dependencies", "optionalDependencies"):
for name, spec in (pkg.get(section) or {}).items():
if name.startswith("tree-sitter-"):
pinned[name] = spec
return pinned
def npm_view_json(pkg: str) -> dict | None:
"""Fetch package metadata from the npm registry via HTTPS.
@@ -185,8 +219,178 @@ def md_h(text: str, level: int = 2) -> str:
return f"{'#' * level} {text}\n"
def _first_sentence(text: str) -> str:
"""Return the leading sentence of a free-form rationale string.
Vendor package.json `_vendoredBy` fields often look like
"<reason>. <install-script breadcrumb>. Do NOT <warning>." — the
first sentence is what reviewers actually want to read; the rest is
noise in this context. Match a sentence-ending '.' followed by
whitespace; fall back to the whole string if nothing matches.
"""
text = text.strip()
match = re.search(r"\.\s+[A-Z]", text)
return text[: match.start() + 1] if match else text
def range_includes(spec: str | None, version: str) -> bool:
"""Return True if pinned-range `spec` accepts the concrete `version`.
Handles the spec shapes we actually use in package.json:
- exact pins ('0.21.4')
- caret / tilde ranges ('^0.23.0', '~0.23.5')
- non-registry pins ('file:./vendor/...', 'git+...') — always False,
because there's no meaningful "behind npm latest" comparison.
"""
if not spec or spec == "—":
return False
if spec.startswith(("file:", "git", "http")):
return False
if spec.startswith(("^", "~")):
return satisfies_target(spec, version)
return spec.strip() == version.strip()
def is_vendored_pin(spec: str | None) -> bool:
return bool(spec) and spec.startswith(("file:", "git", "http"))
def vendored_drift_summary(
name: str, upstream_repo: str, upstream_branch: str, parser_path: str
) -> dict:
"""Inspect a vendored grammar under gitnexus/vendor/<name>.
Returns the vendored package.json's ``version`` and ``_vendoredBy``
fields (which carry the human rationale for vendoring), the vendored
parser's ABI, and a comparison against upstream main. We deliberately
rely on ``_vendoredBy`` rather than a parallel registry in this
script: the rationale belongs next to the vendored sources, not in
a daily-running CI script.
"""
vendor_dir = GITNEXUS_DIR / "vendor" / name
pkg: dict = {}
pkg_path = vendor_dir / "package.json"
if pkg_path.is_file():
try:
pkg = json.loads(pkg_path.read_text(encoding="utf-8", errors="ignore"))
except json.JSONDecodeError:
pass
vendored_parser = vendor_dir / parser_path
if not vendored_parser.is_file():
vendored_parser = vendor_dir / "src" / "parser.c"
vendored_abi = extract_language_version(vendored_parser)
upstream_url = (
f"https://raw.githubusercontent.com/{upstream_repo}/"
f"{upstream_branch}/{parser_path}"
)
upstream_text = fetch_text(upstream_url)
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
sha_text = fetch_text(
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
)
upstream_sha = "?"
if sha_text:
try:
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
except json.JSONDecodeError:
pass
local_text = (
vendored_parser.read_text(encoding="utf-8", errors="ignore")
if vendored_parser.is_file()
else ""
)
in_sync = bool(
upstream_text
and local_text.replace("\r\n", "\n") == upstream_text.replace("\r\n", "\n")
)
return {
"name": name,
"vendored_version": pkg.get("version", "?"),
"vendored_by": pkg.get("_vendoredBy"),
"vendored_abi": vendored_abi,
"upstream_repo": upstream_repo,
"upstream_branch": upstream_branch,
"upstream_sha": upstream_sha,
"upstream_abi": upstream_abi,
"in_sync": in_sync,
}
# ── Main ────────────────────────────────────────────────────────────────
def _classify_grammar(
*,
name: str,
pinned_spec: str | None,
npm_version: str,
peer_range: str | None,
fetch_failed: bool,
target_compat: bool,
current_compat: bool,
upstream_progress: str | None,
) -> dict:
"""Decide a single primary disposition + a separate bump-now hint.
Buckets are mutually exclusive and ordered by what a reviewer should
look at first:
- fetch_failed : npm registry fetch failed (treat as blocker, but
surface separately so reviewers don't confuse it
with an upstream block)
- intentional : pinned in INTENTIONAL_PINS — explicit choice
- ready : npm-latest peer dep already accepts the target
runtime; nothing to do
- waiting : main has a fix (ABI 15 or relaxed peer) but no
published npm release yet
- blocked : peer dep too tight on both npm and main
Independently of bucket, `bump_now` reports whether reviewers can
move the pin forward today without touching the runtime — we only
suggest it when npm-latest's peer dep also accepts our *current*
runtime, otherwise the bump would break `npm install`.
"""
is_vendored = is_vendored_pin(pinned_spec)
behind_latest = (
not is_vendored
and npm_version != "?"
and not range_includes(pinned_spec, npm_version)
)
# Intentional pins must never appear as actionable bumps — by definition
# we're holding them back on purpose. The pin can only be lifted by
# editing INTENTIONAL_PINS and package.json together.
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
if fetch_failed:
bucket = "fetch_failed"
elif name in INTENTIONAL_PINS:
bucket = "intentional"
elif target_compat:
bucket = "ready"
elif upstream_progress:
bucket = "waiting"
else:
bucket = "blocked"
return {
"name": name,
"pinned_spec": pinned_spec or "—",
"npm_version": npm_version,
"peer_range": peer_range,
"target_compat": target_compat,
"current_compat": current_compat,
"upstream_progress": upstream_progress,
"behind_latest": behind_latest,
"bump_now": bump_now,
"bucket": bucket,
"is_vendored": is_vendored,
}
def main() -> int:
blockers: dict[str, str] = {}
lines: list[str] = []
@@ -196,20 +400,68 @@ def main() -> int:
current_runtime = read_current_runtime()
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0))
pinned_versions = read_pinned_grammar_versions()
lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})")
lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})")
lines.append(
f"`tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}–{current_abi_range[1]}) "
f"→ target `tree-sitter@{TARGET_RUNTIME}` "
f"(ABI {target_abi_range[0]}–{target_abi_range[1]})."
)
lines.append("")
# ── Grammar peer-dep compatibility ───────────────────────────────
lines.append(md_h("Grammar compatibility", 2))
lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |")
lines.append("|---|---|---|---|---|---|---|")
# First pass: gather raw data + classification per grammar. We render
# the human-friendly buckets first, then the raw matrix in a <details>
# block at the end. Status text in the matrix is preserved verbatim
# so the workflow's row-diff change-detection keeps working.
grammar_rows: list[dict] = []
raw_matrix: list[str] = [
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
"|---|---|---|---|---|---|---|---|",
]
ready_count = 0
total_count = len(GRAMMARS)
vendored_grammars: list[dict] = []
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
pinned_spec = pinned_versions.get(name, "—")
# Vendored grammars don't have an "npm latest" we install from —
# we ship our own copy under gitnexus/vendor/<name>. Treat them
# as a separate kind of artefact: their readiness for the runtime
# upgrade depends on the vendored ABI being in the target range,
# not on a peer-dep negotiation.
if is_vendored_pin(pinned_spec):
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
v["pinned_spec"] = pinned_spec
# Three-state classification: in-range, out-of-range, or
# not-introspectable (e.g. tree-sitter-swift ships only
# prebuilt .node binaries, no parser.c — assume compatible).
if v["vendored_abi"] is None:
v["target_compat"] = True
v["abi_state"] = "prebuilt"
status = "Vendored (prebuilt — ABI not introspectable)"
elif target_abi_range[0] <= v["vendored_abi"] <= target_abi_range[1]:
v["target_compat"] = True
v["abi_state"] = "in_range"
status = "Vendored (ABI in target range)"
else:
v["target_compat"] = False
v["abi_state"] = "out_of_range"
status = "Vendored (ABI out of range)"
blockers[name] = (
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
f"{target_abi_range[0]}..{target_abi_range[1]}"
)
# Keep vendored grammars in the raw matrix so the workflow's
# row-diff change-detection picks up status transitions on
# them too. npm-only columns get sentinels.
raw_matrix.append(
f"| `{name}` | {pinned_spec} | (vendored) | (vendored) | "
f"{'Yes' if v['target_compat'] else '**No**'} | "
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
)
vendored_grammars.append(v)
continue
# Fetch latest npm metadata.
info = npm_view_json(name)
fetch_failed = info is None
@@ -226,12 +478,14 @@ def main() -> int:
if fetch_failed:
peer_display = "? (fetch failed)"
compatible = False
target_compat = False
current_compat = False
else:
peer_display = peer_range or "none"
if peer_range and not peer_optional:
peer_display += " (required)"
compatible = satisfies_target(peer_range, TARGET_RUNTIME)
target_compat = satisfies_target(peer_range, TARGET_RUNTIME)
current_compat = satisfies_target(peer_range, f"{current_runtime}.0")
# Check installed ABI using the same parser_path from GRAMMARS.
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
@@ -250,22 +504,40 @@ def main() -> int:
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
# Determine status.
# Status text + upstream-progress detection. The Status column
# values are preserved as-is to keep the workflow's row-diff
# change-detection working on the raw matrix below.
upstream_progress: str | None = None
if fetch_failed:
status = "Unknown (fetch failed)"
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
elif compatible:
elif name in INTENTIONAL_PINS:
# An intentional pin is, by definition, a held-back grammar:
# whatever npm-latest's peer dep says, our shipped version is
# the one whose ABI/peer must accept the target runtime, and
# the pin entry exists precisely because it does not. Treat
# it as a blocker until the pin is lifted (entry removed from
# INTENTIONAL_PINS), at which point this grammar falls back
# to standard classification on the next run.
status = "Intentionally pinned"
blockers[name] = (
f"`{name}` intentionally pinned at `{pinned_spec}` "
f"({INTENTIONAL_PINS[name]}) — pin must be lifted "
f"before the {TARGET_RUNTIME} runtime upgrade"
)
elif target_compat:
status = "Ready"
ready_count += 1
elif upstream_abi and upstream_abi >= 15:
status = "Unreleased (ABI 15 on main)"
upstream_progress = f"ABI 15 on `{upstream_repo}@{upstream_branch}` not yet published"
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
else:
status = "Blocking"
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
# Also check upstream package.json for relaxed peer dep.
if not compatible and not fetch_failed:
# Also check upstream package.json for relaxed peer dep — beats
# the ABI-15 hint when both are true.
if not target_compat and not fetch_failed:
upstream_pkg_url = (
f"https://raw.githubusercontent.com/{upstream_repo}/"
f"{upstream_branch}/package.json"
@@ -277,82 +549,250 @@ def main() -> int:
upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter")
if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME):
status = "Unreleased (peer relaxed on main)"
upstream_progress = (
f"peer relaxed to `{upstream_peer}` on "
f"`{upstream_repo}@{upstream_branch}` not yet published"
)
blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm"
except json.JSONDecodeError:
pass
compat_icon = "Yes" if compatible else "**No**"
lines.append(
f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
pinned_spec = pinned_versions.get(name, "—")
compat_icon = "Yes" if target_compat else "**No**"
raw_matrix.append(
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
f"{compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
)
lines.append("")
lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.")
lines.append("")
grammar_rows.append(_classify_grammar(
name=name,
pinned_spec=pinned_spec,
npm_version=npm_version,
peer_range=peer_range,
fetch_failed=fetch_failed,
target_compat=target_compat,
current_compat=current_compat,
upstream_progress=upstream_progress,
))
# ── Vendored proto drift ─────────────────────────────────────────
lines.append(md_h("Vendored tree-sitter-proto", 2))
vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c")
# ── Bucketize ────────────────────────────────────────────────────
by_bucket: dict[str, list[dict]] = {
k: [] for k in ("ready", "intentional", "waiting", "blocked", "fetch_failed")
}
for row in grammar_rows:
by_bucket[row["bucket"]].append(row)
bump_now = [r for r in grammar_rows if r["bump_now"]]
ready_count = len(by_bucket["ready"])
upstream_proto_url = (
f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/"
f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c"
)
upstream_proto_text = fetch_text(upstream_proto_url)
upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None
# ── TL;DR ────────────────────────────────────────────────────────
npm_count = len(grammar_rows)
vendored_count = len(vendored_grammars)
vendored_ready = sum(1 for v in vendored_grammars if v["target_compat"])
sha_url = (
f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/"
f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}"
)
sha_text = fetch_text(sha_url)
upstream_sha = "?"
if sha_text:
try:
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
except json.JSONDecodeError:
pass
local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c"
local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else ""
in_sync = bool(
upstream_proto_text
and local_proto_text.replace("\r\n", "\n")
== upstream_proto_text.replace("\r\n", "\n")
)
lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)")
lines.append(f"- Upstream ABI: **{upstream_proto_abi}**")
lines.append(f"- Vendored ABI: **{vendored_abi}**")
lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}")
if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi:
can_upgrade = upstream_proto_abi <= target_abi_range[1]
lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})")
if can_upgrade:
lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`")
else:
lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}")
blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range"
elif not in_sync:
lines.append("- **Action:** review upstream changes; vendored copy may need updating")
blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream"
# ── Summary ──────────────────────────────────────────────────────
lines.append("")
lines.append(md_h("Summary", 2))
if blockers:
lines.append(f"**{len(blockers)} blocker(s) remaining:**\n")
for b in blockers.values():
lines.append(f"- {b}")
lines.append("")
lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.")
if not blockers:
verdict = "**Ready** — all grammars are 0.25-compatible. The runtime upgrade can proceed."
else:
lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.")
moved = "no" if not by_bucket["waiting"] else f"yes — {len(by_bucket['waiting'])} grammars have unreleased fixes on main"
verdict = (
f"**Blocked** — {len(blockers)} grammars are not yet 0.25-compatible. "
f"Upstream movement: {moved}."
)
lines.append(md_h("TL;DR", 2))
lines.append(verdict)
lines.append("")
lines.append(f"- {ready_count}/{npm_count} npm-installed grammars already accept tree-sitter@{TARGET_RUNTIME}")
if vendored_count:
lines.append(
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the target runtime range"
)
lines.append(f"- {len(by_bucket['intentional'])} intentionally pinned (see below)")
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
if by_bucket['fetch_failed']:
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
if bump_now:
lines.append(
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
f"is newer than the pin AND its peer dep accepts our current runtime)"
)
lines.append("")
# ── What you can do today ───────────────────────────────────────
if bump_now:
lines.append(md_h("What you can do today", 2))
lines.append(
"These pins lag npm latest and the latest version's peer dep already "
"accepts our current `tree-sitter@" + current_runtime + ".x` runtime. "
"Bumping is independent of the 0.25 upgrade and should be a quick PR."
)
lines.append("")
for r in sorted(bump_now, key=lambda r: r["name"]):
lines.append(
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
f"(peer `{r['peer_range'] or 'none'}`)"
)
lines.append("")
# ── Per-disposition sections ────────────────────────────────────
def _emit_bucket(title: str, body_intro: str, rows: list[dict], render) -> None:
if not rows:
return
lines.append(md_h(f"{title} ({len(rows)})", 3))
lines.append(body_intro)
lines.append("")
for r in sorted(rows, key=lambda r: r["name"]):
lines.append(render(r))
lines.append("")
lines.append(md_h("Disposition", 2))
_emit_bucket(
"Ready for 0.25",
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
by_bucket["ready"],
lambda r: (
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
),
)
if by_bucket["intentional"]:
lines.append(md_h(f"Intentionally pinned ({len(by_bucket['intentional'])})", 3))
lines.append(
"Deliberately held below npm latest. These are **not** drift — each entry "
"lists the issue motivating the pin and the condition for unpinning."
)
lines.append("")
for r in sorted(by_bucket["intentional"], key=lambda r: r["name"]):
reason = INTENTIONAL_PINS.get(r["name"], "(no rationale recorded)")
lines.append(
f"- `{r['name']}` pinned at `{r['pinned_spec']}` "
f"(npm latest `{r['npm_version']}`)\n {reason}"
)
lines.append("")
_emit_bucket(
"Waiting on upstream npm release",
"Fixes are merged on the upstream main branch but not yet published to npm. "
"We can move forward as soon as upstream cuts a release.",
by_bucket["waiting"],
lambda r: (
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
f"_{r['upstream_progress']}_"
),
)
_emit_bucket(
"Blocked on upstream",
"Peer dep is too tight on both the latest npm release and on upstream main. "
"These need an upstream issue/PR before we can proceed.",
by_bucket["blocked"],
lambda r: (
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
+ (" _(vendored)_" if r["is_vendored"] else "")
),
)
_emit_bucket(
"Could not check",
"npm registry fetch failed for these grammars. Re-run the workflow to retry.",
by_bucket["fetch_failed"],
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
)
# ── Vendored parsers ────────────────────────────────────────────
if vendored_grammars:
lines.append(md_h(f"Vendored parsers ({len(vendored_grammars)})", 2))
lines.append(
"These grammars ship from `gitnexus/vendor/` rather than the npm "
"registry. Their compatibility is governed by the **vendored "
"ABI** (must lie in the target runtime's range), not by a peer-"
"dep negotiation. The rationale for each vendored copy lives in "
"its own `package.json` `_vendoredBy` field."
)
lines.append("")
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
sync_label = (
"in sync with upstream" if v["in_sync"] else "diverged from upstream"
)
if v["abi_state"] == "in_range":
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
elif v["abi_state"] == "prebuilt":
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
else:
abi_label = (
f"ABI `{v['vendored_abi']}` (**outside** target range "
f"{target_abi_range[0]}..{target_abi_range[1]})"
)
upstream_abi_str = (
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] else "ABI `?`"
)
lines.append(
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
f"{upstream_abi_str} · {sync_label}"
)
if v["vendored_by"]:
# Show the first sentence — vendor package.json fields tend
# to start with the rationale and tail off into install-
# script breadcrumbs that aren't useful in this report.
rationale = _first_sentence(v["vendored_by"])
lines.append(f" - **Why vendored:** {rationale}")
# Action computation: needs regen iff upstream ABI exceeds
# vendored AND is still within target range. If upstream ABI
# exceeds the target, that's a runtime-side blocker. For
# prebuilt-only vendors we can't drive this from source ABI;
# the action is a manual upstream-binary refresh, surfaced
# via the in-sync flag instead.
if v["abi_state"] == "prebuilt":
if not v["in_sync"]:
lines.append(
" - **Action:** check whether upstream has shipped a new "
"prebuilt release; this vendor ships binary-only artefacts."
)
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
if v["upstream_abi"] <= target_abi_range[1]:
lines.append(
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
)
else:
lines.append(
f" - **Action:** wait for a runtime supporting ABI "
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
f"goes up to ABI {target_abi_range[1]}."
)
blockers[f"vendored-{v['name']}-abi"] = (
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
)
elif not v["in_sync"]:
lines.append(
" - **Action:** review upstream changes; vendored copy may "
"need a refresh (no ABI bump required)."
)
lines.append("")
# ── Raw matrix (for completeness + workflow row-diff) ────────────
lines.append(md_h("Full grammar matrix", 2))
lines.append(
"<details><summary>Click to expand the raw per-grammar table "
"(used by the workflow's change-detection bot).</summary>\n"
)
lines.extend(raw_matrix)
lines.append("\n</details>")
lines.append("")
print("\n".join(lines))
return 1 if blockers else 0
if __name__ == "__main__":
# Force UTF-8 output: the report contains em-dashes and arrows that
# Windows' default cp1252 codepage can't encode, while Linux runners
# default to UTF-8 anyway.
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except Exception:
pass
sys.exit(main())
+11 -3
View File
@@ -28,6 +28,9 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Configure e2e GitNexus home
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
- uses: ./.github/actions/setup-gitnexus-web
- name: Install Playwright browsers
@@ -44,9 +47,14 @@ jobs:
- name: Analyze repository (index for backend)
run: |
node gitnexus/dist/cli/index.js analyze || true
if [ ! -d ".gitnexus" ]; then
echo "::error::No .gitnexus index created"
E2E_REPO="${RUNNER_TEMP}/gitnexus-e2e-repo"
rm -rf "${E2E_REPO}"
mkdir -p "${E2E_REPO}"
cp -R gitnexus/test/fixtures/mini-repo/src "${E2E_REPO}/src"
printf '%s\n' '{"name":"e2e-mini-repo","version":"0.0.0","private":true}' > "${E2E_REPO}/package.json"
node gitnexus/dist/cli/index.js analyze "${E2E_REPO}" --skip-git --skip-agents-md --name e2e-mini-repo
if [ ! -d "${E2E_REPO}/.gitnexus" ]; then
echo "::error::No fixture .gitnexus index created"
exit 1
fi
+5 -9
View File
@@ -1,9 +1,6 @@
name: CI
on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
pull_request:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
@@ -15,13 +12,12 @@ on:
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
# Actions versions, and a prefix that could resolve to the caller's name would
# share a concurrency group with the caller → deadlock. A literal prefix is
# immune. Direct `push`/`pull_request` invocations use `CI-<ref>`; invocations
# from a reusable-workflow caller fall into a per-run-unique group that never
# serializes with the caller.
# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other
# event (push to main, workflow_call from publish.yml, etc.).
# immune. Direct `pull_request` invocations use `CI-<ref>`; invocations from a
# reusable-workflow caller fall into a per-run-unique group that never serializes
# with the caller. `push` to main is handled by release-candidate.yml, which
# calls this workflow once before publishing.
concurrency:
group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# ── Reusable workflow orchestration ─────────────────────────────────
-96
View File
@@ -1,96 +0,0 @@
name: Claude Code Review
# Uses pull_request_target so the workflow runs as defined on the default branch,
# which allows access to secrets for posting review comments on fork PRs.
# SECURITY: The checkout pins the fork's HEAD SHA (not the branch name) to
# prevent TOCTOU races (force-push between trigger and checkout). The
# claude-code-action sandboxes execution — it does NOT run arbitrary code
# from the checked-out source.
on:
# Trigger only when explicitly requested:
# - Add the "claude-review" label to a PR, OR
# - Comment "@claude" or "/review" on a PR
pull_request_target:
types: [labeled]
issue_comment:
types: [created]
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize per-PR to avoid racing review comments.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
claude-review:
# Run only when:
# 1. The "claude-review" label is added to a non-draft PR by a trusted contributor, OR
# 2. A trusted contributor comments "@claude" or "/review" on a PR
if: |
(
github.event_name == 'pull_request_target' &&
github.event.label.name == 'claude-review' &&
github.event.pull_request.draft == false &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(contains(github.event.comment.body, '@claude') ||
contains(github.event.comment.body, '/review')) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
steps:
# For issue_comment triggers, resolve the PR number, head SHA, and fork repo
- name: Resolve PR context
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
let pr;
if (context.eventName === 'issue_comment') {
const resp = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
pr = resp.data;
} else {
pr = context.payload.pull_request;
}
core.setOutput('number', pr.number);
core.setOutput('sha', pr.head.sha);
core.setOutput('repo', pr.head.repo.full_name);
core.setOutput('branch', pr.head.ref);
- name: Checkout PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.pr.outputs.repo }}
ref: ${{ steps.pr.outputs.sha }}
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: '*'
show_full_output: true
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
+63 -14
View File
@@ -1,8 +1,17 @@
name: Claude Code
# Label-triggered code-review requests use pull_request_target so the workflow
# runs as defined on the default branch, which allows access to secrets for
# posting review comments on fork PRs. SECURITY: PR checkouts pin the fork's
# HEAD SHA (not the branch name) to prevent TOCTOU races.
# The claude-code-action sandboxes execution; it does not run arbitrary code
# from the checked-out source.
on:
issue_comment:
types: [created]
pull_request_target:
types: [labeled]
pull_request_review_comment:
types: [created]
issues:
@@ -21,7 +30,10 @@ jobs:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
(
contains(github.event.comment.body, '@claude') ||
(github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
) &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
@@ -46,6 +58,14 @@ jobs:
(github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'MEMBER' ||
github.event.issue.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'pull_request_target' &&
github.event.label.name == 'claude-review' &&
github.event.pull_request.draft == false &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')
)
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -63,33 +83,48 @@ jobs:
with:
script: |
// Determine if this event is PR-related
let prNumber = null;
let pr = null;
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
prNumber = context.payload.issue.number;
const resp = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
pr = resp.data;
} else if (context.eventName === 'pull_request_review_comment') {
prNumber = context.payload.pull_request.number;
pr = context.payload.pull_request;
} else if (context.eventName === 'pull_request_review') {
prNumber = context.payload.pull_request.number;
pr = context.payload.pull_request;
} else if (context.eventName === 'pull_request_target') {
pr = context.payload.pull_request;
}
if (!prNumber) {
if (!pr) {
core.setOutput('is_pr', 'false');
return;
}
const resp = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const pr = resp.data;
core.setOutput('is_pr', 'true');
core.setOutput('number', String(prNumber));
core.setOutput('number', String(pr.number));
core.setOutput('sha', pr.head.sha);
core.setOutput('repo', pr.head.repo.full_name);
core.setOutput('branch', pr.head.ref);
- name: Resolve Claude mode
id: mode
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
with:
script: |
const body = (context.payload.comment?.body ?? '').toLowerCase();
const isCodeReview =
(context.eventName === 'pull_request_target' &&
context.payload.label?.name === 'claude-review') ||
(context.eventName === 'issue_comment' &&
Boolean(context.payload.issue?.pull_request) &&
body.includes('/review'));
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -98,6 +133,7 @@ jobs:
fetch-depth: 1
- name: Run Claude Code
if: steps.mode.outputs.code_review != 'true'
id: claude
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
with:
@@ -109,3 +145,16 @@ jobs:
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
- name: Run Claude Code Review
if: steps.mode.outputs.code_review == 'true'
id: claude-review
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: '*'
show_full_output: true
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
+1 -1
View File
@@ -105,7 +105,7 @@ jobs:
# Pinned to v7.2.0. Verify SHA via:
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
with:
config-name: release-drafter.yml
dry-run: true
+1
View File
@@ -107,3 +107,4 @@ local_docs/
.tmp/
.agents/
.context/
gitnexus/web/
+10 -5
View File
@@ -1,5 +1,5 @@
# GitNexus
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
<div align="center">
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
---
@@ -197,6 +197,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
gitnexus list # List all indexed repositories
@@ -218,6 +219,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
```
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.
### What Your AI Agent Gets
**16 tools** exposed via MCP (11 per-repo + 5 group):
@@ -321,19 +324,21 @@ flowchart TD
## Web UI (browser-based)
A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser.
A client-side graph explorer and AI chat — your code never leaves your machine.
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring.
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend.
<img width="2550" height="1343" alt="gitnexus_img" src="https://github.com/user-attachments/assets/cc5d637d-e0e5-48e6-93ff-5bcfdb929285" />
Or run locally:
Or run the frontend locally:
```bash
git clone https://github.com/abhigyanpatwari/gitnexus.git
cd gitnexus/gitnexus-shared && npm install && npm run build
cd ../gitnexus-web && npm install
npm run dev
# Then in another terminal, start the backend the frontend connects to:
npx gitnexus@latest serve
```
## Docker
+1 -1
View File
@@ -19,7 +19,7 @@ services:
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat']
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
interval: 30s
timeout: 5s
retries: 3
+6 -1
View File
@@ -82,6 +82,9 @@ matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
# Exclude noisy paths from cross-link matching (contracts are still extracted)
exclude_links_paths: [/ping, /health, /healthcheck]
exclude_links_param_only_paths: true
```
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
@@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
- `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@<group>/<groupPath>` routes tools to a single member.
- `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`.
- `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync.
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state.
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups:
- `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized.
- `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected.
### 3. Sync the group
+20 -19
View File
@@ -53,13 +53,13 @@ class MCPBridge:
try:
# Find gitnexus binary
gitnexus_bin = self._find_gitnexus()
if not gitnexus_bin:
gitnexus_cmd = self._find_gitnexus_command()
if not gitnexus_cmd:
logger.error("GitNexus not found. Install with: npm install -g gitnexus")
return False
self.process = subprocess.Popen(
[gitnexus_bin, "mcp"],
[*gitnexus_cmd, "mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -152,33 +152,34 @@ class MCPBridge:
return contents[0].get("text", "")
return None
def _find_gitnexus(self) -> str | None:
"""Find the gitnexus CLI binary."""
def _find_gitnexus_command(self) -> list[str] | None:
"""Find the gitnexus CLI command prefix."""
# Check if npx is available (preferred - uses local install)
for cmd in ["npx"]:
try:
result = subprocess.run(
[cmd, "gitnexus", "--version"],
capture_output=True,
text=True,
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
cwd=self.repo_path,
)
if result.returncode == 0:
return cmd # Will use "npx gitnexus mcp"
except Exception:
continue
try:
result = subprocess.run(
["npx", "gitnexus", "--version"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
cwd=self.repo_path,
)
if result.returncode == 0:
return ["npx", "gitnexus"]
except Exception:
pass
# Check for global install
try:
result = subprocess.run(
["gitnexus", "--version"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
)
if result.returncode == 0:
return "gitnexus"
return ["gitnexus"]
except Exception:
pass
+170
View File
@@ -0,0 +1,170 @@
"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn."""
import subprocess
import unittest
from unittest.mock import MagicMock, call, patch
class TestFindGitnexusCommand(unittest.TestCase):
"""Verify _find_gitnexus_command() returns the correct command prefix."""
def _make_bridge(self):
from bridge.mcp_bridge import MCPBridge
return MCPBridge(repo_path="/fake/repo")
def _success(self):
r = MagicMock()
r.returncode = 0
return r
def _failure(self):
r = MagicMock()
r.returncode = 1
return r
def test_npx_path_returns_npx_gitnexus(self):
"""When npx probe succeeds, command prefix is ['npx', 'gitnexus']."""
with patch("subprocess.run", return_value=self._success()) as mock_run:
bridge = self._make_bridge()
result = bridge._find_gitnexus_command()
self.assertEqual(result, ["npx", "gitnexus"])
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
self.assertEqual(args, ["npx", "gitnexus", "--version"])
def test_global_path_returns_gitnexus(self):
"""When npx probe fails but global install exists, prefix is ['gitnexus']."""
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
bridge = self._make_bridge()
result = bridge._find_gitnexus_command()
self.assertEqual(result, ["gitnexus"])
self.assertEqual(mock_run.call_count, 2)
def test_both_fail_returns_none(self):
"""When both probes fail, returns None."""
with patch("subprocess.run", return_value=self._failure()):
bridge = self._make_bridge()
result = bridge._find_gitnexus_command()
self.assertIsNone(result)
def test_npx_exception_falls_back_to_global(self):
"""When npx raises (not installed), falls back to global probe."""
with patch("subprocess.run", side_effect=[FileNotFoundError, self._success()]):
bridge = self._make_bridge()
result = bridge._find_gitnexus_command()
self.assertEqual(result, ["gitnexus"])
def test_both_raise_returns_none(self):
"""When both probes raise exceptions, returns None."""
with patch("subprocess.run", side_effect=FileNotFoundError):
bridge = self._make_bridge()
result = bridge._find_gitnexus_command()
self.assertIsNone(result)
def test_stdin_devnull_on_npx_probe(self):
"""npx probe must pass stdin=DEVNULL to prevent interactive blocking."""
with patch("subprocess.run", return_value=self._success()) as mock_run:
bridge = self._make_bridge()
bridge._find_gitnexus_command()
kwargs = mock_run.call_args[1]
self.assertEqual(kwargs.get("stdin"), subprocess.DEVNULL)
def test_stdin_devnull_on_global_probe(self):
"""global probe must pass stdin=DEVNULL to prevent interactive blocking."""
with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run:
bridge = self._make_bridge()
bridge._find_gitnexus_command()
global_call_kwargs = mock_run.call_args_list[1][1]
self.assertEqual(global_call_kwargs.get("stdin"), subprocess.DEVNULL)
class TestStartSpawnCommand(unittest.TestCase):
"""Verify start() spawns Popen with the correct argv."""
def _make_bridge(self):
from bridge.mcp_bridge import MCPBridge
return MCPBridge(repo_path="/fake/repo")
def test_npx_path_spawns_npx_gitnexus_mcp(self):
"""When npx path found, Popen must receive ['npx', 'gitnexus', 'mcp']."""
bridge = self._make_bridge()
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
patch("subprocess.Popen") as mock_popen, \
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
patch.object(bridge, "_send_notification"):
mock_proc = MagicMock()
mock_proc.stdin = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_popen.return_value = mock_proc
bridge.start()
mock_popen.assert_called_once()
argv = mock_popen.call_args[0][0]
self.assertEqual(argv, ["npx", "gitnexus", "mcp"])
def test_global_path_spawns_gitnexus_mcp(self):
"""When global path found, Popen must receive ['gitnexus', 'mcp']."""
bridge = self._make_bridge()
with patch.object(bridge, "_find_gitnexus_command", return_value=["gitnexus"]), \
patch("subprocess.Popen") as mock_popen, \
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
patch.object(bridge, "_send_notification"):
mock_proc = MagicMock()
mock_proc.stdin = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_popen.return_value = mock_proc
bridge.start()
mock_popen.assert_called_once()
argv = mock_popen.call_args[0][0]
self.assertEqual(argv, ["gitnexus", "mcp"])
def test_no_shell_true(self):
"""Popen must never be called with shell=True."""
bridge = self._make_bridge()
with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \
patch("subprocess.Popen") as mock_popen, \
patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \
patch.object(bridge, "_send_notification"):
mock_proc = MagicMock()
mock_proc.stdin = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stderr = MagicMock()
mock_popen.return_value = mock_proc
bridge.start()
kwargs = mock_popen.call_args[1]
self.assertNotEqual(kwargs.get("shell"), True)
def test_gitnexus_not_found_returns_false(self):
"""start() returns False and does not call Popen when gitnexus not found."""
bridge = self._make_bridge()
with patch.object(bridge, "_find_gitnexus_command", return_value=None), \
patch("subprocess.Popen") as mock_popen:
result = bridge.start()
self.assertFalse(result)
mock_popen.assert_not_called()
if __name__ == "__main__":
unittest.main()
+62 -3
View File
@@ -31,11 +31,25 @@ function readInput() {
* Find the .gitnexus directory by walking up from startDir.
* Returns the path to .gitnexus/ or null if not found.
*/
function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
*/
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) return candidate;
if (fs.existsSync(candidate)) {
if (!isGlobalRegistryDir(candidate)) return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
@@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
return null;
}
/**
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
* any git working tree — including a *linked* worktree created via
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
* upward walk from cwd alone misses the index. Returns null when `cwd` is
* not inside a git repo or `git` is not available.
*
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
* all linked worktrees. The canonical repo root is its parent directory.
*/
function findCanonicalRepoRoot(cwd) {
try {
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) return null;
const commonDir = (result.stdout || '').trim();
if (!commonDir || !path.isAbsolute(commonDir)) return null;
return path.dirname(commonDir);
} catch {
return null;
}
}
function findGitNexusDir(startDir) {
const cwd = startDir || process.cwd();
// Fast path: the cwd is inside the canonical repo (most common case).
const fromCwd = walkForGitNexusDir(cwd);
if (fromCwd) return fromCwd;
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
// only lives in the canonical repo root. Resolve the shared git dir
// and retry from there.
const canonicalRoot = findCanonicalRepoRoot(cwd);
if (canonicalRoot && canonicalRoot !== cwd) {
return walkForGitNexusDir(canonicalRoot);
}
return null;
}
/**
* Extract search pattern from tool input.
*/
+5 -1
View File
@@ -134,7 +134,11 @@ export type {
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
export {
buildScopeTree,
canParentScope,
ScopeTreeInvariantError,
} from './scope-resolution/scope-tree.js';
export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
@@ -740,10 +740,58 @@ function findExportByName(
defs: readonly SymbolDefinition[],
name: string,
): SymbolDefinition | undefined {
// GENERIC RULE (applies to every language using this finalize
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
// name in `localDefs`, prefer callable / type-like defs over plain
// value defs (`Variable`, `Property`, …). The CALLER side of an
// import almost always wants the callable, not a value shadow that
// happens to share the name — and without a deterministic
// preference, capture order silently decides which def the import
// binds to.
//
// The single-def case is unchanged: when only one def has the name,
// it's returned regardless of its type (the `fallback` path below).
//
// TypeScript is the first known language where this matters in
// practice: `const fn = () => {}` emits BOTH a `Function` def (from
// `@declaration.function` on the inner arrow) AND a `Variable` def
// (from the generic `@declaration.variable` pattern matching the
// wrapping `lexical_declaration`), and consumers of `import { fn }`
// need to bind to the callable. Other migrated languages don't
// currently produce dual emits of this shape, so the rule is a no-op
// for them today; future languages get the same correctness
// guarantee for free if they ever do.
//
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
// for the cross-file regression this rule prevents.
let fallback: SymbolDefinition | undefined;
for (const d of defs) {
if (deriveSimpleName(d) === name) return d;
if (deriveSimpleName(d) !== name) continue;
if (isCallableOrTypeLike(d.type)) return d;
if (fallback === undefined) fallback = d;
}
return undefined;
return fallback;
}
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
'Function',
'Method',
'Constructor',
'Class',
'Interface',
'Enum',
'Struct',
'Record',
'Trait',
'Namespace',
'Module',
'TypeAlias',
'Type',
'Typedef',
]);
function isCallableOrTypeLike(type: string): boolean {
return CALLABLE_OR_TYPE_LIKE.has(type);
}
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
@@ -119,10 +119,10 @@ export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
);
}
if (!rangeStrictlyContains(parent.range, scope.range)) {
if (!canParentScope(parent.range, scope.range, parent.kind, scope.kind)) {
throw new ScopeTreeInvariantError(
'parent-must-contain-child',
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not contain child '${scope.id}' at ${formatRange(scope.range)} (allowed: strict containment, or equal-range Module-as-parent).`,
);
}
@@ -230,6 +230,47 @@ function rangeStrictlyContains(outer: Range, inner: Range): boolean {
return outerStartsAtOrBefore && outerEndsAtOrAfter;
}
function rangesEqual(a: Range, b: Range): boolean {
return (
a.startLine === b.startLine &&
a.startCol === b.startCol &&
a.endLine === b.endLine &&
a.endCol === b.endCol
);
}
/**
* Whether `outer` (kind `outerKind`) is a valid parent for `inner` (kind
* `innerKind`).
*
* Strict containment is the general rule. The single carve-out is the
* `Module`/non-`Module` pair whose ranges are exactly equal — this happens
* naturally when tree-sitter reports identical byte spans for the
* `compilation_unit` (or equivalent file-root construct) and the file's
* single top-level scope. Common shape: a C# file consisting of nothing
* but `namespace X { ... }` with no leading or trailing trivia outside the
* namespace's `{}` body — `compilation_unit` and `namespace_declaration`
* both span exactly the same byte range. The `Module` is the universal
* outer of any file-level scope by language semantics, so coincident
* ranges should not break the parent chain.
*
* The carve-out is direction-asymmetric: only `Module`-as-outer parents a
* same-range non-`Module`, never the reverse. This preserves the
* acyclicity buildScopeTree relies on, and matches the corresponding
* helper in `scope-extractor.ts` so `pass1BuildScopes` and the validator
* agree on what a well-formed parent edge looks like.
*/
export function canParentScope(
outer: Range,
inner: Range,
outerKind: Scope['kind'],
innerKind: Scope['kind'],
): boolean {
if (rangeStrictlyContains(outer, inner)) return true;
if (outerKind === 'Module' && innerKind !== 'Module' && rangesEqual(outer, inner)) return true;
return false;
}
/**
* Two ranges overlap when neither finishes before the other begins. Ranges
* that merely touch at a single boundary point (`a.end === b.start`) do
+69 -95
View File
@@ -13,7 +13,7 @@
"@langchain/google-genai": "^2.1.28",
"@langchain/langgraph": "^1.2.9",
"@langchain/ollama": "^1.2.6",
"@langchain/openai": "^1.4.4",
"@langchain/openai": "^1.4.5",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
@@ -32,15 +32,15 @@
"mermaid": "^11.14.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.2.4",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -50,9 +50,9 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.10.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.5.16",
"@vitejs/plugin-react": "^5.1.4",
@@ -1435,9 +1435,9 @@
}
},
"node_modules/@langchain/core": {
"version": "1.1.41",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.41.tgz",
"integrity": "sha512-KdoNEf1YVJ9jnOP+smq4O6teu63tE7GDUryOnZ2lVfooHLrHK/ECUadjOcDSCK/yk/xBw/8nexJ3ZNBMtKnstw==",
"version": "1.1.42",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz",
"integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
@@ -1449,26 +1449,12 @@
"langsmith": ">=0.5.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^11.1.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@langchain/google-genai": {
"version": "2.1.28",
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz",
@@ -1552,9 +1538,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.8.9",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz",
"integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==",
"version": "1.8.10",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz",
"integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
@@ -1594,12 +1580,12 @@
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz",
"integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {
@@ -1621,6 +1607,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -1664,20 +1663,20 @@
}
},
"node_modules/@langchain/openai": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.4.tgz",
"integrity": "sha512-mRr/X5rvlwPj6cSXPxbL+CtOqYANO1/+CQ3Z+5t48kWnrlgPYOazmA+UAWvqQOuwJ6LaYn3SFrt43rR4lte/Ow==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz",
"integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==",
"license": "MIT",
"dependencies": {
"js-tiktoken": "^1.0.12",
"openai": "^6.32.0",
"openai": "^6.34.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.1.39"
"@langchain/core": "^1.1.42"
}
},
"node_modules/@mapbox/node-pre-gyp": {
@@ -2885,13 +2884,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@types/prismjs": {
@@ -2900,30 +2899,23 @@
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-syntax-highlighter": {
@@ -5505,6 +5497,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
@@ -6047,18 +6040,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
@@ -7746,28 +7727,24 @@
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.2.5"
}
},
"node_modules/react-is": {
@@ -7836,9 +7813,9 @@
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz",
"integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==",
"license": "MIT",
"engines": {
"node": ">=8",
@@ -8093,13 +8070,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
@@ -8558,9 +8532,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"devOptional": true,
"license": "MIT"
},
@@ -8693,9 +8667,9 @@
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
+8 -8
View File
@@ -24,7 +24,7 @@
"@langchain/google-genai": "^2.1.28",
"@langchain/langgraph": "^1.2.9",
"@langchain/ollama": "^1.2.6",
"@langchain/openai": "^1.4.4",
"@langchain/openai": "^1.4.5",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
@@ -42,15 +42,15 @@
"mermaid": "^11.14.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.2.4",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -60,9 +60,9 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.10.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.5.16",
"@vitejs/plugin-react": "^5.1.4",
+2 -2
View File
@@ -4,8 +4,8 @@ const DEFAULT_BOTTOM_THRESHOLD = 100;
const USER_SCROLL_EPSILON = 5;
export interface UseAutoScrollResult {
scrollContainerRef: React.RefObject<HTMLDivElement>;
messagesContainerRef: React.RefObject<HTMLDivElement>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
isAtBottom: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
+1 -1
View File
@@ -65,7 +65,7 @@ interface UseSigmaOptions {
}
interface UseSigmaReturn {
containerRef: React.RefObject<HTMLDivElement>;
containerRef: React.RefObject<HTMLDivElement | null>;
sigmaRef: React.RefObject<Sigma | null>;
setGraph: (graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => void;
zoomIn: () => void;
+2 -1
View File
@@ -3,7 +3,8 @@ WORKDIR /app
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm ci --ignore-scripts \
&& node scripts/patch-tree-sitter-swift.cjs \
&& npm rebuild tree-sitter-swift 2>&1 \
&& node -e "require('tree-sitter-swift')" \
&& (npm rebuild 2>&1 || true) \
&& cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1
CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"]
+35
View File
@@ -156,6 +156,7 @@ gitnexus analyze --embeddings # Enable embedding generation (slower, better s
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
@@ -295,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules:
npm install -g gitnexus
```
### Analyze warns about unavailable FTS or VECTOR extensions
GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available.
Configure the behavior with two environment variables:
| Variable | Values | Default | Effect |
|----------|--------|---------|--------|
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
```bash
# Offline/airgapped: never reach the network for extensions
GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze
# Slow network: give extension downloads more time
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
```
### Analysis runs out of memory
For very large repositories:
@@ -323,6 +343,21 @@ npx gitnexus analyze
Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`).
### Analyze reports a worker timeout
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
```bash
# CLI flag, in seconds
npx gitnexus analyze --worker-timeout 60
# Environment variable, in milliseconds
export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000
npx gitnexus analyze
```
For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. The default is **8388608 bytes (8 MB)**.
## Privacy
- All processing happens locally on your machine
+62 -3
View File
@@ -31,11 +31,25 @@ function readInput() {
* Find the .gitnexus directory by walking up from startDir.
* Returns the path to .gitnexus/ or null if not found.
*/
function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
function isGlobalRegistryDir(candidate) {
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
return (
fs.existsSync(path.join(candidate, 'registry.json')) ||
fs.existsSync(path.join(candidate, 'repos'))
);
}
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
*/
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) return candidate;
if (fs.existsSync(candidate)) {
if (!isGlobalRegistryDir(candidate)) return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
@@ -43,6 +57,51 @@ function findGitNexusDir(startDir) {
return null;
}
/**
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
* any git working tree — including a *linked* worktree created via
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
* upward walk from cwd alone misses the index. Returns null when `cwd` is
* not inside a git repo or `git` is not available.
*
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
* all linked worktrees. The canonical repo root is its parent directory.
*/
function findCanonicalRepoRoot(cwd) {
try {
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) return null;
const commonDir = (result.stdout || '').trim();
if (!commonDir || !path.isAbsolute(commonDir)) return null;
return path.dirname(commonDir);
} catch {
return null;
}
}
function findGitNexusDir(startDir) {
const cwd = startDir || process.cwd();
// Fast path: the cwd is inside the canonical repo (most common case).
const fromCwd = walkForGitNexusDir(cwd);
if (fromCwd) return fromCwd;
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
// only lives in the canonical repo root. Resolve the shared git dir
// and retry from there.
const canonicalRoot = findCanonicalRepoRoot(cwd);
if (canonicalRoot && canonicalRoot !== cwd) {
return walkForGitNexusDir(canonicalRoot);
}
return null;
}
/**
* Extract search pattern from tool input.
*/
+108 -205
View File
@@ -1,17 +1,17 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.48",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.15.2",
"@ladybugdb/core": "^0.16.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
@@ -30,9 +30,9 @@
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"tree-sitter": "^0.21.1",
"tree-sitter-c": "0.23.2",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.23.0",
@@ -65,17 +65,17 @@
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
}
},
"../gitnexus-shared": {
"version": "1.0.0",
"dev": true,
"devDependencies": {
"typescript": "^6.0.2"
"typescript": "^6.0.3"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -1159,9 +1159,9 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz",
"integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
"integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1169,16 +1169,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.15.3",
"@ladybugdb/core-linux-arm64": "0.15.3",
"@ladybugdb/core-linux-x64": "0.15.3",
"@ladybugdb/core-win32-x64": "0.15.3"
"@ladybugdb/core-darwin-arm64": "0.16.0",
"@ladybugdb/core-darwin-x64": "0.16.0",
"@ladybugdb/core-linux-arm64": "0.16.0",
"@ladybugdb/core-linux-x64": "0.16.0",
"@ladybugdb/core-win32-x64": "0.16.0"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz",
"integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz",
"integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==",
"cpu": [
"arm64"
],
@@ -1189,9 +1190,9 @@
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz",
"integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz",
"integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==",
"cpu": [
"arm64"
],
@@ -1202,9 +1203,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz",
"integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz",
"integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==",
"cpu": [
"x64"
],
@@ -1215,9 +1216,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz",
"integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
"integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
"cpu": [
"x64"
],
@@ -1227,6 +1228,9 @@
"win32"
]
},
"node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": {
"optional": true
},
"node_modules/@ladybugdb/core/node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -2428,13 +2432,6 @@
"node": ">= 0.8"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -2763,12 +2760,6 @@
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2841,12 +2832,6 @@
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -3270,17 +3255,15 @@
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
"integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==",
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
"globalthis": "^1.0.2",
"matcher": "^4.0.0",
"semver": "^7.3.5",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=10.0"
@@ -3612,12 +3595,6 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
@@ -3951,15 +3928,18 @@
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
@@ -4077,9 +4057,9 @@
}
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
@@ -4214,15 +4194,15 @@
}
},
"node_modules/onnxruntime-common": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -4232,8 +4212,8 @@
],
"dependencies": {
"adm-zip": "^0.5.16",
"global-agent": "^3.0.0",
"onnxruntime-common": "1.24.3"
"global-agent": "^4.1.3",
"onnxruntime-common": "1.25.1"
}
},
"node_modules/onnxruntime-web": {
@@ -4520,23 +4500,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
@@ -4635,12 +4598,6 @@
"node": ">=10"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -4681,12 +4638,12 @@
"license": "MIT"
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.13.1"
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
@@ -4870,12 +4827,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5028,20 +4979,20 @@
}
},
"node_modules/tree-sitter-c": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.2.tgz",
"integrity": "sha512-9kADOx31AF94DHcrsMGW0zM/2LS6v7wFkPHPVm7RQU+vYVVZMKZ2FJ9e99pm5feqsAcjUzB9CarqDLgRT1Fe/w==",
"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.2.2",
"node-gyp-build": "^4.8.2"
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.1"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree-sitter": {
"tree_sitter": {
"optional": true
}
}
@@ -5065,30 +5016,15 @@
}
}
},
"node_modules/tree-sitter-cli": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz",
"integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"bin": {
"tree-sitter": "cli.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tree-sitter-cpp": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz",
"integrity": "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==",
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.2.tgz",
"integrity": "sha512-GTa5Dx1O9ihzW70LvaUviTclh+wlBDRz6opR9Ij4NQIFmq/joeZ/k65UbLV4nLidR7xZ9eNNGT/SonCqAmjGVg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.1",
"node-gyp-build": "^4.8.2",
"tree-sitter-c": "^0.23.1"
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
@@ -5100,31 +5036,8 @@
}
},
"node_modules/tree-sitter-dart": {
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==",
"hasInstallScript": true,
"license": "ISC",
"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-dart/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
"resolved": "vendor/tree-sitter-dart",
"link": true
},
"node_modules/tree-sitter-go": {
"version": "0.23.4",
@@ -5291,49 +5204,8 @@
}
},
"node_modules/tree-sitter-swift": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz",
"integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-cli": "^0.23",
"which": "2.0.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-swift/node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC",
"optional": true
},
"node_modules/tree-sitter-swift/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"optional": true,
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
"resolved": "vendor/tree-sitter-swift",
"link": true
},
"node_modules/tree-sitter-typescript": {
"version": "0.23.2",
@@ -5383,9 +5255,9 @@
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -5768,6 +5640,19 @@
"zod": "^3.25.28 || ^4"
}
},
"vendor/tree-sitter-dart": {
"version": "1.0.0",
"license": "ISC",
"optional": true,
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"vendor/tree-sitter-proto": {
"version": "0.4.1",
"license": "MIT",
@@ -5775,6 +5660,24 @@
"peerDependencies": {
"tree-sitter": ">=0.21.0"
}
},
"vendor/tree-sitter-swift": {
"version": "0.7.1",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.1 || ^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
}
}
}
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.48",
"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",
@@ -35,7 +35,8 @@
"hooks",
"scripts",
"skills",
"vendor"
"vendor",
"web"
],
"scripts": {
"build": "node scripts/build.js",
@@ -46,13 +47,13 @@
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs",
"postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.15.2",
"@ladybugdb/core": "^0.16.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
@@ -71,9 +72,9 @@
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"tree-sitter": "^0.21.1",
"tree-sitter-c": "0.23.2",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.23.0",
@@ -87,10 +88,10 @@
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-dart": "file:./vendor/tree-sitter-dart",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
"tree-sitter-swift": "file:./vendor/tree-sitter-swift"
},
"devDependencies": {
"@types/cli-progress": "^3.11.6",
@@ -108,8 +109,7 @@
"overrides": {
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
},
"tree-sitter-c": "0.23.2"
}
},
"engines": {
"node": ">=20.0.0"
@@ -0,0 +1,42 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
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);
}
+1 -1
View File
@@ -26,7 +26,7 @@
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
* — under npm-managed territory, safe on upgrade.
*
* Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any
* 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.
*/
+22 -2
View File
@@ -21,11 +21,11 @@ const SHARED_DEST = path.join(DIST, '_shared');
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
@@ -70,4 +70,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
const cliEntry = path.join(DIST, 'cli', 'index.js');
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
// ── 6. Build & copy web UI ──────────────────────────────────────────
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
const WEB_DEST = path.join(DIST, '..', 'web');
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
console.log('[build] building gitnexus-web…');
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
console.log('[build] installing gitnexus-web dependencies…');
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
}
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
// Copy dist → gitnexus/web/ (shipped in the npm package)
fs.rmSync(WEB_DEST, { recursive: true, force: true });
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
console.log('[build] copied web UI → gitnexus/web/');
} else {
console.log('[build] skipping web UI (gitnexus-web not found)');
}
console.log(`[build] done — rewrote ${rewritten} files.`);
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
function parseLbugMaxDbSize(raw) {
const parsed = raw ? Number(raw) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid LadybugDB max DB size for extension installer: ${raw ?? '<missing>'}`);
}
return Math.floor(parsed);
}
async function installDuckDbExtension(extensionName) {
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
}
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,
);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
const dbPath = path.join(tmpDir, 'install.lbug');
let db;
let conn;
try {
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
await conn.query(`INSTALL ${extensionName}`);
} finally {
if (conn) await conn.close().catch(() => {});
if (db) await db.close().catch(() => {});
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});
@@ -1,78 +0,0 @@
#!/usr/bin/env node
/**
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
*
* Background:
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
* This is intended for grammar developers, but the published npm package
* already ships pre-generated parser files (parser.c, scanner.c), so the
* actions are unnecessary for consumers. Since consumers don't have
* tree-sitter-cli installed, the actions always fail during `npm install`.
*
* Why we can't just upgrade:
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
* to ^0.21.0 and all other grammar packages depend on that version.
* Upgrading tree-sitter would be a separate breaking change.
*
* How this workaround works:
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
* 2. This script runs as gitnexus's postinstall
* 3. It removes the "actions" array from binding.gyp
* 4. It rebuilds the native binding with the cleaned binding.gyp
*
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
* which allows using tree-sitter-swift@0.7.1+ directly.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
const bindingPath = path.join(swiftDir, 'binding.gyp');
try {
if (!fs.existsSync(bindingPath)) {
process.exit(0);
}
const content = fs.readFileSync(bindingPath, 'utf8');
let needsRebuild = false;
if (content.includes('"actions"')) {
// Strip Python-style comments (#) and trailing commas before JSON parsing
const cleaned = content
.replace(/#[^\n]*/g, '') // Remove # comments
.replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or }
const gyp = JSON.parse(cleaned);
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
delete gyp.targets[0].actions;
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
needsRebuild = true;
}
}
// Check if native binding exists
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
if (!fs.existsSync(bindingNode)) {
needsRebuild = true;
}
if (needsRebuild) {
console.log('[tree-sitter-swift] Rebuilding native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
stdio: 'pipe',
timeout: 120000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
} catch (err) {
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild',
);
}
+155 -12
View File
@@ -17,12 +17,52 @@ import {
getStoragePaths,
getGlobalRegistryPath,
RegistryNameCollisionError,
AnalysisNotFinalizedError,
assertAnalysisFinalized,
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import fs from 'fs/promises';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
// init, progress bar, console redirection) can monkey-patch it. The
// fatal handlers below MUST reach the user even when the analyze path
// has redirected console.* through the progress bar's bar.log() — the
// previous behaviour silently swallowed stack traces and made #1169
// indistinguishable from a no-op success on Windows.
const realStderrWrite = process.stderr.write.bind(process.stderr);
const writeFatalToStderr = (label: string, err: unknown): void => {
const isErr = err instanceof Error;
const message = isErr ? err.message : String(err);
realStderrWrite(`\n ${label}: ${message}\n`);
if (isErr && err.stack) realStderrWrite(`${err.stack}\n`);
};
let fatalHandlersInstalled = false;
/**
* Install one-shot `unhandledRejection` / `uncaughtException` handlers
* that surface the failure to the real stderr (bypassing any console
* redirection installed by the progress bar) and force a non-zero exit
* code. Without these, an async error escaping {@link analyzeCommand}'s
* try/catch was reported as exit 0 with no diagnostic — the silent
* failure mode tracked in #1169.
*/
const installFatalHandlers = (): void => {
if (fatalHandlersInstalled) return;
fatalHandlersInstalled = true;
process.on('unhandledRejection', (err) => {
writeFatalToStderr('Analysis failed (unhandled rejection)', err);
process.exit(1);
});
process.on('uncaughtException', (err) => {
writeFatalToStderr('Analysis failed (uncaught exception)', err);
process.exit(1);
});
};
const HEAP_MB = 8192;
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
@@ -91,11 +131,22 @@ export interface AnalyzeOptions {
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
*/
maxFileSize?: string;
/** Override worker sub-batch idle timeout in seconds. */
workerTimeout?: string;
embeddingThreads?: string;
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;
embeddingDevice?: string;
}
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
// Install fatal handlers immediately after re-exec resolution so any
// async error that escapes the try/catch below (#1169) surfaces with
// a stack trace and a non-zero exit code instead of a silent exit 0.
installFatalHandlers();
if (options?.verbose) {
process.env.GITNEXUS_VERBOSE = '1';
}
@@ -104,26 +155,82 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
}
if (options?.workerTimeout) {
const workerTimeoutSeconds = Number(options.workerTimeout);
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
console.error(' --worker-timeout must be at least 1 second.\n');
process.exitCode = 1;
return;
}
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(
Math.round(workerTimeoutSeconds * 1000),
);
}
const setPositiveEnv = (
optionName: string,
envName: string,
value: string | undefined,
): boolean => {
if (value === undefined) return true;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
console.error(` ${optionName} must be a positive integer.\n`);
process.exitCode = 1;
return false;
}
process.env[envName] = String(parsed);
return true;
};
if (
!setPositiveEnv(
'--embedding-threads',
'GITNEXUS_EMBEDDING_THREADS',
options?.embeddingThreads,
) ||
!setPositiveEnv(
'--embedding-batch-size',
'GITNEXUS_EMBEDDING_BATCH_SIZE',
options?.embeddingBatchSize,
) ||
!setPositiveEnv(
'--embedding-sub-batch-size',
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
options?.embeddingSubBatchSize,
)
) {
return;
}
if (options?.embeddingDevice) {
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
if (!allowed.has(options.embeddingDevice)) {
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
process.exitCode = 1;
return;
}
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
}
console.log('\n GitNexus Analyzer\n');
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (options?.skipGit) {
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
if (!options?.skipGit) {
console.log(
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
// --skip-git: fall back to cwd as the root
repoPath = path.resolve(process.cwd());
} else {
repoPath = gitRoot;
console.log(
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
const repoHasGit = hasGitDir(repoPath);
@@ -252,6 +359,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
);
if (result.alreadyUpToDate) {
// Even the fast path must prove the repo is discoverable. A prior
// run can write meta.json and then fail before registerRepo(); in
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
// on the next invocation unless we check the registry here too.
await assertAnalysisFinalized(repoPath);
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
@@ -264,6 +376,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
return;
}
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
// meta.json and registers the repo, but on Windows it has been
// observed to return successfully with neither artifact present
// (banner-only output, exit 0). Verify both before declaring
// success so the silent-finalize state surfaces with a non-zero
// exit code and an actionable error instead of being mistaken for
// a healthy index.
await assertAnalysisFinalized(repoPath);
// Skill generation (CLI-only, uses pipeline result from analysis)
if (options?.skills && result.pipelineResult) {
updateBar(99, 'Generating skill files...');
@@ -365,7 +486,29 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
return;
}
console.error(`\n Analysis failed: ${msg}\n`);
// Finalize invariant failure (#1169) — keep the rich actionable
// message intact and write through realStderrWrite so it can't be
// erased by a leftover bar refresh on slow terminals.
if (err instanceof AnalysisNotFinalizedError) {
writeFatalToStderr('Analysis did not finalize', err);
realStderrWrite(
`\n Diagnostic checklist:\n` +
` 1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` +
` 2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` +
` 3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` +
` and attach the trace to the GitNexus issue tracker.\n\n`,
);
process.exitCode = 1;
return;
}
// Bypass the redirected console.error and write the full stack to
// the real stderr captured at module load. The redirected
// console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
// and forces a bar.update() afterwards, which on some Windows
// terminals visually erases the failure message — the canonical
// shape of the silent-exit symptom in #1169.
writeFatalToStderr('Analysis failed', err);
// Provide helpful guidance for known failure modes
if (
+32
View File
@@ -0,0 +1,32 @@
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
const embeddingConfig = resolveEmbeddingConfig();
console.log('GitNexus Doctor\n');
console.log('Runtime');
console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`);
console.log(` Node: ${fingerprint.node}`);
console.log(` GitNexus: ${fingerprint.gitnexus}`);
console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`);
console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`);
console.log('');
console.log('Capabilities');
console.log(` Graph store: ${capabilities.graph}`);
console.log(` Full-text search:${capabilities.fts.padStart(10)}`);
console.log(` VECTOR index: ${capabilities.vector}`);
console.log(` Semantic mode: ${capabilities.semanticMode}`);
console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`);
if (capabilities.reason) console.log(` Note: ${capabilities.reason}`);
console.log('');
console.log('Embeddings');
console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`);
console.log(` Device: ${embeddingConfig.device}`);
console.log(` Threads: ${embeddingConfig.threads}`);
console.log(` Batch: ${embeddingConfig.batchSize} nodes`);
console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`);
};
+2 -2
View File
@@ -14,7 +14,7 @@ import fs from 'fs/promises';
import {
getStoragePaths,
loadMeta,
addToGitignore,
ensureGitNexusIgnored,
registerRepo,
} from '../storage/repo-manager.js';
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
@@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
meta.remoteUrl = getRemoteUrl(repoPath);
}
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);
await ensureGitNexusIgnored(repoPath);
const projectName = path.basename(repoPath);
const { stats } = meta;
+21 -1
View File
@@ -32,7 +32,10 @@ program
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option('--skip-git', 'Index a folder without requiring a .git directory')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
)
.option(
'--name <alias>',
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
@@ -48,11 +51,23 @@ program
'--max-file-size <kb>',
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
)
.option(
'--worker-timeout <seconds>',
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
)
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
.addHelpText(
'after',
'\nEnvironment variables:\n' +
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
)
@@ -89,6 +104,11 @@ program
.description('Show index status for current repo')
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
program
.command('doctor')
.description('Show runtime platform capabilities and embedding configuration')
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
program
.command('clean')
.description('Delete GitNexus index for current repo')
+2 -2
View File
@@ -581,13 +581,13 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
}
/**
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
* Install global OpenCode skills to ~/.config/opencode/skills/gitnexus/
*/
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, 'skill');
const skillsDir = path.join(opencodeDir, 'skills');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
+54
View File
@@ -0,0 +1,54 @@
import { defaultEmbeddingThreads } from '../platform/capabilities.js';
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js';
const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer, got "${value}"`);
}
return parsed;
};
const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => {
if (value === undefined) return undefined;
if (
value === 'auto' ||
value === 'dml' ||
value === 'cuda' ||
value === 'cpu' ||
value === 'wasm'
) {
return value;
}
throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`);
};
export const resolveEmbeddingConfig = (
overrides: Partial<EmbeddingConfig> = {},
): EmbeddingConfig => {
const env = process.env;
return {
...DEFAULT_EMBEDDING_CONFIG,
...overrides,
batchSize: parsePositiveInt(
'GITNEXUS_EMBEDDING_BATCH_SIZE',
env.GITNEXUS_EMBEDDING_BATCH_SIZE,
overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize,
),
subBatchSize: parsePositiveInt(
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE,
overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize,
),
threads: parsePositiveInt(
'GITNEXUS_EMBEDDING_THREADS',
env.GITNEXUS_EMBEDDING_THREADS,
overrides.threads ?? defaultEmbeddingThreads(),
),
device:
parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ??
overrides.device ??
DEFAULT_EMBEDDING_CONFIG.device,
};
};
+17 -12
View File
@@ -15,13 +15,14 @@ if (!process.env.ORT_LOG_LEVEL) {
}
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
import os from 'os';
import { existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, dirname } from 'path';
import { createRequire } from 'module';
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
import { resolveEmbeddingConfig } from './config.js';
import { applyHfEnvOverrides } from './hf-env.js';
/**
* Check whether the onnxruntime-node package that @huggingface/transformers
@@ -144,13 +145,12 @@ export const initEmbedder = async (
isInitializing = true;
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
// On Windows, use DirectML for GPU acceleration (via DirectX12)
// CUDA is only available on Linux x64 with onnxruntime-node
const finalConfig = resolveEmbeddingConfig(config);
// CUDA is probe-gated because ONNX Runtime can crash in native code when
// provider libraries are missing. DirectML stays opt-in for the same reason.
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
// if we attempt CUDA without the required shared libraries
const isWindows = process.platform === 'win32';
const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu';
const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
const requestedDevice =
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
@@ -158,11 +158,11 @@ export const initEmbedder = async (
try {
// Configure transformers.js environment
env.allowLocalModels = false;
// Default cache to user-writable location. transformers.js defaults to
// ./node_modules/.cache inside its own install dir, which is unwritable
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
// Bridge user-controlled env vars to transformers.js: HF_HOME →
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
// applyHfEnvOverrides so the MCP embedder entry point behaves
// identically.
applyHfEnvOverrides(env);
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
@@ -205,7 +205,12 @@ export const initEmbedder = async (
device: device,
dtype: 'fp32',
progress_callback: progressCallback,
session_options: { logSeverityLevel: 3 },
session_options: {
logSeverityLevel: 3,
intraOpNumThreads: finalConfig.threads,
interOpNumThreads: 1,
executionMode: 'sequential',
},
});
currentDevice = device;
@@ -27,7 +27,6 @@ import {
type SemanticSearchResult,
type ModelProgress,
type EmbeddingContext,
DEFAULT_EMBEDDING_CONFIG,
EMBEDDABLE_LABELS,
isShortLabel,
LABEL_METHOD,
@@ -35,6 +34,8 @@ import {
STRUCTURAL_LABELS,
collectBestChunks,
} from './types.js';
import { resolveEmbeddingConfig } from './config.js';
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
import {
EMBEDDING_TABLE_NAME,
EMBEDDING_INDEX_NAME,
@@ -42,8 +43,20 @@ import {
STALE_HASH_SENTINEL,
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
import { getExactScanLimit } from '../platform/capabilities.js';
const isDev = process.env.NODE_ENV === 'development';
const vectorUnavailableMessage =
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
const vectorReady = await loadVectorExtension();
if (!vectorReady) {
return false;
}
return true;
};
/**
* Bump this when the embedding text template changes in a way that should
* invalidate existing vectors, such as metadata/header shape changes,
@@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async (
*/
const createVectorIndex = async (
executeQuery: (cypher: string) => Promise<any[]>,
): Promise<void> => {
// Delegate to the adapter which tracks loaded state and handles DB reconnect resets
await loadVectorExtension();
): Promise<boolean> => {
if (!(await ensureVectorExtensionAvailable())) return false;
try {
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
return true;
} catch (error) {
if (isDev) {
console.warn('Vector index creation warning:', error);
}
return false;
}
};
export interface EmbeddingPipelineResult {
nodesProcessed: number;
chunksProcessed: number;
vectorIndexReady: boolean;
semanticMode: 'vector-index' | 'exact-scan';
}
/**
* Run the embedding pipeline
*
@@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async (
skipNodeIds?: Set<string>,
context?: EmbeddingContext,
existingEmbeddings?: Map<string, string>,
): Promise<void> => {
const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
): Promise<EmbeddingPipelineResult> => {
const finalConfig = resolveEmbeddingConfig(config);
let totalChunks = 0;
try {
const vectorAvailable = await ensureVectorExtensionAvailable();
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
// Phase 1: Load embedding model
onProgress({
phase: 'loading-model',
@@ -338,7 +362,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.
await createVectorIndex(executeQuery);
const vectorIndexReady = await createVectorIndex(executeQuery);
onProgress({
phase: 'ready',
@@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async (
nodesProcessed: 0,
totalNodes: 0,
});
return;
return {
nodesProcessed: 0,
chunksProcessed: 0,
vectorIndexReady,
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
};
}
// Phase 3: Chunk + embed nodes
@@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async (
const chunkSize = finalConfig.chunkSize;
const overlap = finalConfig.overlap;
let processedNodes = 0;
let totalChunks = 0;
onProgress({
phase: 'embedding',
@@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async (
}
// Embed chunk texts in sub-batches to control memory
const EMBED_SUB_BATCH = 8;
const EMBED_SUB_BATCH = finalConfig.subBatchSize;
for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH);
const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH);
@@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async (
console.log('📇 Creating vector index...');
}
await createVectorIndex(executeQuery);
const vectorIndexReady = await createVectorIndex(executeQuery);
onProgress({
phase: 'ready',
@@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async (
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
);
}
return {
nodesProcessed: totalNodes,
chunksProcessed: totalChunks,
vectorIndexReady,
semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan',
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -543,27 +577,71 @@ export const semanticSearch = async (
const queryVec = embeddingToArray(queryEmbedding);
const queryVecStr = `[${queryVec.join(',')}]`;
const bestChunks = await collectBestChunks(k, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < ${maxDistance}
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
emb.startLine AS startLine, emb.endLine AS endLine, distance
ORDER BY distance
`;
let bestChunks = new Map<
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
if (await loadVectorExtension()) {
try {
bestChunks = await collectBestChunks(k, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < ${maxDistance}
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
emb.startLine AS startLine, emb.endLine AS endLine, distance
ORDER BY distance
`;
const embResults = await executeQuery(vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
const embResults = await executeQuery(vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch {
bestChunks = new Map();
}
}
if (bestChunks.size === 0) {
const countRows = await executeQuery(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
);
const countRow = countRows[0];
const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0);
const exactLimit = getExactScanLimit();
if (embeddingCount > 0 && embeddingCount <= exactLimit) {
const rows = await executeQuery(`
MATCH (e:${EMBEDDING_TABLE_NAME})
RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex,
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
`);
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
embedding: row.embedding ?? row[4] ?? [],
}));
bestChunks = new Map(
rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [
row.nodeId,
{
distance: row.distance,
chunkIndex: row.chunkIndex,
startLine: row.startLine,
endLine: row.endLine,
},
]),
);
}
}
if (bestChunks.size === 0) {
return [];
@@ -0,0 +1,49 @@
export interface ExactEmbeddingRow {
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
embedding: readonly number[];
}
export interface ExactSearchChunk {
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
distance: number;
}
const cosineDistance = (a: readonly number[], b: readonly number[]): number => {
let dot = 0;
let aNorm = 0;
let bNorm = 0;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
dot += av * bv;
aNorm += av * av;
bNorm += bv * bv;
}
if (aNorm === 0 || bNorm === 0) return 1;
return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
};
export const rankExactEmbeddingRows = (
rows: readonly ExactEmbeddingRow[],
queryEmbedding: readonly number[],
limit: number,
maxDistance: number,
): ExactSearchChunk[] =>
rows
.map((row) => ({
nodeId: row.nodeId,
chunkIndex: row.chunkIndex,
startLine: row.startLine,
endLine: row.endLine,
distance: cosineDistance(row.embedding, queryEmbedding),
}))
.filter((row) => row.distance < maxDistance)
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
+62
View File
@@ -0,0 +1,62 @@
import os from 'node:os';
import { join } from 'node:path';
/**
* @internal Exported only for unit tests and the two embedder entry points
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
* public package API.
*
* Minimal subset of `@huggingface/transformers`' `env` object that gitnexus
* mutates. Defining a local structural type keeps this helper free of a
* transitive dependency on transformers' generated `.d.ts` while still
* giving full type-checking on the two fields we actually touch.
*/
export interface HfEnvSubset {
cacheDir: string;
remoteHost: string;
}
/**
* @internal Exported only for unit tests and the two embedder entry points
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
* public package API.
*
* Apply user-controlled HuggingFace environment overrides to the
* `@huggingface/transformers` `env` object. Centralises the two env-var
* bridges so every gitnexus embedder entry point (the analyze pipeline
* and the MCP server) behaves identically.
*
* - **`HF_HOME`** → `env.cacheDir` (default: `~/.cache/huggingface`).
* transformers.js otherwise defaults to `./node_modules/.cache` inside
* its own install dir, which is unwritable when gitnexus is installed
* globally (e.g. `/usr/lib/node_modules/`).
*
* - **`HF_ENDPOINT`** → `env.remoteHost` (#1205). transformers.js does
* not read `HF_ENDPOINT` on its own — it reads `env.remoteHost` —
* even though `HF_ENDPOINT` is the standard env var the upstream
* `huggingface_hub` Python client and the official HF mirror docs
* tell users to set. Bridging the two unblocks `--embeddings` for
* users behind networks where `huggingface.co` is unreachable
* (corporate proxies, the GFW, air-gapped mirrors). The trailing
* slash is normalised because transformers.js builds URLs by string
* concatenation and a missing slash silently falls through to its
* default `huggingface.co/...` host.
*
* Mutation rather than return-and-apply because callers already hold a
* reference to the live `env` object imported from
* `@huggingface/transformers` — passing the same reference in keeps the
* call site a single line at each entry point.
*/
export function applyHfEnvOverrides(env: HfEnvSubset): void {
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
// `.trim()` guards against the common copy-paste failure mode of
// `HF_ENDPOINT=" https://hf-mirror.com "` (leading/trailing whitespace
// from shell scripts or docs) — without it, a whitespace-only value
// would be truthy and produce an invalid `env.remoteHost = ' /'` that
// silently misroutes downloads. Empty string remains falsy in JS so the
// truthy guard already handles the unset/empty cases.
const endpoint = process.env.HF_ENDPOINT?.trim();
if (endpoint) {
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
}
}
+6
View File
@@ -207,6 +207,10 @@ export interface EmbeddingConfig {
modelId: string;
/** Number of nodes to embed in each batch */
batchSize: number;
/** Number of chunks passed to one local/HTTP embedding call */
subBatchSize: number;
/** Maximum ONNX Runtime CPU threads for local inference */
threads: number;
/** Embedding vector dimensions */
dimensions: number;
/** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */
@@ -229,6 +233,8 @@ export interface EmbeddingConfig {
export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
modelId: 'Snowflake/snowflake-arctic-embed-xs',
batchSize: 16,
subBatchSize: 8,
threads: 2,
dimensions: 384,
device: 'auto',
maxSnippetLength: 500,
+5 -4
View File
@@ -6,7 +6,7 @@
import { execFileSync } from 'node:child_process';
import path from 'path';
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
export interface StalenessInfo {
isStale: boolean;
@@ -101,9 +101,10 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
}
if (bestPath) return { match: 'path', entry: bestPath };
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
// URL, and look for any registered entry with the same fingerprint.
const cwdGitRoot = getGitRoot(cwdResolved);
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
// `.git` checks before shelling out. This keeps MCP startup from
// running git in an unrelated launch cwd such as $HOME (#1138).
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
if (!cwdGitRoot) return { match: 'none' };
const cwdRemote = getRemoteUrl(cwdGitRoot);
+157 -42
View File
@@ -5,8 +5,42 @@ import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import {
closeLbugConnection,
openLbugConnection,
type LbugConnectionHandle,
} from '../lbug/lbug-config.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
/**
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
*
* - `.wal` — write-ahead log; persists across opens but must be associated
* with the same database instance (LadybugDB 0.16.0 enforces this via a
* database-id check and rejects opens with the diagnostic
* `"Database ID for temporary file 'X.wal' does not match the current
* database. This file may have been left behind from a previous database
* with the same name"`).
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
*
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
* it into place. The rename only moves the main file; sidecars must be
* cleaned up explicitly or the next writer trips the database-id check.
*/
const LBUG_SIDECAR_SUFFIXES = ['.wal', '.shadow'] as const;
async function removeLbugFile(basePath: string): Promise<void> {
const candidates = [basePath, ...LBUG_SIDECAR_SUFFIXES.map((s) => `${basePath}${s}`)];
for (const f of candidates) {
try {
await fsp.rm(f, { recursive: true, force: true });
} catch {
/* best-effort: caller will surface real errors via the open path */
}
}
}
export function contractNodeId(
repo: string,
contractId: string,
@@ -127,8 +161,7 @@ export function findContractNode(
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
const parentDir = path.dirname(dbPath);
await fsp.mkdir(parentDir, { recursive: true });
const db = new lbug.Database(dbPath, 0, false, false); // writable
const conn = new lbug.Connection(db);
const { db, conn } = await openLbugConnection(lbug, dbPath);
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
}
@@ -195,6 +228,17 @@ function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]):
}
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
// checkpoint thread can outlive the close call and leave sidecar pages
// pending on disk, which makes a subsequent read-side open either race
// with the WAL replay or trip the database-id check on the sidecars.
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
try {
await (handle._conn as lbug.Connection).query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
try {
await (handle._conn as lbug.Connection).close();
} catch {
@@ -322,12 +366,11 @@ export async function writeBridge(
}
};
// Clean up any leftover tmp
try {
await fsp.rm(tmpPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
// different database instance (database-id check), so any stale sidecar
// from a crashed previous run will fail the next writeBridge.
await removeLbugFile(tmpPath);
// 1. Create temp DB, insert all data.
//
@@ -497,18 +540,43 @@ export async function writeBridge(
}
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
//
// The current database file (with its `.wal` / `.shadow` sidecars) is
// moved aside, then the freshly built tmp database takes its place.
// We move the sidecars together with the main file so the open below
// and any external readers see a consistent set; orphan sidecars from
// the tmp namespace are then removed because LadybugDB looks for them
// under the renamed-to base name and would reject mismatching IDs.
try {
await fsp.access(finalPath);
await retryRename(finalPath, bakPath);
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
try {
await fsp.access(`${finalPath}${suffix}`);
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
} catch {
/* sidecar absent — nothing to move */
}
}
} catch {
/* no existing db */
}
await retryRename(tmpPath, finalPath);
try {
await fsp.rm(bakPath, { recursive: true, force: true });
} catch {
/* ignore */
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
// Rename — not delete — so the WAL (which may carry uncommitted-at-
// close-time pages on a graceful close, depending on
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
// checkpoint snapshot stay paired with the database file under its
// final name. LadybugDB 0.16.0's database-id check rejects an open
// when the sidecars belong to a different base name.
try {
await fsp.access(`${tmpPath}${suffix}`);
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
} catch {
/* sidecar absent — nothing to move */
}
}
await removeLbugFile(bakPath);
// 4. Write meta.json
await writeBridgeMeta(groupDir, {
@@ -524,10 +592,38 @@ export async function writeBridge(
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
/**
* Substrings observed in the message of an `Error` raised by the LadybugDB
* native open path when Windows still holds an exclusive lock on the file
* after a writer's `Database.close()` returned. LadybugDB 0.16.0's
* non-blocking checkpoint thread can briefly outlive the close call, so a
* read-side opener that races in immediately afterwards sees Win32 error
* 33 ("The process cannot access the file because another process has
* locked a portion of the file"). Retrying with a small back-off lets the
* background thread settle and the OS release the handle.
*/
const LBUG_OPEN_RETRY_PATTERNS = [
'process cannot access the file',
'another process has locked',
'could not set lock',
'lock held by another process',
];
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
const LBUG_OPEN_RETRY_BASE_MS = 100;
/** Cap individual back-off delays so the total wait is bounded (~3s). */
const LBUG_OPEN_RETRY_MAX_MS = 500;
function isTransientLockError(err: unknown): boolean {
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
return LBUG_OPEN_RETRY_PATTERNS.some((p) => msg.includes(p));
}
async function ensureBridgeDbFileAvailable(groupDir: string): Promise<boolean> {
const dbPath = path.join(groupDir, 'bridge.lbug');
try {
await fsp.access(dbPath);
return true;
} catch {
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
// exact same reason the rest of this file does: the scenario that
@@ -538,42 +634,62 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
try {
await fsp.access(bakPath);
await retryRename(bakPath, dbPath);
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
try {
await fsp.access(`${bakPath}${suffix}`);
await retryRename(`${bakPath}${suffix}`, `${dbPath}${suffix}`);
} catch {
/* sidecar absent */
}
}
return true;
} catch {
return null;
return false;
}
}
}
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
const dbPath = path.join(groupDir, 'bridge.lbug');
if (!(await ensureBridgeDbFileAvailable(groupDir))) return null;
// Version gate: check meta.json version compatibility
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return null; // incompatible schema version — fallback to JSON or re-sync
}
// Open the native handle. If Connection construction throws AFTER
// Database was successfully allocated, we'd leak the native Database
// object. Wrap each step separately and tear down the partial handle.
let db: lbug.Database | undefined;
let conn: lbug.Connection | undefined;
try {
db = new lbug.Database(dbPath, 0, false, true); // readOnly
conn = new lbug.Connection(db);
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
} catch {
if (conn) {
try {
await conn.close();
} catch {
/* ignore */
}
// Open the native handle with a bounded retry on transient OS-level file
// locks (see LBUG_OPEN_RETRY_PATTERNS). If Connection construction throws
// AFTER Database was successfully allocated, we'd leak the native Database
// object — wrap each step separately and tear down the partial handle.
let lastErr: unknown;
for (let attempt = 1; attempt <= LBUG_OPEN_RETRY_ATTEMPTS; attempt++) {
let handle: LbugConnectionHandle | undefined;
try {
handle = await openLbugConnection(lbug, dbPath, { readOnly: true });
// Force the lazy native init now so a transient lock surfaces here
// (where we can retry) instead of on the first user query.
await handle.db.init();
await handle.conn.init();
return { _db: handle.db, _conn: handle.conn, groupDir } as BridgeHandle;
} catch (err) {
lastErr = err;
if (handle) await closeLbugConnection(handle);
if (!isTransientLockError(err) || attempt === LBUG_OPEN_RETRY_ATTEMPTS) break;
const delay = Math.min(LBUG_OPEN_RETRY_BASE_MS * attempt, LBUG_OPEN_RETRY_MAX_MS);
await new Promise((r) => setTimeout(r, delay));
}
if (db) {
try {
await db.close();
} catch {
/* ignore */
}
}
return null;
}
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
console.warn(
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
lastErr instanceof Error ? lastErr.message : String(lastErr)
}`,
);
}
return null;
}
/* ------------------------------------------------------------------ */
@@ -581,8 +697,7 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
/* ------------------------------------------------------------------ */
export async function bridgeExists(groupDir: string): Promise<boolean> {
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) return false;
await closeBridgeDb(handle);
return true;
if (!(await ensureBridgeDbFileAvailable(groupDir))) return false;
const meta = await readBridgeMeta(groupDir);
return meta.version === 0 || meta.version === BRIDGE_SCHEMA_VERSION;
}
+3
View File
@@ -13,12 +13,15 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
workspace_deps: true,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
exclude_links_paths: [] as string[],
exclude_links_param_only_paths: false,
};
export function parseGroupConfig(yamlContent: string): GroupConfig {
@@ -1,6 +1,7 @@
import * as path from 'node:path';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
@@ -227,11 +228,16 @@ async function buildProtoContext(repoPath: string): Promise<{
servicesByName: Map<string, ProtoServiceInfo[]>;
}> {
const servicesByName = new Map<string, ProtoServiceInfo[]>();
// `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService —
// see `filesystem-walker.ts` for the canonical pattern. Replaces a
// hardcoded `[node_modules, .git, vendor]` array; those names plus the
// rest of `DEFAULT_IGNORE_LIST` are still excluded by default (#1185).
const protoIgnoreFilter = await createIgnoreFilter(repoPath);
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
absolute: false,
nodir: true,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
ignore: protoIgnoreFilter,
});
const contents = new Map<string, string>();
@@ -401,9 +407,14 @@ export class GrpcExtractor implements ContractExtractor {
}
// ─── Source files (+ .proto when plugin available) ────────────
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
// mirrors `filesystem-walker.ts`. Replaces a hardcoded
// `[node_modules, .git, vendor, dist, build]` array; those names are all
// in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved (#1185).
const sourceIgnoreFilter = await createIgnoreFilter(repoPath);
const sourceFiles = await glob(GRPC_SCAN_GLOB, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
ignore: sourceIgnoreFilter,
nodir: true,
});
@@ -1,6 +1,7 @@
import * as path from 'node:path';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
@@ -208,9 +209,16 @@ export class HttpRouteExtractor implements ContractExtractor {
}
private async scanFiles(repoPath: string): Promise<string[]> {
// Honour `.gitnexusignore` and `.gitignore` via the shared IgnoreService
// so contract extraction respects the same exclusion rules as the rest of
// the ingestion pipeline. Mirrors `filesystem-walker.ts` which uses the
// same shape. Replaces a hardcoded `[node_modules, .git, dist, build,
// vendor]` array — those names are still in `DEFAULT_IGNORE_LIST`, so
// default behaviour is preserved (#1185).
const ignoreFilter = await createIgnoreFilter(repoPath);
return glob(HTTP_SCAN_GLOB, {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**'],
ignore: ignoreFilter,
nodir: true,
});
}
@@ -268,6 +268,19 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// V1: exact name-only match on code-definition nodes.
// Positive allowlist mirrors other contract types. If multiple code
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
// the alphabetically-first occurrence deterministically.
rows = await executor(
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
WHERE n.name = $contract
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ contract: link.contract },
);
} else {
return null;
}
@@ -0,0 +1,270 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath } from '../../../config/ignore-service.js';
import { loadIgnoreRules } from '../../../config/ignore-service.js';
/**
* Discover cross-crate contracts in a Rust workspace by reading each
* member's `Cargo.toml` dependencies and scanning source files for
* `use <workspace_dep>::<Type>` imports.
*
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
* existing ManifestExtractor pipeline — no new matching logic needed.
*
* Designed for the group-level sync pipeline: it receives all repos in
* a group and produces cross-repo links between them.
*/
interface CrateMeta {
name: string;
groupPath: string;
repoPath: string;
workspaceDeps: string[];
}
interface ImportedSymbol {
crateName: string;
symbolName: string;
filePath: string;
}
/**
* Parse a Cargo.toml to extract the crate name and workspace dependency
* names. Uses simple line-based parsing — no TOML library needed for
* the subset we care about.
*/
async function parseCrateManifest(
repoPath: string,
): Promise<{ name: string; workspaceDeps: string[] } | null> {
const cargoPath = path.join(repoPath, 'Cargo.toml');
let content: string;
try {
content = await fs.readFile(cargoPath, 'utf-8');
} catch {
return null;
}
let name = '';
const workspaceDeps: string[] = [];
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
if (nameMatch) name = nameMatch[1];
// Match dependencies that use workspace = true, which indicates they
// are workspace-internal deps:
// dep_name = { workspace = true }
// dep_name.workspace = true
//
// Also match plain path dependencies:
// dep_name = { path = "../other" }
const depSections = content.matchAll(
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
);
for (const section of depSections) {
const sectionBody = section[2];
// workspace = true style
const wsMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
);
for (const m of wsMatches) workspaceDeps.push(m[1]);
// dotted workspace style: dep_name.workspace = true
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
for (const m of dottedMatches) workspaceDeps.push(m[1]);
// path = "../other" style (local path deps within workspace)
const pathMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
);
for (const m of pathMatches) workspaceDeps.push(m[1]);
}
if (!name) return null;
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
}
/**
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
* where <crate> is a known workspace dependency.
*/
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const normalizedCrates = new Map<string, string>();
for (const c of knownCrates) {
normalizedCrates.set(c.replace(/-/g, '_'), c);
}
const sourceFiles = await findRustFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
// Match patterns:
// use crate_name::Type;
// use crate_name::module::Type;
// use crate_name::{Type1, Type2};
// use crate_name::module::{Type1, Type2};
const useRegex = /^use\s+(\w+)::(.+);/gm;
let match;
while ((match = useRegex.exec(content)) !== null) {
const crateName = match[1];
const originalCrateName = normalizedCrates.get(crateName);
if (!originalCrateName) continue;
const importPath = match[2].trim();
// Handle grouped imports: {Type1, Type2, module::Type3}
const braceMatch = importPath.match(/\{([^}]+)\}/);
if (braceMatch) {
const items = braceMatch[1].split(',').map((s) => s.trim());
for (const item of items) {
const symbolName = extractSymbolName(item);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
} else {
const symbolName = extractSymbolName(importPath);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
}
}
return results;
}
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
function extractSymbolName(importPath: string): string | null {
const trimmed = importPath.trim();
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
const parts = trimmed.split('::');
return parts[parts.length - 1].trim() || null;
}
/**
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
* Functions and modules are snake_case. We only want types as cross-crate
* contracts — functions are too granular and modules too broad.
*/
function isTypeName(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findRustFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.rs')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface RustWorkspaceResult {
links: GroupManifestLink[];
discoveredCrates: Map<string, CrateMeta>;
}
/**
* Discover cross-crate contracts across all Rust repos in a group.
*
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
*/
export async function extractRustWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<RustWorkspaceResult> {
// Phase 1: Parse all Cargo.toml files to build crate registry
const cratesByName = new Map<string, CrateMeta>();
const cratesByGroupPath = new Map<string, CrateMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseCrateManifest(repoPath);
if (!manifest) continue;
const meta: CrateMeta = {
name: manifest.name,
groupPath,
repoPath,
workspaceDeps: manifest.workspaceDeps,
};
const existing = cratesByName.get(manifest.name);
if (existing) {
console.warn(
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;
}
cratesByName.set(manifest.name, meta);
cratesByGroupPath.set(groupPath, meta);
}
// Phase 2: For each crate, identify which of its workspace deps are
// also in this group (i.e., repos we can link to)
const links: GroupManifestLink[] = [];
const seen = new Set<string>();
for (const [, crate] of cratesByGroupPath) {
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
if (groupCrateDeps.length === 0) continue;
// Phase 3: Scan source files for imports from workspace deps
const knownCrates = new Set(groupCrateDeps);
const imports = await scanImports(crate.repoPath, knownCrates);
for (const imp of imports) {
const providerCrate = cratesByName.get(imp.crateName);
if (!providerCrate) continue;
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerCrate.groupPath,
to: crate.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredCrates: cratesByGroupPath };
}
@@ -1,5 +1,6 @@
import { glob } from 'glob';
import Parser from 'tree-sitter';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
@@ -56,22 +57,21 @@ export class TopicExtractor implements ContractExtractor {
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
// Honour `.gitnexusignore` / `.gitignore` via the shared IgnoreService —
// mirrors `filesystem-walker.ts`. The 5-name hardcoded list
// (`node_modules, .git, vendor, dist, build`) is preserved because every
// entry is in `DEFAULT_IGNORE_LIST`, so default behaviour is unchanged
// (#1185). The Go-specific `**/*_test.go` filter is layered on top via a
// small wrapper so glob-level pruning is preserved (we never read those
// files); the wrapper short-circuits before calling the base filter.
const baseFilter = await createIgnoreFilter(repoPath);
const ignoreFilter: typeof baseFilter = {
ignored: (p) => p.relative().endsWith('_test.go') || baseFilter.ignored(p),
childrenIgnored: (p) => baseFilter.childrenIgnored(p),
};
const files = await glob(TOPIC_SCAN_GLOB, {
cwd: repoPath,
ignore: [
'**/node_modules/**',
'**/.git/**',
'**/vendor/**',
'**/dist/**',
'**/build/**',
// Language-level test file conventions. Go test files
// `*_test.go` live next to source; other languages either use
// separate test directories (Python's `tests/`, Java's
// `src/test/`) or are already covered by the dist/build ignores.
// Pushed to the glob level so the orchestrator stays
// language-agnostic.
'**/*_test.go',
],
ignore: ignoreFilter,
nodir: true,
});
+51 -6
View File
@@ -1,4 +1,4 @@
import type { StoredContract, CrossLink } from './types.js';
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
export interface MatchResult {
matched: CrossLink[];
@@ -14,6 +14,43 @@ function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
}
/**
* Detect HTTP contracts that are too generic or infrastructure-level to
* produce meaningful cross-repo links. These are still extracted (useful
* for documentation / route maps) but excluded from cross-link matching.
*
* Two categories:
* 1. Health-check / readiness endpoints — every service has one, matching
* them produces N×M false links.
* 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that
* collapse to a single catch-all after normalization. These match any
* service with a similar shape, producing false positives.
*
* Both are configurable via matching.exclude_links_paths and
* matching.exclude_links_param_only_paths in group.yaml.
*/
function buildNoisyContractFilter(
matchingConfig?: MatchingConfig,
): (contractId: string) => boolean {
const excludePaths = matchingConfig?.exclude_links_paths?.length
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
: new Set<string>();
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
return function isNoisyHttpContract(contractId: string): boolean {
if (!contractId.startsWith('http::')) return false;
const parts = contractId.split('::');
if (parts.length < 3) return false;
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
if (excludePaths.has(pathPart)) return true;
if (excludeParamOnly) {
const segments = pathPart.split('/').filter(Boolean);
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
}
return false;
};
}
export function normalizeContractId(id: string): string {
const colonIdx = id.indexOf('::');
if (colonIdx === -1) return id;
@@ -91,8 +128,12 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
return [];
}
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
export function buildProviderIndex(
contracts: StoredContract[],
matchingConfig?: MatchingConfig,
): Map<string, StoredContract[]> {
const isNoisy = buildNoisyContractFilter(matchingConfig);
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
@@ -106,11 +147,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map<string, Sto
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
matchingConfig?: MatchingConfig,
): MatchResult {
const index = providerIndex ?? buildProviderIndex(contracts);
const isNoisy = buildNoisyContractFilter(matchingConfig);
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
const consumers = contracts.filter(
(c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
@@ -155,6 +199,7 @@ export function runExactMatch(
// normalUnmatched: contracts that weren't matched in exact pass
const normalUnmatched = contracts.filter((c) => {
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
+2
View File
@@ -103,6 +103,8 @@ matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
# exclude_links_paths: [/ping, /health, /healthcheck]
# exclude_links_param_only_paths: false
`;
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
return groupDir;
+34 -10
View File
@@ -8,6 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
@@ -84,12 +85,14 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
const entries = await readRegistry();
registryEntries = await readRegistry();
const entries = registryEntries;
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
@@ -177,18 +180,39 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Process manifest links declared in group.yaml.
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
// and merge them with explicit manifest links. Discovered links use the same
// ManifestExtractor pipeline as hand-written links in group.yaml.
let allLinks = [...config.links];
if (config.detect.workspace_deps) {
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
if (e) repoPaths.set(groupPath, e.path);
}
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
console.log(
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
);
}
}
}
// Process manifest links declared in group.yaml (plus any auto-discovered).
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
if (allLinks.length > 0) {
const knownRepos = new Set(Object.keys(config.repos));
for (const link of config.links) {
for (const link of allLinks) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
console.warn(
@@ -198,17 +222,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
);
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching);
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
+19
View File
@@ -27,12 +27,31 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
/**
* HTTP paths to exclude from cross-link matching. Contracts at these paths
* are still extracted and visible in the registry, but they don't produce
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
* that every service exposes and would otherwise create N×M false links.
* Trailing slashes are normalized before comparison.
* @default []
*/
exclude_links_paths?: string[];
/**
* When `true`, exclude HTTP routes where every path segment is `{param}`
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
* routes like `/users/{param}` are not affected. These param-only routes
* collapse to a single catch-all after normalization and produce false
* positives across unrelated services.
* @default false
*/
exclude_links_param_only_paths?: boolean;
}
export interface SymbolRef {
+84 -60
View File
@@ -12,7 +12,7 @@ type ReceiverSource = ReceiverEnriched['receiverSource'];
* DAG stage 4 fallback: used when `selectDispatch` is absent or returns null.
* Preserves pre-DAG dispatch semantics:
* - 'constructor' → constructor branch
* - 'free' → free branch (admits Swift/Kotlin class-target fast path)
* - 'free' → free branch (admits class-target fast path)
* - 'member' or undefined → owner-scoped branch
*
* `undefined` callForm MUST route through owner-scoped (not free) so bare
@@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = (
return null;
};
/**
* Collapse Swift-extension duplicate Class/Struct candidates to the primary
* definition, preferring the shortest file path.
*
* Swift extensions (`extension User { ... }` in a separate file) create
* multiple `Class` nodes sharing the same symbol name — one for the primary
* declaration and one per extension file. When overload disambiguation and
* receiver narrowing both fail to converge on a single candidate, this
* heuristic picks the primary definition based on the assumption that it
* lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`).
*
* Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class`
* and `Struct` are considered, not `Record`. Swift extensions only produce
* `Class` duplicates in practice, and C#/Kotlin records do not exhibit the
* same multi-file-definition pattern, so widening this set risks accidental
* dedup of legitimately distinct record types.
*
* Returns a `ResolveResult` when the heuristic fires, `null` when the
* candidate pool does not match the shape (mixed types, non-Class/Struct
* kinds, or `length <= 1`). Callers should fall through to their own null
* return when this helper returns `null`.
*
* Used by `resolveFreeCall`. Having a single source of truth prevents
* duplication if the heuristic is ever tuned.
*/
const dedupSwiftExtensionCandidates = (
const orderProviderSameNameTypeCandidates = (
candidates: readonly SymbolDefinition[],
typeName: string,
filePath: string,
): readonly SymbolDefinition[] | null => {
const language = getLanguageFromFilename(filePath);
if (language == null) return null;
return (
getProvider(language).orderSameNameTypeCandidates?.({
typeName,
callSiteFilePath: filePath,
candidates,
}) ?? null
);
};
const resolveProviderPrimaryTypeCandidate = (
candidates: readonly SymbolDefinition[],
tier: ResolutionTier,
typeName: string,
filePath: string,
): ResolveResult | null => {
if (candidates.length <= 1) return null;
const allSameType = candidates.every((c) => c.type === candidates[0].type);
if (!allSameType) return null;
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length);
return toResolveResult(sorted[0], tier);
const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath);
return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null;
};
/**
@@ -2223,6 +2212,35 @@ const resolveMethodByOwner = (
}
}
if (!firstDef && !ambiguous) {
const orderedTypeCandidates = orderProviderSameNameTypeCandidates(
ctx.model.types.lookupClassByName(receiverTypeName),
receiverTypeName,
filePath,
);
if (orderedTypeCandidates) {
for (const candidate of orderedTypeCandidates) {
const def = canWalkMRO
? lookupMethodByOwnerWithMRO(
candidate.nodeId,
methodName,
heritageMap,
ctx.model,
mroStrategy,
argCount,
)
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
if (!def) continue;
if (!firstDef) {
firstDef = def;
} else if (def.nodeId !== firstDef.nodeId) {
ambiguous = true;
break;
}
}
}
}
if (!firstDef || ambiguous) return undefined;
return { def: firstDef, tier: typeResolved.tier };
};
@@ -2290,9 +2308,9 @@ export const resolveMemberCall = (
* resolution via `ctx.resolve()`.
*
* Used for `foo()`, `doStuff()` — unqualified calls with no receiver.
* Also handles Swift/Kotlin implicit constructors (`User()` without `new`)
* by delegating to {@link resolveStaticCall} when the tiered pool contains
* class-like targets.
* Also handles implicit constructors (`User()` without `new`) by delegating
* to {@link resolveStaticCall} when the tiered pool contains class-like
* targets.
*
* {@link resolveCallTarget} delegates here for `callForm === 'free'`.
*
@@ -2324,33 +2342,30 @@ export const resolveFreeCall = (
let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free');
// Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a
// class. Delegates to resolveStaticCall for O(1) class + constructor lookup.
// Class-target fast path: free-form call targeting a class. Delegates to
// resolveStaticCall for O(1) class + constructor lookup.
// The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` —
// any type admitted here that is not in that set will cause resolveStaticCall
// to return null, wasting two lookup passes per call. `Enum` is deliberately
// excluded; `Record` is included so C# records and Kotlin data classes reach
// the fast path.
// excluded; `Record` is included so record-like class targets reach the fast
// path.
// Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather
// than enumerating literal strings. This converts an invariant that was
// previously enforced by a comment ("keep this list aligned with
// INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future
// extension of the set (e.g. Kotlin `object`) propagates here automatically.
// The `dedupSwiftExtensionCandidates` helper used in the tail of this
// function deliberately uses a narrower literal `'Class' | 'Struct'` check
// — Swift extensions only produce Class duplicates in practice, so Record
// is excluded there by design. Do not collapse that helper into
// INSTANTIABLE_CLASS_TYPES.
// extension of the set propagates here automatically.
// Language providers can still choose a primary same-name type candidate in
// the tail of this function when their grammars index one logical type
// multiple times.
const hasClassTarget =
filteredCandidates.length === 0 &&
tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type));
if (hasClassTarget) {
const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered);
if (staticResult) return staticResult;
// Retry with constructor form: Swift/Kotlin constructor calls look like
// free function calls (no `new` keyword). If resolveStaticCall didn't
// match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES
// applies.
// Retry with constructor form for languages whose constructor calls look
// like free function calls. If resolveStaticCall didn't match, re-filter
// with constructor form so CONSTRUCTOR_TARGET_TYPES applies.
//
// The retry fires for every null return from `resolveStaticCall`, which
// can happen for three distinct reasons — all three are handled below:
@@ -2364,9 +2379,8 @@ export const resolveFreeCall = (
// (b) Homonym ambiguity — two or more instantiable class candidates
// share the name (e.g. `User` in two files, same tier). The
// retry repopulates `filteredCandidates` with both Classes and
// they flow into `dedupSwiftExtensionCandidates` below, which
// either picks the shortest-path primary or null-routes.
// Covered by the R7 Swift-extension dedup test.
// they flow into the provider same-name candidate hook below, which
// can pick a primary definition or null-route.
//
// (c) `resolveStaticCall` step 4 bailed because the tiered pool
// contains ownerless `Constructor` nodes (some extractors emit
@@ -2391,10 +2405,13 @@ export const resolveFreeCall = (
}
if (filteredCandidates.length !== 1) {
// See `dedupSwiftExtensionCandidates` — shared helper, single source of
// truth for the Swift-extension same-name collision heuristic.
const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier);
if (deduped) return deduped;
const primary = resolveProviderPrimaryTypeCandidate(
filteredCandidates,
tiered.tier,
calledName,
filePath,
);
if (primary) return primary;
return null;
}
@@ -2559,9 +2576,16 @@ export const resolveStaticCall = (
// Interface / Trait / Impl). Null-route via the fall-through `return
// null` — this is the dominant Codex-fix case.
// length === 1 → a single instantiable candidate remains, return it.
// length > 1 → two or more instantiable classes share the name (e.g.
// homonym classes across files with no import narrowing). Fall through
// to `return null` so the caller null-routes rather than guess.
// length > 1 → let the call-site provider choose a primary when it can
// prove the candidates are one logical type; otherwise null-route.
const primary = resolveProviderPrimaryTypeCandidate(
instantiableCandidates,
typeResolved.tier,
className,
currentFile,
);
if (primary) return primary;
if (instantiableCandidates.length === 1) {
return toResolveResult(instantiableCandidates[0], typeResolved.tier);
}
@@ -12,18 +12,15 @@
import { detectFrameworkFromPath } from './framework-detection.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { providers } from './languages/index.js';
// ============================================================================
// NAME PATTERNS - All 13 supported languages
// NAME PATTERNS
// ============================================================================
/**
* Common entry point naming patterns by language.
* These patterns indicate functions that are likely feature entry points.
*
* Universal patterns are separated from per-language patterns so the per-language
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
* exhaustiveness — the compiler catches any missing language entry.
* Universal entry point naming patterns shared across all languages.
* Per-language patterns live on each LanguageProvider.entryPointPatterns.
*/
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
/^(main|init|bootstrap|start|run|setup|configure)$/i,
@@ -40,201 +37,11 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
/^emit[A-Z]/, // emitEvent
];
export const ENTRY_POINT_PATTERNS = {
// JavaScript/TypeScript
[SupportedLanguages.JavaScript]: [
/^use[A-Z]/, // React hooks (useEffect, etc.)
],
[SupportedLanguages.TypeScript]: [
/^use[A-Z]/, // React hooks
],
// Python
[SupportedLanguages.Python]: [
/^app$/, // Flask/FastAPI app
/^(get|post|put|delete|patch)_/i, // REST conventions
/^api_/, // API functions
/^view_/, // Django views
],
// Java
[SupportedLanguages.Java]: [
/^do[A-Z]/, // doGet, doPost (Servlets)
/^create[A-Z]/, // Factory patterns
/^build[A-Z]/, // Builder patterns
/Service$/, // UserService
],
// Kotlin
[SupportedLanguages.Kotlin]: [
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
/^create[A-Z]/, // Factory patterns
/^build[A-Z]/, // Builder patterns
/ViewModel$/, // MVVM pattern (Android)
/^module$/, // Ktor module entry point
/Service$/, // Service classes
],
// C#
[SupportedLanguages.CSharp]: [
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
/Action$/, // MVC actions
/^On[A-Z]/, // Event handlers / Blazor lifecycle
/Async$/, // Async entry points
/^Configure$/, // Startup.Configure
/^ConfigureServices$/, // Startup.ConfigureServices
/^Handle$/, // MediatR / generic handler
/^Execute$/, // Command pattern
/^Invoke$/, // Middleware Invoke
/^Map[A-Z]/, // Minimal API MapGet, MapPost
/Service$/, // Service classes
/^Seed/, // Database seeding
],
// Go
[SupportedLanguages.Go]: [
/Handler$/, // http.Handler pattern
/^Serve/, // ServeHTTP
/^New[A-Z]/, // Constructor pattern (returns new instance)
/^Make[A-Z]/, // Make functions
],
// Rust
[SupportedLanguages.Rust]: [
/^(get|post|put|delete)_handler$/i,
/^handle_/, // handle_request
/^new$/, // Constructor pattern
/^run$/, // run entry point
/^spawn/, // Async spawn
],
// C - explicit main() boost plus common C entry point conventions
[SupportedLanguages.C]: [
/^main$/, // THE entry point
/^init_/, // init_server, init_client
/_init$/, // module_init, server_init
/^start_/, // start_server
/_start$/, // thread_start
/^run_/, // run_loop
/_run$/, // event_run
/^stop_/, // stop_server
/_stop$/, // service_stop
/^open_/, // open_connection
/_open$/, // file_open
/^close_/, // close_connection
/_close$/, // socket_close
/^create_/, // create_session
/_create$/, // object_create
/^destroy_/, // destroy_session
/_destroy$/, // object_destroy
/^handle_/, // handle_request
/_handler$/, // signal_handler
/_callback$/, // event_callback
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
/^server_/, // server_start, server_loop
/^client_/, // client_connect
/^session_/, // session_create
/^window_/, // window_resize (tmux)
/^key_/, // key_press
/^input_/, // input_parse
/^output_/, // output_write
/^notify_/, // notify_client
/^control_/, // control_start
],
// C++ - same as C plus OOP/template patterns
[SupportedLanguages.CPlusPlus]: [
/^main$/, // THE entry point
/^init_/,
/_init$/,
/^Create[A-Z]/, // Factory patterns
/^create_/,
/^Run$/, // Run methods
/^run$/,
/^Start$/, // Start methods
/^start$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^OnEvent/, // Event callbacks
/^on_/,
/::Run$/, // Class::Run
/::Start$/, // Class::Start
/::Init$/, // Class::Init
/::Execute$/, // Class::Execute
],
// Swift / iOS
[SupportedLanguages.Swift]: [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
[SupportedLanguages.PHP]: [
/Controller$/, // UserController (class name convention)
/^handle$/, // Job::handle(), Listener::handle()
/^execute$/, // Command::execute()
/^boot$/, // ServiceProvider::boot()
/^register$/, // ServiceProvider::register()
/^__invoke$/, // Invokable controllers/actions
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
/^run$/, // Command/Job run()
/^fire$/, // Event fire()
/^dispatch$/, // Dispatchable jobs
/Service$/, // UserService (Service layer)
/Repository$/, // UserRepository (Repository pattern)
/^find$/, // Repository::find()
/^findAll$/, // Repository::findAll()
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
[SupportedLanguages.Ruby]: [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
// Dart / Flutter
[SupportedLanguages.Dart]: [
/^main$/, // App entry
/^build$/, // Widget.build — fundamental Flutter render entry point
/^createState$/, // StatefulWidget.createState
/^initState$/, // State lifecycle initialization
/^dispose$/, // State lifecycle teardown
/^didChangeDependencies$/, // State lifecycle — InheritedWidget changes
/^didUpdateWidget$/, // State lifecycle — widget rebuild with new config
/^runApp$/, // App entry point
/^onEvent$/, // BLoC event handler
/^mapEventToState$/, // Legacy BLoC pattern
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
} satisfies Record<SupportedLanguages, RegExp[]>;
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
/** Pre-computed merged patterns (universal + language-specific) from providers. */
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
Object.values(SupportedLanguages).map((lang) => [
Object.entries(providers).map(([lang, provider]) => [
lang,
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])],
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])],
]),
) as Record<SupportedLanguages, RegExp[]>;
@@ -106,15 +106,13 @@ export function finalizeScopeModel(
const allScopes: Scope[] = [];
const allDefs: SymbolDefinition[] = [];
const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = [];
const allReferenceSites = [] as ReturnType<typeof collectReferenceSites>;
const allReferenceSites = collectReferenceSites(parsedFiles);
for (const file of parsedFiles) {
for (const s of file.scopes) allScopes.push(s);
for (const d of file.localDefs) allDefs.push(d);
moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope });
}
// References kept out of the loop above to centralize list-init.
allReferenceSites.push(...collectReferenceSites(parsedFiles));
const scopeTree = buildScopeTree(allScopes);
const defs = buildDefIndex(allDefs);
@@ -11,6 +11,8 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { AstFrameworkPatternConfig } from './language-provider.js';
import { providers } from './languages/index.js';
// ============================================================================
// TYPES
@@ -518,395 +520,14 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
// AST-BASED FRAMEWORK DETECTION
// ============================================================================
/**
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
nestjs: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
'expo-router': [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
express: ['app.get', 'app.post', 'app.put', 'app.delete', 'router.get', 'router.post'],
// Python decorators
fastapi: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
flask: ['@app.route', '@blueprint.route'],
// Java annotations
spring: ['@RestController', '@Controller', '@GetMapping', '@PostMapping', '@RequestMapping'],
jaxrs: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
// C# attributes
aspnet: [
'[ApiController]',
'[HttpGet]',
'[HttpPost]',
'[HttpPut]',
'[HttpDelete]',
'[Route]',
'[Authorize]',
'[AllowAnonymous]',
],
signalr: ['[HubMethodName]', ': Hub', ': Hub<'],
blazor: ['@page', '[Parameter]', '@inject'],
efcore: ['DbContext', 'DbSet<', 'OnModelCreating'],
// Go patterns (function signatures include framework types)
'go-http': [
'http.Handler',
'http.HandlerFunc',
'ServeHTTP',
'http.ResponseWriter',
'http.Request',
],
gin: ['gin.Context', 'gin.Default', 'gin.New'],
echo: ['echo.Context', 'echo.New'],
fiber: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
// ORM patterns
prisma: ['prisma.', 'PrismaClient', '@prisma/client'],
supabase: ['supabase.from', 'createClient', '@supabase/supabase-js'],
// PHP/Laravel
laravel: [
'Route::get',
'Route::post',
'Route::put',
'Route::delete',
'Route::resource',
'Route::apiResource',
'#[Route(',
],
// Rust macros (proc-macro attributes in definition text)
actix: ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
axum: ['Router::new', 'axum::extract', 'axum::routing'],
rocket: ['#[get', '#[post', '#[launch', 'rocket::'],
tokio: ['#[tokio::main]', '#[tokio::test]'],
// C++ patterns (Qt, Boost)
qt: [
'Q_OBJECT',
'Q_INVOKABLE',
'Q_PROPERTY',
'Q_SIGNALS',
'Q_SLOTS',
'Q_SIGNAL',
'Q_SLOT',
'QWidget',
'QApplication',
],
// Swift/iOS
uikit: [
'viewDidLoad',
'viewWillAppear',
'viewDidAppear',
'UIViewController',
'@IBOutlet',
'@IBAction',
'@objc',
],
swiftui: [
'@main',
'WindowGroup',
'ContentView',
'@StateObject',
'@ObservedObject',
'@EnvironmentObject',
'@Published',
],
vapor: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
// Ruby patterns (class-level macros in definition text)
rails: [
'ApplicationController',
'ApplicationRecord',
'ActiveRecord::Base',
'before_action',
'after_action',
'has_many',
'belongs_to',
'has_one',
'validates',
],
sinatra: ['Sinatra::Base', 'Sinatra::Application'],
// Dart/Flutter
flutter: [
'StatelessWidget',
'StatefulWidget',
'BuildContext',
'Widget build',
'ChangeNotifier',
'GetxController',
'Cubit<',
'Bloc<',
'ConsumerWidget',
],
riverpod: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
};
interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
[SupportedLanguages.JavaScript]: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
},
],
[SupportedLanguages.TypeScript]: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
},
],
[SupportedLanguages.Python]: [
{
framework: 'fastapi',
entryPointMultiplier: 3.0,
reason: 'fastapi-decorator',
patterns: FRAMEWORK_AST_PATTERNS.fastapi,
},
{
framework: 'flask',
entryPointMultiplier: 2.8,
reason: 'flask-decorator',
patterns: FRAMEWORK_AST_PATTERNS.flask,
},
],
[SupportedLanguages.Java]: [
{
framework: 'spring',
entryPointMultiplier: 3.2,
reason: 'spring-annotation',
patterns: FRAMEWORK_AST_PATTERNS.spring,
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
},
],
[SupportedLanguages.Kotlin]: [
{
framework: 'spring-kotlin',
entryPointMultiplier: 3.2,
reason: 'spring-kotlin-annotation',
patterns: FRAMEWORK_AST_PATTERNS.spring,
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
},
{
framework: 'ktor',
entryPointMultiplier: 2.8,
reason: 'ktor-routing',
patterns: ['routing', 'embeddedServer', 'Application.module'],
},
{
framework: 'android-kotlin',
entryPointMultiplier: 2.5,
reason: 'android-annotation',
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
},
],
[SupportedLanguages.CSharp]: [
{
framework: 'aspnet',
entryPointMultiplier: 3.2,
reason: 'aspnet-attribute',
patterns: FRAMEWORK_AST_PATTERNS.aspnet,
},
{
framework: 'signalr',
entryPointMultiplier: 2.8,
reason: 'signalr-attribute',
patterns: FRAMEWORK_AST_PATTERNS.signalr,
},
{
framework: 'blazor',
entryPointMultiplier: 2.5,
reason: 'blazor-attribute',
patterns: FRAMEWORK_AST_PATTERNS.blazor,
},
{
framework: 'efcore',
entryPointMultiplier: 2.0,
reason: 'efcore-pattern',
patterns: FRAMEWORK_AST_PATTERNS.efcore,
},
],
[SupportedLanguages.PHP]: [
{
framework: 'laravel',
entryPointMultiplier: 3.0,
reason: 'php-route-attribute',
patterns: FRAMEWORK_AST_PATTERNS.laravel,
},
],
[SupportedLanguages.Go]: [
{
framework: 'go-http',
entryPointMultiplier: 2.5,
reason: 'go-http-handler',
patterns: FRAMEWORK_AST_PATTERNS['go-http'],
},
{
framework: 'gin',
entryPointMultiplier: 3.0,
reason: 'gin-handler',
patterns: FRAMEWORK_AST_PATTERNS.gin,
},
{
framework: 'echo',
entryPointMultiplier: 3.0,
reason: 'echo-handler',
patterns: FRAMEWORK_AST_PATTERNS.echo,
},
{
framework: 'fiber',
entryPointMultiplier: 3.0,
reason: 'fiber-handler',
patterns: FRAMEWORK_AST_PATTERNS.fiber,
},
{
framework: 'go-grpc',
entryPointMultiplier: 2.8,
reason: 'grpc-service',
patterns: FRAMEWORK_AST_PATTERNS['go-grpc'],
},
],
[SupportedLanguages.Rust]: [
{
framework: 'actix-web',
entryPointMultiplier: 3.0,
reason: 'actix-attribute',
patterns: FRAMEWORK_AST_PATTERNS.actix,
},
{
framework: 'axum',
entryPointMultiplier: 3.0,
reason: 'axum-routing',
patterns: FRAMEWORK_AST_PATTERNS.axum,
},
{
framework: 'rocket',
entryPointMultiplier: 3.0,
reason: 'rocket-attribute',
patterns: FRAMEWORK_AST_PATTERNS.rocket,
},
{
framework: 'tokio',
entryPointMultiplier: 2.5,
reason: 'tokio-runtime',
patterns: FRAMEWORK_AST_PATTERNS.tokio,
},
],
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
[SupportedLanguages.CPlusPlus]: [
{
framework: 'qt',
entryPointMultiplier: 2.8,
reason: 'qt-macro',
patterns: FRAMEWORK_AST_PATTERNS.qt,
},
],
[SupportedLanguages.Swift]: [
{
framework: 'uikit',
entryPointMultiplier: 2.5,
reason: 'uikit-lifecycle',
patterns: FRAMEWORK_AST_PATTERNS.uikit,
},
{
framework: 'swiftui',
entryPointMultiplier: 2.8,
reason: 'swiftui-pattern',
patterns: FRAMEWORK_AST_PATTERNS.swiftui,
},
{
framework: 'vapor',
entryPointMultiplier: 3.0,
reason: 'vapor-routing',
patterns: FRAMEWORK_AST_PATTERNS.vapor,
},
],
[SupportedLanguages.Ruby]: [
{
framework: 'rails',
entryPointMultiplier: 3.0,
reason: 'rails-pattern',
patterns: FRAMEWORK_AST_PATTERNS.rails,
},
{
framework: 'sinatra',
entryPointMultiplier: 2.8,
reason: 'sinatra-pattern',
patterns: FRAMEWORK_AST_PATTERNS.sinatra,
},
],
[SupportedLanguages.Dart]: [
{
framework: 'flutter',
entryPointMultiplier: 2.5,
reason: 'flutter-widget',
patterns: FRAMEWORK_AST_PATTERNS.flutter,
},
{
framework: 'riverpod',
entryPointMultiplier: 2.8,
reason: 'riverpod-pattern',
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
},
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<
string,
Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>
> = Object.fromEntries(
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
/** Pre-lowercased patterns for O(1) pattern matching at runtime — built from providers. */
const AST_PATTERNS_LOWERED: Record<string, AstFrameworkPatternConfig[]> = Object.fromEntries(
Object.entries(providers).map(([lang, provider]) => [
lang,
cfgs.map((cfg) => ({ ...cfg, patterns: cfg.patterns.map((p) => p.toLowerCase()) })),
(provider.astFrameworkPatterns ?? []).map((cfg) => ({
...cfg,
patterns: cfg.patterns.map((p) => p.toLowerCase()),
})),
]),
);
@@ -78,6 +78,14 @@ export type ImportSemantics =
| 'namespace'
| 'explicit-reexport';
/** Configuration for AST-based framework detection patterns. */
export interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
/**
* Everything a language needs to provide.
* Required fields must be explicitly set; optional fields have defaults
@@ -89,6 +97,16 @@ interface LanguageProviderConfig {
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
readonly extensions: readonly string[];
/** Entry-point function name patterns specific to this language.
* Merged with universal patterns at runtime for process detection scoring.
* Default: [] (only universal patterns apply). */
readonly entryPointPatterns?: readonly RegExp[];
/** AST-based framework detection patterns for this language.
* Used by detectFrameworkFromAST to identify framework entry points.
* Default: [] (no AST framework detection for this language). */
readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[];
// ── Parser ────────────────────────────────────────────────────────
/** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter.
* 'standalone' means the language has its own regex-based processor and
@@ -498,6 +516,15 @@ interface LanguageProviderConfig {
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
/** Order same-name type candidates when a language can index multiple
* definitions for one logical type. Return null to keep shared ambiguity
* handling. */
readonly orderSameNameTypeCandidates?: (params: {
readonly typeName: string;
readonly callSiteFilePath: string;
readonly candidates: readonly SymbolDefinition[];
}) => readonly SymbolDefinition[] | null;
/**
* Is this callable definition compatible with the given call-site arity?
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default
@@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
import { cCppExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -317,6 +318,38 @@ const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
export const cProvider = defineLanguage({
id: SupportedLanguages.C,
extensions: ['.c'],
entryPointPatterns: [
/^main$/,
/^init_/,
/_init$/,
/^start_/,
/_start$/,
/^run_/,
/_run$/,
/^stop_/,
/_stop$/,
/^open_/,
/_open$/,
/^close_/,
/_close$/,
/^create_/,
/_create$/,
/^destroy_/,
/_destroy$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^cmd_/,
/^server_/,
/^client_/,
/^session_/,
/^window_/,
/^key_/,
/^input_/,
/^output_/,
/^notify_/,
/^control_/,
],
treeSitterQueries: C_QUERIES,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
@@ -338,6 +371,44 @@ export const cProvider = defineLanguage({
export const cppProvider = defineLanguage({
id: SupportedLanguages.CPlusPlus,
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
entryPointPatterns: [
/^main$/,
/^init_/,
/_init$/,
/^Create[A-Z]/,
/^create_/,
/^Run$/,
/^run$/,
/^Start$/,
/^start$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^OnEvent/,
/^on_/,
/::Run$/,
/::Start$/,
/::Init$/,
/::Execute$/,
],
astFrameworkPatterns: [
{
framework: 'qt',
entryPointMultiplier: 2.8,
reason: 'qt-macro',
patterns: [
'Q_OBJECT',
'Q_INVOKABLE',
'Q_PROPERTY',
'Q_SIGNALS',
'Q_SLOTS',
'Q_SIGNAL',
'Q_SLOT',
'QWidget',
'QApplication',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CPP_QUERIES,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
@@ -16,6 +16,8 @@ export const cobolProvider = defineLanguage({
id: SupportedLanguages.Cobol,
parseStrategy: 'standalone',
extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: '',
typeConfig: {
declarationNodeTypes: new Set(),
@@ -16,6 +16,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
@@ -135,6 +136,55 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const csharpProvider = defineLanguage({
id: SupportedLanguages.CSharp,
extensions: ['.cs'],
entryPointPatterns: [
/^(Get|Post|Put|Delete|Patch)/,
/Action$/,
/^On[A-Z]/,
/Async$/,
/^Configure$/,
/^ConfigureServices$/,
/^Handle$/,
/^Execute$/,
/^Invoke$/,
/^Map[A-Z]/,
/Service$/,
/^Seed/,
],
astFrameworkPatterns: [
{
framework: 'aspnet',
entryPointMultiplier: 3.2,
reason: 'aspnet-attribute',
patterns: [
'[ApiController]',
'[HttpGet]',
'[HttpPost]',
'[HttpPut]',
'[HttpDelete]',
'[Route]',
'[Authorize]',
'[AllowAnonymous]',
],
},
{
framework: 'signalr',
entryPointMultiplier: 2.8,
reason: 'signalr-attribute',
patterns: ['[HubMethodName]', ': Hub', ': Hub<'],
},
{
framework: 'blazor',
entryPointMultiplier: 2.5,
reason: 'blazor-attribute',
patterns: ['@page', '[Parameter]', '@inject'],
},
{
framework: 'efcore',
entryPointMultiplier: 2.0,
reason: 'efcore-pattern',
patterns: ['DbContext', 'DbSet<', 'OnModelCreating'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CSHARP_QUERIES,
typeConfig: csharpConfig,
exportChecker: csharpExportChecker,
@@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { dartClassConfig } from '../class-extractors/configs/dart.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
import { dartExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -93,6 +94,42 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const dartProvider = defineLanguage({
id: SupportedLanguages.Dart,
extensions: ['.dart'],
entryPointPatterns: [
/^main$/,
/^build$/,
/^createState$/,
/^initState$/,
/^dispose$/,
/^didChangeDependencies$/,
/^didUpdateWidget$/,
/^runApp$/,
/^onEvent$/,
/^mapEventToState$/,
],
astFrameworkPatterns: [
{
framework: 'flutter',
entryPointMultiplier: 2.5,
reason: 'flutter-widget',
patterns: [
'StatelessWidget',
'StatefulWidget',
'BuildContext',
'Widget build',
'ChangeNotifier',
'GetxController',
'Cubit<',
'Bloc<',
'ConsumerWidget',
],
},
{
framework: 'riverpod',
entryPointMultiplier: 2.8,
reason: 'riverpod-pattern',
patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: DART_QUERIES,
typeConfig: dartConfig,
exportChecker: dartExportChecker,
@@ -18,6 +18,7 @@ import { goExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { goImportConfig } from '../import-resolvers/configs/go.js';
import { GO_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@@ -32,6 +33,45 @@ import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
export const goProvider = defineLanguage({
id: SupportedLanguages.Go,
extensions: ['.go'],
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
astFrameworkPatterns: [
{
framework: 'go-http',
entryPointMultiplier: 2.5,
reason: 'go-http-handler',
patterns: [
'http.Handler',
'http.HandlerFunc',
'ServeHTTP',
'http.ResponseWriter',
'http.Request',
],
},
{
framework: 'gin',
entryPointMultiplier: 3.0,
reason: 'gin-handler',
patterns: ['gin.Context', 'gin.Default', 'gin.New'],
},
{
framework: 'echo',
entryPointMultiplier: 3.0,
reason: 'echo-handler',
patterns: ['echo.Context', 'echo.New'],
},
{
framework: 'fiber',
entryPointMultiplier: 3.0,
reason: 'fiber-handler',
patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
},
{
framework: 'go-grpc',
entryPointMultiplier: 2.8,
reason: 'grpc-service',
patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: GO_QUERIES,
typeConfig: goConfig,
exportChecker: goExportChecker,
@@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
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 { javaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -30,6 +31,27 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js';
export const javaProvider = defineLanguage({
id: SupportedLanguages.Java,
extensions: ['.java'],
entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/],
astFrameworkPatterns: [
{
framework: 'spring',
entryPointMultiplier: 3.2,
reason: 'spring-annotation',
patterns: [
'@RestController',
'@Controller',
'@GetMapping',
'@PostMapping',
'@RequestMapping',
],
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: JAVA_QUERIES,
typeConfig: javaTypeConfig,
exportChecker: javaExportChecker,
@@ -18,6 +18,7 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
@@ -105,6 +106,47 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const kotlinProvider = defineLanguage({
id: SupportedLanguages.Kotlin,
extensions: ['.kt', '.kts'],
entryPointPatterns: [
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/,
/^do[A-Z]/,
/^create[A-Z]/,
/^build[A-Z]/,
/ViewModel$/,
/^module$/,
/Service$/,
],
astFrameworkPatterns: [
{
framework: 'spring-kotlin',
entryPointMultiplier: 3.2,
reason: 'spring-kotlin-annotation',
patterns: [
'@RestController',
'@Controller',
'@GetMapping',
'@PostMapping',
'@RequestMapping',
],
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
},
{
framework: 'ktor',
entryPointMultiplier: 2.8,
reason: 'ktor-routing',
patterns: ['routing', 'embeddedServer', 'Application.module'],
},
{
framework: 'android-kotlin',
entryPointMultiplier: 2.5,
reason: 'android-annotation',
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: KOTLIN_QUERIES,
typeConfig: kotlinTypeConfig,
exportChecker: kotlinExportChecker,
@@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -239,6 +240,41 @@ function isPhpRouteFile(filePath: string): boolean {
export const phpProvider = defineLanguage({
id: SupportedLanguages.PHP,
extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
entryPointPatterns: [
/Controller$/,
/^handle$/,
/^execute$/,
/^boot$/,
/^register$/,
/^__invoke$/,
/^(index|show|store|update|destroy|create|edit)$/,
/^(get|post|put|delete|patch)[A-Z]/,
/^run$/,
/^fire$/,
/^dispatch$/,
/Service$/,
/Repository$/,
/^find$/,
/^findAll$/,
/^save$/,
/^delete$/,
],
astFrameworkPatterns: [
{
framework: 'laravel',
entryPointMultiplier: 3.0,
reason: 'php-route-attribute',
patterns: [
'Route::get',
'Route::post',
'Route::put',
'Route::delete',
'Route::resource',
'Route::apiResource',
'#[Route(',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: PHP_QUERIES,
typeConfig: phpConfig,
exportChecker: phpExportChecker,
@@ -10,10 +10,12 @@
* - namedBindingExtractor: present (from X import Y)
*/
import type { NodeLabel } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { pythonClassConfig } from '../class-extractors/configs/python.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
import { pythonExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -29,8 +31,11 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { pythonCallConfig } from '../call-extractors/configs/python.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import type { CaptureMap } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import {
emitPythonScopeCaptures,
pythonFunctionDefinitionLabel,
interpretPythonImport,
interpretPythonTypeBinding,
pythonArityCompatibility,
@@ -71,9 +76,52 @@ const BUILT_INS: ReadonlySet<string> = new Set([
'abs',
]);
function pythonDescriptionExtractor(
nodeLabel: NodeLabel,
_nodeName: string,
captureMap: CaptureMap,
): string | undefined {
if (nodeLabel !== 'Function' && nodeLabel !== 'Method') return undefined;
const functionNode = captureMap['definition.function'] ?? captureMap['definition.method'];
if (functionNode === undefined) return undefined;
return extractPythonDocstring(functionNode);
}
function extractPythonDocstring(functionNode: SyntaxNode): string | undefined {
const body = functionNode.childForFieldName('body');
const firstStatement = body?.namedChild(0);
if (firstStatement?.type !== 'expression_statement') return undefined;
const literal = firstStatement.namedChild(0);
if (literal?.type !== 'string') return undefined;
return normalizePythonStringLiteral(literal.text);
}
function normalizePythonStringLiteral(text: string): string | undefined {
const match = text.match(/^[rRuUbBfF]*("""|'''|"|')([\s\S]*)\1$/);
const raw = match?.[2]?.trim();
if (!raw) return undefined;
return raw.replace(/\s+/g, ' ');
}
export const pythonProvider = defineLanguage({
id: SupportedLanguages.Python,
extensions: ['.py'],
entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/],
astFrameworkPatterns: [
{
framework: 'fastapi',
entryPointMultiplier: 3.0,
reason: 'fastapi-decorator',
patterns: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
},
{
framework: 'flask',
entryPointMultiplier: 2.8,
reason: 'flask-decorator',
patterns: ['@app.route', '@blueprint.route'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: PYTHON_QUERIES,
typeConfig: pythonConfig,
exportChecker: pythonExportChecker,
@@ -87,7 +135,9 @@ export const pythonProvider = defineLanguage({
variableExtractor: createVariableExtractor(pythonVariableConfig),
classExtractor: createClassExtractor(pythonClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Python),
descriptionExtractor: pythonDescriptionExtractor,
builtInNames: BUILT_INS,
labelOverride: pythonFunctionDefinitionLabel,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
// Python is the first migration. See ./python/index.ts for the
@@ -24,6 +24,7 @@ import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
export function emitPythonScopeCaptures(
sourceText: string,
@@ -37,14 +38,24 @@ export function emitPythonScopeCaptures(
// here at the use site.
let tree = cachedTree as ReturnType<ReturnType<typeof getPythonParser>['parse']> | undefined;
if (tree === undefined) {
tree = getPythonParser().parse(sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
try {
tree = getPythonParser().parse(sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
throw scopeExtractionError('parse', _filePath, err);
}
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getPythonScopeQuery().matches(tree.rootNode);
let rawMatches: ReturnType<ReturnType<typeof getPythonScopeQuery>['matches']>;
try {
rawMatches = getPythonScopeQuery().matches(tree.rootNode);
} catch (err) {
throw scopeExtractionError('scope query', _filePath, err);
}
const out: CaptureMatch[] = [];
@@ -98,6 +109,10 @@ export function emitPythonScopeCaptures(
const anchorCap = grouped['@declaration.function']!;
const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition');
if (fnNode !== null) {
if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') {
delete grouped['@declaration.function'];
grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' };
}
const arity = computePythonArityMetadata(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
@@ -133,3 +148,10 @@ export function emitPythonScopeCaptures(
return out;
}
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
const reason = err instanceof Error ? err.message : String(err);
return new Error(
`[python] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
);
}
@@ -61,60 +61,159 @@ export function resolvePythonImportTarget(
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
if (pathLike.includes('/')) {
const [leadingSegment] = pathLike.split('/').filter(Boolean);
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths)) {
if (!leadingSegment || !hasRepoCandidate(leadingSegment, ctx.allFilePaths, ctx.fromFile)) {
return null;
}
}
// Multi-segment absolute resolve: try exact paths first, then suffix
// match in nested repos. Using direct `Set.has` + `endsWith` instead of
// `suffixResolve`'s shared helper because that helper requires a
// pre-built `SuffixIndex` to disambiguate ties — without one it falls
// back to an O(files) scan that silently picks the wrong file when
// the last segment collides across directories (e.g. `accounts.models`
// matching `billing/models.py` when both files exist).
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths);
// Multi-segment absolute resolve: try exact paths first, then ancestor
// walk (mirrors the single-segment ancestor walk in
// `resolvePythonImportInternal`), then a suffix match in nested repos.
// Using direct `Set.has` + `endsWith` instead of `suffixResolve`'s shared
// helper because that helper requires a pre-built `SuffixIndex` to
// disambiguate ties — without one it falls back to an O(files) scan that
// silently picks the wrong file when the last segment collides across
// directories (e.g. `accounts.models` matching `billing/models.py` when
// both files exist).
return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile);
}
/**
* Resolve `package/sub/module` style paths (already dot-flattened) to a
* concrete file in `allFilePaths`. Tries the exact path first, then the
* `__init__.py` variant, then a suffix match for nested layouts.
* concrete file in `allFilePaths`. Tries the exact path first, then walks
* ancestors of `fromFile` looking for `<ancestor>/<pathLike>.py` (or
* `__init__.py`), then falls back to a suffix match for nested layouts.
* Returns the original (un-normalized) path from the set.
*
* Precedence order:
* 1. Workspace-root direct hit (`<pathLike>.py`, `<pathLike>/__init__.py`).
* 2. Closest-ancestor match walking up from the importer's directory.
* 3. Suffix fallback (deterministic: fewest path segments, then
* lexicographic on the normalized path).
*
* Root wins over ancestor by construction — if both `services/sync.py` and
* `backend/services/sync.py` exist, `backend/routers/cron.py`'s
* `from services.sync import X` resolves to the root file. This mirrors
* Python's `sys.path` semantics where the project root is searched first.
*
* The ancestor walk mirrors the single-segment behavior in
* `resolvePythonImportInternal`. For `from services.sync import X` in
* `backend/routers/cron.py`, walk up: `backend/routers/services/sync.py` →
* `backend/services/sync.py` ✓.
*/
function resolveAbsoluteFromFiles(pathLike: string, allFilePaths: Set<string>): string | null {
function resolveAbsoluteFromFiles(
pathLike: string,
allFilePaths: Set<string>,
fromFile: string,
): string | null {
const directFile = `${pathLike}.py`;
const directPkg = `${pathLike}/__init__.py`;
const suffixFile = `/${directFile}`;
const suffixPkg = `/${directPkg}`;
let suffixMatch: string | null = null;
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (f === directFile || f === directPkg) return raw;
if (suffixMatch === null && (f.endsWith(suffixFile) || f.endsWith(suffixPkg))) {
suffixMatch = raw;
// Direct hit at workspace root.
if (allFilePaths.has(directFile)) return directFile;
if (allFilePaths.has(directPkg)) return directPkg;
// Ancestor walk — match the single-segment resolver's behavior at
// multi-segment granularity. Closest match wins. Stop at `i > 0` because
// `i === 0` would re-check the workspace-root candidates already covered
// by the direct check above.
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
if (importerDir) {
const dirParts = importerDir.split('/').filter(Boolean);
for (let i = dirParts.length; i > 0; i--) {
const ancestor = dirParts.slice(0, i).join('/');
const prefix = `${ancestor}/`;
const candidateFile = `${prefix}${directFile}`;
const candidatePkg = `${prefix}${directPkg}`;
if (allFilePaths.has(candidateFile)) return candidateFile;
if (allFilePaths.has(candidatePkg)) return candidatePkg;
}
}
return suffixMatch;
// Suffix-match fallback (preserved for monorepo/nested-repo layouts
// that don't share a directory ancestor with the importer).
//
// Tie-break order when multiple files match the same suffix:
// 1. Fewest path segments (shorter, more canonical paths win — `lib/x.py`
// beats `tooling/extras/x.py`).
// 2. Lexicographic order over the normalized path (final stable
// tiebreak independent of file-set insertion order).
//
// Without an explicit tie-break the previous implementation returned
// the first match in `Set` iteration order, which depended on file
// ingestion order and produced non-deterministic edges across runs in
// multi-directory collision repos.
const suffixFile = `/${directFile}`;
const suffixPkg = `/${directPkg}`;
const matches: { raw: string; norm: string }[] = [];
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
if (norm.endsWith(suffixFile) || norm.endsWith(suffixPkg)) {
matches.push({ raw, norm });
}
}
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0].raw;
matches.sort((a, b) => {
const aDepth = a.norm.split('/').length;
const bDepth = b.norm.split('/').length;
if (aDepth !== bDepth) return aDepth - bDepth;
if (a.norm < b.norm) return -1;
if (a.norm > b.norm) return 1;
return 0;
});
return matches[0].raw;
}
/**
* Does the repo contain a module/package named `leadingSegment` at the top
* level? Used to guard against false-positive suffix matches on external
* dotted imports (e.g. `django.apps` matching a local `accounts/apps.py`).
* Does the repo contain a module/package named `leadingSegment` somewhere
* the importer can plausibly reach?
*
* Checks, in order: `<segment>.py` root file, `<segment>/__init__.py`
* regular package, or any `<segment>/**.py` file (namespace package).
* Used to guard against false-positive suffix matches on external dotted
* imports (e.g. `django.apps` matching a local `accounts/apps.py`).
*
* Checks, in order:
* 1. `SEGMENT.py` root file or `SEGMENT/__init__.py` regular package.
* 2. Any `SEGMENT/...py` file at the workspace root (namespace package).
* 3. Any `<importer-ancestor>/SEGMENT/...py` file (nested namespace
* package the importer could reach via an ancestor walk, e.g.
* `backend/services/sync.py` from `backend/routers/cron.py`).
*
* The nested case is bounded to the importer's own ancestors so a
* vendored copy of an external package (e.g. `vendor/django/urls.py`)
* does not gate-pass external imports like `from django.urls import path`
* issued from `app/main.py`. Files inside the vendored tree itself
* (importer under `vendor/django/...`) still resolve correctly because
* the ancestor walk includes their own parents.
*/
function hasRepoCandidate(leadingSegment: string, allFilePaths: Set<string>): boolean {
function hasRepoCandidate(
leadingSegment: string,
allFilePaths: Set<string>,
fromFile: string,
): boolean {
const prefix = `${leadingSegment}/`;
const rootFile = `${leadingSegment}.py`;
const initFile = `${leadingSegment}/__init__.py`;
// Build importer-ancestor prefixes: for `backend/routers/cron.py`,
// produces `["backend/routers/services/", "backend/services/"]` for
// segment `services` (closest first, root excluded — covered above).
const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : [];
const ancestorPrefixes: string[] = [];
for (let i = dirParts.length; i > 0; i--) {
ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`);
}
for (const raw of allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (f === rootFile || f === initFile) return true;
if (f.startsWith(prefix) && f.endsWith('.py')) return true;
if (f.endsWith('.py')) {
for (const ap of ancestorPrefixes) {
if (f.startsWith(ap)) return true;
}
}
}
return false;
}
@@ -80,6 +80,7 @@ export { pythonArityCompatibility } from './arity.js';
export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js';
export {
pythonBindingScopeFor,
pythonFunctionDefinitionLabel,
pythonImportOwningScope,
pythonReceiverBinding,
} from './simple-hooks.js';
@@ -8,12 +8,30 @@
import type {
CaptureMatch,
NodeLabel,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
import type { SyntaxNode } from 'tree-sitter';
import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js';
const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet<string> = new Set(['class_definition']);
export function pythonFunctionDefinitionLabel(
functionNode: SyntaxNode,
defaultLabel: NodeLabel,
): NodeLabel {
if (defaultLabel !== 'Function') return defaultLabel;
const ancestor = findAncestorBeforeBoundary(
functionNode,
PYTHON_METHOD_CONTAINER_TYPES,
FUNCTION_NODE_TYPES,
);
return ancestor === null ? 'Function' : 'Method';
}
// ─── bindingScopeFor ──────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
import { routeRubyCall } from '../call-routing.js';
@@ -151,6 +152,31 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
export const rubyProvider = defineLanguage({
id: SupportedLanguages.Ruby,
extensions: ['.rb', '.rake', '.gemspec'],
entryPointPatterns: [/^call$/, /^perform$/, /^execute$/],
astFrameworkPatterns: [
{
framework: 'rails',
entryPointMultiplier: 3.0,
reason: 'rails-pattern',
patterns: [
'ApplicationController',
'ApplicationRecord',
'ActiveRecord::Base',
'before_action',
'after_action',
'has_many',
'belongs_to',
'has_one',
'validates',
],
},
{
framework: 'sinatra',
entryPointMultiplier: 2.8,
reason: 'sinatra-pattern',
patterns: ['Sinatra::Base', 'Sinatra::Application'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: RUBY_QUERIES,
typeConfig: rubyConfig,
exportChecker: rubyExportChecker,
@@ -22,6 +22,7 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
import { extractRustNamedBindings } from '../named-bindings/rust.js';
import { RUST_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@@ -121,6 +122,41 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const rustProvider = defineLanguage({
id: SupportedLanguages.Rust,
extensions: ['.rs'],
entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/],
astFrameworkPatterns: [
{
framework: 'actix-web',
entryPointMultiplier: 3.0,
reason: 'actix-attribute',
patterns: [
'#[get',
'#[post',
'#[put',
'#[delete',
'#[actix_web',
'HttpRequest',
'HttpResponse',
],
},
{
framework: 'axum',
entryPointMultiplier: 3.0,
reason: 'axum-routing',
patterns: ['Router::new', 'axum::extract', 'axum::routing'],
},
{
framework: 'rocket',
entryPointMultiplier: 3.0,
reason: 'rocket-attribute',
patterns: ['#[get', '#[post', '#[launch', 'rocket::'],
},
{
framework: 'tokio',
entryPointMultiplier: 2.5,
reason: 'tokio-runtime',
patterns: ['#[tokio::main]', '#[tokio::test]'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: RUST_QUERIES,
typeConfig: rustConfig,
exportChecker: rustExportChecker,
+75 -1
View File
@@ -11,10 +11,11 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
import { swiftExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -128,6 +129,24 @@ const swiftExtractFunctionName = (
return null; // fall through to generic
};
const orderSwiftSameNameTypeCandidates = ({
callSiteFilePath,
candidates,
}: {
readonly typeName: string;
readonly callSiteFilePath: string;
readonly candidates: readonly SymbolDefinition[];
}): readonly SymbolDefinition[] | null => {
if (!callSiteFilePath.endsWith('.swift')) return null;
if (candidates.length <= 1) return null;
if (!candidates.every((c) => c.type === candidates[0].type)) return null;
if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null;
if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null;
return [...candidates].sort(
(a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath),
);
};
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
'debugPrint',
@@ -241,6 +260,60 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const swiftProvider = defineLanguage({
id: SupportedLanguages.Swift,
extensions: ['.swift'],
entryPointPatterns: [
/^viewDidLoad$/,
/^viewWillAppear$/,
/^viewDidAppear$/,
/^viewWillDisappear$/,
/^viewDidDisappear$/,
/^application\(/,
/^scene\(/,
/^body$/,
/Coordinator$/,
/^sceneDidBecomeActive$/,
/^sceneWillResignActive$/,
/^didFinishLaunchingWithOptions$/,
/ViewController$/,
/^configure[A-Z]/,
/^setup[A-Z]/,
/^makeBody$/,
],
astFrameworkPatterns: [
{
framework: 'uikit',
entryPointMultiplier: 2.5,
reason: 'uikit-lifecycle',
patterns: [
'viewDidLoad',
'viewWillAppear',
'viewDidAppear',
'UIViewController',
'@IBOutlet',
'@IBAction',
'@objc',
],
},
{
framework: 'swiftui',
entryPointMultiplier: 2.8,
reason: 'swiftui-pattern',
patterns: [
'@main',
'WindowGroup',
'ContentView',
'@StateObject',
'@ObservedObject',
'@EnvironmentObject',
'@Published',
],
},
{
framework: 'vapor',
entryPointMultiplier: 3.0,
reason: 'vapor-routing',
patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: SWIFT_QUERIES,
typeConfig: swiftConfig,
exportChecker: swiftExportChecker,
@@ -257,5 +330,6 @@ export const swiftProvider = defineLanguage({
classExtractor: createClassExtractor(swiftClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift),
implicitImportWirer: wireSwiftImplicitImports,
orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates,
builtInNames: BUILT_INS,
});
@@ -10,6 +10,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import {
typescriptClassConfig,
@@ -57,8 +58,30 @@ import {
} from './typescript/index.js';
/**
* TypeScript/JavaScript: arrow_function and function_expression get their name
* from the parent variable_declarator (e.g. `const foo = () => {}`).
* TypeScript/JavaScript: arrow_function and function_expression are
* anonymous AST nodes — they take their name from the surrounding
* declarative context.
*
* Recognised contexts:
* - `const foo = () => {}` (variable_declarator) → "foo"
* - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem"
* Covers Zustand stores, TanStack Query factories, React Context
* providers, and most other HOF-heavy idioms (issue #1166).
* - `const X = HOC((args) => { ... })` (arguments → call_expression →
* variable_declarator) → "X". Covers `React.forwardRef`, `memo`,
* `useCallback`, `useMemo`, `observer`, `debounce`, and other HOC
* factories that wrap their behaviour-defining arrow. Without this
* branch, every shadcn/Radix UI component (`const Button =
* React.forwardRef(...)`) registered as an anonymous arrow with
* calls inside falling back to File-level attribution. The same
* applied to all `useCallback` / `useMemo` callbacks bound to a
* const — the sole way to give them a named caller anchor.
*
* Returns `null` for funcName when the arrow lives in a context that has
* no static name — bare call arguments (not bound to a const), computed
* keys, return-from-arrow positions. The parent walk in
* findEnclosingFunctionId then continues up to the next named ancestor
* (or to the file).
*/
const tsExtractFunctionName = (
node: SyntaxNode,
@@ -66,19 +89,74 @@ const tsExtractFunctionName = (
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
const parent = node.parent;
if (parent?.type !== 'variable_declarator') return null;
if (!parent) return null;
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
if (parent.type === 'variable_declarator') {
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Function' };
}
return { funcName: nameNode?.text ?? null, label: 'Function' };
// Object property pair: `{ addItem: (item) => ... }`.
// tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes
// `pair`. (Older grammars used `property_assignment`; we accept both.)
if (parent.type === 'pair' || parent.type === 'property_assignment') {
const keyNode = parent.childForFieldName?.('key');
if (!keyNode) return { funcName: null, label: 'Function' };
if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') {
return { funcName: keyNode.text, label: 'Function' };
}
if (keyNode.type === 'string') {
// `"add-item": () => ...` — the literal text inside the quotes.
const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment');
const text = fragment?.text ?? null;
return { funcName: text, label: 'Function' };
}
// computed_property_name (`[ACTION_KEY]`) and other dynamic keys have
// no static name — fall through anonymous.
return { funcName: null, label: 'Function' };
}
// HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`,
// `const handleClick = useCallback(() => doStuff(), [deps])`,
// `const Card = React.memo((props) => { ... })`. The arrow's `parent` is
// `arguments`, grandparent is `call_expression`, great-grandparent is
// `variable_declarator`. Walk the chain up and take the variable's name
// — the meaningful identifier the developer wrote on the LHS. Mirrors
// the four registry-primary patterns in `typescript/query.ts`. The
// wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`,
// user-defined HOCs) is intentionally NOT constrained: any function
// call whose result is bound to a const and whose first/positional
// argument is an arrow takes the const's name. Chained array-method
// calls (`const x = arr.find((y) => p(y))`) match too and produce a
// mostly-harmless `Function:x` (consumed as a value, never invoked),
// accepted as a small false-positive cost vs. the much larger gain of
// capturing the React UI-component idiom.
if (parent.type === 'arguments') {
const callExpr = parent.parent;
if (!callExpr || callExpr.type !== 'call_expression') {
return { funcName: null, label: 'Function' };
}
const declarator = callExpr.parent;
if (!declarator || declarator.type !== 'variable_declarator') {
return { funcName: null, label: 'Function' };
}
const nameNode = declarator.childForFieldName?.('name');
if (nameNode?.type === 'identifier') {
return { funcName: nameNode.text, label: 'Function' };
}
return { funcName: null, label: 'Function' };
}
return { funcName: null, label: 'Function' };
};
export const BUILT_INS: ReadonlySet<string> = new Set([
@@ -181,6 +259,29 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
export const typescriptProvider = defineLanguage({
id: SupportedLanguages.TypeScript,
extensions: ['.ts', '.tsx'],
entryPointPatterns: [/^use[A-Z]/],
astFrameworkPatterns: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
@@ -220,6 +321,29 @@ export const typescriptProvider = defineLanguage({
export const javascriptProvider = defineLanguage({
id: SupportedLanguages.JavaScript,
extensions: ['.js', '.jsx'],
entryPointPatterns: [/^use[A-Z]/],
astFrameworkPatterns: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: JAVASCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
@@ -84,6 +84,11 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu
* as `@reference.write.member`).
* 4. The member_expression is the `function:` of an `await_expression`
* being called (handled by the member-call capture).
* 5. The member_expression is the `name:` of a `jsx_self_closing_element`
* or `jsx_opening_element` (it's a JSX component invocation, already
* captured as `@reference.call.member` by the TSX-only query suffix).
* Without this filter, `<Foo.Bar />` would emit a phantom ACCESSES
* edge to `Foo.Bar` IN ADDITION to the CALLS edge.
*
* Returns `true` when the capture should be kept as a read reference,
* `false` when it should be dropped.
@@ -99,6 +104,9 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
case 'assignment_expression':
case 'augmented_assignment_expression':
return parent.childForFieldName('left')?.id !== memberNode.id;
case 'jsx_self_closing_element':
case 'jsx_opening_element':
return parent.childForFieldName('name')?.id !== memberNode.id;
default:
return true;
}
@@ -232,6 +240,20 @@ export function emitTsScopeCaptures(
// arity filter can narrow overloads. Count the `argument` named
// children of the backing `arguments` node. TypeScript constructor
// calls use `new_expression`; regular calls use `call_expression`.
//
// JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element`
// captured by the TSX-only suffix in `query.ts`) intentionally do
// NOT carry arity metadata. The lookup below would resolve `callNode`
// to `null` for a JSX anchor (the anchor is neither a call_expression
// nor a new_expression), so the synthesis branch silently no-ops and
// the JSX call enters the registry with name-only resolution. This
// is acceptable for React: components are virtually never
// overloaded in the current GitNexus graph model, so name-only
// dispatch matches the single component definition. If a future
// codebase introduces overloaded React components AND needs JSX
// calls to disambiguate by props-arity, a JSX-aware arity
// synthesizer would need to count `jsx_attribute` children of the
// opening tag instead of `arguments`.
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const callNode =
@@ -136,25 +136,172 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; Arrow/function-expression assigned to a const/let/var — named by the
;; variable_declarator. Covers \`const fn = () => {}\` and its export
;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern.
;;
;; The \`@declaration.function\` anchor sits on the INNER arrow_function /
;; function_expression node (NOT the wrapping lexical_declaration), so
;; \`anchor.range\` aligns with the corresponding \`@scope.function\` scope
;; range. \`pass2AttachDeclarations\` then resolves \`innermost\` to the
;; arrow's own scope (instead of the module scope) and the def is owned
;; by the arrow itself. Without this alignment, calls inside the arrow
;; body lose caller attribution: \`resolveCallerGraphId\` walks up past
;; the empty arrow scope into the module scope and grabs whichever
;; Function-like def appears first there — silently mis-attributing
;; every nested call (Zustand stores, TanStack hooks, Promise-all/map,
;; etc.). See \`typescript-hof-callbacks.test.ts\`.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
value: (arrow_function) @declaration.function))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
value: (function_expression) @declaration.function))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (arrow_function))) @declaration.function
value: (arrow_function) @declaration.function))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (function_expression))) @declaration.function
value: (function_expression) @declaration.function))
;; Object-property arrows / function expressions named by their pair key:
;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy
;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps
;; scope-resolution declarations in sync (issue #1166). Computed keys
;; (\`[K]: () => ...\`) intentionally fall through anonymous.
;;
;; Same anchor discipline as the \`lexical_declaration\` block above: the
;; \`@declaration.function\` capture must sit on the INNER \`arrow_function\`
;; / \`function_expression\` node — NOT the outer \`pair\`. The pair node
;; starts at the property-key token, BEFORE the arrow's
;; \`@scope.function\` range. \`pass2AttachDeclarations.atPosition(pair.startLine,
;; pair.startCol)\` therefore resolves to the PARENT scope (the enclosing
;; function-like, e.g. the \`(set) => ({...})\` callback in
;; \`persist((set) => ({...}))\`), not the inner arrow's own scope.
;;
;; With the anchor on \`pair\`, ALL pair-function defs from the same object
;; literal land in the same parent scope's \`ownedDefs\`. \`resolveCallerGraphId\`
;; walking up from a call inside any of those arrows then matches the
;; FIRST Function-like def via \`ownedDefs.find()\` — silently mis-attributing
;; every call to the first sibling. Multi-action Zustand stores
;; (\`{ addItem, removeItem, fetchData, … }\`) — the dominant 0%-capture
;; pattern in the bug report — would land all calls on \`addItem\`.
;;
;; With the anchor on the inner \`arrow_function\` / \`function_expression\`,
;; \`anchor.range\` matches the arrow's own \`@scope.function\` range; the
;; def lands in the arrow scope's own \`ownedDefs\` and \`pass2AttachDeclarations\`'s
;; auto-hoist (\`rangesEqual(anchor.range, innermost.range)\`) promotes
;; the BINDING to the parent scope (so importers and lookups still find
;; the name in the object's surrounding scope). Each pair-arrow becomes
;; an independent caller anchor in the walk.
(pair
key: (property_identifier) @declaration.name
value: (arrow_function) @declaration.function)
(pair
key: (property_identifier) @declaration.name
value: (function_expression) @declaration.function)
(pair
key: (string (string_fragment) @declaration.name)
value: (arrow_function) @declaration.function)
(pair
key: (string (string_fragment) @declaration.name)
value: (function_expression) @declaration.function)
;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
;;
;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`,
;; bare \`forwardRef\` / \`memo\` / \`observer\`), Hook callbacks
;; (\`useCallback\`, \`useMemo\`), and library-wrapper factories (\`debounce\`,
;; \`throttle\`, user-defined \`withErrorBoundary\` / \`createHook\`, etc.).
;; All produce the same AST shape:
;;
;; lexical_declaration
;; variable_declarator
;; name: identifier "X" ← we want this name
;; value: call_expression
;; function: identifier | member_expression ← any callee
;; arguments: arguments
;; arrow_function | function_expression ← the actual code
;;
;; The pre-fix \`tsExtractFunctionName\` only handled \`variable_declarator\`
;; and \`pair\` parents, so HOC-wrapped arrows fell through anonymous. The
;; registry-primary \`query.ts\` had no pattern for this shape either —
;; \`const Button = forwardRef((p, r) => { ... })\` registered as a
;; \`Variable\` with no \`Function\` def, and every call inside the arrow
;; body lost caller attribution: \`resolveCallerGraphId\` walked up past
;; the empty arrow scope to the module's File fallback. Sourcerer-fe alone
;; has ~296 such declarations (57 forwardRef + 21 memo + 161 useCallback
;; + 57 useMemo) — all invisible to \`gitnexus_context\` /
;; \`gitnexus_impact\` for outgoing edges before this fix.
;;
;; Anchor discipline: same as the \`lexical_declaration\` / \`pair\` blocks
;; above — on the INNER \`arrow_function\` / \`function_expression\`, NOT
;; the outer \`call_expression\`. The arrow's range matches its own
;; \`@scope.function\` range, so \`pass2AttachDeclarations.atPosition\`
;; resolves \`innermost\` to the arrow's own scope and
;; \`rangesEqual(anchor.range, innermost.range)\` triggers the auto-hoist
;; that promotes the binding to the parent scope (where \`const X\`
;; lives).
;;
;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\`
;; has the same syntactic shape and would also match, naming the
;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly
;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never
;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The
;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that
;; could in principle be fixed by adding a \`function: [(identifier)
;; (member_expression)]\` predicate that excludes property-identifiers
;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\`
;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here
;; because (a) the false-positive cost is negligible, (b) the blocklist
;; would need maintenance, and (c) any user-defined fluent-API method
;; with a callback argument would still false-positive — there's no
;; clean syntactic line.
;;
;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\`
;; would emit TWO matches with the same name \`x\`. tree-sitter-query
;; iterates all arrow_function direct children of \`arguments\`, so each
;; emits its own \`(name=x, function=...)\` pair. \`pass2AttachDeclarations\`
;; pushes both \`Function:x\` defs into the same arrow scopes (each in
;; its own arrow's \`ownedDefs\`) and hoists both bindings to the parent.
;; The downstream registry's qualified-name dedup then collapses them
;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable;
;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the
;; main one and takes a single executor).
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(arrow_function) @declaration.function))))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(function_expression) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(arrow_function) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(function_expression) @declaration.function))))
;; Method definitions — regular + private (#field) methods.
(method_definition
@@ -723,6 +870,53 @@ const TYPESCRIPT_SCOPE_QUERY = `
property: (property_identifier) @reference.name) @reference.read.member
`;
/**
* JSX-only query suffix. Appended to the base query when compiling
* against the TSX grammar; NOT compiled against the plain TS grammar
* (which has no \`jsx_*\` node types and would reject these patterns).
*
* Why JSX as a CALLS edge: \`<Foo />\` is syntactic sugar for \`Foo(props)\`
* and the React component is invoked by the renderer, so for blast-radius
* (\`gitnexus_impact("Badge", direction: "upstream")\`) and call-graph
* (\`gitnexus_context("Foo")\`) purposes JSX usage IS a call. Routing
* through \`@reference.call.free\` / \`@reference.call.member\` makes the
* downstream caller-walk + edge-emission paths handle JSX uniformly with
* ordinary call expressions — no new edge type, no schema changes.
*
* Identifier-only JSX is filtered to PascalCase via \`(#match? ... "^[A-Z]")\`
* so \`<div>\`, \`<span>\`, \`<button>\` and other native HTML elements (which
* by JSX convention start lowercase) don't emit edges to nonexistent
* "div" / "span" symbols. Member-form JSX (\`<Foo.Bar />\`) is always a
* component (HTML element names can't contain dots), so no predicate
* filter is applied there.
*
* Both \`jsx_self_closing_element\` (\`<Foo />\`) and \`jsx_opening_element\`
* (\`<Foo>...</Foo>\`) emit; the closing tag is intentionally NOT captured —
* each JSX element should emit exactly one CALLS edge per use site.
*/
const TSX_JSX_QUERY_SUFFIX = `
;; <Foo />
((jsx_self_closing_element
name: (identifier) @reference.name) @reference.call.free
(#match? @reference.name "^[A-Z]"))
;; <Foo> ... </Foo> (paired form — match the opening tag only)
((jsx_opening_element
name: (identifier) @reference.name) @reference.call.free
(#match? @reference.name "^[A-Z]"))
;; <Foo.Bar /> / <Container.Section.Title /> — namespaced JSX
(jsx_self_closing_element
name: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.call.member
(jsx_opening_element
name: (member_expression
object: (_) @reference.receiver
property: (property_identifier) @reference.name)) @reference.call.member
`;
let _tsParser: Parser | null = null;
let _tsxParser: Parser | null = null;
let _tsQuery: Parser.Query | null = null;
@@ -753,11 +947,18 @@ export function getTsParser(filePath?: string): Parser {
* executed against a Tree produced by the `tsx` grammar — tree-sitter
* matches by node-type id, and the two grammars have separate id
* spaces.
*
* The TSX query is compiled with the JSX-as-call patterns appended.
* Those patterns reference `jsx_self_closing_element` /
* `jsx_opening_element` which exist only in the TSX grammar — embedding
* them in the plain TS query would throw `Query.InvalidNodeType` at
* compile time (and even if it didn't, the patterns would never fire on
* `.ts` source).
*/
export function getTsScopeQuery(filePath?: string): Parser.Query {
if (filePath !== undefined && isTsxFile(filePath)) {
if (_tsxQuery === null) {
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY);
_tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX);
}
return _tsxQuery;
}
@@ -68,6 +68,8 @@ const vueClassExtractor = createClassExtractor(vueClassConfig);
export const vueProvider = defineLanguage({
id: SupportedLanguages.Vue,
extensions: ['.vue'],
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
@@ -1,5 +1,5 @@
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
// Verified against tree-sitter-swift 0.6.0
// Verified against tree-sitter-swift 0.7.x
import { SupportedLanguages } from 'gitnexus-shared';
import type {
@@ -110,7 +110,7 @@ function extractSwiftReturnType(node: SyntaxNode): string | undefined {
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
// In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration.
// In tree-sitter-swift, parameters are direct children of function_declaration.
// Default value tokens ('=', literal) are siblings of the parameter node at the
// function_declaration level, not children of the parameter node.
for (let i = 0; i < node.childCount; i++) {
@@ -264,8 +264,7 @@ function extractSwiftAnnotations(node: SyntaxNode): string[] {
export const swiftMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Swift,
// tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions,
// and actors — but this cannot be verified until the grammar installs on Node 22+.
// Keep this conservative until Swift type-shape coverage is expanded.
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
// protocol_declaration is a separate, confirmed node type.
@@ -542,10 +542,13 @@ const processParsingSequential = async (
}
}
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
// Functions are not suffixed — they don't overload by name in the same scope.
// Append #<paramCount> to owned callable IDs to disambiguate overloads.
// Top-level Function IDs stay stable; functions inside an owner may overload.
// When same-arity collisions exist, append ~type1,type2 for further disambiguation.
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
const needsAritySuffix =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
(nodeLabel === 'Function' && enclosingClassId !== null);
let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
if (arityTag && seqDefMethods && seqDefMethodInfo && seqClassNodeId !== undefined) {
// Use cached method map + collision groups (built once per class, not per method)
@@ -725,6 +728,14 @@ export const processParsing = async (
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,
): Promise<WorkerExtractedData | null> => {
let lastProgress = 0;
const reportProgress: FileProgressCallback | undefined = onFileProgress
? (current, total, detail) => {
lastProgress = Math.max(lastProgress, current);
onFileProgress(lastProgress, total, detail);
}
: undefined;
if (workerPool) {
if (scopeTreeCache !== undefined && process.env.PROF_SCOPE_RESOLUTION === '1') {
// Trees can't cross MessageChannels, so worker-parsed files land
@@ -742,12 +753,15 @@ export const processParsing = async (
symbolTable,
astCache,
workerPool,
onFileProgress,
reportProgress,
);
} catch (err) {
console.warn(
'Worker pool parsing failed, falling back to sequential:',
err instanceof Error ? err.message : err,
const message = err instanceof Error ? err.message : String(err);
console.warn('Worker pool parsing stopped; continuing with sequential parser:', message);
reportProgress?.(
lastProgress,
files.length,
`Sequential fallback after worker issue: ${message}`,
);
}
}
@@ -759,7 +773,7 @@ export const processParsing = async (
symbolTable,
astCache,
scopeTreeCache,
onFileProgress,
reportProgress,
);
return null;
};
@@ -112,12 +112,15 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
}
list.push(url);
}
const toolsByFile = new Map<string, string[]>();
const toolsByHandlerId = new Map<string, string[]>();
const toolsWithoutHandlerByFile = new Map<string, string[]>();
for (const td of toolDefs) {
let list = toolsByFile.get(td.filePath);
const key = td.handlerNodeId ?? td.filePath;
const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile;
let list = targetMap.get(key);
if (!list) {
list = [];
toolsByFile.set(td.filePath, list);
targetMap.set(key, list);
}
list.push(td.name);
}
@@ -145,7 +148,9 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
linked++;
}
}
const toolNames = toolsByFile.get(entryFile);
const exactToolNames = toolsByHandlerId.get(proc.entryPointId);
const fallbackToolNames = toolsWithoutHandlerByFile.get(entryFile);
const toolNames = exactToolNames ?? fallbackToolNames;
if (toolNames) {
for (const toolName of toolNames) {
const toolNodeId = generateId('Tool', toolName);
@@ -20,6 +20,7 @@ export interface ToolDef {
name: string;
filePath: string;
description: string;
handlerNodeId?: string;
}
export interface ToolsOutput {
@@ -42,7 +43,14 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
for (const td of allToolDefs) {
if (seenToolNames.has(td.toolName)) continue;
seenToolNames.add(td.toolName);
toolDefs.push({ name: td.toolName, filePath: td.filePath, description: td.description });
const handlerNodeId =
td.handlerNodeId && ctx.graph.getNode(td.handlerNodeId) ? td.handlerNodeId : undefined;
toolDefs.push({
name: td.toolName,
filePath: td.filePath,
description: td.description,
...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
});
}
// TS tool definition arrays — require inputSchema nearby
@@ -84,10 +92,10 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
properties: { name: td.name, filePath: td.filePath, description: td.description },
});
const handlerFileId = generateId('File', td.filePath);
const handlerId = td.handlerNodeId ?? generateId('File', td.filePath);
ctx.graph.addRelationship({
id: generateId('HANDLES_TOOL', `${handlerFileId}->${toolNodeId}`),
sourceId: handlerFileId,
id: generateId('HANDLES_TOOL', `${handlerId}->${toolNodeId}`),
sourceId: handlerId,
targetId: toolNodeId,
type: 'HANDLES_TOOL',
confidence: 1.0,
@@ -13,8 +13,11 @@
* `emitScopeCaptures`. Returns `undefined`; zero work done. This is
* the state of every language today — `ParsedFile` production stays
* dormant until a language migrates.
* 2. Invokes the hook + feeds its output to `ScopeExtractor.extract`.
* 3. **Swallows exceptions from either side.** A failure here returns
* 2. Short-circuits empty / whitespace-only files. There is no scope
* content to extract, and some tree-sitter queries do not match an
* otherwise valid empty root node.
* 3. Invokes the hook + feeds its output to `ScopeExtractor.extract`.
* 4. **Swallows exceptions from either side.** A failure here returns
* `undefined` and emits a warning via `onWarn`; legacy parsing on
* the same file continues unaffected by the scope-extraction miss.
* Scope-based resolution is the new path under construction — it
@@ -41,6 +44,7 @@ export function extractParsedFile(
cachedTree?: unknown,
): ParsedFile | undefined {
if (provider.emitScopeCaptures === undefined) return undefined;
if (sourceText.trim().length === 0) return undefined;
try {
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree);
return extractScope(captures, filePath, provider);
+50 -31
View File
@@ -74,7 +74,7 @@ import type {
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
import { buildPositionIndex, buildScopeTree, makeScopeId } from 'gitnexus-shared';
import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared';
import type { LanguageProvider } from './language-provider.js';
// ─── Narrow hook surface the extractor actually uses ───────────────────────
@@ -122,6 +122,7 @@ export function extract(
// ── Pass 1: build the scope tree ─────────────────────────────────────
const scopeDrafts = pass1BuildScopes(partitioned.scope, filePath, provider);
const moduleScope = ensureModuleScope(scopeDrafts, matches.length, filePath);
const scopes = scopeDrafts.map(draftToScope);
// buildScopeTree validates invariants (throws on violation) and exposes
// the lookup contract consumed by Passes 2-5.
@@ -136,14 +137,6 @@ export function extract(
const scopeTree = buildScopeTree(scopes);
const positionIndex = buildPositionIndex(scopes);
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
if (moduleScope === undefined) {
throw new Error(
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
`Provider must emit at least one @scope.module capture per file.`,
);
}
// ── Pass 2: attach declarations + local bindings ────────────────────
const localDefs: SymbolDefinition[] = [];
pass2AttachDeclarations(
@@ -283,6 +276,33 @@ interface ScopeDraft {
readonly typeBindings: Map<string, TypeRef>;
}
function ensureModuleScope(
scopeDrafts: ScopeDraft[],
matchCount: number,
filePath: string,
): ScopeDraft {
const moduleScope = scopeDrafts.find((s) => s.kind === 'Module');
if (moduleScope !== undefined) return moduleScope;
if (scopeDrafts.length === 0 && matchCount === 0) {
const range: Range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 };
const synthetic = makeDraft(
makeScopeId({ filePath, range, kind: 'Module' }),
null,
'Module',
range,
filePath,
);
scopeDrafts.push(synthetic);
return synthetic;
}
throw new Error(
`ScopeExtractor: no Module scope found for '${filePath}'. ` +
`Provider must emit at least one @scope.module capture per file.`,
);
}
function draftToScope(draft: ScopeDraft): Scope {
const frozenBindings = new Map<string, readonly BindingRef[]>();
for (const [name, refs] of draft.bindings) {
@@ -331,20 +351,37 @@ function pass1BuildScopes(
}
// Sort by (startLine, startCol) ASC, (endLine, endCol) DESC so outer
// scopes appear before their children for parent-resolution.
// scopes appear before their children for parent-resolution. When two
// candidates have exactly equal ranges (e.g. a `compilation_unit` and
// the only top-level scope in the file — see `canParentScope`), Module
// sorts first so it lands on the stack ahead of the candidate that will
// claim it as parent.
candidates.sort((a, b) => {
if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine;
if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol;
if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine;
return b.range.endCol - a.range.endCol;
if (a.range.endCol !== b.range.endCol) return b.range.endCol - a.range.endCol;
if (a.kind === b.kind) return 0;
if (a.kind === 'Module') return -1;
if (b.kind === 'Module') return 1;
return 0;
});
const drafts: ScopeDraft[] = [];
const stack: Candidate[] = []; // enclosing real scopes, outermost at [0]
for (const cand of candidates) {
// Pop the stack until the top strictly contains this candidate.
while (stack.length > 0 && !rangeStrictlyContains(stack[stack.length - 1]!.range, cand.range)) {
// Pop the stack until the top can parent this candidate (strict
// containment, plus the equal-range Module carve-out).
while (
stack.length > 0 &&
!canParentScope(
stack[stack.length - 1]!.range,
cand.range,
stack[stack.length - 1]!.kind,
cand.kind,
)
) {
stack.pop();
}
@@ -907,24 +944,6 @@ function rangesEqual(a: Range, b: Range): boolean {
);
}
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
if (
outer.startLine === inner.startLine &&
outer.startCol === inner.startCol &&
outer.endLine === inner.endLine &&
outer.endCol === inner.endCol
) {
return false;
}
const startsBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const endsAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return startsBefore && endsAfter;
}
/**
* Capture names that are never anchors — they are sub-tags nested inside a
* larger anchor (e.g., the receiver expression inside a `@reference.call`
@@ -20,12 +20,38 @@
import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import {
isLinkableLabel,
qualifiedKey,
simpleKey,
type GraphNodeLookup,
} from '../graph-bridge/node-lookup.js';
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
/**
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
* source ("caller"). A Variable / Property can be the TARGET of an
* edge (e.g., a write-access to `user.name`), but it cannot be a
* caller — variables don't execute code, so attributing a call to a
* sibling Variable in the same scope produces nonsense edges like
* `Variable:create → Function:create` (which the simpleKey fallback
* in `resolveDefGraphId` then silently rewrites to
* `Function:create → Function:create`, a self-loop that doesn't exist
* in the source).
*
* Module-level call expressions inside a `const X = expr(args)`
* declaration are the canonical case where this used to fail: the
* walk-up over module scope's ownedDefs (only Variables) would land
* on the FIRST Variable, get name-aliased to its sibling Function
* with the same simple name, and emit a self-CALLS. With this label
* restricted to function/class-likes, those calls correctly fall
* through to the File-node fallback at the bottom of the walk.
*/
function isCallerAnchorLabel(label: NodeLabel): boolean {
return (
label === 'Function' ||
label === 'Method' ||
label === 'Constructor' ||
label === 'Class' ||
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum'
);
}
/**
* Look up a `SymbolDefinition` in the graph node lookup.
@@ -105,7 +131,9 @@ export function resolveCallerGraphId(
if (scope === undefined) break;
lastFilePath = scope.filePath;
// Prefer Function/Method anchors; fall back to Class.
// Prefer Function/Method/Constructor anchors; fall back to
// Class/Interface/Struct/Enum. Variable/Property are NOT valid
// caller anchors — see `isCallerAnchorLabel` for why.
const fnDef = scope.ownedDefs.find(
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
);
@@ -113,7 +141,7 @@ export function resolveCallerGraphId(
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
if (id !== undefined) return id;
}
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type));
if (classDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
if (id !== undefined) return id;
@@ -61,6 +61,86 @@ export const TYPESCRIPT_QUERIES = `
name: (identifier) @name
value: (function_expression)))) @definition.function
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
; The pair's key field carries the meaningful name. Without these patterns,
; calls inside the arrow are attributed to the file (issue #1166), and the
; arrow itself is invisible to context() / impact() despite carrying real
; behaviour (Zustand actions, TanStack queryFn, React Context providers).
; String-key variant covers \`"add-item": () => ...\`; computed keys
; (\`[K]: () => ...\`) intentionally fall through anonymous.
(pair
key: (property_identifier) @name
value: (arrow_function)) @definition.function
(pair
key: (property_identifier) @name
value: (function_expression)) @definition.function
(pair
key: (string (string_fragment) @name)
value: (arrow_function)) @definition.function
(pair
key: (string (string_fragment) @name)
value: (function_expression)) @definition.function
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\`
; so the legacy Call-Resolution DAG and the registry-primary pipeline
; produce the same set of \`Function\` nodes — required for the CI parity
; gate. Covers React.forwardRef / memo / useCallback / useMemo / observer
; / debounce / user-defined HOC factories. The \`var X = HOC(...)\` form is
; mirrored too (registry-primary has it) so that codebases mixing \`var\` and
; \`const\` see identical attribution on both pipelines. See
; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\`
; comment for the full anchor-discipline rationale and the chained-
; array-method trade-off.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression)))))) @definition.function
; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any
; transpiler output that downlevels \`const\` to \`var\`) hits this shape.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -219,6 +299,75 @@ export const JAVASCRIPT_QUERIES = `
name: (identifier) @name
value: (function_expression)))) @definition.function
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
; See TYPESCRIPT_QUERIES for rationale (issue #1166).
(pair
key: (property_identifier) @name
value: (arrow_function)) @definition.function
(pair
key: (property_identifier) @name
value: (function_expression)) @definition.function
(pair
key: (string (string_fragment) @name)
value: (arrow_function)) @definition.function
(pair
key: (string (string_fragment) @name)
value: (function_expression)) @definition.function
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166
; follow-up — covers forwardRef / memo / useCallback / useMemo / observer
; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms
; are mirrored so JS code that uses \`var\` (or transpiler output) gets the
; same attribution as the registry-primary path.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression)))))) @definition.function
; \`var X = HOC(...)\` parity with registry-primary.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -41,6 +41,10 @@ function unwrapSwiftExpression(node: SyntaxNode): SyntaxNode {
return node;
}
function swiftNavigationSuffixName(node: SyntaxNode | null): string | undefined {
return node?.type === 'navigation_suffix' ? node.lastNamedChild?.text : node?.text;
}
/** Swift: let x: Foo = ... */
const extractDeclaration: TypeBindingExtractor = (
node: SyntaxNode,
@@ -119,8 +123,10 @@ const extractInitializer: InitializerExtractor = (
// Explicit init: User.init(name: "alice") — navigation_expression with .init suffix
if (callee.type === 'navigation_expression') {
const receiver = callee.firstNamedChild;
const suffix = callee.lastNamedChild;
if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') {
if (
receiver?.type === 'simple_identifier' &&
swiftNavigationSuffixName(callee.lastNamedChild) === 'init'
) {
const calleeName = receiver.text;
if (calleeName && classNames.has(calleeName)) {
env.set(varName, calleeName);
@@ -133,7 +139,7 @@ const extractInitializer: InitializerExtractor = (
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'property_declaration') return undefined;
if (hasTypeAnnotation(node)) return undefined;
const pattern = node.childForFieldName('pattern');
const pattern = node.childForFieldName('pattern') ?? findChild(node, 'pattern');
if (!pattern) return undefined;
const varName = pattern.text;
if (!varName) return undefined;
@@ -162,7 +168,7 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (callee.type === 'navigation_expression') {
const receiver = callee.firstNamedChild;
const suffix = callee.lastNamedChild;
if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') {
if (receiver?.type === 'simple_identifier' && swiftNavigationSuffixName(suffix) === 'init') {
return { varName, calleeName: receiver.text };
}
// General qualified call: service.getUser() → extract method name.
@@ -166,6 +166,21 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
companion_object: 'Class',
};
/** Return the first matching ancestor unless a boundary ancestor is reached first. */
export function findAncestorBeforeBoundary(
node: SyntaxNode,
targetTypes: ReadonlySet<string>,
boundaryTypes: ReadonlySet<string>,
): SyntaxNode | null {
let current = node.parent;
while (current !== null) {
if (boundaryTypes.has(current.type)) return null;
if (targetTypes.has(current.type)) return current;
current = current.parent;
}
return null;
}
/**
* Determine the graph node label from a tree-sitter capture map.
* Handles language-specific reclassification via the provider's labelOverride hook
@@ -414,10 +429,24 @@ export const findSiblingChild = (
/** Generic name extraction from a function-like AST node.
* Tries `node.childForFieldName('name')?.text`, then scans children for
* `identifier` / `property_identifier` / `simple_identifier`. */
* `identifier` / `property_identifier` / `simple_identifier`.
*
* `arrow_function` and `function_expression` (TS/JS) are inherently
* anonymous — they have no `name` field, and their first identifier
* child is a *parameter*, not a function name. Returning a parameter
* identifier here would synthesize phantom Function IDs (e.g. callers
* walking up from a call inside `arr.map(x => fn(x))` would get
* attributed to a non-existent "Function x"). The language's
* `methodExtractor.extractFunctionName` hook is responsible for naming
* these via parent context (variable_declarator, pair, etc.); when it
* declines, the parent walk should continue rather than fall through
* here. See issue #1166. */
export const genericFuncName = (node: SyntaxNode): string | null => {
const nameField = node.childForFieldName?.('name');
if (nameField) return nameField.text;
if (node.type === 'arrow_function' || node.type === 'function_expression') {
return null;
}
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (
@@ -214,6 +214,7 @@ export interface ExtractedToolDef {
toolName: string;
description: string;
lineNumber: number;
handlerNodeId?: string;
}
export interface ExtractedORMQuery {
@@ -762,7 +763,7 @@ const processBatch = (
let totalProcessed = 0;
let lastReported = 0;
const PROGRESS_INTERVAL = 100; // report every 100 files
const PROGRESS_INTERVAL = Math.max(1, Math.min(100, Math.ceil(files.length / 10)));
const onFileProcessed = onProgress
? () => {
@@ -828,6 +829,10 @@ const processBatch = (
}
}
if (onProgress && totalProcessed !== lastReported) {
onProgress(totalProcessed);
}
return result;
};
@@ -1436,10 +1441,16 @@ const processFileGroup = (
// Runs BEFORE legacy extraction and its result is independent: a
// failure here is caught inside `extractParsedFile` and does NOT
// affect the legacy DAG path that follows.
const parsedFile = extractParsedFile(provider, parseContent, file.path, (message) => {
if (parentPort) parentPort.postMessage({ type: 'warning', message });
else console.warn(message);
});
const parsedFile = extractParsedFile(
provider,
parseContent,
file.path,
(message) => {
if (parentPort) parentPort.postMessage({ type: 'warning', message });
else console.warn(message);
},
tree,
);
if (parsedFile !== undefined) result.parsedFiles.push(parsedFile);
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
@@ -2073,10 +2084,13 @@ const processFileGroup = (
}
}
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
// Functions are not suffixed — they don't overload by name in the same scope.
// Append #<paramCount> to owned callable IDs to disambiguate overloads.
// Top-level Function IDs stay stable; functions inside an owner may overload.
// When same-arity collisions exist, append ~type1,type2 for further disambiguation.
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
const needsAritySuffix =
nodeLabel === 'Method' ||
nodeLabel === 'Constructor' ||
(nodeLabel === 'Function' && enclosingClassId !== null);
let arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
if (arityTag && defMethodMap && defMethodInfo) {
const groups = buildCollisionGroups(defMethodMap);
@@ -2145,8 +2159,9 @@ const processFileGroup = (
result.toolDefs.push({
filePath: file.path,
toolName: nodeName,
description: dec.arg || '',
description: (dec.arg || description || '').slice(0, 200),
lineNumber: definitionNode.startPosition.row + lineOffset,
handlerNodeId: nodeId,
});
}
fileDecorators.delete(checkLine);
@@ -5,9 +5,8 @@ import { fileURLToPath } from 'node:url';
export interface WorkerPool {
/**
* Dispatch items across workers. Items are split into chunks (one per worker),
* each worker processes its chunk via sub-batches to limit peak memory,
* and results are concatenated back in order.
* Dispatch items across workers. Items are split into bounded jobs, each job
* is committed independently, and stalled jobs are split/retried locally.
*/
dispatch<TInput, TResult>(
items: TInput[],
@@ -21,6 +20,14 @@ export interface WorkerPool {
readonly size: number;
}
export interface WorkerPoolOptions {
subBatchSize?: number;
subBatchMaxBytes?: number;
subBatchIdleTimeoutMs?: number;
maxTimeoutRetries?: number;
timeoutBackoffFactor?: number;
}
/** Message shapes sent back by worker threads. */
type WorkerOutgoingMessage =
| { type: 'progress'; filesProcessed: number }
@@ -29,20 +36,122 @@ type WorkerOutgoingMessage =
| { type: 'error'; error: string }
| { type: 'result'; data: unknown };
interface WorkerJob<TInput> {
startIndex: number;
items: TInput[];
estimatedBytes: number;
attempt: number;
splitDepth: number;
timeoutMs: number;
}
interface WorkerJobResult<TResult> {
startIndex: number;
data: TResult;
}
/**
* Max files to send to a worker in a single postMessage.
* Keeps structured-clone memory bounded per sub-batch.
*/
const SUB_BATCH_SIZE = 1500;
const SUB_BATCH_MAX_BYTES = 8 * 1024 * 1024;
/** Per sub-batch timeout. If a single sub-batch takes longer than this,
* likely a pathological file (e.g. minified 50MB JS). Fail fast. */
const SUB_BATCH_TIMEOUT_MS = 30_000;
const DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS = 30_000;
const DEFAULT_TIMEOUT_RETRIES = 1;
const DEFAULT_TIMEOUT_BACKOFF_FACTOR = 2;
function positiveInteger(value: unknown): number | undefined {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0
? Math.floor(parsed)
: undefined;
}
function nonNegativeInteger(value: unknown): number | undefined {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) && parsed >= 0
? Math.floor(parsed)
: undefined;
}
export function resolveWorkerPoolOptions(
options: WorkerPoolOptions = {},
): Required<WorkerPoolOptions> {
return {
subBatchSize: positiveInteger(options.subBatchSize) ?? SUB_BATCH_SIZE,
subBatchMaxBytes:
positiveInteger(options.subBatchMaxBytes) ??
positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES) ??
SUB_BATCH_MAX_BYTES,
subBatchIdleTimeoutMs:
positiveInteger(options.subBatchIdleTimeoutMs) ??
positiveInteger(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS) ??
DEFAULT_SUB_BATCH_IDLE_TIMEOUT_MS,
maxTimeoutRetries: nonNegativeInteger(options.maxTimeoutRetries) ?? DEFAULT_TIMEOUT_RETRIES,
timeoutBackoffFactor:
positiveInteger(options.timeoutBackoffFactor) ?? DEFAULT_TIMEOUT_BACKOFF_FACTOR,
};
}
function estimateItemBytes(item: unknown): number {
if (typeof item !== 'object' || item === null) return 0;
const content = (item as { content?: unknown }).content;
return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : 0;
}
function itemPath(item: unknown): string | undefined {
if (typeof item !== 'object' || item === null) return undefined;
const path = (item as { path?: unknown }).path;
return typeof path === 'string' ? path : undefined;
}
function createJobs<TInput>(
items: TInput[],
maxItems: number,
maxBytes: number,
timeoutMs: number,
): WorkerJob<TInput>[] {
const jobs: WorkerJob<TInput>[] = [];
let startIndex = 0;
let batch: TInput[] = [];
let batchBytes = 0;
const flush = () => {
if (batch.length === 0) return;
jobs.push({
startIndex,
items: batch,
estimatedBytes: batchBytes,
attempt: 0,
splitDepth: 0,
timeoutMs,
});
startIndex += batch.length;
batch = [];
batchBytes = 0;
};
for (const item of items) {
const itemBytes = estimateItemBytes(item);
const wouldExceedItems = batch.length >= maxItems;
const wouldExceedBytes = batch.length > 0 && batchBytes + itemBytes > maxBytes;
if (wouldExceedItems || wouldExceedBytes) flush();
batch.push(item);
batchBytes += itemBytes;
}
flush();
return jobs;
}
/**
* Create a pool of worker threads.
*/
export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool => {
export const createWorkerPool = (
workerUrl: URL,
poolSize?: number,
options?: WorkerPoolOptions,
): WorkerPool => {
// Validate worker script exists before spawning to prevent uncaught
// MODULE_NOT_FOUND crashes in worker threads (e.g. when running from src/ via vitest)
const workerPath = fileURLToPath(workerUrl);
@@ -51,7 +160,10 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
}
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
const poolOptions = resolveWorkerPoolOptions(options);
const workers: Worker[] = [];
let poolBroken = false;
let poolFailure: Error | undefined;
for (let i = 0; i < size; i++) {
workers.push(new Worker(workerUrl));
@@ -61,78 +173,209 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
items: TInput[],
onProgress?: (filesProcessed: number) => void,
): Promise<TResult[]> => {
if (items.length === 0) return Promise.resolve([]);
const chunkSize = Math.ceil(items.length / size);
const chunks: TInput[][] = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
if (poolBroken) {
const reason = poolFailure ? `: ${poolFailure.message}` : '';
return Promise.reject(
new Error(`Worker pool is unavailable after a previous failure${reason}`),
);
}
if (items.length === 0) return Promise.resolve([]);
if (workers.length === 0) return Promise.reject(new Error('Worker pool has no active workers'));
const workerProgress = new Array(chunks.length).fill(0);
const jobs = createJobs(
items,
poolOptions.subBatchSize,
poolOptions.subBatchMaxBytes,
poolOptions.subBatchIdleTimeoutMs,
);
const promises = chunks.map((chunk, i) => {
const worker = workers[i];
return new Promise<TResult>((resolve, reject) => {
return new Promise<TResult[]>((resolve, reject) => {
const results: WorkerJobResult<TResult>[] = [];
const inFlightProgress = new Array(workers.length).fill(0);
let completedFiles = 0;
let activeWorkers = 0;
let stopped = false;
let maxReported = 0;
const reportProgress = () => {
if (!onProgress) return;
const inFlight = inFlightProgress.reduce((sum, value) => sum + value, 0);
const next = Math.min(items.length, Math.max(maxReported, completedFiles + inFlight));
if (next === maxReported) return;
maxReported = next;
onProgress(next);
};
const replaceWorker = async (workerIndex: number) => {
const worker = workers[workerIndex];
await worker?.terminate().catch(() => undefined);
if (!stopped) workers[workerIndex] = new Worker(workerUrl);
};
const fail = async (err: Error) => {
poolBroken = true;
poolFailure = err;
if (stopped) return;
stopped = true;
await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
reject(err);
};
const maybeDone = () => {
if (stopped) return;
if (jobs.length === 0 && activeWorkers === 0) {
stopped = true;
results.sort((a, b) => a.startIndex - b.startIndex);
if (onProgress && maxReported < items.length) onProgress(items.length);
resolve(results.map((result) => result.data));
}
};
const requeueAfterTimeout = (
workerIndex: number,
job: WorkerJob<TInput>,
lastProgress: number,
): boolean => {
const nextTimeout = Math.ceil(job.timeoutMs * poolOptions.timeoutBackoffFactor);
if (job.items.length > 1) {
const midpoint = Math.ceil(job.items.length / 2);
const firstItems = job.items.slice(0, midpoint);
const secondItems = job.items.slice(midpoint);
const first: WorkerJob<TInput> = {
startIndex: job.startIndex,
items: firstItems,
estimatedBytes: firstItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
attempt: job.attempt,
splitDepth: job.splitDepth + 1,
timeoutMs: nextTimeout,
};
const second: WorkerJob<TInput> = {
startIndex: job.startIndex + midpoint,
items: secondItems,
estimatedBytes: secondItems.reduce((sum, item) => sum + estimateItemBytes(item), 0),
attempt: job.attempt,
splitDepth: job.splitDepth + 1,
timeoutMs: nextTimeout,
};
console.warn(
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
`(${job.items.length} items, ${job.estimatedBytes} bytes, last progress: ${lastProgress}). ` +
`Splitting into ${first.items.length}/${second.items.length} item jobs with ` +
`${nextTimeout / 1000}s timeout.`,
);
// Preserve intuitive retry order; final result order is still enforced by startIndex sort.
jobs.unshift(first, second);
return true;
}
const nextAttempt = job.attempt + 1;
if (nextAttempt <= poolOptions.maxTimeoutRetries) {
console.warn(
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
`(single item, attempt ${nextAttempt}/${poolOptions.maxTimeoutRetries + 1}). ` +
`Retrying with ${nextTimeout / 1000}s timeout.`,
);
jobs.unshift({
...job,
attempt: nextAttempt,
timeoutMs: nextTimeout,
});
return true;
}
void fail(
new Error(
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
`(single item${itemPath(job.items[0]) ? `: ${itemPath(job.items[0])}` : ''}, ` +
`${job.estimatedBytes} bytes, last progress: ${lastProgress}). ` +
`Analyze will retry through sequential fallback. Increase with ` +
`--worker-timeout or GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS.`,
),
);
return false;
};
const runWorker = (workerIndex: number) => {
if (stopped) return;
const job = jobs.shift();
if (!job) {
maybeDone();
return;
}
activeWorkers++;
inFlightProgress[workerIndex] = 0;
const worker = workers[workerIndex];
let settled = false;
let subBatchTimer: ReturnType<typeof setTimeout> | null = null;
let waitingForFlush = false;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
const cleanup = () => {
if (subBatchTimer) clearTimeout(subBatchTimer);
if (idleTimer) clearTimeout(idleTimer);
worker.removeListener('message', handler);
worker.removeListener('error', errorHandler);
worker.removeListener('exit', exitHandler);
};
const resetSubBatchTimer = () => {
if (subBatchTimer) clearTimeout(subBatchTimer);
subBatchTimer = setTimeout(() => {
const finishJob = () => {
activeWorkers--;
inFlightProgress[workerIndex] = 0;
runWorker(workerIndex);
maybeDone();
};
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
if (!settled) {
settled = true;
cleanup();
reject(
new Error(
`Worker ${i} sub-batch timed out after ${SUB_BATCH_TIMEOUT_MS / 1000}s (chunk: ${chunk.length} items).`,
),
);
activeWorkers--;
inFlightProgress[workerIndex] = 0;
const shouldContinue = requeueAfterTimeout(workerIndex, job, lastProgress);
if (!shouldContinue) return;
await replaceWorker(workerIndex);
reportProgress();
runWorker(workerIndex);
maybeDone();
}
}, SUB_BATCH_TIMEOUT_MS);
};
let subBatchIdx = 0;
const sendNextSubBatch = () => {
const start = subBatchIdx * SUB_BATCH_SIZE;
if (start >= chunk.length) {
worker.postMessage({ type: 'flush' });
return;
}
const subBatch = chunk.slice(start, start + SUB_BATCH_SIZE);
subBatchIdx++;
resetSubBatchTimer();
worker.postMessage({ type: 'sub-batch', files: subBatch });
}, job.timeoutMs);
};
const handler = (msg: WorkerOutgoingMessage) => {
if (settled) return;
if (settled || stopped) return;
if (msg.type === 'progress') {
workerProgress[i] = msg.filesProcessed;
if (onProgress) {
const total = workerProgress.reduce((a, b) => a + b, 0);
onProgress(total);
}
const bounded = Math.min(job.items.length, Math.max(0, msg.filesProcessed));
inFlightProgress[workerIndex] = bounded;
lastProgress = bounded;
resetIdleTimer();
reportProgress();
} else if (msg.type === 'warning') {
resetIdleTimer();
console.warn(msg.message);
} else if (msg.type === 'sub-batch-done') {
sendNextSubBatch();
waitingForFlush = true;
resetIdleTimer();
worker.postMessage({ type: 'flush' });
} else if (msg.type === 'error') {
settled = true;
cleanup();
reject(new Error(`Worker ${i} error: ${msg.error}`));
void fail(new Error(`Worker ${workerIndex} error: ${msg.error}`));
} else if (msg.type === 'result') {
if (!waitingForFlush) {
settled = true;
cleanup();
void fail(new Error(`Worker ${workerIndex} protocol error: result before flush`));
return;
}
settled = true;
cleanup();
resolve(msg.data as TResult);
results.push({ startIndex: job.startIndex, data: msg.data as TResult });
completedFiles += job.items.length;
reportProgress();
finishJob();
}
};
@@ -140,7 +383,7 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
if (!settled) {
settled = true;
cleanup();
reject(err);
void fail(err);
}
};
@@ -148,9 +391,9 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
if (!settled) {
settled = true;
cleanup();
reject(
void fail(
new Error(
`Worker ${i} exited with code ${code}. Likely OOM or native addon failure.`,
`Worker ${workerIndex} exited with code ${code}. Likely OOM or native addon failure.`,
),
);
}
@@ -159,11 +402,16 @@ export const createWorkerPool = (workerUrl: URL, poolSize?: number): WorkerPool
worker.on('message', handler);
worker.once('error', errorHandler);
worker.once('exit', exitHandler);
sendNextSubBatch();
});
});
resetIdleTimer();
if (stopped) {
cleanup();
return;
}
worker.postMessage({ type: 'sub-batch', files: job.items });
};
return Promise.all(promises);
for (let i = 0; i < workers.length; i++) runWorker(i);
});
};
const terminate = async (): Promise<void> => {
+267
View File
@@ -0,0 +1,267 @@
import { spawn } from 'child_process';
import { fileURLToPath } from 'node:url';
import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
/**
* Lifecycle policy for an optional DuckDB extension.
*
* - `auto` — try `LOAD`, fall back to one bounded out-of-process `INSTALL`
* attempt per process if `LOAD` fails. Default for analyze.
* - `load-only`— try `LOAD` only; never spawn an installer. Used by serve/MCP
* read paths so user queries never block on a network install.
* - `never` — skip the extension entirely. Operators can use this to
* forcibly disable optional search features.
*/
export type ExtensionInstallPolicy = 'auto' | 'load-only' | 'never';
export interface ExtensionInstallResult {
success: boolean;
timedOut: boolean;
message: string;
}
/** Snapshot of one optional extension's resolved capability state. */
export interface ExtensionCapability {
name: string;
loaded: boolean;
/** Human-readable reason when `loaded` is false. */
reason?: string;
}
/** Per-call overrides applied on top of `ExtensionManager` defaults. */
export interface ExtensionEnsureOptions {
policy?: ExtensionInstallPolicy;
installTimeoutMs?: number;
}
export interface ExtensionManagerOptions {
policy?: ExtensionInstallPolicy;
installTimeoutMs?: number;
installExtension?: (extensionName: string, timeoutMs: number) => Promise<ExtensionInstallResult>;
warn?: (message: string) => void;
}
const alreadyAvailable = (message: string): boolean =>
message.includes('already loaded') ||
message.includes('already installed') ||
message.includes('already exists');
const resolvePolicyFromEnv = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'auto';
};
export const getExtensionInstallTimeoutMs = (): number => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
const parsed = raw ? Number(raw) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS;
};
export const getExtensionInstallChildProcessArgs = (
extensionName: string,
maxDbSize: number = LBUG_MAX_DB_SIZE,
): string[] => {
const childScript = new URL('../../../scripts/install-duckdb-extension.mjs', import.meta.url);
return [fileURLToPath(childScript), extensionName, String(maxDbSize)];
};
/**
* Run `INSTALL <extension>` in a short-lived child Node process so the parent
* event loop is never blocked by DuckDB's synchronous network call.
*
* The child opens its own scratch LadybugDB, executes the install, and exits.
* If the child exceeds `timeoutMs` the parent kills it with SIGKILL and
* resolves with `timedOut: true`.
*/
export const installDuckDbExtensionOutOfProcess = async (
extensionName: string,
timeoutMs: number = getExtensionInstallTimeoutMs(),
): Promise<ExtensionInstallResult> => {
if (!EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName}`);
}
return await new Promise<ExtensionInstallResult>((resolve) => {
const child = spawn(process.execPath, getExtensionInstallChildProcessArgs(extensionName), {
env: {
...process.env,
GITNEXUS_LBUG_EXTENSION_NAME: extensionName,
},
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
let stderr = '';
child.stderr?.setEncoding('utf8');
child.stderr?.on('data', (chunk) => {
stderr = (stderr + chunk).slice(-4000);
});
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
success: false,
timedOut: true,
message: `INSTALL ${extensionName} timed out after ${timeoutMs}ms`,
});
}, timeoutMs);
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ success: false, timedOut: false, message: err.message });
});
child.on('exit', (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
success: code === 0,
timedOut: false,
message:
code === 0
? `INSTALL ${extensionName} completed`
: `INSTALL ${extensionName} failed with ${signal ?? `exit code ${code}`}${stderr ? `: ${stderr.trim()}` : ''}`,
});
});
});
};
/**
* Centralized lifecycle manager for optional LadybugDB extensions.
*
* Always tries `LOAD EXTENSION <name>` first — it is per-connection,
* idempotent, and never touches the network. If `LOAD` fails and the active
* policy permits, the manager runs a single bounded out-of-process `INSTALL`
* attempt per process and retries `LOAD`. Capability outcomes are cached so
* unavailable extensions degrade search features without ever blocking
* subsequent analyze or query calls.
*
* Policy precedence (most specific wins):
* per-call `opts.policy` → constructor `options.policy` → env → `auto`
*/
export class ExtensionManager {
private readonly capabilities = new Map<string, ExtensionCapability>();
private readonly installAttempted = new Map<string, ExtensionInstallResult>();
private readonly warnedKeys = new Set<string>();
constructor(private readonly options: ExtensionManagerOptions = {}) {}
/** Reset cached capability and install state. Test-only. */
reset(): void {
this.capabilities.clear();
this.installAttempted.clear();
this.warnedKeys.clear();
}
/** Snapshot of currently-known optional extension capabilities. */
getCapabilities(): ExtensionCapability[] {
return Array.from(this.capabilities.values());
}
/**
* Ensure an optional extension is loaded on the supplied connection.
*
* Returns `true` when the extension is usable on `query`, `false` when it
* is unavailable. Never throws on install failure — analyze and query
* paths are expected to degrade gracefully.
*/
async ensure(
query: (sql: string) => Promise<unknown>,
name: string,
label: string,
opts: ExtensionEnsureOptions = {},
): Promise<boolean> {
if (!EXTENSION_NAME_PATTERN.test(name)) {
throw new Error(`Invalid DuckDB extension name: ${name}`);
}
const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv();
const timeoutMs =
opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
const warn = this.options.warn ?? console.warn;
if (policy === 'never') {
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
return false;
}
if (await this.tryLoad(query, name)) {
this.markLoaded(name);
return true;
}
if (policy === 'load-only') {
this.markUnavailable(name, label, 'load-only policy: extension not pre-installed', warn);
return false;
}
let install = this.installAttempted.get(name);
if (!install) {
const installFn = this.options.installExtension ?? installDuckDbExtensionOutOfProcess;
install = await installFn(name, timeoutMs);
this.installAttempted.set(name, install);
}
if (!install.success) {
this.markUnavailable(name, label, install.message, warn);
return false;
}
if (await this.tryLoad(query, name)) {
this.markLoaded(name);
return true;
}
this.markUnavailable(name, label, `LOAD ${name} failed after successful INSTALL`, warn);
return false;
}
private async tryLoad(query: (sql: string) => Promise<unknown>, name: string): Promise<boolean> {
try {
await query(`LOAD EXTENSION ${name}`);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return alreadyAvailable(msg);
}
}
private markLoaded(name: string): void {
this.capabilities.set(name, { name, loaded: true });
}
private markUnavailable(
name: string,
label: string,
reason: string,
warn: (message: string) => void,
): void {
this.capabilities.set(name, { name, loaded: false, reason });
const key = `${name}:${reason}`;
if (this.warnedKeys.has(key)) return;
this.warnedKeys.add(key);
warn(
`GitNexus: ${label} extension unavailable; continuing without ${label} features. ${reason}`,
);
}
}
/** Process-wide singleton shared by core and pool adapters. */
export const extensionManager = new ExtensionManager();
/** Snapshot of which optional DuckDB extensions are loaded in this process. */
export const getExtensionCapabilities = (): ExtensionCapability[] =>
extensionManager.getCapabilities();
/** Test-only: clear the singleton's cached capability and install state. */
export const resetExtensionState = (): void => extensionManager.reset();
+118 -112
View File
@@ -16,6 +16,13 @@ import {
} from './schema.js';
import { streamAllCSVsToDisk } from './csv-generator.js';
import type { CachedEmbedding } from '../embeddings/types.js';
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
import {
closeLbugConnection,
openLbugConnection,
type LbugConnectionHandle,
} from './lbug-config.js';
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
// ---------------------------------------------------------------------------
// Relationship CSV splitting — extracted for testability (PR #818)
@@ -145,17 +152,16 @@ let ftsLoaded = false;
let vectorExtensionLoaded = false;
/**
* In-process cache of FTS indexes that have been ensured against the current
* connection. Prevents repeated `CALL CREATE_FTS_INDEX` round-trips inside a
* single CLI/MCP session — the first call to `ensureFTSIndex` for a given
* `(tableName, indexName)` pays the LadybugDB cost (~440 ms even when the
* index already exists on disk), subsequent calls are a Set lookup. Cleared
* by `closeLbug` so a re-init starts fresh.
* In-process cache of FTS indexes observed against the current singleton
* connection. Avoids repeated `CALL CREATE_FTS_INDEX` calls, which can trip
* native duplicate-index/WAL edge cases. Cleared on re-init and close.
*
* Key format: `${tableName}:${indexName}`.
*/
const ensuredFTSIndexes = new Set<string>();
const ftsIndexKey = (tableName: string, indexName: string): string => `${tableName}:${indexName}`;
/**
* Check if an error indicates a missing column or table (schema-level problem)
* rather than a transient/connection error. Used for legacy DB fallback logic.
@@ -193,6 +199,19 @@ export const isDbBusyError = (err: unknown): boolean => {
);
};
/**
* Return true when the error message indicates a write was attempted against
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
* so any path that calls a `CREATE_*` procedure there will surface this
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
* path should ignore this error — index creation is owned by `gitnexus
* analyze` and either already happened or will happen on the next run.
*/
export const isReadOnlyDbError = (err: unknown): boolean => {
const msg = err instanceof Error ? err.message : String(err);
return /read-only database/i.test(msg);
};
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const previous = sessionLock;
let release: (() => void) | null = null;
@@ -253,6 +272,7 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
ensuredFTSIndexes.clear();
});
// Sleep outside the lock — no need to block others while waiting
await new Promise((resolve) => setTimeout(resolve, DB_LOCK_RETRY_DELAY_MS * attempt));
@@ -285,6 +305,7 @@ const doInitLbug = async (dbPath: string) => {
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
ensuredFTSIndexes.clear();
}
// LadybugDB stores the database as a single file (not a directory).
@@ -317,14 +338,14 @@ const doInitLbug = async (dbPath: string) => {
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
db = new lbug.Database(dbPath);
conn = new lbug.Connection(db);
const opened = await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
for (const schemaQuery of SCHEMA_QUERIES) {
try {
await conn.query(schemaQuery);
} catch (err) {
// Only ignore "already exists" errors - log everything else
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('already exists')) {
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
@@ -332,8 +353,9 @@ const doInitLbug = async (dbPath: string) => {
}
}
// Load VECTOR extension for semantic search support
await loadVectorExtension();
// FTS powers baseline search, so initialize it with the core DB. VECTOR is
// only required for semantic embeddings and is probed lazily there.
await loadFTSExtension();
currentDbPath = dbPath;
return { db, conn };
@@ -663,18 +685,12 @@ export const insertNodeToLbug = async (
// Use per-query connection if dbPath provided (avoids lock conflicts)
if (targetDbPath) {
const tempDb = new lbug.Database(targetDbPath);
const tempConn = new lbug.Connection(tempDb);
const tempHandle = await openLbugConnection(lbug, targetDbPath);
try {
await tempConn.query(query);
await tempHandle.conn.query(query);
return true;
} finally {
try {
await tempConn.close();
} catch {}
try {
await tempDb.close();
} catch {}
await closeLbugConnection(tempHandle);
}
} else if (conn) {
// Use existing persistent connection (when called from analyze)
@@ -710,8 +726,8 @@ export const batchInsertNodesToLbug = async (
};
// Open a single connection for all inserts
const tempDb = new lbug.Database(dbPath);
const tempConn = new lbug.Connection(tempDb);
const tempHandle = await openLbugConnection(lbug, dbPath);
const tempConn = tempHandle.conn;
let inserted = 0;
let failed = 0;
@@ -752,12 +768,7 @@ export const batchInsertNodesToLbug = async (
}
}
} finally {
try {
await tempConn.close();
} catch {}
try {
await tempDb.close();
} catch {}
await closeLbugConnection(tempHandle);
}
return { inserted, failed };
@@ -847,8 +858,11 @@ export const executeWithReusedStatement = async (
await conn.execute(stmt, params);
}
} catch (e) {
// Log the error and continue with next batch
console.warn('Batch execution error:', e);
const msg = e instanceof Error ? e.message : String(e);
const queryPreview = cypher.replace(/\s+/g, ' ').slice(0, 120);
throw new Error(
`Batch execution failed for rows ${i + 1}-${i + subBatch.length}: ${msg} (${queryPreview})`,
);
}
// Note: LadybugDB PreparedStatement doesn't require explicit close()
}
@@ -1067,13 +1081,13 @@ export const deleteNodesForFile = async (
const usePerQuery = !!dbPath;
// Set up connection (either use existing or create per-query)
let tempDb: lbug.Database | null = null;
let tempHandle: LbugConnectionHandle | null = null;
let tempConn: lbug.Connection | null = null;
let targetConn: lbug.Connection | null = conn;
if (usePerQuery) {
tempDb = new lbug.Database(dbPath);
tempConn = new lbug.Connection(tempDb);
tempHandle = await openLbugConnection(lbug, dbPath);
tempConn = tempHandle.conn;
targetConn = tempConn;
} else if (!conn) {
throw new Error('LadybugDB not initialized. Provide dbPath or call initLbug first.');
@@ -1123,16 +1137,7 @@ export const deleteNodesForFile = async (
return { deletedNodes };
} finally {
// Close per-query connection if used
if (tempConn) {
try {
await tempConn.close();
} catch {}
}
if (tempDb) {
try {
await tempDb.close();
} catch {}
}
if (tempHandle) await closeLbugConnection(tempHandle);
}
};
@@ -1143,18 +1148,21 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
// ============================================================================
/**
* Load the FTS extension (required before using FTS functions).
* Load the FTS extension on the supplied connection (or the singleton
* writable connection when none is given).
*
* Safe to call multiple times — when invoked without arguments, tracks loaded
* state via module-level `ftsLoaded`. When invoked with an explicit
* connection, loads on that connection and returns whether the load
* succeeded — letting callers (e.g. the pool adapter) track their own state.
*
* Tries `LOAD EXTENSION fts` first so previously-cached installs skip the
* network entirely; falls back to `INSTALL` + `LOAD` only when the extension
* hasn't been cached yet.
* Delegates to the shared `ExtensionManager` so install policy (auto /
* load-only / never), out-of-process bounded INSTALL, and capability
* caching are owned in one place. The module-level `ftsLoaded` flag is
* kept purely as a per-call short-circuit on the singleton writable
* connection so repeated callers (e.g. createFTSIndex) avoid an extra
* `LOAD` round-trip per invocation. Pool adapter callers pass
* `{ policy: 'load-only' }` so query paths never block on a network install.
*/
export const loadFTSExtension = async (targetConn?: lbug.Connection): Promise<boolean> => {
export const loadFTSExtension = async (
targetConn?: lbug.Connection,
opts: ExtensionEnsureOptions = {},
): Promise<boolean> => {
const useModuleState = targetConn === undefined;
if (useModuleState && ftsLoaded) return true;
@@ -1163,60 +1171,32 @@ export const loadFTSExtension = async (targetConn?: lbug.Connection): Promise<bo
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const markLoaded = (): true => {
if (useModuleState) ftsLoaded = true;
return true;
};
try {
// Try loading locally first (no network required)
await c.query('LOAD EXTENSION fts');
return markLoaded();
} catch {
// Fall back to install + load (requires network)
try {
await c.query('INSTALL fts');
await c.query('LOAD EXTENSION fts');
return markLoaded();
} catch (err: any) {
const msg = err?.message || '';
if (
msg.includes('already loaded') ||
msg.includes('already installed') ||
msg.includes('already exists')
) {
return markLoaded();
}
console.error('GitNexus: FTS extension load failed:', msg);
return false;
}
}
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'fts', 'FTS', opts);
if (loaded && useModuleState) ftsLoaded = true;
return loaded;
};
/**
* Load the VECTOR extension (required before using QUERY_VECTOR_INDEX).
* Safe to call multiple times -- tracks loaded state via module-level vectorExtensionLoaded.
* Load the VECTOR extension on the supplied connection (or the singleton
* writable connection when none is given). Returns false when VECTOR is
* unavailable so semantic search can fall back to exact scan.
*/
export const loadVectorExtension = async (): Promise<void> => {
if (vectorExtensionLoaded) return;
if (!conn) {
export const loadVectorExtension = async (
targetConn?: lbug.Connection,
opts: ExtensionEnsureOptions = {},
): Promise<boolean> => {
const useModuleState = targetConn === undefined;
if (useModuleState && vectorExtensionLoaded) return true;
if (!isVectorExtensionSupportedByPlatform()) return false;
const c: lbug.Connection | null = targetConn ?? conn;
if (!c) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
try {
await conn.query('INSTALL VECTOR');
await conn.query('LOAD EXTENSION VECTOR');
vectorExtensionLoaded = true;
} catch (err: any) {
const msg = err?.message || '';
if (
msg.includes('already loaded') ||
msg.includes('already installed') ||
msg.includes('already exists')
) {
vectorExtensionLoaded = true;
} else {
console.error('GitNexus: VECTOR extension load failed:', msg);
}
}
const loaded = await extensionManager.ensure((sql) => c.query(sql), 'VECTOR', 'VECTOR', opts);
if (loaded && useModuleState) vectorExtensionLoaded = true;
return loaded;
};
/**
* Create a full-text search index on a table
@@ -1235,30 +1215,44 @@ export const createFTSIndex = async (
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
await loadFTSExtension();
const key = ftsIndexKey(tableName, indexName);
if (ensuredFTSIndexes.has(key)) return;
if (!(await loadFTSExtension())) {
return;
}
const propList = properties.map((p) => `'${p}'`).join(', ');
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
try {
await conn.query(query);
ensuredFTSIndexes.add(key);
} catch (e: any) {
if (!e.message?.includes('already exists')) {
throw e;
if (e.message?.includes('already exists')) {
ensuredFTSIndexes.add(key);
return;
}
throw e;
}
};
/**
* Lazy-create an FTS index, caching the fact in-process.
*
* Used by `queryFTS` so that `analyze` doesn't pay the ~440 ms × 5 fixed
* LadybugDB cost up-front (it dominates analyze on small repos). Instead,
* the cost is moved to the first `query`/`context` call in a session,
* where it's amortised across many lookups.
* Kept for writable maintenance paths that need to lazily materialize an
* index. Read-only query paths must not call this; production analysis owns
* creating the configured search indexes before the database is served.
*
* Safe to call repeatedly — the in-process Set guarantees only the first
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
*
* Defense in depth: if the active connection is read-only (e.g. the MCP
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
* operations in a read-only database". Treat that as a no-op and cache
* the key so callers don't loop on a path that can never succeed here —
* the index is owned by `gitnexus analyze` (writable) and either already
* exists or will be created on the next analyze.
*/
export const ensureFTSIndex = async (
tableName: string,
@@ -1266,10 +1260,20 @@ export const ensureFTSIndex = async (
properties: string[],
stemmer: string = 'porter',
): Promise<void> => {
const key = `${tableName}:${indexName}`;
const key = ftsIndexKey(tableName, indexName);
if (ensuredFTSIndexes.has(key)) return;
await createFTSIndex(tableName, indexName, properties, stemmer);
ensuredFTSIndexes.add(key);
try {
await createFTSIndex(tableName, indexName, properties, stemmer);
} catch (e) {
// Read-only DB: writable analyze owns index creation; silently skip
// and cache so callers don't loop on a path that can never succeed
// here (the MCP query pool opens DBs read-only by design).
if (isReadOnlyDbError(e)) {
ensuredFTSIndexes.add(key);
return;
}
throw e;
}
};
/**
@@ -1341,5 +1345,7 @@ export const dropFTSIndex = async (tableName: string, indexName: string): Promis
await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
} catch {
// Index may not exist
} finally {
ensuredFTSIndexes.delete(ftsIndexKey(tableName, indexName));
}
};
+88
View File
@@ -0,0 +1,88 @@
import type lbug from '@ladybugdb/core';
/**
* Shared configuration for `@ladybugdb/core` `Database` construction.
*
* Two values changed meaningfully in `@ladybugdb/core` 0.16.0 and need to be
* pinned explicitly by every caller, otherwise GitNexus regresses:
*
* 1. `maxDBSize` defaults to `0`, which the native runtime interprets as
* "use the platform's full mmap address space" — typically 8 TB on
* 64-bit Linux. Constrained environments (CI runners, containers, WSL)
* cannot reserve that much address space and crash with
* `Buffer manager exception: Mmap for size 8796093022208 failed.`
* See LadybugDB upstream JSDoc:
* > "introduced temporarily for now to get around with the default 8TB
* > mmap address space limit some environment".
*
* 2. `enableCompression` flipped its default from `false` (0.15.x) to
* `true` (0.16.0). Existing call sites that relied on the positional
* default must now pass `false` explicitly to preserve behaviour.
*
* Putting both in one shared module guarantees every `new lbug.Database(...)`
* call site agrees on the same ceiling and behaviour.
*/
/**
* Upper bound for any single GitNexus LadybugDB file (graph index, group
* bridge, install scratch, test fixture). 16 GiB is intentionally generous
* for real-world code graphs (the GitNexus self-index uses < 50 MiB) while
* remaining far below any 64-bit OS mmap ceiling.
*
* Override with the `GITNEXUS_LBUG_MAX_DB_SIZE` environment variable when
* indexing genuinely huge monorepos. Values are coerced to a positive
* integer; anything invalid falls back to the default.
*/
export const LBUG_MAX_DB_SIZE: number = (() => {
const raw = process.env.GITNEXUS_LBUG_MAX_DB_SIZE;
if (raw) {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
}
return 16 * 1024 * 1024 * 1024;
})();
type LbugModule = typeof lbug;
export interface LbugDatabaseOptions {
readOnly?: boolean;
}
export interface LbugConnectionHandle {
db: lbug.Database;
conn: lbug.Connection;
}
export function createLbugDatabase(
lbugModule: LbugModule,
databasePath: string,
options: LbugDatabaseOptions = {},
): lbug.Database {
return new lbugModule.Database(
databasePath,
0,
false,
options.readOnly ?? false,
LBUG_MAX_DB_SIZE,
);
}
export async function openLbugConnection(
lbugModule: LbugModule,
databasePath: string,
options: LbugDatabaseOptions = {},
): Promise<LbugConnectionHandle> {
let db: lbug.Database | undefined;
try {
db = createLbugDatabase(lbugModule, databasePath, options);
return { db, conn: new lbugModule.Connection(db) };
} catch (err) {
if (db) await db.close().catch(() => {});
throw err;
}
}
export async function closeLbugConnection(handle: LbugConnectionHandle): Promise<void> {
await handle.conn.close().catch(() => {});
await handle.db.close().catch(() => {});
}
+12 -35
View File
@@ -18,6 +18,7 @@
import fs from 'fs/promises';
import lbug from '@ladybugdb/core';
import { loadFTSExtension } from './lbug-adapter.js';
import { createLbugDatabase } from './lbug-config.js';
/** Per-repo pool: one Database, many Connections */
interface PoolEntry {
@@ -69,7 +70,6 @@ interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
vectorLoaded: boolean;
/** When true, closeOne skips db.close() — the Database is owned externally. */
external?: boolean;
}
@@ -175,7 +175,6 @@ function closeOne(repoId: string): void {
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
shared.ftsLoaded = false;
shared.vectorLoaded = false;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
@@ -307,14 +306,9 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
silenceStdout();
try {
const db = new lbug.Database(
dbPath,
0, // bufferManagerSize (default)
false, // enableCompression (default)
true, // readOnly
);
const db = createLbugDatabase(lbug, dbPath, { readOnly: true });
restoreStdout();
shared = { db, refCount: 0, ftsLoaded: false, vectorLoaded: false };
shared = { db, refCount: 0, ftsLoaded: false };
dbCache.set(dbPath, shared);
break;
} catch (err: any) {
@@ -354,19 +348,11 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
// Load FTS extension once per shared Database.
// Done BEFORE pool registration so no concurrent checkout can grab
// the connection while the async FTS load is in progress.
// policy: 'load-only' — the read pool must never trigger a network
// install; analyze owns extension installation. If LOAD fails, search
// features degrade gracefully and the user-facing query path proceeds.
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0]);
}
// Load VECTOR extension once per shared Database for semantic search support.
if (!shared.vectorLoaded) {
try {
await available[0].query('INSTALL VECTOR');
await available[0].query('LOAD EXTENSION VECTOR');
shared.vectorLoaded = true;
} catch {
// VECTOR extension may not be available
}
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
// Register pool entry only after all connections are pre-warmed and FTS is
@@ -412,7 +398,7 @@ export async function initLbugWithDb(
// closeOne() respects the external flag and skips db.close().
let shared = dbCache.get(dbPath);
if (!shared) {
shared = { db: existingDb, refCount: 0, ftsLoaded: false, vectorLoaded: false, external: true };
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
dbCache.set(dbPath, shared);
}
shared.refCount++;
@@ -427,20 +413,11 @@ export async function initLbugWithDb(
preWarmActive = false;
}
// Load FTS extension if not already loaded on this Database
// Load FTS extension if not already loaded on this Database.
// policy: 'load-only' — same contract as initLbug above; the read pool
// must not block on a network install during query execution.
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0]);
}
// Load VECTOR extension for semantic search support
if (!shared.vectorLoaded) {
try {
await available[0].query('INSTALL VECTOR');
await available[0].query('LOAD EXTENSION VECTOR');
shared.vectorLoaded = true;
} catch {
// VECTOR extension may not be available
}
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
pool.set(repoId, {
@@ -0,0 +1,87 @@
import os from 'os';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
export type CapabilityStatus = 'available' | 'degraded' | 'unavailable';
export type SemanticSearchMode = 'vector-index' | 'exact-scan' | 'unavailable';
export interface RuntimeFingerprint {
platform: NodeJS.Platform;
arch: string;
node: string;
gitnexus: string;
ladybugdb?: string;
onnxruntime?: string;
}
export interface RuntimeCapabilities {
graph: CapabilityStatus;
fts: CapabilityStatus;
vector: CapabilityStatus;
semanticMode: SemanticSearchMode;
exactScanLimit: number;
reason?: string;
}
const packageVersion = (name: string): string | undefined => {
try {
return require(`${name}/package.json`).version;
} catch {
return undefined;
}
};
const gitnexusVersion = (): string => {
try {
return require('../../../package.json').version;
} catch {
return 'unknown';
}
};
const parsePositiveInt = (value: string | undefined, fallback: number): number => {
if (value === undefined) return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
};
export const DEFAULT_EXACT_SCAN_LIMIT = 10_000;
export const getExactScanLimit = (): number =>
parsePositiveInt(process.env.GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT, DEFAULT_EXACT_SCAN_LIMIT);
export const getRuntimeFingerprint = (): RuntimeFingerprint => ({
platform: process.platform,
arch: process.arch,
node: process.version,
gitnexus: gitnexusVersion(),
ladybugdb: packageVersion('@ladybugdb/core'),
onnxruntime: packageVersion('onnxruntime-node'),
});
export const isVectorExtensionSupportedByPlatform = (
platform: NodeJS.Platform = process.platform,
): boolean => platform !== 'win32';
export const getRuntimeCapabilities = (): RuntimeCapabilities => {
const vector = isVectorExtensionSupportedByPlatform() ? 'available' : 'unavailable';
const exactScanLimit = getExactScanLimit();
return {
graph: 'available',
fts: 'available',
vector,
semanticMode: vector === 'available' ? 'vector-index' : 'exact-scan',
exactScanLimit,
reason:
vector === 'unavailable'
? 'LadybugDB VECTOR is disabled on this platform; semantic search uses exact scan when embeddings exist.'
: undefined,
};
};
export const defaultEmbeddingThreads = (): number => {
const available =
typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
return Math.max(1, Math.min(4, Math.floor(available / 2) || 1));
};
+45 -14
View File
@@ -21,11 +21,12 @@ import {
closeLbug,
loadCachedEmbeddings,
} from './lbug/lbug-adapter.js';
import { createSearchFTSIndexes } from './search/fts-indexes.js';
import {
getStoragePaths,
saveMeta,
loadMeta,
addToGitignore,
ensureGitNexusIgnored,
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
@@ -165,6 +166,7 @@ export async function runFullAnalysis(
if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
if (currentCommit !== '') {
await ensureGitNexusIgnored(repoPath);
return {
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
repoPath,
@@ -250,7 +252,8 @@ export async function runFullAnalysis(
const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
const scaled = Math.round(p.percent * 0.6);
progress(p.phase, scaled, phaseLabel);
const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel;
progress(p.phase, scaled, message);
});
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
@@ -280,12 +283,9 @@ export async function runFullAnalysis(
});
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
// FTS indexes are created lazily on first `query`/`context` call instead
// of eagerly here. On small repos / CI runners the LadybugDB
// CREATE_FTS_INDEX cost is ~440 ms × 5 (≈2 s) regardless of table size,
// which dominated `analyze` runtime and pushed Windows CI past its
// 30 s test budget. Lazy creation is implemented in
// `core/search/bm25-index.ts` via `ensureFTSIndex`.
progress('fts', 85, 'Creating search indexes...');
await createSearchFTSIndexes();
progress('fts', 90, 'Search indexes ready');
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
if (cachedEmbeddings.length > 0) {
@@ -318,6 +318,7 @@ export async function runFullAnalysis(
// ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
const stats = await getLbugStats();
let embeddingSkipped = true;
let semanticMode: 'vector-index' | 'exact-scan' | undefined;
if (shouldGenerateEmbeddings) {
if (stats.nodes <= EMBEDDING_NODE_LIMIT) {
@@ -346,7 +347,7 @@ export async function runFullAnalysis(
const { readServerMapping } = await import('./embeddings/server-mapping.js');
const projectName = path.basename(repoPath);
const serverName = await readServerMapping(projectName);
await runEmbeddingPipeline(
const embeddingResult = await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
(p) => {
@@ -364,6 +365,15 @@ export async function runFullAnalysis(
{ repoName: projectName, serverName },
existingEmbeddings,
);
if (embeddingResult.semanticMode === 'exact-scan') {
semanticMode = 'exact-scan';
log(
'Semantic embeddings were generated without a VECTOR index; ' +
'queries will use exact-scan fallback within the configured limit.',
);
} else {
semanticMode = 'vector-index';
}
}
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
@@ -375,11 +385,24 @@ export async function runFullAnalysis(
const embResult = await executeQuery(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`,
);
embeddingCount = embResult?.[0]?.cnt ?? 0;
const row = embResult?.[0];
embeddingCount = Number(row?.cnt ?? row?.[0] ?? 0);
} catch {
/* table may not exist if embeddings never ran */
}
if (!embeddingSkipped && stats.nodes > 0 && embeddingCount === 0) {
throw new Error(
'Embedding generation completed without persisted embeddings. ' +
'The index was not registered to avoid silently reporting embeddings: 0.',
);
}
const { getRuntimeCapabilities } = await import('./platform/capabilities.js');
const runtimeCapabilities = getRuntimeCapabilities();
const effectiveSemanticMode =
semanticMode ??
(runtimeCapabilities.semanticMode === 'vector-index' ? 'vector-index' : 'exact-scan');
const meta = {
repoPath,
lastCommit: currentCommit,
@@ -399,6 +422,16 @@ export async function runFullAnalysis(
processes: pipelineResult.processResult?.stats.totalProcesses,
embeddings: embeddingCount,
},
capabilities: {
graph: { provider: 'ladybugdb', status: runtimeCapabilities.graph },
fts: { provider: 'ladybugdb-fts', status: runtimeCapabilities.fts },
vectorSearch: {
provider: effectiveSemanticMode === 'vector-index' ? 'ladybugdb-vector' : 'exact-scan',
status: embeddingCount > 0 ? effectiveSemanticMode : 'unavailable',
exactScanLimit: runtimeCapabilities.exactScanLimit,
reason: runtimeCapabilities.reason,
},
},
};
await saveMeta(storagePath, meta);
// Forward the --name alias and the registry-collision bypass bit.
@@ -415,10 +448,8 @@ export async function runFullAnalysis(
allowDuplicateName: options.allowDuplicateName,
});
// Only attempt to update .gitignore when a .git directory is present.
if (hasGitDir(repoPath)) {
await addToGitignore(repoPath);
}
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
await ensureGitNexusIgnored(repoPath);
// ── Generate AI context files (best-effort) ───────────────────────
let aggregatedClusterCount = 0;
+9 -150
View File
@@ -3,15 +3,10 @@
*
* Uses LadybugDB's built-in full-text search indexes for keyword-based search.
* Always reads from the database (no cached state to drift).
*
* FTS indexes are created lazily on first query (via `ensureFTSIndex`) — see
* `lbug-adapter.ts` for the rationale. This keeps `analyze` fast (the
* ~440 ms × 5 LadybugDB CREATE_FTS_INDEX cost dominates pipeline time on
* small repos / CI runners) at the cost of paying that overhead on the
* first `query`/`context` call in a session.
*/
import { queryFTS, ensureFTSIndex } from '../lbug/lbug-adapter.js';
import { queryFTS } from '../lbug/lbug-adapter.js';
import { FTS_INDEXES } from './fts-schema.js';
export interface BM25SearchResult {
filePath: string;
@@ -20,104 +15,6 @@ export interface BM25SearchResult {
nodeIds?: string[];
}
/**
* FTS schema served by `searchFTSFromLbug`. Centralised so that both the
* CLI/pipeline path and the MCP pool path use identical (table, index,
* properties) tuples and the lazy-create logic stays in one place.
*/
const FTS_INDEXES: ReadonlyArray<{
table: string;
indexName: string;
properties: readonly string[];
}> = [
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
{ table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
{ table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
{ table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
{ table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
];
/**
* Per-process cache for the MCP pool path: tracks which `(repoId, table)`
* pairs have been ensured. The CLI/pipeline path gets its own cache inside
* `lbug-adapter.ts` keyed by table/index, scoped to the singleton connection.
*
* IMPORTANT: an entry is added ONLY when the index was confirmed to exist
* (CREATE_FTS_INDEX succeeded, or failed with `'already exists'`). Other
* failures (transient lock errors, missing extension, etc.) leave the key
* unset so the next query retries instead of silently caching the failure.
*
* Entries for a given repoId are invalidated when its pool is closed —
* see the `addPoolCloseListener` registration in `searchFTSFromLbug`.
*/
const ensuredPoolFTS = new Set<string>();
/**
* Drop all ensured-FTS cache entries for a given repoId.
*
* Called from the pool-close listener so that a pool teardown / recreation
* forces the next `searchFTSFromLbug` call to re-issue `CREATE_FTS_INDEX`
* against the fresh connection rather than trust stale ensure-state from a
* previous pool lifetime.
*
* Exported for tests; the listener wiring is internal.
*/
export function invalidateEnsuredFTSForRepo(repoId: string): void {
const prefix = `${repoId}:`;
for (const key of ensuredPoolFTS) {
if (key.startsWith(prefix)) ensuredPoolFTS.delete(key);
}
}
/**
* Tracks whether we've already wired the pool-close listener for this
* process. The pool adapter is dynamically imported, so registration
* happens lazily on the first MCP-pool-backed FTS query.
*/
let poolCloseListenerRegistered = false;
function registerPoolCloseListenerOnce(
addPoolCloseListener: (listener: (repoId: string) => void) => void,
): void {
if (poolCloseListenerRegistered) return;
poolCloseListenerRegistered = true;
addPoolCloseListener((repoId) => invalidateEnsuredFTSForRepo(repoId));
}
async function ensureFTSIndexViaExecutor(
executor: (cypher: string) => Promise<any[]>,
repoId: string,
table: string,
indexName: string,
properties: readonly string[],
): Promise<void> {
const key = `${repoId}:${table}:${indexName}`;
if (ensuredPoolFTS.has(key)) return;
const propList = properties.map((p) => `'${p}'`).join(', ');
try {
await executor(
`CALL CREATE_FTS_INDEX('${table}', '${indexName}', [${propList}], stemmer := 'porter')`,
);
// Index was created successfully — safe to cache.
ensuredPoolFTS.add(key);
} catch (e: any) {
// 'already exists' is the happy path (index persists on disk between
// process invocations) — cache it. Anything else is treated as a
// transient failure: surface a one-time warning and leave the key
// unset so the NEXT query retries rather than silently using a
// cached failure (which previously disabled BM25 for the whole
// process for that repo).
const msg = String(e?.message ?? '');
if (msg.includes('already exists')) {
ensuredPoolFTS.add(key);
} else {
console.warn(
`[gitnexus] FTS index ensure failed for repo "${repoId}" table "${table}" ` +
`(index "${indexName}"): ${msg || e}. Will retry on next query.`,
);
}
}
}
/**
* Execute a single FTS query via a custom executor (for MCP connection pool).
* Returns the same shape as core queryFTS (from LadybugDB adapter).
@@ -169,58 +66,24 @@ export const searchFTSFromLbug = async (
limit: number = 20,
repoId?: string,
): Promise<BM25SearchResult[]> => {
let fileResults: any[],
functionResults: any[],
classResults: any[],
methodResults: any[],
interfaceResults: any[];
const resultsByIndex: any[][] = [];
if (repoId) {
// Use MCP connection pool via dynamic import
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
// The MCP pool supports multiple connections, but FTS is best run serially.
const poolMod = await import('../lbug/pool-adapter.js');
const { executeQuery, addPoolCloseListener } = poolMod;
// Register the pool-close listener lazily on first use so a teardown of
// the pool entry (LRU eviction, idle timeout, explicit close) drops the
// matching `ensuredPoolFTS` entries. Without this, stale ensure-state
// can outlive the pool that produced it.
registerPoolCloseListenerOnce(addPoolCloseListener);
const { executeQuery } = poolMod;
const executor = (cypher: string) => executeQuery(repoId, cypher);
// Lazy-create FTS indexes on first query for this repo (analyze no longer
// creates them up-front, so we ensure them here). Cached per-process.
for (const { table, indexName, properties } of FTS_INDEXES) {
await ensureFTSIndexViaExecutor(executor, repoId, table, indexName, properties);
for (const { table, indexName } of FTS_INDEXES) {
resultsByIndex.push(await queryFTSViaExecutor(executor, table, indexName, query, limit));
}
fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);
classResults = await queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit);
methodResults = await queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit);
interfaceResults = await queryFTSViaExecutor(
executor,
'Interface',
'interface_fts',
query,
limit,
);
} else {
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
// Lazy-create FTS indexes on first query (analyze no longer does it).
for (const { table, indexName, properties } of FTS_INDEXES) {
await ensureFTSIndex(table, indexName, [...properties]).catch(() => {});
for (const { table, indexName } of FTS_INDEXES) {
resultsByIndex.push(await queryFTS(table, indexName, query, limit, false).catch(() => []));
}
fileResults = await queryFTS('File', 'file_fts', query, limit, false).catch(() => []);
functionResults = await queryFTS('Function', 'function_fts', query, limit, false).catch(
() => [],
);
classResults = await queryFTS('Class', 'class_fts', query, limit, false).catch(() => []);
methodResults = await queryFTS('Method', 'method_fts', query, limit, false).catch(() => []);
interfaceResults = await queryFTS('Interface', 'interface_fts', query, limit, false).catch(
() => [],
);
}
// Collect all node scores per filePath to track which nodes actually matched
@@ -233,11 +96,7 @@ export const searchFTSFromLbug = async (
}
};
addResults(fileResults);
addResults(functionResults);
addResults(classResults);
addResults(methodResults);
addResults(interfaceResults);
for (const results of resultsByIndex) addResults(results);
// Sum the top-3 highest-scoring nodes per file and collect their nodeIds.
// Summing all nodes naively inflates scores for files with many mediocre
+8
View File
@@ -0,0 +1,8 @@
import { createFTSIndex } from '../lbug/lbug-adapter.js';
import { FTS_INDEXES } from './fts-schema.js';
export async function createSearchFTSIndexes(): Promise<void> {
for (const { table, indexName, properties } of FTS_INDEXES) {
await createFTSIndex(table, indexName, [...properties]);
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface FTSIndexDefinition {
readonly table: string;
readonly indexName: string;
readonly properties: readonly string[];
}
export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
{ table: 'File', indexName: 'file_fts', properties: ['name', 'content'] },
{ table: 'Function', indexName: 'function_fts', properties: ['name', 'content'] },
{ table: 'Class', indexName: 'class_fts', properties: ['name', 'content'] },
{ table: 'Method', indexName: 'method_fts', properties: ['name', 'content'] },
{ table: 'Interface', indexName: 'interface_fts', properties: ['name', 'content'] },
];
+223 -65
View File
@@ -1,97 +1,255 @@
import Parser from 'tree-sitter';
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: tree-sitter-c-sharp declares `type: "module"` with
// `main: "bindings/node"` (no extension) and no `exports` field, which triggers
// Node 22's DEP0151 deprecation warning on the bare-package import. Importing
// the built entrypoint directly bypasses the deprecated ESM main-field
// resolution. (#1013)
import CSharp from 'tree-sitter-c-sharp/bindings/node/index.js';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
// tree-sitter-swift and tree-sitter-dart are optionalDependencies — may not be installed
const _require = createRequire(import.meta.url);
let Swift: any = null;
try {
Swift = _require('tree-sitter-swift');
} catch {}
let Dart: any = null;
try {
Dart = _require('tree-sitter-dart');
} catch {}
// tree-sitter-kotlin is an optionalDependency — may not be installed
let Kotlin: any = null;
try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
/**
* One row per (language, optional variant) describes how to obtain a
* grammar object suitable for `Parser.setLanguage`.
*
* - `load` — returns the grammar object (lazy, called on
* first use, then cached).
* - `unavailableNote` — actionable message surfaced *whenever* the
* grammar can't be loaded. Mandatory for every
* row so failures are never silent and never
* generic.
* - `optional` — when true, a load failure does not throw:
* we report the language as unavailable and
* let callers skip files of this language.
* When false (the default), a load failure
* re-throws the original error so the
* pipeline halts loudly.
* - `severity` — log level for failure diagnostics. Defaults
* to `error` for required grammars and `warn`
* for optional ones. Set explicitly to `error`
* on optional rows whose package is listed in
* `dependencies` (not `optionalDependencies`):
* those failures indicate a real install
* problem and should never be hidden behind
* a low-severity warning.
*
* Adding or removing a grammar is one entry in this table — there is
* no second list, no conditional spread, and no per-grammar branch in
* the resolver.
*/
interface GrammarSource {
load: () => unknown;
unavailableNote: string;
optional?: boolean;
severity?: 'warn' | 'error';
}
let parser: Parser | null = null;
const ISSUES_URL = 'https://github.com/abhigyanpatwari/GitNexus/issues';
const languageMap: Record<string, any> = {
[SupportedLanguages.JavaScript]: JavaScript,
[SupportedLanguages.TypeScript]: TypeScript.typescript,
[`${SupportedLanguages.TypeScript}:tsx`]: TypeScript.tsx,
[SupportedLanguages.Python]: Python,
[SupportedLanguages.Java]: Java,
[SupportedLanguages.C]: C,
[SupportedLanguages.CPlusPlus]: CPP,
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
const SOURCES: Record<string, GrammarSource> = {
[SupportedLanguages.JavaScript]: {
load: () => _require('tree-sitter-javascript'),
unavailableNote:
'JavaScript parsing requires `tree-sitter-javascript`. ' +
'Check that the package and its native binding installed cleanly (`npm ci`).',
},
[SupportedLanguages.TypeScript]: {
load: () => _require('tree-sitter-typescript').typescript,
unavailableNote:
'TypeScript parsing requires `tree-sitter-typescript`. ' +
'Check that the package and its native binding installed cleanly (`npm ci`).',
},
[`${SupportedLanguages.TypeScript}:tsx`]: {
load: () => _require('tree-sitter-typescript').tsx,
unavailableNote:
'TSX parsing requires `tree-sitter-typescript` (re-uses the same native binding as TS).',
},
[SupportedLanguages.Python]: {
load: () => _require('tree-sitter-python'),
unavailableNote:
'Python parsing requires `tree-sitter-python`. Check the install and native binding.',
},
[SupportedLanguages.Java]: {
load: () => _require('tree-sitter-java'),
unavailableNote:
'Java parsing requires `tree-sitter-java`. Check the install and native binding.',
},
// tree-sitter-c-sharp declares `type: "module"` with `main: "bindings/node"`
// (no extension) and no `exports` field, which triggers Node 22's DEP0151
// deprecation warning on the bare-package import. The explicit subpath
// bypasses the deprecated ESM main-field resolution. (#1013)
[SupportedLanguages.CSharp]: {
load: () => _require('tree-sitter-c-sharp/bindings/node/index.js'),
unavailableNote:
'C# parsing requires `tree-sitter-c-sharp/bindings/node/index.js`. ' +
`If the subpath is missing, see ${ISSUES_URL}/1013.`,
},
[SupportedLanguages.CPlusPlus]: {
load: () => _require('tree-sitter-cpp'),
unavailableNote:
'C++ parsing requires `tree-sitter-cpp`. Check the install and native binding.',
},
[SupportedLanguages.Go]: {
load: () => _require('tree-sitter-go'),
unavailableNote: 'Go parsing requires `tree-sitter-go`. Check the install and native binding.',
},
[SupportedLanguages.Rust]: {
load: () => _require('tree-sitter-rust'),
unavailableNote:
'Rust parsing requires `tree-sitter-rust`. Check the install and native binding.',
},
[SupportedLanguages.PHP]: {
load: () => _require('tree-sitter-php').php_only,
unavailableNote:
'PHP parsing requires `tree-sitter-php` (the `php_only` export). ' +
'Check the install and native binding.',
},
[SupportedLanguages.Ruby]: {
load: () => _require('tree-sitter-ruby'),
unavailableNote:
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
},
[SupportedLanguages.Vue]: {
load: () => _require('tree-sitter-typescript').typescript,
unavailableNote:
'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.
[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. ' +
`If the failure persists, file details at ${ISSUES_URL}/1242.`,
},
// optionalDependencies — may be absent on platforms without prebuilds
// or when users skip optional installs.
[SupportedLanguages.Swift]: {
load: () => _require('tree-sitter-swift'),
optional: true,
unavailableNote:
'Swift parsing disabled: vendored `tree-sitter-swift` (under ' +
'`gitnexus/vendor/tree-sitter-swift`) failed to load. ' +
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
`See ${ISSUES_URL}/1130.`,
},
[SupportedLanguages.Dart]: {
load: () => _require('tree-sitter-dart'),
optional: true,
unavailableNote:
'Dart parsing disabled: vendored `tree-sitter-dart` (under ' +
'`gitnexus/vendor/tree-sitter-dart`) failed to load. ' +
'Likely cause: native compile failed at install (missing python3/make/g++). ' +
`See ${ISSUES_URL}/1125.`,
},
[SupportedLanguages.Kotlin]: {
load: () => _require('tree-sitter-kotlin'),
optional: true,
unavailableNote:
'Kotlin parsing disabled: `tree-sitter-kotlin` is an optionalDependency ' +
'and is not installed (or its native binding failed to build).',
},
};
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>
language in languageMap;
type LoadResult =
| { ok: true; grammar: unknown }
| { ok: false; error: Error; note: string; fatal: boolean; severity: 'warn' | 'error' };
const loadCache = new Map<string, LoadResult>();
const logged = new Set<string>();
const logFailure = (key: string, result: LoadResult): void => {
if (result.ok === true) return;
if (logged.has(key)) return;
logged.add(key);
const message = `[gitnexus] ${result.note} (${result.error.message})`;
if (result.severity === 'error') console.error(message);
else console.warn(message);
};
export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string =>
language === SupportedLanguages.TypeScript && filePath?.endsWith('.tsx')
? `${language}:tsx`
: language;
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): any => {
const key = resolveLanguageKey(language, filePath);
const lang = languageMap[key];
if (!lang) {
throw new Error(`Unsupported language: ${language}`);
const loadGrammar = (key: string): LoadResult => {
const cached = loadCache.get(key);
if (cached) return cached;
const source = SOURCES[key];
if (!source) {
const result: LoadResult = {
ok: false,
error: new Error(`Unsupported language: ${key}`),
note: `No grammar registered for language key \`${key}\`. Add a row to SOURCES.`,
fatal: true,
severity: 'error',
};
loadCache.set(key, result);
return result;
}
return lang;
let result: LoadResult;
try {
result = { ok: true, grammar: source.load() };
} catch (err) {
const fatal = !source.optional;
result = {
ok: false,
error: err as Error,
note: source.unavailableNote,
fatal,
severity: source.severity ?? (fatal ? 'error' : 'warn'),
};
}
loadCache.set(key, result);
if (result.ok === false) logFailure(key, result);
return result;
};
export const loadParser = async (): Promise<Parser> => {
if (parser) return parser;
parser = new Parser();
return parser;
export const isLanguageAvailable = (language: SupportedLanguages, filePath?: string): boolean =>
loadGrammar(resolveLanguageKey(language, filePath)).ok;
export const getLanguageGrammar = (language: SupportedLanguages, filePath?: string): unknown => {
const key = resolveLanguageKey(language, filePath);
const result = loadGrammar(key);
if (result.ok === true) return result.grammar;
// Fatal failures throw the original underlying error (preserving stack)
// after the note has been logged. Optional failures fall through to the
// standard "Unsupported language" message that callers already handle.
if (result.fatal) throw result.error;
throw new Error(`Unsupported language: ${language}`);
};
let sharedParser: Parser | null = null;
export const loadParser = async (): Promise<Parser> => (sharedParser ??= new Parser());
export const loadLanguage = async (
language: SupportedLanguages,
filePath?: string,
): Promise<void> => {
if (!parser) await loadParser();
parser!.setLanguage(getLanguageGrammar(language, filePath));
const parser = await loadParser();
parser.setLanguage(getLanguageGrammar(language, filePath));
};
export const createParserForLanguage = async (
language: SupportedLanguages,
filePath?: string,
): Promise<Parser> => {
const freshParser = new Parser();
freshParser.setLanguage(getLanguageGrammar(language, filePath));
return freshParser;
const parser = new Parser();
parser.setLanguage(getLanguageGrammar(language, filePath));
return parser;
};

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