Compare commits

...
70 Commits
Author SHA1 Message Date
Gergő Magyar b10d25bbca chore: release v1.6.0 — update CHANGELOG and package-lock (#798) 2026-04-12 12:31:21 +01:00
CopilotandGergo Magyar a94d6ef80b Extract registries into model/ module with SemanticModel interface (#786)
* Initial plan

* feat(SM-20): extract registries into model/ module with SemanticModel interface

- Create model/type-registry.ts — TypeRegistry interface + factory
- Create model/method-registry.ts — MethodRegistry interface + factory
- Create model/field-registry.ts — FieldRegistry interface + factory
- Create model/semantic-model.ts — SemanticModel interface + factory
- Create model/heritage-map.ts — re-export HeritageMap types
- Create model/binding-accumulator.ts — re-export BindingAccumulator types
- Create model/resolve.ts — move lookupMethodByOwnerWithMRO from call-processor
- Update symbol-table.ts — delegate to SemanticModel for registry ops
- Update call-processor.ts — re-export lookupMethodByOwnerWithMRO from model/resolve

No circular dependencies: model/resolve.ts does NOT import resolution-context.ts.
All 775 related unit tests pass with no regressions.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* fix: clarify re-export comment per code review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* refactor(SM-20): wire up SemanticModel as first-class resolution input

PR #786 extracted TypeRegistry/MethodRegistry/FieldRegistry into model/
behind SemanticModel, but consumers still routed through SymbolTable
delegates. This change completes Phase 6 of the fuzzy-lookup elimination
roadmap by making call-processor, resolution-context, type-env, and
heritage-map query the model directly via `table.model.{types,methods,fields}`.

Also absorbs the open PR #786 review findings so the branch lands clean:
- Removed duplicate JSDoc block on lookupMethodByOwner (symbol-table.ts)
- Added model/index.ts barrel for the public model/ surface
- Fixed O(n) buildParentMapFromHeritage BFS via head-pointer queue
- Clarified re-export facade framing on binding-accumulator.ts and
  heritage-map.ts inside model/
- Refined @internal JSDoc on lookupMethodByOwnerWithMRO

Changes:
- symbol-table.ts: expose `readonly model: SemanticModel` on the
  SymbolTable interface. SymbolTable delegate wrappers (lookupClassByName
  etc.) stay as thin pass-throughs for backward compat; deletion is a
  follow-up once all internal callers are migrated.
- model/resolve.ts: lookupMethodByOwnerWithMRO now takes SemanticModel
  instead of SymbolTable, removing the last SymbolTable import from the
  model/ module. Preserves circular-dependency firewall.
- call-processor.ts: 6 call sites in D0 member resolution, field
  resolution, ctor override, and ctor disambiguation migrated to
  model.types/methods/fields.
- resolution-context.ts: tier 3 class+impl lookup migrated.
- type-env.ts: 5 sites across lookupClassDefsByName, resolveFieldType,
  and resolveMethodReturnType migrated.
- heritage-map.ts: parent/child class-name resolution migrated.

Tests:
- symbol-table.test.ts: +10 parity and feeding-audit tests covering
  every model.{types,methods,fields} path (Class, Method, Property,
  Impl, Function-with-ownerId, Property-without-ownerId skip, arity
  filtering, clear cascade).
- call-processor.test.ts: classLookupSpy now targets
  ctx.symbols.model.types since the wrapper is bypassed.
- type-env.test.ts: createMockSymbolTable and the destructured-call
  makeSymbolTable helpers gained a model shim that forwards to the
  (possibly overridden) top-level lookup stubs.

Validation: full suite 5603 passed / 159 skipped, resolver integration
suite (19 files, 1766 tests) clean, tsc --noEmit clean.

* refactor(SM-21): invert ownership — SemanticModel contains SymbolTable

Follow-up to SM-20. Previously SymbolTable owned a `model` subfield;
this commit turns the ownership direction around so the SemanticModel
is the top-level container and SymbolTable is nested as `.symbols`:

    SemanticModel (top-level, passed everywhere)
      ├── types   (TypeRegistry)
      ├── methods (MethodRegistry)
      ├── fields  (FieldRegistry)
      └── symbols (SymbolTable — file-indexed + callable-name index)

The owner-scoped registries live directly on the model; file and
callable-name lookups go through `.symbols`. Consumers receive a
`SemanticModel` and reach into the appropriate field — no more
`table.model.types.X` double-hop.

Core changes:
- symbol-table.ts: createSymbolTable now takes injected
  TypeRegistry/MethodRegistry/FieldRegistry via a SymbolTableDeps
  argument. When omitted (test fallback), it creates standalone
  registries locally and clears them in clear() — production callers
  always inject. The five registry convenience delegates
  (lookupClassByName, lookupMethodByOwner, lookupFieldByOwner,
  lookupClassByQualifiedName, lookupImplByName) remain as thin
  forwards to the injected registries so standalone SymbolTable use
  (chiefly tests) stays ergonomic.
- model/semantic-model.ts: createSemanticModel() now creates the
  three registries AND a SymbolTable wired to them, exposing the
  SymbolTable as `.symbols`. clear() cascades through all four.
- resolution-context.ts: `readonly symbols: SymbolTable` field is
  replaced with `readonly model: SemanticModel`. Internal factory
  builds a SemanticModel and keeps a local `symbols` alias for
  backward-compatible inner body.

Consumer migrations (src/):
- call-processor.ts: ctx.symbols.add/.lookupExactAll/
  .lookupCallableByName → ctx.model.symbols.*; ctx.symbols.model.X →
  ctx.model.X. buildTypeEnv option key renamed symbolTable → model.
- type-env.ts: symbolTable parameter renamed model (type
  SemanticModel), all internal call sites rewritten to use
  model.types.*, model.methods.*, model.fields.*,
  model.symbols.lookupExactAll / .lookupCallableByName.
- heritage-map.ts: 2 class-lookup sites migrated.
- pipeline.ts: ctx.symbols → ctx.model.symbols throughout.

Test migrations:
- symbol-table.test.ts: parity tests (which validated the old
  table.model.X hop) replaced with direct SemanticModel coverage via
  createSemanticModel(). New tests exercise types/methods/fields/
  symbols feeding end-to-end.
- type-env.test.ts: createMockSymbolTable rebuilt as a
  SemanticModel-shaped mock that still accepts the legacy flat
  override bag for backward compat; inline `makeSymbolTable` helpers
  for destructured-call and importedReturnTypes suites rewritten to
  match the new shape; buildTypeEnv options `symbolTable: X` and
  `{ symbolTable }` shorthand renamed to `model:`; one real
  createSymbolTable-based test rewritten to use createSemanticModel.
- call-processor.test.ts, heritage-map.test.ts,
  heritage-processor.test.ts, symbol-resolver.test.ts: bulk sed
  `ctx.symbols.` → `ctx.model.symbols.`. call-processor.test.ts spy
  updated to target `ctx.model.types.lookupClassByName`.

Validation: full test suite 5589 passed / 169 skipped / 0 failed;
tsc --noEmit clean; pre-commit eslint + prettier + typecheck all
green. CLAUDE.md / AGENTS.md stats bumped from an earlier `npx
gitnexus analyze` refresh (3965 symbols / 10012 edges / 243 flows).

* refactor(SM-22/SM-23): dispatch table + DAG rearchitecture

SM-22: Extract registration dispatch table into model/registration-table.ts.
Replaces the if/else ladder inside SymbolTable.add() with an O(1)
Map<NodeLabel, RoutingDecision> fan-out. SemanticModel wires the table
per-instance so hooks close over the correct registries.

SM-23: DAG rearchitecture. symbol-table.ts is now a pure 2-index leaf
(fileIndex + callableByName) with zero imports from model/. All
type/method/field routing lives in the model/ layer. Tests migrated to
createSemanticModel() + model.symbols access pattern.

Tests: 5632 passed, 0 failures.

* refactor: delete dead code (skipCallableIndex + model/ facades)

Removes the unused skipCallableIndex flag from the registration dispatch
table and deletes two facade files that had zero consumers.

skipCallableIndex was declared on RoutingDecision and populated for all
10 entries but never read at runtime — semantic-model.ts explicitly
documented that the flag was NOT consulted. The callable-index gate
lives inside SymbolTable.add() via CALLABLE_TYPES.has(type), which is
the single source of truth. Deleting the flag keeps SymbolTable as the
sole decision point and removes documentation-as-data.

model/binding-accumulator.ts and model/heritage-map.ts were facade
pass-throughs of their parent-directory counterparts. Grep confirms no
consumer imports either from the model/ path — all usage goes through
../binding-accumulator.js and ../heritage-map.js directly. model/index.ts
was the only "user" and re-exported them with a note about unifying the
import boundary, but that boundary has no actual consumers today.

Resolves review findings M-01 and M-03 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures (1 less than pre-Unit-1: the
skipCallableIndex-specific assertion was removed).

* refactor: remove lookupMethodByOwnerWithMRO backward-compat shim

call-processor.ts re-exported lookupMethodByOwnerWithMRO from
./model/resolve.js as a backward-compat shim for symbol-table.test.ts.
The function already lives in model/resolve.ts and is re-exported
properly from model/index.ts (the barrel) — the call-processor shim
was a duplicate export path with no durable reason to exist.

Migrated the test import from call-processor.js to model/index.js
(the canonical barrel). Deleted the re-export statement and the stale
"re-exported for backward compatibility" comment block. Hoisted the
remaining import to the top of the file with the other imports; the
bottom-of-file position was a relic of the shim pattern.

Resolves review finding M-02 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures.

* refactor: harden registration dispatch runtime safety

Two hardening changes in semantic-model.ts, both closing silent-failure
paths in the SM-series dispatcher-bypass failure mode.

1. model.symbols.clear() now cascades to the owner-scoped registries.
   Previously, the SymbolTable facade exposed rawSymbols.clear directly,
   which only emptied fileIndex + callableByName — the types/methods/
   fields registries stayed populated. Any caller holding a SymbolTable
   reference that invoked .clear() left the model in a split state where
   subsequent .add() calls double-registered in the registries. No
   current caller exercises this path, but it was a latent phantom-
   resolution risk that didn't belong in a public API. Extracted the
   cascade into a single cascadeClear closure wired into both
   model.clear() and the facade's clear field.

2. runExhaustivenessGuard now throws instead of console.warn on drift.
   The production short-circuit via NODE_ENV === 'production' is
   preserved, so real users never see the throw — but CI and dev runs
   now fail loudly if a NodeLabel is added to gitnexus-shared without
   being placed in one of the three registration-table allowlists. The
   previous warn-only behavior was silent in test output volume; SM-19
   already documented dispatcher-bypass as the dominant silent-failure
   mode in this codebase.

Test-first: added test/unit/model/semantic-model.test.ts covering
model.symbols.clear() cascade (4 registries × clear = 4 tests), the
existing model.clear() cascade (regression guard), and a happy-path
construction test that verifies the current allowlists have zero drift.

Resolves correctness P2 finding (symbols.clear() partial clear),
correctness P3 (exhaustiveness warn-only), and kieran-typescript KT-03
(same exhaustiveness finding, agreement boost).

Tests: 5638 passed (+7 new), 0 failures.

* docs: fix stale JSDoc references in resolveStaticCall

call-processor.ts:2215-2216 referenced SymbolTable.lookupClassByName
and SymbolTable.lookupMethodByOwner via {@link}. Both methods were
removed from SymbolTable during SM-20 — they now live on TypeRegistry
and MethodRegistry respectively, accessible via model.types and
model.methods.

Other SymbolTable.* references in the codebase (lookupExactFull, add,
lookupCallableByName in call-processor.ts:593, symbol-table.ts:86,
type-extractors/types.ts:57) target methods that are still on
SymbolTable and remain valid.

Resolves correctness P3 and kieran-typescript KT-02 (same finding,
agreement boost).

* refactor: deduplicate ALL_NODE_LABELS constant

ALL_NODE_LABELS was private in semantic-model.ts and duplicated
verbatim in registration-table.test.ts. Two hardcoded lists meant a
new NodeLabel added to gitnexus-shared could land in one copy but not
the other, silently drifting the exhaustiveness invariant.

Exported ALL_NODE_LABELS from semantic-model.ts, re-exported through
model/index.ts for barrel consistency, and switched the test to import
it instead of redeclaring. The explanatory comment now describes the
single-source-of-truth contract.

Resolves maintainability M-04.

Tests: 5638 passed, 0 failures.

* refactor: add compile-time NodeLabel exhaustiveness check

The runtime exhaustiveness guard in semantic-model.ts caught drift at
test time. Added a type-level check in registration-table.ts that
catches drift at BUILD time — if a new NodeLabel is added to
gitnexus-shared without being classified into one of the three
allowlists, TypeScript fails the _exhaustiveCheck assignment and
names the missing label.

The runtime guard stays as belt-and-suspenders: if a future contributor
bypasses the type check with @ts-ignore, the runtime guard still fires
in dev/test.

Implementation: converted the three allowlist Set<NodeLabel> initializers
to use `as const` tuples, then derived a union type from the tuples and
asserted `Exclude<NodeLabel, union> extends never`. Zero runtime impact
— the exported Sets are unchanged, Map.get hot-path performance is
unchanged, the test API is unchanged.

Resolves kieran-typescript KT-04.

Tests: 21/21 registration-table tests pass with zero modifications.

* refactor(test): restore type safety to createMockSymbolTable

createMockSymbolTable was widened to (overrides: any = {}): any with an
eslint-disable-next-line, and every buildTypeEnv call site passed the
mock as `model: mockSymbolTable as any`. The widening masked silent
false-green tests: buildTypeEnv accesses model.types/methods/fields,
and a flat any-typed override could silently return undefined from a
path that TypeScript should have caught at compile time.

Defined LegacyMockOverrides interface with typed stubs for each method
the mock can override (SymbolTable reads + TypeRegistry/MethodRegistry/
FieldRegistry lookups). Return type is now SemanticModel, so the mock
object is compile-checked against the real interface — a missing
registry method is a type error, not a silent runtime undefined.

Removed the eslint-disable and all 9 `as any` casts at call sites
(lines 1287, 1300, 1307, 2124, 2138, 5823, 5835, 5850, 5870). The
mock's return value now flows through buildTypeEnv's typed `model`
option without coercion.

Resolves kieran-typescript KT-01 and testing gap TG-02. This was the
highest-value cleanup in the plan — the only finding representing real
hidden test weakness.

Tests: 360 passed | 7 skipped (type-env.test.ts), typecheck clean.

* test: close coverage gaps in model/ registries

Added direct unit tests for the three owner-scoped registries that
previously had only transitive coverage via symbol-table.test.ts and
registration-table.test.ts. These new tests pin behaviors that were
flagged by the testing reviewer as untested or undertested.

method-registry.test.ts (14 tests):
- T-01: arity-fallback branch — when argCount matches no overload,
  fall back to the full pool so fuzzy resolution still has candidates.
  Previously untested and would have returned undefined instead of
  a valid candidate if the branch regressed.
- T-02: requiredParameterCount range filtering — methods with default
  parameters accept any argCount in [requiredParameterCount,
  parameterCount]. Previously untested at the registry level.
- Variadic fallback (parameterCount=undefined is retained during arity
  narrowing, bypassing range check).
- Return-type dedup paths: shared returnType → first wins, differing
  returnTypes → undefined, firstReturnType=undefined → undefined,
  single-overload skips dedup entirely.

type-registry.test.ts (9 tests):
- classByName homonym accumulation (two User classes in different
  packages both returned).
- classByQualifiedName disambiguation — same simple name, different
  FQNs resolve independently.
- Partial classes with identical simple + qualified name accumulate
  in both indexes.
- registerImpl stores Rust impl blocks separately from classes.
- Multiple impl blocks per type accumulate.

field-registry.test.ts (6 tests):
- register/lookup round-trip, owner-scope isolation, last-wins on
  duplicate key (flat map, not overload list).
- clear + re-register round-trip.

Extended symbol-table.test.ts cascade test (renamed from "both
registries" to "all three registries and the nested symbol table") to
also assert model.methods and model.fields are cleared — the test
name previously implied full coverage but only asserted types + symbols.

Resolves testing findings T-01, T-02, T-03, T-05.

Tests: 5667 passed (+29 new), 0 failures.

* refactor(test): replace brittle reference-equality tests + add intent comments

Two cleanups flagged as low-severity P3 by the testing reviewer:

1. registration-table.test.ts: Replaced three reference-equality tests
   (hook identity via toBe) with behavioral tests that survive a future
   refactor to per-label closures. The new "class-like behavior group"
   describe iterates Class/Struct/Interface/Enum/Record/Trait and
   verifies each one writes to types.registerClass. Same pattern for
   Method/Constructor. A separate "behavior group isolation" describe
   verifies class-like hooks don't leak into methods/fields and Impl
   never pollutes registerClass. Strictly more coverage than the
   reference-equality tests provided and implementation-independent.

2. symbol-resolver.test.ts: Added a comment above the lookupExactFull
   and SM-16: getFiles() describes explaining why they intentionally
   use createSymbolTable() directly instead of createSemanticModel().
   The DAG leaf-only behaviors they test do not involve registries, so
   testing the bare SymbolTable keeps the unit isolated. Prevents a
   future reader from "fixing" the inconsistency.

3. qualified-class-lookups.test.ts: Added a comment above
   `const symbolTable = model.symbols` explaining that processParsing
   writes still reach the owner-scoped registries via SemanticModel's
   fan-out — the alias is convenience, not a leaf in isolation.

Resolves testing T-04, kieran-typescript KT-05, kieran-typescript KT-06.

Tests: affected files all green (112 passed in registration-table +
symbol-resolver + qualified-class-lookups).

* refactor(model): collapse RoutingDecision wrapper and trim barrel surface

Two cleanups against the advanced-review findings on post-Unit-9 state:

S2 (cross-reviewer agreement — architecture-strategist + code-simplicity):
Delete the RoutingDecision single-field wrapper interface. Post-Unit-1
it held exactly one field (hook: RegistrationHook) and added pure
ceremony at every call site — `dispatchTable.get(key)!.hook(name, def)`
vs the now-direct `dispatchTable.get(key)!(name, def)`. Change the Map
type from Map<NodeLabel, RoutingDecision> to Map<NodeLabel,
RegistrationHook>, drop the interface, and update 17 test call sites.

A3 (architecture-strategist): Trim model/index.ts barrel surface.
createRegistrationTable, RegistrationHook, and RegistrationTableDeps
were re-exported from the barrel despite having zero legitimate
consumers outside model/ itself. The only callers (semantic-model.ts
and registration-table.test.ts) import directly from
./registration-table.js. Barrel exposure invited external callers to
construct orphan dispatch tables with independent registries,
weakening the SM-21 ownership inversion where SemanticModel is the
composition root. Kept CALLABLE_ONLY_LABELS, INERT_LABELS,
DISPATCH_LABELS exported since those remain useful for downstream
resolution logic and have no construction risk.

Resolves review findings:
- S2 (code-simplicity P3, 0.85) + architecture-strategist residual
- A3 (architecture-strategist P3, 0.82)

Tests: 5674 passed, 0 failures. Typecheck clean.

* refactor(model): replace runtime exhaustiveness guard with compile-time bijection

Replace the three-layer drift protection (hardcoded ALL_NODE_LABELS
array + 3 tuple consts + _ExhaustiveLabelCheck type + runExhaustivenessGuard
runtime + CI taxonomy test) with a single Record<NodeLabel, LabelBehavior>
map that structurally proves every invariant at compile time.

## Before

- ALL_NODE_LABELS hardcoded in semantic-model.ts (36 entries, could drift)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private tuples (36 more entries total, could overlap or miss)
- _ClassifiedLabel / _UncoveredLabel type-level check (caught missing
  labels but NOT duplicates across tuples)
- runExhaustivenessGuard runtime throw (only defense against duplicates)
- NodeLabel taxonomy coverage test in CI (same check as runtime guard)

Four defenses for invariants that the type system can express directly.

## After

```ts
type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';

const LABEL_BEHAVIOR = {
  Class: 'dispatch',
  // ...36 entries...
  Tool: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior>;
```

The `as const satisfies Record<NodeLabel, LabelBehavior>` combo enforces:

1. **Every NodeLabel must be a key** — Record requires all K keys.
   Adding a NodeLabel to gitnexus-shared without classifying it here
   fails with "Property 'X' is missing in type ..." naming the drifted label.
2. **No non-NodeLabel keys allowed** — `satisfies` with object literals
   triggers excess-property checking. A typo'd key fails to compile.
3. **No duplicate classification** — impossible by construction; object
   keys are unique at the source level.
4. **Valid category** — LabelBehavior is a narrow union, typos caught.

`ALL_NODE_LABELS`, `DISPATCH_LABELS`, `CALLABLE_ONLY_LABELS`, and
`INERT_LABELS` are now derived via `Object.keys(LABEL_BEHAVIOR)` and
`filter(l => LABEL_BEHAVIOR[l] === ...)` — single source of truth,
structurally impossible to drift.

## Deleted

- runExhaustivenessGuard() function in semantic-model.ts (~18 lines)
- ALL_NODE_LABELS hardcoded array in semantic-model.ts (~38 lines)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private consts in registration-table.ts (~30 lines)
- _ClassifiedLabel / _UncoveredLabel / _exhaustiveCheck type machinery
  (~20 lines)

## Kept named proofs: none

The `as const satisfies` on the object literal already catches all four
drift modes. Named type-level proofs (_MissingFromMap / _ExtraKeysInMap)
are pure duplication and were removed per review.

## Also in this commit

- S6: trim wrappedAdd narration comments in semantic-model.ts
  (Step 1/2/3 block comments removed; kept the Function+ownerId WHY note)
- A3: tighten model/index.ts barrel — createRegistrationTable,
  RegistrationHook, RegistrationTableDeps remain direct-imports only;
  ALL_NODE_LABELS and LabelBehavior re-exported from the new home in
  registration-table.ts

## Resolves

- Advanced-review S4 (runtime guard per-call cost) — guard no longer exists
- Advanced-review S1 (tuple three-defenses indirection) — single Record replaces all tuples
- Correctness P3 (exhaustiveness warns-only) — structurally impossible to drift
- Unit 6 type-level check — subsumed by the Record type
- Unit 3 runtime throw — no longer needed

Tests: 5674 passed, 0 failures. Typecheck clean.

* test(model): delete duplicate closure-isolation spy tests

S5 (code-simplicity P3): The 'closure isolation — each hook can only
write to its registry' describe block duplicated the 'behavior group
isolation' block's coverage via a different mechanism.

Behavioral tests (lines 151-174, kept):
  table.get('Class')!('User', def);
  expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined();
  expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined();

Spy tests (deleted, ~55 lines):
  vi.spyOn(deps.methods, 'register')
  table.get('Class')!('User', def);
  expect(methodsSpy).not.toHaveBeenCalled();

Both assert the same invariant — classHook does not touch the methods or
fields registries. The behavioral form observes the END STATE of the
registry (lookup returns undefined), which is the actual contract.
The spy form asserts the IMPLEMENTATION (a specific method was not
called), which couples to internal wiring — a refactor to a different
register function name would break the spy test while the behavioral
test would still pass.

Also dropped the now-unused `vi` import from vitest.

Tests: 24/24 registration-table.test.ts pass (-4 from spy deletion).

* refactor(model): compile-time cross-invariant between CLASS_TYPES and dispatch classHook

A1 (architecture-strategist P2, 0.90): CLASS_TYPES in symbol-table.ts
and the class-like entries of the dispatch table were two independent
hardcoded sets. Adding a new class-like label (e.g. Swift 'Extension')
to one but not the other would silently degrade qualifiedName
population — the symptom is subtle (partial qualified-name lookups)
and no test asserted the co-extensive invariant.

Fixed with a single source of truth and a two-layer compile-time
enforcement:

## symbol-table.ts

- Add `CLASS_TYPES_TUPLE` as `readonly [...] as const satisfies
  readonly NodeLabel[]`. The `satisfies` forces every tuple entry to
  be a valid NodeLabel at compile time.
- Export derived type `ClassLikeLabel = typeof CLASS_TYPES_TUPLE[number]`.
- Derive `CLASS_TYPES` Set from the tuple — same runtime shape as
  before, now typed `ReadonlySet<NodeLabel>`.

## registration-table.ts

- Import `CLASS_TYPES_TUPLE` and `ClassLikeLabel` from symbol-table.ts.
- Narrow the `satisfies` on `LABEL_BEHAVIOR` via intersection:
      Record<NodeLabel, LabelBehavior> & Record<ClassLikeLabel, 'dispatch'>
  This forces every class-like label to have value 'dispatch' at
  compile time. Adding a label to CLASS_TYPES_TUPLE without
  classifying it as dispatch in LABEL_BEHAVIOR fails to compile with
  a type error naming the drifted label.
- Build the class-like entries of the dispatch Map by iterating
  `CLASS_TYPES_TUPLE` at factory time. Adding a label to the tuple
  automatically wires it to classHook — no second place to update.

## What the design prevents

1. Drift scenario A (A1 original): 'Extension' added to CLASS_TYPES_TUPLE
   but not to LABEL_BEHAVIOR → compile error on LABEL_BEHAVIOR's
   satisfies.
2. Drift scenario B: 'Extension' added to CLASS_TYPES_TUPLE but not
   wired to classHook → impossible because the Map is derived from the
   tuple.
3. Drift scenario C: class-like label classified as something other
   than 'dispatch' in LABEL_BEHAVIOR → compile error on the narrowed
   intersection.

Runtime behavior unchanged: same 6 labels in CLASS_TYPES, same 6
class-like entries in the dispatch Map. Tests pin the behavior via
the existing behavior-group tests in registration-table.test.ts.

DAG unchanged: registration-table.ts already imported from symbol-table.ts
(the allowed upward direction). symbol-table.ts still imports nothing
from model/.

Tests: 5670 passed, 0 failures. Typecheck clean.

* test(field-extraction): use SemanticModel facade instead of raw SymbolTable

A6 (architecture-strategist P3, 0.85): field-extraction.test.ts created
its FieldExtractorContext fixture with `symbolTable: createSymbolTable()` —
a raw SymbolTable leaf, not the facade. In production, the context's
symbolTable field is always `model.symbols` (the SemanticModel-wrapped
facade where .add() dispatches through the owner-scoped registries).

The current field extractors don't call symbolTable.add() at all, so
this change is behavior-neutral today. The value is architectural
consistency — matching the test fixture to the production shape
prevents silent drift if a future field extractor starts registering
dynamically-discovered properties via the context. Without the fix,
such writes would hit the raw leaf and skip the fan-out, and tests
would pass even though the symptom (empty FieldRegistry) would
manifest in production.

Tests: 50/50 field-extraction.test.ts pass. Production tsc --noEmit
clean. Test-tsconfig error count unchanged (634 pre-existing errors
in unrelated test files, out of scope).

* refactor(A5): decouple model/resolve.ts from language registry

Move the MroStrategy type into gitnexus-shared and replace the
language: SupportedLanguages parameter on lookupMethodByOwnerWithMRO
with a direct mroStrategy: MroStrategy literal. Callers derive the
strategy from their language provider before invoking the resolver.

model/resolve.ts no longer imports from ../languages/index.js, so the
model/ layer is free of cross-layer coupling with the language
registry — this closes finding A5 from the SM-20/21/22/23 advanced
review (plan 006).

* feat(A4): add MethodRegistry.lookupMethodByName flat-by-name index

Add a secondary `methodsByName: Map<string, SymbolDefinition[]>` index
on MethodRegistry that returns every method with a given unqualified
name, accumulated across owners and overloads. The new index shares
SymbolDefinition references with methodByOwner — no duplication.

This is step 1 of the A4 double-index removal (plan 006). Tier 3
global resolution will switch to this index in Unit 3 so Method and
Constructor can be removed from CALLABLE_TYPES in Unit 4.

* refactor(A4): extend Tier 3 + memberCallByFile to consult method registry

Add model.methods.lookupMethodByName to Tier 3 global resolution in
resolution-context.ts and to the callable-pool build in
call-processor.ts (resolveMemberCallByFile + D2 widen path).

Intentionally behavior-preserving: Method and Constructor are still
in CALLABLE_TYPES so the new lookup returns identical candidates that
already reach Tier 3 through callableByName. Both paths dedup by
nodeId during this intermediate state — Unit 4 shrinks CALLABLE_TYPES
and the dedup is removed.

Part of plan 006 A4 step 2.

* refactor(A4): shrink CALLABLE_TYPES to free callables only

CALLABLE_TYPES = {Function, Macro, Delegate}. Method and Constructor
are no longer double-indexed in callableByName — they reach resolvers
through model.methods.lookupMethodByName instead.

Companion changes:
- Introduce CALL_TARGET_TYPES = CALLABLE_TYPES ∪ {Method, Constructor}
  for the resolver's kind filter (filterCallableCandidates,
  countCallableCandidates). Separates registration semantics (narrow)
  from the resolver's acceptable-target set (wide).
- type-env.ts for-loop return-type inference consults both indexes,
  treating the union as the authoritative call pool.
- resolveMemberCallByFile + D2 widen path keep the nodeId dedup in
  place: Python/Rust/Kotlin class methods emitted as Function+ownerId
  still land in both indexes until Unit 5 unblocks the normalization.
- Tier 3 global resolution (resolution-context.ts) keeps the same
  dedup for the same reason.

Test updates reflect the new contract: Method/Constructor live in
methodsByName, not callableByName. Orphan Method-without-ownerId now
lives only in the file index (no registry coverage).

Part of plan 006 — closes A4 for strictly-labeled methods. Python/
Rust/Kotlin Function+ownerId normalization is tracked as Unit 5
(blocked).

* refactor: rename CALLABLE_TYPES → FREE_CALLABLE_TYPES

Pure rename. The constant's meaning changed in Unit 4 (free callables
only — no methods, no constructors) so the name now reflects that
scope: "callables that have no owner scope". Updates the constant
declaration and every consumer in src/ and test/.

Closes plan 006 Unit 6.

* refactor(A2): strict SymbolTableReader (pure reads) + SymbolTableWriter (+add)

Split the SymbolTable interface into three strictly layered surfaces:

- SymbolTableReader: lookups + iteration. NO add, NO clear. Holders
  cannot mutate the table in any way.
- SymbolTableWriter extends Reader: + add. NO clear. Holders can
  register new symbols but cannot trigger a leaf-index reset.
- InternalSymbolTable (private, not exported): + clear. The cascading
  reset capability is reachable only through createSymbolTable's
  return type, held exclusively by SemanticModel.rawSymbols.

SemanticModel.symbols is now typed as SymbolTableWriter — external
consumers (workers, processors, pipelines) can register symbols and
query them, but cannot reach .clear(). The A2 LSP fix holds: callers
holding any public reference cannot desync the leaf indexes from the
owner-scoped registries.

Delete the transitional `type SymbolTable = SymbolTableReader` alias
and migrate every consumer (src + test) to the explicit names:
- Field and parameter annotations use SymbolTableReader by default;
  only code that calls .add() uses SymbolTableWriter.
- parsing-processor (workers + sequential paths) takes
  SymbolTableWriter so it can register extracted symbols.
- field-types, call-processor, named-binding-processor,
  workers/parse-worker: use SymbolTableReader (query-only).
- Tests: drop the stale `clear` fields from mock factories and
  migrate the semantic-model cascade tests from the removed
  model.symbols.clear() path to model.clear().

Closes plan 006 Unit 7. Industry sources: TypeScript compiler API
builder pattern, Salsa ParallelDatabase, .NET IReadOnlyList. See the
a2-lsp-clear-contract-research artifact for full citations.

* feat(A2): add SemanticModel.resetFileIndex() partial-reset entry point

Add a named method that clears only the leaf file and callable
indexes without cascading to the three owner-scoped registries
(types, methods, fields). Replaces the rare partial-reset use case
that was previously reachable via the now-removed symbols.clear()
path from A2 (plan 006 Unit 7).

JSDoc makes the semantic difference with model.clear() explicit so
future readers don't have to guess which method to call for a given
reingestion scenario.

Test-first: three scenarios cover the partial-vs-full semantics,
re-add after reset, and idempotency.

Closes plan 006 Unit 8.

* docs(S7): trim registration-table module JSDoc

Remove the ~24 lines of design-provenance citations from the module
JSDoc. The rust-analyzer, TypeScript-compiler, and Fowler references
are preserved in git history via the original SM-22 commits and in
plan 006 Unit 9.

Keep the ownership diagram, behavior-group table, and the
'How to add a new NodeLabel' checklist — those are load-bearing for
future contributors.

Closes plan 006 Unit 9 (S7 advanced-review finding).

* test(S3): migrate type-env.test.ts off LegacyMockOverrides

Replace the createMockSymbolTable bridge and LegacyMockOverrides
interface with real createSemanticModel() + add() calls across all
14 call sites. Where a test needs a specific registry lookup that
can't be pre-populated cleanly, use vi.spyOn on the real registry
instead.

Pattern breakdown:
- Pattern A (pre-populate via model.symbols.add): 13 sites
- Pattern B (vi.spyOn on registry lookup): 1 site

Deletes LegacyMockOverrides + createMockSymbolTable entirely. The
real MethodRegistry arity/returnType semantics match the hand-rolled
mock behavior in every migrated case, and no 'as any' casts remain
in the file.

Closes plan 006 Unit 10 (S3 advanced-review finding).

* refactor: remove unused MroStrategy type exports from language-provider and resolve modules

* refactor: relocate symbol-table, heritage-map, resolution-context into model/

Use git mv so blame and history follow each file:
- gitnexus/src/core/ingestion/symbol-table.ts → model/symbol-table.ts
- gitnexus/src/core/ingestion/heritage-map.ts → model/heritage-map.ts
- gitnexus/src/core/ingestion/resolution-context.ts → model/resolution-context.ts

These three files are part of the SemanticModel layer (file/callable
indexes, heritage parent map, tiered resolver) and now sit alongside
the registries they collaborate with. Updates every consumer import
path across src/ and test/ to the new locations.

* refactor(model): enforce pure-leaf DAG + delete legacy re-exports

model/ is now a pure leaf: zero upward imports and zero compat
shims in its parent processors. Completes the DAG cleanup started
in the previous commit.

1. walkBindingChain — moved into model/resolution-context.ts;
   named-binding-processor.ts deleted.

2. NamedImportMap + NamedImportBinding + isFileInPackageDir —
   moved into model/resolution-context.ts. Every consumer now
   imports from the canonical location directly. Legacy re-exports
   in import-processor.ts deleted.

3. c3Linearize + gatherAncestors — moved into model/resolve.ts.
   mro-processor.ts imports them back for computeMRO. Legacy
   c3Linearize re-export from mro-processor.ts deleted.

4. ExtractedHeritage type — moved into model/heritage-map.ts.
   call-processor.ts, parsing-processor.ts, pipeline.ts,
   heritage-processor.ts, and the test files now import it from
   the canonical location. Legacy re-exports in parse-worker.ts
   and heritage-processor.ts deleted.

5. resolveExtendsType — rewritten in model/heritage-map.ts to
   take an explicit HeritageResolutionStrategy (A5-style DI).
   buildHeritageMap accepts an optional getHeritageStrategy
   callback; production uses getHeritageStrategyForLanguage from
   heritage-processor.ts. Legacy resolveExtendsType re-export
   from heritage-processor.ts deleted.

Verified:
- grep 'from "..' gitnexus/src/core/ingestion/model → empty
- grep 'Re-export for legacy' gitnexus/src/core/ingestion → empty
- npx tsc --noEmit → clean
- npx vitest run → 5686 passing

* docs(model): strip phase/plan references from module comments

Remove SM-20/21/22/23, A2/A4/A5, plan 006, Unit N labels and historical
phrasing ("previously", "legacy", "model-leaf DAG cleanup") from all 10
files in src/core/ingestion/model/. Preserve domain vocabulary (Tier
1/2/3), invariants, and caveats — only the plan archaeology is gone.

* refactor(model): tighten interface segregation + compile-time invariants

Apply four gated findings from branch-wide code review:

- SemanticModel.symbols now typed as SymbolTableReader; MutableSemanticModel
  widens it back to SymbolTableWriter. ResolutionContext.model is typed as
  MutableSemanticModel since it owns the lifecycle. Resolvers that only
  query symbols can annotate their own fields as SemanticModel to drop
  write access at the type level.

- Lookup methods (lookupExactAll, lookupCallableByName, lookupClassByName,
  lookupClassByQualifiedName, lookupImplByName) now return
  readonly SymbolDefinition[]. The returned arrays are live views into
  the internal indexes; the readonly marker prevents accidental caller
  mutation. walkBindingChain return type narrowed to match.

- FREE_CALLABLE_TUPLE + FreeCallableLabel exported from symbol-table.ts
  as the single source of truth for free-callable labels. LABEL_BEHAVIOR
  now satisfies Record<FreeCallableLabel, 'callable-only'> as a second
  cross-invariant alongside Record<ClassLikeLabel, 'dispatch'>. Adding a
  label to the tuple without classifying it as 'callable-only' fails at
  build time. CALLABLE_ONLY_LABELS is now a re-export alias of
  FREE_CALLABLE_TYPES so the two sets cannot drift.

- walkBindingChain fast-exits before allocating its cycle-detection Set
  when the caller's file has no named bindings. Skips ~200k transient
  Set allocations per large-repo resolution pass.

Also fixes five stale comments flagged by the review: duplicate JSDoc
block on RegistrationHook merged; resolve.ts "delegates to mro-processor"
direction corrected; RegistrationTableDeps JSDoc names
createRegistrationTable (not createSymbolTable); mro-processor.ts
"re-exported at top" stale comment removed; gatherAncestors export
comment matches reality.

tsc --noEmit clean, full test suite green (5786 tests).

* refactor(model): resolve four deferred P2 review findings

Address the four gated items from the branch-wide review that needed
design decisions before applying:

F#3 — Method/Constructor without ownerId fallback to callable index.
The dispatch hook silently skips owner-scoped labels that lack an owner
(an extractor contract violation — AST-degraded parse, or a buggy
language extractor). Pre-dispatch-table code let such defs fall through
to callableByName and stay reachable at Tier 3 global resolution. This
restores that fallback in SymbolTable.add so orphaned Methods and
Constructors don't silently vanish. Property deliberately does NOT
participate in the fallback to avoid polluting common names like
id / name / type.

F#4 — Delete MutableSemanticModel.resetFileIndex. The method had zero
production callers (only three tests), documented a "rare partial-
reingestion flow" that was never implemented, and contained the
adversarial-reviewer's double-populate trap: calling resetFileIndex
followed by re-adding the same class symbol would push a duplicate
SymbolDefinition into TypeRegistry.classByName without ever clearing
the first one. If incremental reingestion is ever needed, it can be
designed properly with per-file TypeRegistry invalidation. For now,
deleting the footgun is safer than documenting it.

F#5 — Compile-time dispatch-table completeness check. `LABEL_BEHAVIOR`
already enforces "every NodeLabel is classified" via
`Record<NodeLabel, LabelBehavior>`, but the dispatch-table factory
populated its Map with manual `table.set(...)` calls that TypeScript
could not correlate back to the `'dispatch'` classification. Add a
type-level `DispatchLabel` extracted from `LABEL_BEHAVIOR` via a
conditional mapped type, and build the table from an object literal
that satisfies `Record<DispatchLabel, RegistrationHook>`. Adding a new
dispatch-classified label without wiring it to a hook now fails the
build with a named-key error — no more silent no-op hooks.

F#7 — Tier 3 dedup fast-path via MethodRegistry.hasFunctionMethods.
The Set-based dedup between callableDefs and methodDefs is only needed
when a Python/Rust/Kotlin class method (emitted as Function+ownerId by
the worker) lands in both indexes. For TS/Java/C#/C++/Ruby-only repos
— where the two indexes are disjoint by construction — the dedup was
pure overhead on every global-tier hit. MethodRegistry now tracks
whether any Function-typed def was ever registered, and resolution-
context branches Tier 3 into a concat-only fast path when that flag
is false. Slow path with dedup survives unchanged for mixed-language
repos.

New tests pin the invariants: hasFunctionMethods flag transitions,
Method/Constructor orphan fallback, Property non-fallback, and the
MethodRegistry clear() reset. Full test suite green (5756 tests).

* refactor(model): close remaining P3 review findings + coverage gaps

Address the remaining review items in one batch.

Production refactors:

- Rename classHook → classLikeHook (M05). The hook handles Class /
  Struct / Interface / Enum / Record / Trait; the vocabulary used in
  surrounding docs and the behavior-group table is "class-like". The
  rename makes the code match the taxonomy without forcing readers
  through a mental glossary.

- Extract MAX_BINDING_CHAIN_DEPTH constant in resolution-context.ts
  and document it as a known silent false-negative source (ADV-003).
  Five hops cover the common TypeScript monorepo pattern; raising the
  cap is a one-line change if a real repo exceeds it. walkBindingChain
  consumes the constant so the 5 magic number no longer floats free.

- Replace defs.filter() allocation in MethodRegistry.lookupMethodByOwner
  with a two-pass streaming count + conditional materialization
  (PERF-04). Pure-match and pure-reject arity paths now skip the
  filtered-array allocation entirely; only the discriminating case
  (at least one match AND at least one rejection) pays it.

- Rewrite NOOP_SYMBOL_TABLE in parse-worker.ts and NOOP_SYMBOL_TABLE_SEQ
  in parsing-processor.ts to implement all six SymbolTableReader
  methods (ADV-005). The `as unknown as SymbolTableReader` cast is
  removed in favor of a direct SymbolTableReader annotation, so future
  additions to the interface surface as compile errors on the stubs
  instead of silently falling through.

- type-env.ts getCallableUnionCount and getFirstCallable now take
  `model: SemanticModel` as an explicit argument instead of reaching
  into the enclosing `model!` non-null assertion (KT-003). Callers
  enter via an `if (model)` guard and pass the narrowed reference, so
  the non-null precondition is visible at the type level and the
  closures cannot be accidentally extracted into a context without
  the guard.

- Tier 3 dedup in resolution-context.ts now covers all four index reads
  (classDefs, implDefs, callableDefs, methodDefs) via a pushUnique
  helper (C-03). Previously classDefs and implDefs were spread directly
  without dedup; any theoretical nodeId collision would have produced
  duplicates in globalDefs.

Test infrastructure:

- Extract makeDef / makeMethod factory helpers into
  test/unit/model/helpers.ts (T-07). The four registry/table test
  files now import the shared helper and specialize with overrides,
  removing ~25 lines of duplicated boilerplate and creating a single
  point of maintenance.

New test coverage:

- T-01: c3 BFS fallback — cyclic Python hierarchy that fails c3
  linearization and must fall back to heritageMap.getAncestors() BFS
  order. Added to the lookupMethodByOwnerWithMRO describe block.

- T-02: Tier 2a-named precedence — verifies the binding chain walker
  fires before Tier 2a import-scoped when an aliased import
  `import { User as U } from B` competes with a raw same-name Tier 2a
  hit. Also pins Tier 1 same-file precedence over Tier 2a-named.

- T-03: Tier 3 Function+ownerId dedup — end-to-end test that a Python
  class method emitted as `Function + ownerId` yields exactly ONE Tier
  3 candidate (not two). Companion test pins the fast-path branch for
  hasFunctionMethods === false repos.

- T-06: walkBindingChain guards — circular re-export detection,
  depth-cap exceeded drop, and boundary case at exactly
  MAX_BINDING_CHAIN_DEPTH hops resolving successfully.

All tests added to a new test/unit/model/resolution-context.test.ts
dedicated to ResolutionContext.resolve() tier-precedence invariants.

Full suite: 5708 passing (minus the known Windows LBUG lock flake
that passes in isolation).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-12 01:06:55 +01:00
ivkondandClaude 1ff324ca16 feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795)
* feat(group): bridge.lbug storage + contract matching expansion

Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a
revised plan per @magyargergo's request).

## What changed

Adds the LadybugDB-backed bridge storage infrastructure and extends
the contract matching algorithm with wildcard support. All changes are
additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts
are left on their upstream main versions and will migrate to the new
bridge in follow-up PRs (#792, #793, #794).

### Files

**New (844 LOC prod):**
- `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with
  `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via
  `WriteBridgeReport`, `findContractNode` with three-tier symbol
  lookup (uid → filePath+name → filePath)
- `gitnexus/src/core/group/bridge-schema.ts` — schema DDL
- `gitnexus/src/core/group/normalization.ts` — contract ID
  canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers
  used by both matching and bridge write

**Modified (+137 LOC prod):**
- `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for
  `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper,
  and canonical gRPC ID handling in `normalizeContractId`
- `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`;
  new `BridgeHandle` and `BridgeMeta` interfaces

**New tests (658 LOC):**
- `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round
  trip, `WriteBridgeReport` shape, dropped-links counter, retryRename
  behavior on EBUSY/ENOENT/EPERM/EACCES
- `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases
  (malformed meta, missing contract nodes, concurrent access)

**Modified tests (+225 LOC):**
- `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer
  matching, gRPC canonical ID handling, same-service guard

### Self-review fixes folded in

Carried forward from the original #606 self-review:
- `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel
- `openBridgeDbReadOnly` partial-handle cleanup
- `writeBridgeMeta` uses `retryRename` for Windows consistency
- `retryRename` unit tests (was zero coverage)
- Per-item try/catch around every CREATE loop so one malformed contract
  doesn't abort the whole write
- Dropped cross-link counter (`linksDroppedMissingNode`)

### Why now

magyargergo asked for the #606 PR to be split so we can iterate with
confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271).
This is the foundational layer — pure infra, no user-facing surface,
no callers of the new APIs in this PR. Later PRs wire it in.

### How to verify

- `cd gitnexus && npx tsc --noEmit`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks`
- Pre-commit hook runs clean

### Risk / rollback

**Low.** All new code sits under `src/core/group/` in new files plus a
minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts`
(both purely additive). No existing callers reference the new APIs
(bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire
them in come later in the split chain. Rollback = `git revert` of the
merge commit; no state introduced, no schema migration triggered.

### Scope discipline (per GUARDRAILS.md)

- Only the 8 files listed above are touched; no drive-by refactors
- No CI/release/security config changes
- No secrets, tokens, or machine-specific paths
- Content is lifted from the #606 branch which already passed CI 11/11
  green on `d15b8cb` (before the split)

### Dependencies

- **Base:** `main` (no dependencies on other split PRs)
- **Blocks:** extractor expansion (#792), sync pipeline (#793),
  cross-impact feature (#794)
- **Related ticket:** #791

Co-authored-by: Claude <noreply@anthropic.com>

* fix(group): address @claude review on #795

Addresses the findings from the automated review on PR #795
(https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000
— posted by @magyargergo / claude-code Action run).

### Medium severity (reviewer flagged as blockers)

- **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak`
  recovery path used bare `fsp.rename(bakPath, dbPath)`, which is
  exactly the scenario most likely to hit Windows EBUSY/EPERM (an
  interrupted writer still holding the handle for a few ms). Switched
  to `retryRename` for consistency with the rest of the file's
  Windows-safe rename path.
- **bridge-db.ts `ensureBridgeSchema` error detection** — the inline
  `msg.includes('already exists')` substring match has been lifted
  into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment
  documenting the coupling to LadybugDB's error message wording and
  why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it)
  or typed errors (LadybugDB's JS driver doesn't expose error codes).
  Also tightened the `catch (err: any)` to `catch (err: unknown)`.
- **bridge-db.ts `findContractNode` — extracted out of writeBridge**
  — the 35-line async closure living inside `writeBridge` has been
  lifted to three module-level functions: `createContractLookupIndex`,
  `indexContract`, and `findContractNode`. `findContractNode` is now
  a pure synchronous function taking a prebuilt index instead of
  doing its own DB queries. The `writeBridge` cross-link loop is now
  ~25 lines instead of ~100.
- **bridge-db.ts `findContractNode` — N+1 query elimination** — the
  old inner-closure version issued up to 6 DB round-trips per
  cross-link (2 endpoints × up to 3 tiers of fallback queries). For a
  group with 1000 cross-links, that's up to 6000 DB queries just to
  resolve endpoints. The new version consults an in-memory
  `ContractLookupIndex` built incrementally as contracts are inserted
  (`indexContract` called AFTER each successful insert so failed
  inserts don't poison the index). Cross-link resolution is now
  O(1) per link instead of O(3) DB queries per link, with zero DB
  round-trips during the cross-link loop.

### Minor severity

- **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB
  ever returns an empty `QueryResult[]` at the top level (shouldn't
  happen with single-statement calls, but driver contract isn't
  explicit), the old code would call `.getAll()` on `undefined` and
  crash with a confusing stack. Added an `unwrapQueryResult` helper
  that throws an explicit `'empty QueryResult array'` error instead,
  making a potential driver regression visible immediately.
- **normalization.ts `contractRichness` weights** — added a
  block-level comment documenting the weight ordering (+3 for
  symbolUid, +2 for each symbol-identifying field, +1 for service
  tag or non-manifest origin) and explicitly noting that the
  absolute numbers don't matter, only the relative ordering. Matches
  the "comment for contributors" suggestion in the review.
- **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** —
  added a 4-point contract explaining what bumping the constant
  means ("discard and re-sync" strategy for V1, no in-place
  migration yet, new migration logic should live in a separate
  `bridge-migrations.ts` module when it becomes necessary).
- **test/unit/group/fixtures.ts** — extracted the `makeContract`
  helper previously copy-pasted between `bridge-db.test.ts` and
  `bridge-db-edge.test.ts` into a shared fixtures module. Both test
  files now import from `./fixtures.js`. Kept the scope minimal:
  fixtures is NOT a general-purpose factory module, just the shared
  baseline contract builder.

### New tests

Added 9 pure-function unit tests for the now-extracted
`findContractNode` in `bridge-db.test.ts`:
  - returns null on empty index
  - tier 1 (symbolUid) match, including repo-scope and role-scope
    isolation
  - tier 2 (filePath + symbolName) fallback when symbolUid is empty
    or mismatches
  - tier 3 (filePath only) when exactly one contract lives in the
    file, and refusal when multiple do
  - priority ordering when multiple tiers could resolve

These are fully isolated — no DB, no temp directories, no native
LadybugDB binding — so they run in &lt;10ms total and are
immediately trustworthy as a regression safety net.

### Deliberately deferred (reviewer marked as "fine for now")

- `BridgeHandle._db` / `._conn` typing to `unknown` with casts in
  `bridge-db.ts` — reviewer's note: "The typing is fine for now."
- Batch inserts via `UNWIND` — needs LadybugDB support confirmation,
  tracked as a follow-up; the per-item pattern remains.
- `queryBridge` prepared-statement lifecycle — the current pattern
  (prepare → execute → GC) relies on LadybugDB's internals, worth
  verifying against their docs in a separate audit.

### Scope discipline (per `GUARDRAILS.md`)

- Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`,
  `normalization.ts`, both bridge test files, new `fixtures.ts`) —
  no drive-by refactors
- No CI/release/security config changes
- No secrets

### Test + typecheck status

- `npx tsc --noEmit` clean
- `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in
  isolation. The full-file run still hits the pre-existing native
  LadybugDB cleanup segfault that flakes the reported count — same
  as every prior commit on this branch, not a regression.
- `bridge-db-edge.test.ts`: 4/4 pass
- `matching.test.ts`: 28/28 pass
- `types.test.ts`: 5/5 pass
- `retryRename` tests (4/4) and `findContractNode` tests (9/9)
  verified in isolation via `-t` filter

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 19:46:12 +01:00
Dave Brophy 9364739fb4 fix: restore tree-sitter-swift postinstall patch for macOS ARM64 (#788)
* fix: restore tree-sitter-swift postinstall patch for macOS ARM64

PR #516 (77dcb06) deleted `scripts/patch-tree-sitter-swift.cjs` and
the `postinstall` script entry when bumping to `tree-sitter-swift@0.7.1`,
since 0.7.1 ships prebuilt darwin-arm64 binaries and no longer needs the
patch. PR #538 (01ddc3e) then had to revert `tree-sitter-swift` back to
`^0.6.0` (and `tree-sitter` back to `^0.21.1`) because `npm overrides`
doesn't apply when gitnexus is installed via `npx -y` (gitnexus isn't the
root project, so overrides are silently ignored, producing ERESOLVE errors).

PR #538 reverted the grammar package changes but did not restore the patch
script, leaving `tree-sitter-swift@0.6.0` unable to build its native
binding on macOS ARM64. The symptom is `gitnexus analyze` printing
"Skipping swift" or "swift parser not available".

`Dockerfile.test` still references `node scripts/patch-tree-sitter-swift.cjs`
(added in the same PR #516), confirming the regression — the test image
build is also broken.

This commit restores the patch script from commit `0c8ec95` (the last
revision before it was deleted) and re-adds the `postinstall` entry to
`package.json`. No logic changes — it is an exact restoration.

The TODO comment in the script ("Remove this script when tree-sitter is
upgraded to ^0.22.x") still applies.

* style: run prettier on patch-tree-sitter-swift.cjs
2026-04-11 18:24:02 +01:00
smTheApexandProta100 75635638b1 feat(csharp): capture interface-to-interface heritage (#789)
The C# tree-sitter query set only matched `base_list` on
`class_declaration`, so interfaces extending other interfaces
(`interface IFoo : IBar`) were never captured as heritage edges.

This broke transitive interface implementation chains. For example,
given:

    interface IBase { }
    interface IFoo : IBase { }
    class MyClass : IFoo { }

only `MyClass -> IFoo` was emitted, and the `IFoo -> IBase` edge was
silently dropped. Any analysis that relies on walking the full
interface inheritance chain (e.g. "which classes implement IBase?")
therefore returned incomplete results.

This patch adds two new query patterns mirroring the existing
class_declaration heritage patterns, but targeting
`interface_declaration`:

    (interface_declaration name: (identifier) @heritage.class
      (base_list (identifier) @heritage.extends)) @heritage
    (interface_declaration name: (identifier) @heritage.class
      (base_list (generic_name (identifier) @heritage.extends))) @heritage

The existing heritage-processor pipeline already handles these
captures correctly once the query emits them, so no changes are
needed outside of tree-sitter-queries.ts.

Testing:
- New fixture `csharp-interface-heritage/` covering:
    * interface : interface  (single base)
    * interface : interface, interface  (multiple bases)
    * class : interface (where that interface derives from others)
- 6 new test cases in test/integration/resolvers/csharp.test.ts
  asserting exactly 4 IMPLEMENTS edges and 0 EXTENDS edges for the
  fixture.
- Full C# resolver suite: 175/175 passing, no regressions.

Co-authored-by: Prota100 <Prota100@users.noreply.github.com>
2026-04-11 17:41:51 +01:00
JWWD | ModusOpandClaude Opus 4.6 5be0537ce4 Fix stack overflow on large PHP files — iterative AST traversal (#783)
* Fix: replace recursive AST traversal with iterative stack to prevent stack overflow on large files

Fixes #752

Large PHP files (2000+ lines) with deeply nested AST structures (closures,
array literals, chained method calls) cause "Maximum call stack size exceeded"
during analysis. This converts three recursive tree traversal functions to
iterative loops using explicit stacks:

1. `walk()` in type-env.ts — the main AST walker that processes every node.
   On a 2,462-line PHP controller, this recurses through 5,000-10,000+ nodes.

2. `findRelationCall()` in languages/php.ts — recursive search for Eloquent
   relationship calls within method bodies.

3. `findDescendant()` in utils/ast-helpers.ts — generic recursive utility
   used by PHP property extraction and other parsers.

All three now use a while loop with an array-based stack instead of function
call recursion, eliminating V8's ~10K frame call stack limit as a constraint.

Tested against a production Laravel codebase with 373 PHP files (87,723 lines
total, largest file 2,462 lines) — indexes successfully in 17.4s with zero
errors, where the recursive version would crash with stack overflow.

* Fix: reverse child push order in findRelationCall iterative traversal

The iterative stack-based traversal pushed children in forward order,
causing the last child to be processed first (LIFO). This reversed the
original recursive left-to-right DFS order. Push children in reverse
so the first child ends up on top of the stack.

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

* Fix: move stack declaration before processNode, rename walk to processNode

Move the stack initialization above the function that pushes onto it,
making the data-flow order match the code order. Rename walk to
processNode since it now processes a single node rather than
recursively traversing the tree.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:32:06 +01:00
Mr. WorldwideBrown 6d9ec1009e fix: load VECTOR extension during DB init for semantic search (#782)
* fix: load VECTOR extension during DB init for semantic search

The VECTOR extension was only loaded inside the embedding generation

pipeline (createVectorIndex). On a fresh gitnexus serve session,

semantic and hybrid search failed because QUERY_VECTOR_INDEX was

unknown.

Now loads the VECTOR extension alongside FTS during database

initialization in both the single-connection and pool-based paths.

Fixes #766

* fix: reset vectorExtensionLoaded on DB close and retry paths

The vectorExtensionLoaded flag was not being reset in closeLbug() or
the busy-retry cleanup path in withLbugDb(). This caused the VECTOR
extension to not be re-loaded after a close+re-init cycle, breaking
semantic search on reconnection.

Also resets shared.ftsLoaded and shared.vectorLoaded in the pool
adapter closeOne() for external DB entries, preventing stale extension
state when the pool is re-opened.

Adds integration tests covering vector extension loading, idempotency,
and state reset on both close and busy-retry paths.

* fix: set ftsLoaded flag in initLbugWithDb to avoid redundant extension reloads

* fix: set shared.vectorLoaded flag in initLbugWithDb to avoid redundant reloads
2026-04-11 12:17:40 +01:00
Mr. WorldwideBrown 4911201664 fix: map diff hunks to symbol line ranges in detect_changes (#779)
* fix: map diff hunks to symbol line ranges in detect_changes

The detect_changes tool previously used `git diff --name-only` and
picked the first 20 arbitrary symbols from each changed file. This
produced false positives (unchanged symbols reported as modified) and
false negatives (actually changed symbols dropped by the LIMIT).

Now uses `git diff -U0` to get unified diff with hunk headers, parses
the @@ line ranges, and queries for symbols whose [startLine, endLine]
range overlaps the diff hunks. Only truly touched symbols are reported.

Also fixed the CONTAINS path match to ENDS WITH to prevent cross-file
false positives from substring matching.

Fixes #758

* fix: address review feedback - variable shadowing, batch queries, tests

- Rename `params` to `queryParams` in detectChanges hunk-mapping loop
  to avoid shadowing the outer method parameter
- Replace N+1 per-symbol process lookup with a single batched query
  using WHERE n.id IN $ids (same pattern as impact BFS traversal)
- Add unit tests for parseDiffHunks covering single/multi file,
  single/multi hunk, omitted count, pure-deletion, and empty input

* style: fix prettier formatting in parse-diff-hunks test
2026-04-11 11:29:52 +01:00
Mr. WorldwideBrown 08541e2857 Fix HTTP client vs Express route detection and Spring interface attribution (#780)
* fix: correctly identify HTTP client calls vs Express routes in receiver extraction

* fix: skip Spring route extraction for Feign client interfaces

* fix: address review feedback - receiver walk edge case, regex anchoring, add tests

* style: fix prettier formatting in route extractor and test files
2026-04-11 11:24:47 +01:00
d9960c62bf SM-19: Delete resolveCallTarget — replace with thin dispatcher (#770)
* Initial plan

* SM-19: Replace resolveCallTarget with thin dispatcher

Delete the monolithic resolveCallTarget function (~200 lines) and replace it
with a 15-line thin dispatcher that routes to resolveMemberCall,
resolveStaticCall, or resolveFreeCall. Extract module-alias resolution and
file-based member-call fallback into dedicated helper functions.

- resolveCallTarget body reduced from ~200 lines to ~15 lines
- Extract resolveModuleAliasedCall helper (Python/Ruby module imports)
- Extract resolveMemberCallByFile helper (trait dispatch, overload disambiguation)
- Extract singleCandidate helper (constructor alias fallback, name-based fallback)
- Update unit tests for new dispatcher semantics
- Update doc comments referencing deleted D0-D4 paths

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* SM-19: Add singleCandidate tail fallback for member calls with unresolvable receiver type

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-19): address all PR #770 review findings + fix CI

Fixes all 5 test failures (2 unit + 3 integration) and addresses 10
review findings from comment 4225312416.

Critical fix — singleCandidate null-route guard
The SM-19 dispatcher chained singleCandidate as an unconditional tail
fallback for member calls with receiverTypeName. This bypassed the
SM-10 R3 null-route contract: when the receiver type IS in the index
but file/owner filtering produced zero matches, the old code returned
null (genuine miss), but the new code fell through to singleCandidate
(false-positive CALLS edge).

Root cause: resolveMemberCallByFile returns null for two semantically
different reasons — (1) type not found in the index at all, and
(2) type found but no candidate matched after narrowing. The dispatcher
treated both as "try the next fallback." The old resolveCallTarget
exited the entire function on case 2.

Fix: after the scoped resolvers both return null, check whether the
receiver type resolves in the index. If it does (case 2), null-route
— the scoped resolvers made the right decision. If it doesn't (case 1,
e.g. PHP 'mixed', dynamic types), singleCandidate is the correct last
resort. ctx.resolve is cached so the check is free.

This fixes:
- Unit: no heritageMap null-route test (was getting 1 edge, expects 0)
- Integration: Rust c.trait_only() negative test
- Integration: 3 PHP heritage + alias tests (singleCandidate correctly
  fires when the receiver type is not in the index)

Performance (findings #1, #2, #3)
- Thread pre-computed tiered result into resolveModuleAliasedCall via
  new tieredOverride parameter — eliminates the duplicate ctx.resolve
  call on every module-alias path.
- Add countCallableCandidates helper that short-circuits at threshold
  without allocating an intermediate array — replaces the
  filterCallableCandidates(...).length > 1 allocation in skipMember.
- resolveMemberCallByFile lookupCallableByName caching deferred to a
  follow-up (finding #2) — the fix requires threading widenCache
  through the file-scoped resolver which is a larger change.

Code quality (findings #4, #5)
- Remove dead code: redundant conditional in resolveMemberCallByFile
  where both branches returned null.
- Move WidenCache type declaration from mid-file (between JSDoc blocks)
  to adjacent to CONSTRUCTOR_TARGET_TYPES with other type declarations.

Formatting
- Applied prettier to call-processor.ts (CI format check was failing).

Verification
- tsc --noEmit clean
- 3188 unit tests pass (0 skipped real tests)
- 1766 resolver integration tests pass
- Zero regressions — all PHP, Rust, and no-heritageMap tests green

Review: https://github.com/abhigyanpatwari/GitNexus/pull/770#issuecomment-4225312416

* fix(SM-19): restore module-alias narrowing and constructor disambiguation

Codex adversarial review on PR #770 surfaced two silent regressions in the
SM-19 thin dispatcher:

Finding 1 [high] — Typed member calls bypassed module-alias narrowing.
When two homonym receiver types are both imported by the caller, the
import-scoped tier no longer narrows and the owner/file resolvers see
genuine ambiguity. The dispatcher null-routed silently, dropping valid
CALLS edges. Fix: consult `resolveModuleAliasedCall` at the top of the
typed-member branch so an active alias on `call.receiverName` picks the
aliased file before the generic resolvers run.

Finding 2 [medium] — Constructor dispatch lost overload disambiguation.
When `resolveStaticCall` bails (ambiguous or ownerless Constructor pool)
and the caller supplied `overloadHints` / `preComputedArgTypes`, the
branch fell straight through to `singleCandidate` — which also bails on
multiple same-arity survivors. Fix: between `resolveStaticCall` and
`singleCandidate`, run constructor-filtered overload disambiguation on
the tiered pool. Only engages when a narrowing signal is present;
preserves SM-10 R3 null-route for genuinely ambiguous cases.

Tests:
- call-processor.test.ts: 3 new dispatcher-level regression tests
  covering real-homonym alias narrowing, constructor overload
  disambiguation with `argTypes`, and null-route control
- symbol-table.test.ts: update `module alias homonyms` test which
  previously codified the Finding 1 regression; now asserts resolution
  to the aliased file's method

Verification: 3191 unit + 2398 integration tests pass; tsc --noEmit
clean; prettier clean.

* refactor(SM-19): address code review findings with clean-code pass

Code review on commit f424685e surfaced one P1 correctness regression and
two P2 maintainability concerns. This commit closes all ten findings:

P1 — Alias helper placement regression
  - resolveModuleAliasedCall now runs as a FALLBACK in the typed-member
    branch, after resolveMemberCall/resolveMemberCallByFile return null.
    Previously it short-circuited BEFORE scoped resolvers, leaking unrelated
    homonyms from the aliased file when a local var coincidentally matched
    a module alias.
  - Added type-file verification guard: alias narrowing only fires when the
    alias target file is among the receiver type's defining files. Prevents
    cross-type false positives and hardens SM-10 R3.

P2 — Thin-dispatcher drift (roadmap Phase 3)
  - Extracted disambiguateByOverloadOrArgTypes shared helper. Centralizes
    the overloadHints → preComputedArgTypes precedence rule used by both
    member and constructor resolvers.
  - Folded constructor overload disambiguation into resolveStaticCall as
    step 4.5 (between the ambiguous-pool bail and the instantiable-class
    fallback). resolveStaticCall now accepts optional overloadHints /
    preComputedArgTypes symmetric with resolveMemberCallByFile.
  - Dispatcher's constructor branch returns to a 2-line delegation.
  - resolveMemberCallByFile now calls the shared helper instead of inlining
    the ternary.

P2 — Missing test coverage
  - owner-scoped wins over alias narrowing (alias with unrelated target
    class must not override unique owner-scoped answer)
  - alias narrowing rejects unrelated target type (type-file guard)
  - alias fallthrough: receiverName not in alias map
  - alias fallthrough: alias target file has no matching method
  (overloadHints-for-constructor variant transitively covered via the
   extracted helper's member-path tests; direct dispatcher test deferred
   as it requires real OverloadHints fixture parsing)

P3 — Clarity and durability
  - Stripped "Codex SM-19 Finding N" prefixes from comments. Replaced with
    durable explanations of WHY each guarded branch exists.
  - Added cross-reference comment at the tail-branch resolveModuleAliasedCall
    call site pointing to the typed-member branch usage.

Verification: 3195 unit + 1766 resolver integration + 2398 full integration
tests pass. tsc --noEmit clean. prettier clean.

Plan: docs/plans/2026-04-11-002-fix-sm19-code-review-findings-plan.md

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-11 10:20:57 +01:00
Mr. WorldwideBrown 7c983d798f Fix OpenCode config path, FTS extension load order, error messages, and CLAUDE.md stats (#781) 2026-04-11 06:18:14 +01:00
Yogesh Singh d87744fffc fix: resolve false 404 errors and stale repo context during multi-repo switching on Windows (#633)
* fix: resolve false 404s and stale repo context during multi-repo switching on Windows

* test(e2e): add repo-switching tests — hold-queue 503, ?project= URL, Windows path normalization

* test(e2e): fix repo-switching specs — use live backend with ?server= param
2026-04-10 19:59:16 +01:00
100858f8c8 feat(SM-18): Delete lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex (#769)
* Initial plan

* Update test files for SymbolTable interface changes

Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, and callableIndex
references from all test files. Replace lookupFuzzyCallable with
lookupCallableByName. Update getStats assertions to only expect
{ fileCount }. Remove tests that exclusively tested removed methods.

Files updated:
- symbol-table.test.ts: Remove lookupFuzzy describe block and all
  globalIndex/callableIndex tests, update callable method references
- symbol-resolver.test.ts: Remove SM-16 lookupFuzzy test block,
  update Tier 3 describe title
- type-env.test.ts: Update all mock SymbolTable objects and spy
  variable names
- call-form.test.ts: Update ownerId propagation test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(SM-18): Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex

Remove from SymbolTable interface and implementation:
- lookupFuzzy method
- lookupFuzzyCallable method
- globalIndex Map
- callableIndex Map (renamed to callableByName, backing lookupCallableByName)

Add lookupCallableByName as the targeted replacement for fuzzy callable
lookups. Migrate all production callers:
- resolution-context.ts: lookupFuzzyCallable → lookupCallableByName
- type-env.ts: lookupFuzzyCallable → lookupCallableByName
- call-processor.ts: lookupFuzzy → lookupCallableByName (D2 widen paths)

Remove fuzzyCallCount/fuzzyCallableCallCount stats and globalSymbolCount
from getStats(). Update pipeline.ts logging accordingly.

Memory savings: globalIndex stored every non-Property symbol (typically
the largest index by entry count). Removing it eliminates one Map plus
all its per-name arrays — net savings proportional to unique symbol
count in the project.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4a658c69-41a9-4d57-8527-50ca544ca967

* fix(SM-18): address all PR #769 review findings

1. type-env.test.ts mock: add missing lookupImplByName + getFiles methods.

2. Macro/Delegate tests: 2 new tests confirm C/C++ Macro and C# Delegate
   are indexed in callableByName.

3. D2 widen path test: module-alias scenario verifying lookupCallableByName
   resolves methods in aliased files that shadow same-file definitions.

4. CALLABLE_TYPES unified: exported from symbol-table.ts (single source of
   truth), imported in call-processor.ts. Removed duplicate
   CALLABLE_SYMBOL_TYPES constant.

5. getStats() observability restored: tier hit counters (tierSameFile,
   tierImportScoped, tierGlobal, tierMiss) replace the removed
   fuzzyCallCount diagnostic.

* chore(SM-18): remove unnecessary `as any` casts on valid NodeLabel types

Macro, Delegate, TypeAlias, Const, and Variable are all valid NodeLabel
values in gitnexus-shared. The casts suppressed type checking without
purpose and signaled false uncertainty.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 14:56:24 +01:00
e5dafce9f2 feat(SM-16): Restructure resolveUncached — replace lookupFuzzy data source for all tiers (#764)
* Initial plan

* chore: initial plan for SM-16 resolveUncached refactor

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(SM-16): restructure resolveUncached — replace lookupFuzzy with targeted index lookups

- Remove single lookupFuzzy call that fed all Tier 2a/2b/3 in resolveUncached
- Tier 2a: iterate importedFiles with lookupExactAll per file (O(imports) × O(1))
- Tier 2b: iterate symbols.getFiles() filtered by isFileInPackageDir + lookupExactAll
  (O(files) × O(1), avoids global name scan)
- Tier 3: replace with lookupClassByName + lookupImplByName + lookupFuzzyCallable
  (three O(1) index lookups covering class-like, Rust impl blocks, and callables)
- Add getFiles() to SymbolTable interface (exposes fileIndex.keys() for Tier 2b)
- Add lookupImplByName() to SymbolTable — dedicated Rust Impl index kept separate
  from classByName to preserve correct heritage-map resolution
- Remove allDefs parameter from walkBindingChain; always use lookupExactAll directly
- Add 29 new unit tests covering SM-16 changes and per-language fixtures
- fuzzyCallCount in getStats() is now 0 for all resolve() calls (acceptance criterion)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-16): clean up — readable Tier 3 if-else, correct doc comment, remove unused import

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-16): address all PR #764 review findings

1. Eager callableIndex — maintained on add() like classByName/implByName,
   removing the O(globalIndex) lazy rebuild on the Tier 3 hot path.

2. Tier 2b inverted index — packageDirSuffix→Set<filePath> built lazily on
   first Tier 2b hit. Changes O(allFiles×packages) per resolution to
   O(packages×filesInPackage).

3. Tier 3 type exclusion documented — TypeAlias, Const, Variable are
   intentionally not reachable at Tier 3. 4 negative/positive tests added.

4. Tier 3 allocation guard simplified — single spread replaces 4-way if-else.

5. getFiles() live iterator documented with safety contract.

6. Remaining lookupFuzzy callers in call-processor.ts documented in the
   Tier 3 comment block.

7. Tier 2b language fixtures — added Rust, Kotlin, PHP tests (3 new).

Also merges origin/main (SM-15 accumulator fixes).

* fix(SM-16): address Codex adversarial review — Tier 2b cache lifecycle + Macro/Delegate at Tier 3

1. Tier 2b packageDirIndex now invalidated in clearCache() and clear(),
   preventing stale snapshots when symbols/packages are added between
   chunk processing phases.

2. Macro (C/C++) and Delegate (C#) added to CALLABLE_TYPES in the eager
   callableIndex, restoring Tier 3 reachability for these call targets
   that the old lookupFuzzy returned.

* fix(SM-16): address ce:review findings — Tier 2b cache lifecycle + Tier 3 perf + test gaps

1. packageDirIndex no longer invalidated in clearCache() — the index
   persists across file boundaries since packageMap and symbols are
   append-only during the calls phase. Only clear() (pipeline reset)
   invalidates. Prevents O(files×dirs) rebuild per-file.

2. Tier 3 short-circuit: return null before spread when all three
   indexes are empty, avoiding allocation on the common miss path.

3. Add Macro (C/C++) and Delegate (C#) Tier 3 regression tests —
   the only newly-added CALLABLE_TYPES were completely untested.

4. Add packageDirIndex invalidation regression test — verifies clear()
   resets the index and newly-added symbols are visible.

* fix(SM-16): address final review — deduplicate NamedImportMap + doc fixes

1. NamedImportMap: removed duplicate definition from resolution-context.ts,
   now imported directly from import-processor.ts (no re-export needed —
   no consumers imported it from resolution-context).

2. packageDirIndex build cost documented accurately in comment.

3. fuzzyCallCount scope documented in test comment.

4. Tier 2a test suite: added comment about Go/Kotlin/PHP coverage.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 13:00:33 +01:00
CopilotandGergo Magyar ab956f113c feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763)
* Initial plan

* Initial setup - Phase 9 BindingAccumulator cross-file return type wiring

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* fix(SM-15): address all PR #763 review findings

Performance (R1)
- Changed _fileScopeByFile from Map<string, [string,string][]> to
  Map<string, Map<string,string>>. fileScopeGet(filePath, name) is
  now O(1) — replaces the O(n) linear scan + defensive-copy alloc
  that ran once per ConstructorBinding entry. fileScopeEntries()
  reconstructs tuples from Map.entries() for backward compat.
- Updated finalize() dev-mode invariant to compare deduplicated Map
  size rather than raw array length (Map.set deduplicates same-name).

Lifecycle (R2)
- Documented that Phase 9 intentionally reads pre-finalize because
  finalize() cannot move before both the worker consumer (line 984)
  AND the sequential-path writer (line 1061). Pre-finalize reads are
  safe because finalize() is write-lock-only with no side effects.
  Replaced the ambiguous "populated but not yet finalized" comment
  with the full lifecycle ordering explanation.

Sequential-path parity (R3)
- Wired bindingAccumulator into processCalls at line 797 (sequential
  path) so verifyConstructorBindings gets the Phase 9 fallback.
- Added bindingAccumulator parameter to processAssignmentsFromExtracted
  signature and wired it at the pipeline.ts call site (line 1026).
- Both paths now produce identical Phase 9 behavior for the same code.

Tracking comments (R4)
- Added "Overlapping mechanism (N of 3)" cross-references at:
  1. buildImportedReturnTypes (~line 109)
  2. collectExportedBindings (~line 168)
  3. Phase 9 fallback in verifyConstructorBindings (~line 563)
  Each links to the other two and notes future unification.

Language coverage (R5)
- Added 5 new Phase 9 integration test suites in cross-file-binding.test.ts:
  JavaScript, C++, C#, PHP, Ruby. Each uses the existing fixture
  directories and asserts getUser() → User → user.save() resolves.
  Total cross-file binding tests: 52 (was 37).

Quality asymmetry (R6)
- Added inline comment at the Phase 9 fallback noting worker-path
  entries are Tier 0/1 only and that binding accuracy is structurally
  lower for large repos where the worker path dominates.

Tests (+21 new)
- 6 fileScopeGet unit tests (happy path, unknown file/name, mixed
  scopes, post-dispose, duplicate varName last-write-wins)
- 15 integration tests across 5 new language suites

Verification
- tsc --noEmit clean
- 3147 unit tests pass (+6 new)
- 52 cross-file binding integration tests pass (+15 new)
- 1766 resolver integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-10-001-fix-sm15-review-findings-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/763#issuecomment-4220354242

* fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency

Two Codex adversarial reviews identified medium-severity bugs in the Phase 9
BindingAccumulator fallback:

1. Local-first violation: the fallback fired regardless of whether ctx.resolve()
   found same-file candidates, letting an imported callee shadow a local one
   and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file'
   and callableDefs.length <= 1.

2. Sequential file-order dependency: processCalls flushed and verified per-file,
   so consumer files processed before their providers missed accumulator bindings.
   Fixed by splitting into a flush pre-pass (all files) then a resolution loop,
   mirroring the worker path's "all appends before any reads" pattern.

Also adds 11 consumer-before-provider integration test fixtures (one per
supported language) and 4 unit tests for tier gating edge cases.

* refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split

Replace the duplicated pre-pass + legacy-path code (parse → query → heritage
→ TypeEnv → exports) with a single preparation loop followed by a resolution
loop. Both paths now share the same preparation code — the only conditional
is the accumulator flush.

Side benefit: globalParentMap is now fully populated before any resolution
runs, improving cross-file isSubclassOf accuracy regardless of file order.

Net -118 lines (226 removed, 108 added).

* fix(SM-15): address PR #763 third-pass review findings

1. Update stale dispose() JSDoc — remove forward-reference to Phase 9
   wiring that is now complete; document actual consumers.

2. Add processAssignmentsFromExtracted Phase 9 unit test — verifies the
   accumulator fallback produces ACCESSES write edges when the SymbolTable
   has no returnType for the callee.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 10:29:31 +01:00
Mr. WorldwideBrown 6147579e54 Fix security issues and critical bugs found in code review (#709) 2026-04-10 05:20:29 +01:00
Abhigyan Patwari 4a1f912aee feat(sm-14): add BindingAccumulator — collect TypeEnv outputs across files (#743) 2026-04-09 21:03:25 +01:00
d09078925e Extract resolveFreeCall from resolveCallTarget (SM-13) (#756)
* Initial plan

* feat(SM-13): extract resolveFreeCall from resolveCallTarget

Extract the free-function call resolution path into a dedicated
`resolveFreeCall(calledName, filePath, ctx)` function that uses
`lookupExact` + import-scoped resolution via `ctx.resolve()`.

- Free function calls (foo()) now route through `resolveFreeCall`
- Swift/Kotlin implicit constructors (User()) delegate to
  `resolveStaticCall` within `resolveFreeCall`
- `resolveCallTarget` dispatches `callForm === 'free'` early,
  removing the inline freeFormHasClassTarget logic
- S0 block simplified to only handle `callForm === 'constructor'`
- Global (Tier 3) fallthrough preserved via ctx.resolve() until Phase 5
- 9 new unit tests for resolveFreeCall
- All 163 unit tests pass, all 1199 integration resolver tests pass

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unrelated package-lock.json change

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-13): address PR #756 review findings on resolveFreeCall

Addresses all 7 findings from the PR #756 review comment.

Code (R1, finding #1)
- Replace the literal `'Class' | 'Struct' | 'Record'` check in
  `hasClassTarget` with `INSTANTIABLE_CLASS_TYPES.has(c.type)`. Converts
  an invariant that was previously comment-enforced ("keep this list
  aligned with INSTANTIABLE_CLASS_TYPES") into one enforced structurally.
  Any future extension of the set propagates here automatically. The
  narrower Swift extension dedup block below still uses literal
  `'Class' | 'Struct'` by design — Swift extensions only produce Class
  duplicates in practice, Record is deliberately excluded there, and
  the inline comment now documents that asymmetry.

Tests (+12 regression scenarios)

Finding #2 — language coverage
- Go free function (doStuff())
- Python free function (def helper(): ... helper())
- Rust free function outside any impl block
- Java statically-imported function
- JavaScript module-level function
Each exercises `_resolveCallTargetForTesting` with `callForm='free'`
and the language-specific file extension. `resolveFreeCall` has no
file-extension branching, so these guard the dispatch chain per
language without assuming extractor-specific symbol shapes.

Finding #3 — argCount threading
- 2-arg overload selected when argCount=2
- 0-arg overload selected when argCount=0

Finding #5 — Tier 3 (global) resolution
- Function globally visible but not imported. Asserts exact
  `TIER_CONFIDENCE.global === 0.5` and `reason === 'global'` to catch
  silent drift if the tier table is ever refactored.

Finding #6 — preComputedArgTypes worker path
- String overload matched via preComputedArgTypes=['String']
- Int overload matched via preComputedArgTypes=['int'] (lowercase,
  mirroring the parse-worker's inferred-literal shape; stored 'Int' is
  normalized via normalizeJvmTypeName at comparison time)

Finding #7 — Enum null-route documentation
- Enum-only free call asserts `toBeNull()` with an explanatory comment
  linking to the INSTANTIABLE_CLASS_TYPES rationale. NOT marked skipped
  — current behavior is intentional, not broken.

Finding #4 — Swift extension dedup guard
- Two same-name Class entries at different path lengths; exercises the
  full dispatch chain:
    1. filterCallableCandidates with 'free' strips Class → length 0
    2. hasClassTarget triggers resolveStaticCall
    3. Homonym ambiguity null-routes per SM-12 round-1 contract
    4. Constructor-form retry repopulates with both Classes
    5. Dedup block sorts by filePath.length → shortest path wins

Verification
- `tsc --noEmit` clean
- 3064 unit tests pass (+12)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-003-fix-sm13-resolve-free-call-review-findings-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4213879002

* refactor(SM-13): extract dedupSwiftExtensionCandidates shared helper

Follow-up to the PR #756 review fix. SM-13 duplicated the Swift
extension same-name collision dedup block between `resolveCallTarget`
and `resolveFreeCall` — two copies of identical 15-line logic with the
same heuristic (`filePath.length` sort, Class/Struct-only, `length > 1`
guard). Extract a single shared helper so the two sites cannot drift.

Changes
- New `dedupSwiftExtensionCandidates(candidates, tier)` helper defined
  alongside `tryOverloadDisambiguation`, with JSDoc documenting:
  - The Swift extension scenario it addresses
  - Why it is intentionally narrower than INSTANTIABLE_CLASS_TYPES
    (Class/Struct only, not Record — C#/Kotlin records don't exhibit
    the multi-file definition pattern, widening risks accidental
    dedup of legitimately distinct record types)
  - The return-null-on-no-match contract so callers can fall through
- `resolveCallTarget` tail dedup (was lines 1593-1610): replaced with
  a single `dedupSwiftExtensionCandidates` call
- `resolveFreeCall` tail dedup (was lines 1994-2012): same replacement
- Net line count: -32 insertions, -9 deletions in the consumer sites,
  +36 for the shared helper + JSDoc

Verification
- `tsc --noEmit` clean
- 3064 unit tests pass (including the R7 Swift dedup guard test added
  in the previous commit that exercises the full free-form retry
  chain through this helper)
- 1766 integration tests pass
- Zero regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756

* docs(SM-13): address PR #756 final review — comment cleanup only

Three documentation-only findings from the approval review. No
behavior change, no new tests, no code path modifications.

Finding #1 — stale line-number comment
- The comment inside `resolveFreeCall` at the `hasClassTarget` site
  referenced "lines ~1994-2008" for the Swift extension dedup block.
  Those lines were the inlined pre-SM-13 version; the block has since
  been extracted to `dedupSwiftExtensionCandidates`. Replaced the line
  reference with the helper name so future readers don't chase dead
  line numbers.

Finding #2 — fuzzy-widening asymmetry undocumented
- `resolveFreeCall` intentionally has no `widenCache` parameter and no
  D2 fuzzy-widening pass (unlike `resolveCallTarget`'s member-call
  path). Added an explicit "Asymmetry vs `resolveCallTarget`" paragraph
  to the JSDoc so a caller comparing the two signatures knows the
  skipped pass is deliberate and tied to Phase 5.

Finding #3 — constructor-form retry reasons undocumented
- `resolveStaticCall` can return null for three distinct reasons
  (empty instantiable pool, homonym ambiguity, ownerless Constructor
  nodes). The retry below it unconditionally re-filters with
  `'constructor'` form, which is correct for all three but not
  obvious. Added a structured three-case comment enumerating each
  reason and linking (a) to the SM-12 null-route contract, (b) to
  the R7 dedup test, and (c) to the currently-uncovered ownerless-
  Constructor path (noted as a future test candidate).

Verification
- `tsc --noEmit` clean
- 175 `resolveFreeCall` + `resolveStaticCall` + sibling tests pass
  (sanity check — no behavior change expected)
- No regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052

* test(SM-13): cover ownerless-Constructor retry + PHP free function

Two low-severity test gaps from PR #756 review comment 4215739052 —
previously addressed doc-only, now have concrete test coverage.

Finding #3 low — ownerless-Constructor retry path (previously comment-only)
- The retry after resolveStaticCall returns null handles three distinct
  null-return reasons. Cases (a) and (b) were already tested (Interface/
  Trait null-route from SM-12, Swift shadowing dedup from R7). Case (c) —
  resolveStaticCall step-4 bailout when the tiered pool contains
  ownerless Constructor nodes — was only covered by a comment.
- New test: Class + ownerless Constructor in tiered pool, callForm='free'.
  Exercises the full chain:
    1. resolveStaticCall step 3 walks classCandidates via
       lookupMethodByOwner — ownerless Constructor not in methodByOwner,
       nothing found.
    2. Step 4 detects Constructor in tiered pool, bails with null.
    3. resolveFreeCall retry re-runs filterCallableCandidates with
       'constructor' form, which prefers Constructor over Class per
       CONSTRUCTOR_TARGET_TYPES ordering.
    4. Single survivor returned.
- Asserts the Constructor node (not the Class) is the resolved target.

Low — PHP free function coverage gap
- The language coverage table in the same review flagged PHP free
  functions (top-level `function helper()` outside any class) as
  uncovered. Added a test mirroring the existing Go/Python/Rust/Java/
  JS language tests — exercises the `.php` dispatch path for free
  calls. Ruby and C/C++ remain uncovered; deferred to a future round
  since those languages also have other gaps in the broader test file.

Verification
- `tsc --noEmit` clean
- 3066 unit tests pass (+2 new regression tests)
- 1766 integration tests pass
- Zero regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 17:41:28 +01:00
JaysonAlbertandgfwangjie 338cb01ee0 [codex] fix large repository graph loading (#732)
* fix(web): stream large graph responses

* fix(server): harden graph streaming

* fix(ci): stabilize graph loading coverage

---------

Co-authored-by: gfwangjie <gfwangjie@gf.com.cn>
2026-04-09 17:40:24 +01:00
4450a14b98 feat(SM-12): Extract resolveStaticCall from resolveCallTarget (#754)
* Initial plan

* feat(SM-12): extract resolveStaticCall from resolveCallTarget

- Add resolveStaticCall(className, methodName, currentFile, ctx, argCount?) using
  lookupClassByName + lookupMethodByOwner for O(1) constructor/static resolution
- Add S0 fast path in resolveCallTarget for constructor/free-form class calls
- Export resolveStaticCall from call-processor.ts
- Add 11 unit tests covering constructor resolution, confidence tiers,
  arity disambiguation, and resolveCallTarget delegation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unrelated package-lock.json change

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: shorten verbose test name per code review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-12): address PR #754 review findings

Addresses Claude's review comments on PR #754:

Performance
- Pass pre-computed `tiered` result into `resolveStaticCall` as optional
  `tieredOverride` parameter, eliminating the duplicate `ctx.resolve(className,
  currentFile)` on every constructor call path.
- Cache `freeFormHasClassTarget` in `resolveCallTarget` so the S0 fast path
  and the free-form constructor retry share a single `.some()` scan.

Architecture
- Reconcile `CLASS_LIKE_TYPES` (call-processor) with `CLASS_TYPES`
  (symbol-table): `CLASS_LIKE_TYPES = [...CLASS_TYPES, 'Impl']`. This makes
  the relationship explicit — the call resolver's set is a strict superset
  of the heritage-index set, guaranteeing anything reachable via
  `lookupClassByName` also passes the resolver filter. Trait is now included
  (harmless: traits have no Constructor nodes, so step-3 returns undefined
  and step-5 still returns the class-like node when unique). Documented
  the Interface inclusion rationale (static methods + MRO walker).
- Collapse `resolveStaticCall`'s `methodName` parameter into `className` —
  all call sites passed identical values. Named constructors (Dart
  `User.fromJson()`) arrive as member calls and go through
  `resolveMemberCall`. Documented the reserved path for when a language
  surfaces a static-method-shaped call with a distinct member name.
- Document the known gap: `callForm === 'member'` constructor patterns
  (e.g. Python `models.User()`) are handled by the tail fallback, not S0.

Tests
- Add tiered-override test asserting `ctx.resolve` is not re-invoked when
  a pre-computed result is passed in.
- Add language-specific `_resolveCallTargetForTesting` integration tests
  for Java (`new User()`), Python (`User()`), and Kotlin (`User()`).

Verification: 3031 unit + 1766 integration tests pass, zero regressions.

* fix(SM-12): restrict resolveStaticCall fallback to instantiable kinds

Addresses the high-severity finding from the Codex adversarial review of
PR #754: `resolveStaticCall`'s step-5 "return the class itself when no
Constructor node is found" fallback reused `CLASS_LIKE_TYPES`, which —
after SM-11 and PR #754's reconciliation — now includes `Interface`,
`Trait`, and `Impl`. That is the method-dispatch set, not the
instantiable set, so constructor-shaped calls could resolve to
non-instantiable nodes and emit false `CALLS` edges.

Concrete failure: Rust same-file `impl User { ... }` alongside
`struct User { ... }` — both land at same-file tier, the Impl is not
filtered out, and the step-5 fallback produces a `CALLS` edge to the
`Impl` block instead of the `Struct`. The same widening exposed
Interface / Trait targets in Java / C# / PHP / Scala.

Fix
- Introduce `INSTANTIABLE_CLASS_TYPES = {'Class', 'Struct', 'Record'}`
  as a sibling to `CLASS_LIKE_TYPES`, documenting the contract
  explicitly and cross-referencing `CONSTRUCTOR_TARGET_TYPES`.
- Update `CLASS_LIKE_TYPES` JSDoc to clarify it is the method-dispatch
  set and add an anti-pattern warning against reusing it for
  constructor-fallback filtering.
- Tighten `resolveStaticCall` step 5: filter `classCandidates` through
  `INSTANTIABLE_CLASS_TYPES` before the `length === 1` check. This
  strips `Impl` from the Rust shadowing scenario (leaving `Struct` as
  the sole instantiable target) and null-routes Interface / Trait /
  `Impl`-alone scenarios, matching the SM-10 R3 null-route precedent.
- Step 3 (explicit Constructor lookup via `lookupMethodByOwner`) is
  intentionally unchanged — its `def.type === 'Constructor'` check is
  the correct contract, and legitimate Constructor nodes attached to
  `Impl` owners still resolve correctly.

Tests (+10 regression scenarios)
- Positive guards: Struct, Record fallback paths.
- Null-route: Interface (Java/C#/TS), PHP Trait, Rust Trait.
- Rust same-file shadowing: Struct wins over Impl.
- Rust Impl-alone: null-routes (no Struct present).
- Step-3 preservation: Constructor owned by Impl still resolves to the
  Constructor node, proving step-5 tightening doesn't leak into step 3.
- Full cascade via `_resolveCallTargetForTesting` for Interface and
  Trait — confirms no downstream path silently re-introduces the edge.

Verification
- `tsc --noEmit` clean
- 3041 unit tests pass (+10)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md
Codex review job: review-mnrao7fr-nv9y0e

* fix(SM-12): address PR #754 second review round

Addresses the 9 findings from the follow-up review on PR #754.

Performance
- Align `freeFormHasClassTarget` with `INSTANTIABLE_CLASS_TYPES`: drop
  `Enum` (S0 would always return null for it — wasted lookup work) and
  add `Record` (C# records and Kotlin data classes were bypassing S0
  entirely). The trigger set and the fallback filter set now agree by
  construction, documented inline.

Documentation
- Remove stale single-line JSDoc on `CLASS_LIKE_TYPES` (line 57) that
  duplicated the full multi-line block immediately below it — tooling
  picks up the first block so the old one-liner was shadowing the
  current explanation.
- Rewrite the `resolveStaticCall` JSDoc step list to match the actual
  step boundaries in the implementation (steps 3, 4, 5 were blurred in
  the old description).
- Add inline comment on step 3 documenting the same-name lookup
  assumption (`${candidate.nodeId}\0${className}`) and the symmetric
  miss case for Python `__init__`-style constructors.
- Add inline comment on step 4 documenting that it also catches the
  ambiguous-step-3 case, and warning against removing the check
  without handling that path explicitly.
- Add inline comment on step 5 enumerating the three length outcomes
  (0 / 1 / >1) so future readers see the dominant null-route case.
- Document Ruby `User.new` as a known gap alongside Python
  `models.User()` in the S0 header comment.

Tests (+2 scenarios)
- Record free-form constructor call via `_resolveCallTargetForTesting`
  exercises the aligned `freeFormHasClassTarget` trigger end-to-end,
  closing the gap where the direct `resolveStaticCall` test passed
  but the integration path was silently bypassing S0.
- Arity threading via `_resolveCallTargetForTesting` asserts that
  `call.argCount` flows through resolveCallTarget → S0 →
  resolveStaticCall → lookupMethodByOwner, catching any future
  regression where the argCount is dropped at the S0 call site.

Verification
- `tsc --noEmit` clean
- 3043 unit tests pass (+2)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/754#issuecomment-4213536094

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 12:09:07 +01:00
Kunal Hemnani 3f0b8c1a5b fix(ingestion): replace lookupExact with lookupExactAll in named-binding-processor (#755) 2026-04-09 11:57:36 +01:00
bb68cc1eb0 Extract resolveMemberCall from resolveCallTarget (SM-11) (#744)
* Initial plan

* feat(SM-11): extract resolveMemberCall from resolveCallTarget

- Create resolveMemberCall(ownerType, methodName, currentFile, ctx, heritageMap?)
  that uses owner-scoped + MRO resolution only (no fuzzy lookup)
- resolveCallTarget delegates member calls (D0 path) to resolveMemberCall
- walkMixedChain uses resolveMemberCall for owner-scoped member-call resolution
- Add 7 unit tests for resolveMemberCall covering direct, inherited, MRO,
  null cases, and confidence tier assertions
- Export resolveMemberCall for external use

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3b7889a9-5f2f-4572-8904-45084210f10d

* fix(SM-11): address PR #744 review

Blocking fixes:

- B1: Revert unrelated package-lock.json gitnexus-shared addition

- B2: Document confidence-tier semantic change on resolveMemberCall

Performance / coupling fixes:

- S1: walkMixedChain now calls resolveMethodByOwner directly (hot path) to avoid throwaway ResolveResult allocation per chain step

- S2: Thread tier from resolveMethodByOwner via { def, tier } tuple; eliminates double ctx.resolve

Alignment with semantic-model plan (Phase 3 target):

- resolveMethodByOwner now iterates ALL class-like candidates from ctx.resolve, deduplicating matches by nodeId. Absorbs D4's ownerId-filtering into the owner-scoped path.

- Handles homonym classes (two Users in different files) without falling through to D1-D4 fuzzy widening

- Shared-ancestor MRO walks automatically dedup (both homonyms walk to same base method)

- Unified direct-vs-MRO lookup under a single canWalkMRO check

Tests added:

- T1: Three D0 skip-condition tests via new _resolveCallTargetForTesting internal export (overloadHints, preComputedArgTypes, hasActiveModuleAlias)

- T2: Rust qualified-syntax null test (trait-inherited method) + direct impl control

- T3: C++ leftmost-base diamond inheritance test

- B2 lock-in: cross-file class tier assertion

- Homonym disambiguation: only-one-owns-method, both-own-method ambiguity, shared-ancestor MRO convergence

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3014 passed

- vitest run test/integration/resolvers/: 1746 passed

* test(SM-11): address second PR #744 review round + per-language integration tests

Review fixes (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4211877593):

P1 (Performance): Replace Map allocation in resolveMethodByOwner with a firstDef+ambiguous flag pattern. Zero allocation for the common single-candidate case on the hot path — the previous Map approach allocated on every member call regardless of whether deduplication was needed.

P2 (Test gap): Strengthen the module-alias D0 skip test with a homonym fixture (two Users in different files). Previously the test passed whether or not D0 was actually bypassed; the new version proves D0 must be skipped by showing that resolveMemberCall directly returns null (ambiguous) but D1-D4 with alias narrowing picks the right one. Also fixes the underlying D2-vs-alias widening interaction: when filteredCandidates was narrowed by module-alias disambiguation, D2 no longer widens back to the full fuzzy pool (introduces aliasNarrowed boolean flag).

L1 (Language coverage): Add C# and Kotlin implements-split tests at the resolveMemberCall layer.

L2 (Maintainability): Export OverloadHints as @internal so the test can use a direct cast instead of fragile Parameters<...> type inference.

Per-language integration tests:

- rust-child-extends-parent: Direct impl method resolution via D0 (with honest documentation of the trait-method-as-Function gap that is Phase 5 / SM-16 scope)

- java-interface-default-method: User implements Validator with default method resolved via implements-split MRO

- csharp-interface-default-method: Same pattern for C# 8.0+ default interface methods

- kotlin-interface-default-method: Same pattern for Kotlin interfaces with default implementations

- python-multi-level-mro: 3-level C3 linearization (Grandparent ← Parent ← Child)

- cpp-diamond-inheritance: Classic diamond (Base ← A, B ← Derived) via leftmost-base MRO

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3015 passed

- vitest run test/integration/resolvers/: 1763 passed (+17 new per-language tests)

* fix(SM-11): Codex adversarial review corrections + deeper D0 fixes

Addresses the three high-severity findings from the Codex adversarial review of PR #744 (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4212075120), plus four deeper fixes discovered during regression triage. All discovered issues are now addressed end-to-end rather than papered over with tail-return fallbacks.

Codex review findings:

R1 (C++ diamond): The cpp-diamond-inheritance fixture used non-virtual inheritance, which is genuinely ambiguous in real C++ (two Base subobjects). Changed A and B to use 'virtual public Base' so there's a single shared Base subobject and d.method() is an unambiguous call that the leftmost-base MRO walk correctly resolves.

R2 (C# default-interface): The csharp-interface-default-method fixture called user.Validate() via a User-typed variable, but C# does not inherit default interface methods as callable class members — the call is only valid through an interface-typed variable. Changed App.cs to 'IValidator user = new User(...)' which is the idiomatic dispatch pattern.

R3 (resolveCallTarget tail-return): When D1-D4 receiver filtering produced zero file-matched and zero owner-matched candidates for a member call, the function fell through to the permissive single-candidate tail return — silently emitting CALLS edges for methods that don't belong to the receiver. Added an explicit null-route inside the D1-D4 block that fires only when both filters yielded 0.

R4 (Rust negative assertion): Added the c.trait_only() negative integration test in rust.test.ts demonstrating that direct member calls on Rust structs do not walk trait ancestry. The test now passes because of R3 (previously fell through to the tail return).

Regression triage discoveries:

1. D0 was dead code on the sequential pipeline. The sequential path sets overloadHints for every call regardless of whether the method is overloaded, and the original D0 skip condition '!overloadHints && !preComputedArgTypes' was therefore always false. The Java/C#/C++ SM-9/SM-10 inheritance tests were passing ONLY via the tail-return fallback. Fix: narrow the skip to 'overloadHints && filteredCandidates.length > 1' — skip D0 only when there are actually multiple candidates that need overload disambiguation.

2. lookupMethodByOwner couldn't disambiguate arity-differing overloads (e.g. C++ greet() vs greet(string)). With D0 now firing on the sequential path, same-name/different-arity overloads would collapse to an arbitrary first pick. Fix: added an optional argCount parameter to lookupMethodByOwner + lookupMethodByOwnerWithMRO that filters the overload set by parameterCount/requiredParameterCount before the returnType dedup.

3. Python and Rust class methods are captured as Function nodes (not Method) with ownerId set to the class. The methodByOwner index only accepted 'Method' and 'Constructor' types, so Python class methods and Rust trait methods were invisible to D0. Fix: extended the methodByOwner indexing condition to include 'Function' when ownerId is set. This also unlocks the Rust trait-method negative assertion by ensuring the qualified-syntax MRO strategy has something to return null for.

4. D0 was being skipped when a local variable shadowed an imported module name (Python 'from models.c import C; c = C()' creates both a module alias 'c → models/c.py' AND a typed local 'c'). Fix: the D0 skip now gates on 'aliasNarrowed' (a new boolean tracking whether the alias block actually narrowed filteredCandidates) instead of 'hasActiveModuleAlias'. If the method isn't in the aliased module, the receiver is a typed local variable and D0 should run.

5. PHP trait walk missed the HasTimestamps trait because lookupClassByName did not include 'Trait' type. buildHeritageMap uses lookupClassByName to resolve parent names, so 'BaseModel use HasTimestamps' was failing to register an ancestor edge for BaseModel → HasTimestamps. Fix: added 'Trait' to CLASS_TYPES. The trait is now a valid class-like type for heritage resolution (PHP use, Rust impl Trait for Struct, Scala traits).

Test updates:

- Updated the 'no heritageMap' unit test in call-processor.test.ts to assert the correct null-route behavior instead of the old tail-return fallback.

- Added a new unit test asserting Trait inclusion in the class set.

- Updated the 'does NOT include other type-like labels' test to remove Trait from its rejection set.

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3016 passed (+1 new Trait inclusion test)

- vitest run test/integration/resolvers/: 1764 passed (+1 new Rust negative assertion)

- Zero regressions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 09:52:12 +01:00
Roshan Warrierandtxhno d6debf3324 fix(symbol-table): index constructors in methodByOwner (#753)
Co-authored-by: txhno <198242577+txhno@users.noreply.github.com>
2026-04-09 08:26:15 +01:00
Pratyush Sharma 9ab92a97d0 fix(deps): pin tree-sitter-c override to resolve peer dep conflict (#720) (#723) 2026-04-09 06:40:17 +01:00
Murat Çelik 4fde5f241b feat: print skipped large file paths in verbose analyze output (#745) 2026-04-09 06:18:32 +01:00
evolution 9f9bbcd744 feat: support GITNEXUS_HOME env var to customize global directory (#746) 2026-04-09 06:18:17 +01:00
Cocoon-Break fd67cfd5a7 docs: fix web UI install link spacing in README (#731) 2026-04-09 06:16:14 +01:00
Pratyush Sharma 3f28f7ead5 fix(web): correct dev-mode serve command in OnboardingGuide (#725) 2026-04-09 06:15:14 +01:00
d9ba9aa998 SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741)
* Initial plan

* Add MRO fast path before D2 fuzzy widening in resolveCallTarget

When receiverTypeName is known, try resolveMethodByOwner (owner-scoped
+ MRO lookup) before falling back to the expensive lookupFuzzy in D2.
This short-circuits cross-file member call resolution for the common
non-overloaded case.

The fast path is skipped when overload disambiguation hints are
available (overloadHints or preComputedArgTypes) to avoid picking the
wrong overload for same-return-type overloaded methods.

Passes heritageMap to resolveCallTarget from all 4 call sites:
- Language seed path (processCalls)
- Sequential path (processCalls)
- walkMixedChain fallback
- Worker path (processCallsFromExtracted)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9e49521f-2472-47bc-96e9-be4a46b073f0

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-10): address PR #741 review

Correctness:
- Module-alias guard for D0. When call.receiverName matches an active
  entry in ctx.moduleAliasMap for the current file, D0 is now skipped
  and resolution falls through to D1-D4 which respects the
  alias-narrowed candidate pool. Prevents a homonymous class in a
  different file from being picked by ctx.resolve(receiverTypeName)
  inside resolveMethodByOwner. New unit test pins the contract.

Unit tests (call-processor.test.ts — 3 new):
- D0 hit: child.parentMethod() resolves via MRO walk when
  heritageMap is provided.
- D0 skipped: same scenario still resolves via D1-D4 when heritageMap
  is undefined (backward-compat guard).
- Module-alias guard: two files both define class User with a save()
  method; 'import auth_mod as auth' in app.py must resolve
  auth.user.save() to auth_mod.py, not user_mod.py.

Integration language coverage (+3 fixtures/tests):
- swift-child-extends-parent — first-wins, gated on swiftAvailable.
- ruby-child-extends-parent   — first-wins.
- php-child-extends-parent    — first-wins (uses ParentClass since
  'Parent' is a PHP reserved word).

* test(SM-10): address second PR #741 review round

Unit tests (call-processor.test.ts, +2 new):
- overloadHints guard: Java source with two same-return-type overloads
  method(int) and method(String), int added first so lookupMethodByOwner
  would return it. processCalls auto-generates overloadHints for Java,
  forcing D0 to be skipped. o.method("hello") must resolve to
  method(String) via literal-inferred disambiguation.
- preComputedArgTypes guard: worker-path equivalent via
  processCallsFromExtracted with ExtractedCall.argTypes=['String'].
  Same two overloads, same correctness guarantee.

Integration tests (+2 fixtures + test blocks):
- go-child-extends-parent    — struct embedding, first-wins
  (Go structs are labeled 'Struct' not 'Class' in GitNexus).
- dart-child-extends-parent  — extends, first-wins, gated on
  dartAvailable like other Dart tests.

Documentation:
- Expanded the fallthrough comment in resolveMethodByOwner to clarify
  that unknown-extension paths land on plain lookupMethodByOwner
  without an ancestor walk, and that D1-D4 still runs on D0 miss.

* test(SM-10): D0 miss with heritageMap present falls through to D1-D4

Closes the last remaining gap from PR #741 review round 3. The existing
'D0 skipped' test only covered the heritageMap=undefined case, leaving
the miss-with-heritageMap path implicitly covered by integration tests
only. This adds a focused unit test where:

- Class Obj has a method doWork findable via tiered resolution
  (import-scoped) but intentionally NOT registered in methodByOwner
  (no ownerId), so lookupMethodByOwner misses.
- heritageMap is provided but built from an empty heritage array, so
  getAncestors(class:Obj) returns []. The MRO walk yields no parents.
- lookupMethodByOwnerWithMRO therefore returns undefined → D0 miss.
- D1 resolves the receiver type; D2 widens via lookupFuzzy;
  D3 file-filter picks the single matching candidate.
- A CALLS edge must still be emitted — D0 miss must not swallow
  the call.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 23:24:07 +01:00
c19e76a4a3 feat(SM-9): Add lookupMethodByOwnerWithMRO using HeritageMap (#740)
* Initial plan

* feat(SM-9): add lookupMethodByOwnerWithMRO with HeritageMap parent chain walking

- Export c3Linearize from mro-processor.ts for reuse
- Add lookupMethodByOwnerWithMRO in call-processor.ts with MRO strategy support
- Update resolveMethodByOwner to fall back to MRO walk when HeritageMap available
- Thread heritageMap through walkMixedChain for chain resolution
- Add 10 unit tests covering all acceptance criteria

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(SM-9): add Java integration test with class Child extends Parent fixture

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: address code review comments on MRO strategy documentation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* perf(SM-9): address PR #740 review comments

- Eliminate double direct lookup in resolveMethodByOwner: delegate
  straight to lookupMethodByOwnerWithMRO when a HeritageMap is
  available (the MRO helper already does the direct lookup before
  walking ancestors). Fallback path handles the no-HeritageMap case.
- Memoize C3 linearization per HeritageMap via a WeakMap keyed cache.
  HeritageMap is immutable after build, so C3 results are stable for
  its lifetime; WeakMap lets the cache auto-drain when the HeritageMap
  is GC'd. Null sentinel caches linearization failures so cyclic
  hierarchies are not reprocessed. Eliminates per-call buildParentMap +
  c3Linearize on Python codebases.
- ancestors variable typed as readonly to accept the cached result
  without copying.
- Add four missing MRO unit tests: Kotlin implements-split, C#
  implements-split, JavaScript first-wins (separate provider from TS),
  and C++ leftmost-base diamond (first diamond test for C++).

* fix(SM-9): CI prettier + address PR #740 follow-up review

- Fix CI prettier failure in test/integration/resolvers/java.test.ts
  (auto-formatted — was introduced in 37563a31 before my first fix
  commit but had not been caught locally).
- Pin caller on the SM-9 Java integration test (parentMethodCall.source
  === 'run') so a regression that misattributes the CALLS edge fails.
- Add two implements-split unit tests:
  * Ambiguous default from two interfaces → BFS first-wins. Pins the
    contract that lookupMethodByOwnerWithMRO returns a defined result
    (full ambiguity detection is deferred to computeMRO graph pass).
  * Class method precedence over interface default: Child extends Base
    implements IFoo where both define handle() — documents that BFS
    visits the extends edge first, matching Java's class-wins rule.
- Add @internal JSDoc on lookupMethodByOwnerWithMRO clarifying it is
  exported only for testing; resolveMethodByOwner is the proper entry
  point for callers.

* test(SM-9): per-language integration fixtures and tests for inherited method resolution

Extends the SM-9 integration coverage beyond Java with six new
child-extends-parent fixtures, one per MRO strategy:

- python-child-extends-parent       → C3 strategy
- typescript-child-extends-parent   → first-wins
- javascript-child-extends-parent   → first-wins (separate provider)
- kotlin-child-extends-parent       → implements-split
- csharp-child-extends-parent       → implements-split
- cpp-child-extends-parent          → leftmost-base

Each fixture follows the java-child-extends-parent pattern:
- Parent class with a single method
- Child class extending Parent, no override
- App class/function that instantiates Child and calls
  the parent method — exercises the full ingestion pipeline,
  HeritageMap construction, and lookupMethodByOwnerWithMRO walk.

For every fixture the matching integration test asserts:
- Parent and Child classes are detected
- Child → Parent EXTENDS edge is emitted
- The parent-method call resolves to the correct target file
- The caller is pinned (source === 'run' / 'Run') to catch
  edge misattribution regressions

Rust is intentionally omitted — its qualified-syntax strategy
returns undefined from lookupMethodByOwnerWithMRO by design, so
there is no inherited-method resolution to assert against.

All 1739 integration resolver tests pass (+18 new SM-9 tests
across 6 languages).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 20:46:45 +01:00
b75e76d44a feat(SM-8): Build HeritageMap from accumulated ExtractedHeritage[] (#739)
* Initial plan

* feat(SM-8): add HeritageMap with MRO-aware parent/ancestor lookup

- New heritage-map.ts: HeritageMap interface with getParents() and getAncestors()
- buildHeritageMap() consumes ExtractedHeritage[], resolves names via lookupClassByName
- Cycle protection and bounded depth (MAX_ANCESTOR_DEPTH=32) in getAncestors
- Worker path: HeritageMap built from deferredWorkerHeritage, threaded into processCallsFromExtracted
- Sequential path: Heritage accumulated across chunks, HeritageMap built after all chunks, passed to processCalls
- 18 unit tests covering parent lookup, multi-level, diamond, cycles, missing parent, bounded depth

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: rename cycle test for clarity per code review

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(SM-8): merge implementor map into heritage map

- Add `getImplementorFiles(interfaceName)` to HeritageMap interface
- Build implementor index (interface name → file paths) alongside parent
  lookup in `buildHeritageMap`, using same `resolveExtendsType` logic
- Remove `ImplementorMap` type, `buildImplementorMap`, `mergeImplementorMaps`
  from call-processor.ts
- Update `findInterfaceDispatchTargets`, `processCalls`, and
  `processCallsFromExtracted` to use HeritageMap for both parent
  lookup and implementor dispatch
- Pipeline: single `buildHeritageMap` call replaces separate
  buildImplementorMap + buildHeritageMap for both worker and
  sequential paths
- Migrate implementor tests from call-processor.test.ts to
  heritage-map.test.ts (4 new getImplementorFiles tests)
- Update interface dispatch test to use buildHeritageMap instead
  of hand-constructed ImplementorMap

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: rename implementor test for clarity per code review

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-8): address PR #739 review comments

- pipeline.ts: cache chunk file contents from Pass 1 to eliminate
  double-read of sequential chunks in Pass 2. Peak memory drains
  incrementally as Pass 2 processes each chunk.
- heritage-map.ts: document Rust trait-impl omission from implementor
  index and the interface-name collision limitation.
- heritage-map.test.ts: add six tests covering the extends->IMPLEMENTS
  path across C# (interfaceNamePattern), Swift (heritageDefaultEdge),
  Java (symbol-table Interface lookup), Kotlin, PHP, and the Rust
  trait-impl omission.
- pipeline.ts: comment why the heritage accumulation uses a manual
  push loop instead of spread (ref #650).

* test(SM-8): address second PR #739 review pass

- Add TypeScript implements test to getImplementorFiles (closes
  the .ts coverage gap flagged by the bot reviewer).
- Tighten deep-chain boundary assertion from toBeLessThanOrEqual(32)
  to toBe(32) so a future regression returning fewer ancestors
  fails loudly. Added an ancestors[31] === 'class:Level32' check
  to pin the upper boundary.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 19:00:08 +01:00
MyShiningand许恩宁 83b5bec293 [cli] Replace owner-filtered method lookups in type-env (#736)
* refactor(type-env): use owner method lookup

* test(type-env): cover owner lookup edge cases

* test(type-env): cover inherited overload ambiguity

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 17:41:09 +01:00
MyShiningand许恩宁 3388ae16d7 [cli] Replace Phase P class checks with class lookup index (#734)
* refactor(call-processor): use class lookup index in phase p

* test(call-processor): cover class lookup fallback

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 14:48:54 +01:00
MyShiningand许恩宁 d784f591b2 [cli] Replace class-type fuzzy lookups in type-env.ts (#733)
* refactor(type-env): use class lookup index for type resolution

* test(type-env): add lookupClassByName regression coverage

* test(type-env): expand class lookup regression coverage

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 14:12:01 +01:00
Kunal Hemnani 0f43190543 feat(symbol-table): add fuzzy lookup counters (#708) 2026-04-08 07:53:30 +01:00
Roshan Warrier fe87ff8f74 fix(symbol-table): index constructors in methodByOwner (#694) 2026-04-08 06:32:02 +01:00
Deepak Chauhan be2401061e [cli] Add qualified class lookups to SymbolTable (#716) 2026-04-07 22:57:18 +01:00
Deepak Chauhan b73233d232 feat(symbol-table): add class name lookup index (#707) 2026-04-07 13:29:50 +01:00
Tushar Dhawas (Kyo) 1c8ae5eb46 refactor: extract CLASS_LIKE_TYPES constant (#693)
* refactor: extract CLASS_LIKE_TYPES constant

* chore: apply prettier formatting
2026-04-07 11:56:03 +01:00
Zander Raycraft b73928f732 scarf (#688) 2026-04-06 18:39:43 -05:00
Dmytro Semchuk 19faf3b326 fix(docs): fix codex duplicate typo in main readme file (#687) 2026-04-06 22:20:35 +01:00
Gergő Magyar cb772b9e29 feat: lookupMethodByOwner index for O(1) cross-class chain resolution (#665)
Add eagerly-populated methodByOwner index to SymbolTable, keyed by
ownerNodeId\0methodName. Used by walkMixedChain as a fast path for
resolving intermediate method calls in cross-class chains like
user.getAddress().getCity().getZipCode(), avoiding expensive fuzzy
lookups when the owner type is already known.

Handles overloaded methods: returns the first match when all overloads
share the same returnType, undefined when return types differ (ambiguous).

- Add lookupMethodByOwner to SymbolTable interface + implementation
- Add resolveMethodByOwner helper in call-processor.ts
- Add fast path in walkMixedChain before resolveCallTarget fallback
- Add Java cross-class chain fixture + 6 integration tests
- Add 148 unit tests for methodByOwner index behavior
2026-04-06 10:20:04 +01:00
ivkondandClaude Opus 4.6 10f8815639 fix(ignore): respect negation patterns in .gitnexusignore (#654)
* fix(ignore): respect negation patterns in .gitnexusignore childrenIgnored

childrenIgnored checked `ig.ignores(rel) || ig.ignores(rel + '/')` which
short-circuited on the bare path — directory-only negation patterns like
`!iOS/` were missed because `ig.ignores('iOS')` treats the path as a file.
Now only checks with trailing slash since childrenIgnored is only called
for directories. Bare-name patterns (e.g. `local`) still match per gitignore spec.

Fixes #596

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

* test(ignore): add edge-case for bare `!dir` negation pattern

Verifies that `!iOS` (without trailing slash) also un-ignores the iOS/
directory — confirms the `ignore` package normalizes both `!dir` and
`!dir/` forms consistently when tested with a trailing-slash path.

Addresses non-blocking review suggestion on #654.

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

* docs(ignore): link ignore package docs for bare-name normalization

Adds references to the `ignore` package documentation in both the
childrenIgnored comment and the bare-negation test, explaining why
`!iOS` (without trailing slash) also re-includes the iOS/ directory.

Addresses non-blocking review suggestion on #654.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:32:47 +01:00
Abhigyan Patwari 6ead5e5986 fix(setup): prefer global gitnexus binary over npx for MCP config (#653) 2026-04-06 07:46:10 +01:00
Abhigyan Patwari 14791ded4a fix(server): return clean CORS rejection instead of 500 error (#646) 2026-04-06 07:45:19 +01:00
tantkandtian kian tan 9eeb20bb04 fix: replace Array.push(...spread) with loop to prevent stack overflow (#650)
* fix: replace Array.push(...spread) with loop to prevent stack overflow

On large codebases (78K+ C files), deferred arrays in
runChunkedParseAndResolve accumulate 100K+ entries. The spread
operator in push(...array) puts every element on the call stack
as a function argument, exceeding the maximum call stack size.

Replace all 11 occurrences of `arr.push(...other)` with
`for (const _item of other) arr.push(_item)` which uses
constant stack space regardless of array size.

Fixes #649

* fix: also replace push(...spread) in parsing-processor.ts

* style: format pipeline.ts to match prettier config

---------

Co-authored-by: tian kian tan <tan@example.com>
2026-04-05 21:54:09 +01:00
Gergő Magyar 5a7c0fdbb1 feat: same-arity overload disambiguation via type-hash suffix (#651) (#658)
* feat: same-arity overload disambiguation via type-hash suffix (#651)

Add ~type1,type2 suffix to Method/Constructor node IDs when same-arity
overloads with different parameter types exist in the same class. Also add
$const suffix for C++ const-qualified method overloads via new isConst field.

Key changes:
- typeTagForId() detects same-arity collisions and appends ~typeTag
- constTagForId() detects const/non-const collisions and appends $const
- TS/JS excluded from type-hashing (overload signatures collapse to impl body)
- Sequential findEnclosingFunction fixed: falls through on ambiguous same-class
  candidates instead of picking first; fallback path includes typeTag + constTag
- Per-call-site integration tests across Java, C#, Kotlin, C++, TypeScript
- Cross-file + chain resolution tests for all 5 languages
- C++ isConst extraction via tree-sitter type_qualifier in function_declarator

1710 integration + 18 unit tests pass.

* fix: preserve generic/template args in type-hash, perf + type safety fixes

- Add rawType field to ParameterInfo preserving full type text (vector<int>)
  while type stays simplified (vector). typeTagForId uses rawType for tags.
- Populate rawType in all 11 language method extractors
- Add buildCollisionGroups() to pre-group methods by name#arity (O(N) once
  per class instead of O(N) per method call)
- Cache method extraction in call-processor findEnclosingFunction fallback
- Fix null guards on getLanguageFromFilename in all findEnclosing paths
- Tighten SKIP_TYPE_HASH_LANGUAGES to ReadonlySet<SupportedLanguages>
- Document ID stability invariant on first overload introduction
- C++ integration tests: template overloads (vector<int> vs vector<string>),
  cross-file template + chain resolution, out-of-class method definitions

1718 integration + 20 unit tests pass.

* fix: add rawType to method-extraction unit test assertions

All 26 parameter .toEqual() assertions in method-extraction.test.ts
needed the new rawType field added to match ParameterInfo schema change.

* perf: cache tempMap/groups per class, consolidate extractFromNode

- Cache derived method map + collision groups per classNode.id in
  parsing-processor (avoids rebuild per method in same class)
- Replace per-call extractFromNode with cached class extraction +
  funcName:line lookup in call-processor fallback (avoids AST walk
  per call site)
- Remove dead clearEnclosingFunctionCache export, fix JSDoc

* test: add sequential-path integration test for same-arity overloads

Add skipWorkers option to PipelineOptions to force sequential parsing.
New test suite verifies type-hash disambiguation produces identical
results through the sequential path (parsing-processor + call-processor
findEnclosingFunction) as the worker path.
2026-04-05 21:51:55 +01:00
Gergő Magyar 0561d24efd feat: METHOD_IMPLEMENTS edges, overload disambiguation, MethodExtractor unification (#574) (#642) 2026-04-04 18:41:47 +01:00
Abhigyan Patwari 153262304c fix(mcp): unify stdout silencing to prevent embedder/pool-adapter conflicts (#645) 2026-04-04 11:56:49 +01:00
Abhigyan Patwari 16cf4c503e fix(web): replace aggressive heartbeat disconnect with graceful reconnection (#643) 2026-04-04 11:56:35 +01:00
Abhigyan Patwari 57951a197b fix(web): scope all backend calls to the active repo, not always the first (#644) 2026-04-04 11:55:34 +01:00
Gergő MagyarandClaude Opus 4.6 63fc4c795f feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby (#624)
* feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby with exhaustive integration tests

Add per-language MethodExtractionConfig for all remaining tree-sitter languages
(RFC #568 PR 2). Each config follows the established createMethodExtractor()
factory pattern — no new types, no parse-worker changes.

Configs:
- Python: @abstractmethod, @staticmethod/@classmethod, *args/**kwargs, type hints, _/__ visibility
- PHP: abstract/final/static keywords, PHP 8 #[] attributes, __construct/__destruct
- Swift: 5-level visibility, protocol-as-abstract, static/class methods, @ attributes
- Dart: _ convention visibility, abstract (no body), method_signature unwrapping
- Rust: pub visibility, &self receiver, trait_item + impl_item, #[] attributes
- Ruby: positional visibility via sibling-walk, singleton_method as static

Integration fixtures (18 directories) covering 3 resolution patterns:
- Method enrichment: parameterTypes, isAbstract, isFinal, annotations on graph nodes
- Overload dispatch: arity-based CALLS resolution via parameterTypes
- Abstract dispatch: abstract/concrete method distinction (Python, PHP, Rust, Swift)

Go deferred — requires factory changes for receiver-based method extraction.

Closes #571

* fix: address code review findings across 6 MethodExtractor configs

Fix all actionable items from the PR #624 deep-dive review:

Dart (critical — fixes 6 CI failures):
- isDartStatic: check children first, siblings as fallback
- isDartAbstract: handle declaration nodes for abstract methods
- extractSingleParam: detect required keyword as sibling token
- Add declaration to methodNodeTypes, mixin_declaration to typeDeclarationNodes
- Add member call query for variable assignments in tree-sitter-queries

Python:
- hasDecorator now matches dotted paths (e.g. @abc.abstractmethod)
- Fix version comment from ^0.23.6 to 0.23.4

PHP:
- Add enum_declaration to typeDeclarationNodes (PHP 8.1+)
- Add version comment for 0.23.12

Swift:
- Add isOverride using hasKeyword/hasModifier pattern

Rust:
- Fix version comment from ^0.23.2 to 0.23.1

Also: identifier fallback in generic.ts for mixin owner names,
Dart integration test label fix (Method vs Function), version
comment for tree-sitter-dart 1.0.0.

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

* fix: Dart extension_declaration and Ruby module_function support

Dart:
- Add extension_declaration to typeDeclarationNodes and extension_body
  to bodyNodeTypes — extension methods are now extracted into the graph
- Add extension_declaration and mixin_declaration to CLASS_CONTAINER_TYPES
  for HAS_METHOD edge resolution

Ruby:
- module_function now maps to visibility 'private' in extractRubyVisibility
- module_function methods marked isStatic via backward-walk in isStatic
- Override semantics: private/public after module_function resets isStatic

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

* feat(go): Go MethodExtractor config with receiver-based extraction

Add Go as the 13th language with a per-language MethodExtractor config.
Go methods are top-level (not nested in struct bodies), so this adds
extractFromNode() to the MethodExtractor interface for direct method
node extraction without an enclosing class.

Config extracts:
- Name from field_identifier (methods) / identifier (functions)
- Return type including multi-return (first type from parameter_list)
- Parameters with variadic support
- Visibility via uppercase/lowercase convention
- Receiver type with pointer unwrapping (*User → User)
- isStatic for functions (no receiver)

Infrastructure:
- extractOwnerName optional hook on MethodExtractionConfig
- extractFromNode on MethodExtractor (factory auto-implements)
- Parse-worker uses extractFromNode when no enclosing class found
- method_declaration added to CLASS_CONTAINER_TYPES

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

* test: method enrichment integration tests for 7 languages + TS abstract class fix

Add method-enrichment integration test fixtures and test blocks for
Go, C++, Java, Kotlin, TypeScript, JavaScript, and C#. Each fixture
tests: class detection, HAS_METHOD edges, EXTENDS edges, isAbstract,
isStatic, annotations, parameterTypes, and CALLS edge resolution.

Fixes found during testing:
- Remove method_declaration from CLASS_CONTAINER_TYPES (added for Go
  but broke Java/C# HAS_METHOD edge resolution — method_declaration
  is also Java's method node type)
- Add abstract_class_declaration query to TypeScript tree-sitter
  queries (was missing, so abstract classes were invisible to pipeline)

1699 integration tests pass across 20 test files, 0 regressions.

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

* style: format typeDeclarationNodes array for better readability in PHP config

* fix: Go interface methods + Rust impl-for-Struct owner resolution

Go:
- Add method_elem to methodNodeTypes so interface method signatures
  are extractable as abstract methods
- Integration test: Animal interface detected, Speak isAbstract,
  CALLS edges from app.go

Rust:
- Add extractOwnerName to resolve impl Trait for Struct to the
  concrete Struct (not the Trait) — fixes method misattribution
- Fix findEnclosingClassId to generate Struct: label (not Impl:)
  for impl blocks so HAS_METHOD edges resolve to struct nodes
- Tighten abstract-dispatch test: assert SqlRepo owns find/save

generic.ts:
- Fix extractOwnerName fallback: when hook returns a value, skip
  both name-field and type_identifier scan (was overwriting result)

1703 integration tests pass, 0 regressions.

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

* fix: code review response — Rust impl label, Swift params, Dart async, sequential methodExtractor

Address code review findings from PR #624:

- ast-helpers: Rust `impl Trait for Struct` uses Struct label (matches existing
  graph node), plain `impl Struct` uses Impl label (matches definition.impl)
- swift: fix parameter type extraction (user_type not type_annotation), detect
  default values as function_declaration siblings, add version comment
- dart: isDartAsync now detects async*/sync* generators, add clarifying comment
  for declaration nodes in extension bodies
- python: correct isFinal comment (PEP 591 @typing.final exists, just not modeled)
- parsing-processor: port methodExtractor enrichment to sequential path so
  isAbstract/isStatic/visibility/annotations/isFinal populate on <15-file repos
- tests: remove silent `if (prop !== undefined)` guards, assert properties
  directly, fix label queries (Dart Method vs Function, Swift Method for protocol
  methods), add Rust HAS_METHOD sourceLabel tests, Swift parameterTypes tests,
  and Dart async/sync* integration tests with fixture

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

* fix: Rust grammar gap + qualified method IDs to resolve same-file collisions

Phase 1 — Rust grammar:
- Add function_signature_item query to RUST_QUERIES so abstract trait methods
  (fn speak(&self) -> String;) become graph nodes with isAbstract=true

Phase 2 — Qualified method IDs:
- findEnclosingClassInfo returns {classId, className} for AST-based class lookup
- Both parsing paths (sequential + worker) qualify method/property IDs with
  enclosing class: Method:file:ClassName.method instead of Method:file:method
- extractFuncNameFromSourceId handles ClassName.method format
- Fixes silent data loss when same-name methods in different classes shared a
  file (e.g., Animal.speak and Dog.speak both now exist as distinct graph nodes)

Test updates:
- Rust: abstract+concrete trait methods both verified, function count adjusted
- Python: static method disambiguation now emits 2 CALLS edges (correct — no
  more ID collision masking the second call)

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

* fix: owner-aware resolution for qualified method IDs

Address Codex adversarial review findings after qualified ID change:

- findEnclosingFunction: disambiguate candidates by ownerId when multiple
  same-name methods exist in file; qualify fallback-generated IDs
- findEnclosingFunctionId (worker): qualify sourceIds with enclosing class
  name so CALLS source attribution matches definition-phase node IDs
- buildExportedTypeMapFromGraph: use lookupExactAll + nodeId match instead
  of lookupExactFull which returns first definition for bare name

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

* fix: methodExtractor variadic arity, return type preservation, PHP abstract dispatch

Three bugs in the methodExtractor enrichment path broke 17 integration tests:

1. Variadic parameterCount: buildMethodProps and parse-worker set
   parameterCount = info.parameters.length even for variadic functions,
   causing arity filtering to reject valid calls. Now checks isVariadic
   and sets parameterCount = undefined (matching extractMethodSignature).

2. C++ bare `...` token: extractCppParameters only iterated named
   children, missing the unnamed `...` token in C-style variadics like
   log_entry(const char* fmt, ...). Added fallback scan of all children.

3. Return type stripping: All 11 language extractReturnType functions
   used extractSimpleTypeName() which strips generic parameters
   (List<User> → "List", Task<User> → "Task"). Changed to .text?.trim()
   to preserve full generic types needed for for-loop iterable resolution,
   async-await binding, and return-type inference.

Also fixes PHP abstract dispatch test that matched SqlRepository instead
of the interface due to ambiguous filePath.includes('Repository') filter,
and adds parent-walk fallback in PHP isAbstract for extractFromNode path.

* chore: remove plan and review artifacts from PR

* fix: address Round 4 review findings + infrastructure improvements

- Ruby: add singleton_class support for class << self methods (4 new tests)
- PHP: add enum_declaration to CLASS_CONTAINER_TYPES
- Dart: add mixin/extension labels to CONTAINER_TYPE_TO_LABEL
- Swift: add TODO for unverifiable struct/enum node types on Node 22
- C#: add grammar version comment (0.23.1)
- Ruby: fix version comment range to pin (0.23.1)
- Rust/ast-helpers: add cross-reference comments for impl_item duplication
- ast-helpers: document CLASS_CONTAINER_TYPES ↔ typeDeclarationNodes invariant
- generic.ts: replace Array.includes with Set for O(1) dedup in addNestedBodies
- Go/Python/Ruby: align isAbstract signature with 2-param interface contract
- CLAUDE.md: fix malformed backtick around gitnexus:start HTML comment
- parsing-processor: add per-class method extraction cache (eliminates O(N*M))
- ast-helpers: add scoped_type_identifier to impl_item resolution
- call-processor: add dev-mode warnings at silent candidates[0] fallbacks
- MCP context(): surface methodMetadata for Method/Function/Constructor nodes
- resources.ts: update schema to list all stored Method properties

* fix: singleton_class HAS_METHOD edge regression in findEnclosingClassInfo

singleton_class (class << self) was added to CLASS_CONTAINER_TYPES but
has no name field — its receiver `self` has node type 'self', not
'identifier'. findEnclosingClassInfo now walks up to the enclosing
class/module to inherit its name, matching ruby.ts:extractOwnerName.

Also fixes findEnclosingClassNode in parse-worker.ts to skip
singleton_class and return the actual class/module node.

Adds integration test assertions for from_habitat (class << self method):
HAS_METHOD edge from Animal, isStatic=true, parameterCount=1.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:11:31 +01:00
Abhigyan Patwari 5c4fca21c3 Merge pull request #626 from ivkond/feat/intra-repo-service-tracking-clean
[group] Intra-repo service communication tracking
2026-04-03 17:04:24 +05:30
Nguyen Hai SonandClaude Opus 4.6 dd0f5eed7d feat(vue): Vue SFC support + destructured call result tracking (#604)
* feat(vue): add Vue SFC (.vue) support for indexing

Vue Single File Components are now fully supported in the indexing pipeline.
The implementation extracts <script> / <script setup> blocks from .vue files
and parses them using the existing TypeScript tree-sitter grammar — no new
npm dependencies required.

Key changes:
- SFC script extractor: regex-based extraction of <script setup lang="ts">
  blocks with correct line offset mapping back to the .vue file
- Vue language provider: reuses TypeScript queries, type config, field
  extractors, and named binding extraction
- Import resolution: .vue added to EXTENSIONS so `import Foo from './Foo'`
  resolves to Foo.vue; Vue import resolver delegates to TS resolver for
  tsconfig path alias support
- Export detection: <script setup> top-level bindings are implicitly exported
- Template component detection: PascalCase tags in <template> emit CALLS edges
- Line offsets applied to all emitted positions (startLine, endLine, route
  lineNumbers, decorator positions) in both worker and sequential paths

Validated on a 3,553-file Vue project:
  Before: 24,693 nodes | 73,614 edges | 0 symbols from .vue
  After:  30,495 nodes | 112,324 edges | 5,213 symbols from .vue
          18,682 imports from .vue | 5,826 vue-to-vue imports

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

* feat(typescript): track destructured call results in TypeEnv

Extend `extractPendingAssignment` to handle object destructuring from
function calls and await expressions:

  const { isMaker } = useUserRole()
  const { data } = await fetchData()
  const { name } = repo.getProfile()

Previously, only `const { x } = someVariable` (identifier RHS) produced
TypeEnv bindings. Call-expression RHS was silently skipped, leaving
destructured properties untracked.

The fix emits a synthetic `callResult` item plus N `fieldAccess` items
per destructured property, which the existing fixpoint resolver processes
in 2 iterations. No changes needed to type-env.ts, PendingAssignment
types, or call-processor — the existing infrastructure handles it.

Also extracts a `collectDestructuredFields` helper to share the
object_pattern property iteration logic between the identifier and
call-expression branches.

Note: Full property-type resolution requires the callee to have a
declared returnType in the SymbolTable. Arrow-function composables
without type annotations (common in Vue/React) won't resolve property
types until return-type inference is added in a future change.

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

* fix(vue): address PR review issues for Vue SFC support

- Extract duplicated isVueSetupTopLevel to vue-sfc-extractor.ts shared
  utility, removing identical copies from parse-worker.ts and
  parsing-processor.ts
- Fix VUE_BUILT_INS to be a superset of TS BUILT_INS by importing and
  spreading the TypeScript set, preventing spurious unresolved calls for
  standard built-ins (Symbol, BigInt, WeakMap, array methods, etc.)
- Add Vue template component CALLS edge resolution in both sequential
  and worker paths (call-processor.ts), matching PascalCase template
  tags against imported .vue file basenames via the import map
- Add integration test for template PascalCase CALLS edges
  (App.vue → Button.vue)
- Add integration test for isExported: false on non-setup <script>
  blocks (OldStyle.vue options API)
- Add comment explaining TEMPLATE_RE greedy regex behavior for nested
  template tags
- Fix stale language count comment (14 → 15) and remove dead code
  branch in test

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:18:55 +05:30
Chirag Nighut e3d73a7aed Java method reference (#622) 2026-04-02 15:16:35 +01:00
ivkondandClaude Opus 4.6 255e3e79eb fix(group): address 4 HIGH-priority issues from PR #626 review
1. Path traversal via group name — add validateGroupName() with regex
   [a-zA-Z0-9][a-zA-Z0-9_-]*, called in getGroupDir (defense in depth)

2. gRPC proto regex can't handle nested braces — replace serviceRe with
   extractServiceBlocks() brace-depth counter (init depth=1, skip
   malformed protos)

3. Service boundary detector directory exclusions — add EXCLUDED_DIRS
   set (vendor, target, build, dist, __pycache__, .venv, venv, .tox,
   .mypy_cache, .gradle, .mvn, out, bin) replacing inline node_modules

4. Double-close of LadybugDB pools — remove blanket closeLbug() from
   cli/group.ts; sync.ts per-id cleanup is sufficient

Tests: 22 new tests across 5 files. Full suite: 4706 passed, 0 failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:33 +03:00
ivkondandClaude Opus 4.6 be4458b650 docs: add design spec and implementation plan for PR #626 HIGH fixes
Spec covers 4 HIGH-priority issues from review: path traversal via
group name, gRPC proto regex nested braces, service boundary detector
directory exclusions, double-close of LadybugDB pools.

Plan: 6 tasks with TDD, ordered by complexity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:18 +03:00
ivkondandClaude Opus 4.6 4fed097abb feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture
Wire extractors into the sync pipeline with service boundary detection.
GroupService provides high-level API for all group operations.

- Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with
  service boundary assignment and exact matching
- GroupService: groupList, groupSync, groupContracts, groupQuery,
  groupStatus (groupImpact deferred to cross-repo follow-up PR)
- CLI: group create/add/remove/list/sync/contracts/query/status
- MCP tools: group_list, group_sync, group_contracts, group_query,
  group_status
- Monorepo fixture: 3 services (auth/orders/gateway) connected via
  gRPC + Kafka + HTTP — all intra-repo cross-links discovered
- Documentation: CLI commands and MCP tools added to both READMEs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:31 +03:00
ivkondandClaude Opus 4.6 4fa395f4b6 feat(group): add service boundary detection and contract extractors
Service communication detection for microservice monorepos:

- ServiceBoundaryDetector: auto-detects service boundaries via markers
  (package.json, go.mod, Dockerfile, pom.xml, Cargo.toml, build.gradle,
  pyproject.toml, etc.)
- HttpRouteExtractor: graph-assisted (Strategy A) with source-scan
  fallback (Strategy B) for Spring, Express, Laravel, FastAPI providers
  and fetch/axios consumers
- GrpcExtractor: parses .proto files, detects Go/Java/Python/TS gRPC
  servers (RegisterXxxServer, @GrpcService, add_XxxServicer_to_server,
  @GrpcMethod) and clients (NewXxxClient, newBlockingStub, XxxStub)
- TopicExtractor: Kafka (@KafkaListener, producer.send), RabbitMQ
  (@RabbitListener, channel.publish/consume), NATS (nc.Subscribe/Publish)
  across Java, Node, Go, and Python

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:12 +03:00
ivkondandClaude Opus 4.6 52277247fe feat(group): add group infrastructure and contract matching
Core foundation for repository group analysis:
- Type system: ContractType, ExtractedContract, StoredContract, CrossLink
  with optional `service` field for intra-repo matching
- Config parser for group.yaml (repos, detection flags, matching thresholds)
- Contract registry storage with atomic writes
- Exact matching engine with per-type normalization (HTTP, gRPC, topic)
  and intra-repo support (different services within same repo can match)
- Extract LadybugDB pool-adapter from MCP backend for reuse by sync pipeline
- Git staleness checker for group status reporting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:39:43 +03:00
Gergő Magyar ba5de0bde4 feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#617)
* feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#572)

- Pure virtual (= 0) detected as isAbstract via token scanning
- virtual/final/override via hasKeyword and virtual_specifier children
- Access specifier visibility via backward sibling walk (public:/private:/protected:)
- Pointer/reference parameter types extracted correctly
- Constructor and destructor support via declaration node type
- Static detection via storage_class_specifier
- 16 new tests covering all acceptance criteria

* fix(cpp): isVirtual infers from override/final + out-of-class resolution

- isVirtual returns true for override/final methods (C++ mandates these
  are virtual)
- Add findClassNodeByQualifiedName to parse-worker: resolves Foo::bar()
  back to the Foo class declaration for method extractor enrichment
- Handles pointer/ref return types, constructors, destructors
- Integration test for virtual/static/constructor inline methods
- 233 unit+integration tests pass, 97 C++ resolver tests pass

* fix(cpp): address review — deep pointers, templates, unions, trailing returns

- Fix extractParamName: recursive unwrap for int** ptr → "ptr" (not "**ptr")
- Fix findFunctionDeclarator: recursive unwrap for multi-level pointer chains
- Template methods: generic extractor unwraps template_declaration to inner node
- union_specifier: added to typeDeclarationNodes, visibility defaults to public
- Trailing return type: auto foo() -> T now extracts T instead of "auto"
- Fix version comment: ^0.22.4 → ^0.23.4 to match package.json
- 4 new tests: double pointer params, template methods, union methods, trailing returns

* fix(cpp): template method visibility + union isTypeDeclaration test

extractCppVisibility now walks from the template_declaration parent
when the node is wrapped by a template, restoring correct access-
specifier resolution for templated class methods.

Also adds missing isTypeDeclaration assertion for union_specifier and
expands the template method test with explicit visibility checks.

* fix(cpp): address deep gap analysis review findings

- findClassNodeByQualifiedName: recursive pointer/reference
  declarator unwrap, fixing out-of-class linking for deep pointer
  return types (e.g. int** Foo::bar())
- findClassNodeByQualifiedName: recurse into namespace_definition
  blocks so namespace-wrapped classes resolve correctly
- Suppress = delete / = default special members from extraction
  via delete_method_clause / default_method_clause node detection
- Update known-gaps: namespace-wrapped classes, const-overload collapse
- Add tree-sitter-c version comment for consistency
- toBeFalsy() → toBe(undefined) for precise isVirtual assertion
- Tests: = delete, = default, = 0 non-regression, operator overloads,
  deep pointer return types, default visibility (class vs struct),
  multiple access specifier sections
2026-04-01 18:07:11 +01:00
Abhigyan PatwariandClaude Opus 4.6 dc86ea96dc chore: release v1.5.3 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:35:24 +05:30
Abhigyan PatwariandClaude Opus 4.6 d1adc8331a chore: release v1.6.0 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:32:11 +05:30
80d363f145 fix(wiki): Azure OpenAI compat and HTML viewer script injection (#618)
* fix(wiki): Azure OpenAI compat and HTML viewer script injection

- Use max_completion_tokens instead of deprecated max_tokens for all models
- Skip sending temperature for Azure provider (some models reject non-default values)
- Simplify Azure interactive setup: endpoint + deployment + key (3 prompts instead of 7)
- Escape </script> in embedded JSON to prevent premature script tag closure

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

* fix(test): align wiki-llm-client test with max_completion_tokens change

The test expected max_tokens for non-reasoning models, but the source
now uses max_completion_tokens for all models since max_tokens is
deprecated by newer OpenAI models.

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

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:30:43 +05:30
Gergő Magyar 12be2025f1 feat(ts,js): TypeScript/JavaScript MethodExtractor config (#588)
* feat(ts,js): MethodExtractor config for TypeScript and JavaScript (#570)

Add per-language method extraction config following the established
JVM and C# patterns. Shared config base mirrors the field extractor's
typescript-javascript.ts pattern — TS-only node types are harmless
no-ops for JS.

Key features:
- isAbstract for abstract class methods and interface methods
- Parameter extraction with isOptional (?:, defaults) and isVariadic (...)
- Decorator extraction from preceding body-level siblings
- isAsync and isOverride detection
- Visibility via accessibility_modifier two-pass pattern
- Return type extraction unwrapping type_annotation

* test(ts,js): add override, getter/setter, destructured param tests

Address code review findings:
- Add override method detection test
- Add getter/setter extraction test
- Add destructured parameter with type annotation test
- Tighten constructor and private method assertions

* refactor(ts,js): address code review findings

- Replace O(M*N) decorator index scan with previousNamedSibling walk
- Remove dead findVisibility 'modifiers' fallback (TS uses
  accessibility_modifier, not a modifiers wrapper)
- Document call_signature/construct_signature as known gaps
- Document that TS constructors are method_definition nodes
- Remove unused findVisibility import

* fix(ts,js): type guard before cast, add generator/computed/overload tests

- Use type guard pattern (Set.has check before as-cast) in visibility
  extraction to ensure string is validated before narrowing
- Add generator method test (*items()) — confirms extraction works
- Add computed property name test ([Symbol.iterator]) — documents
  bracket-in-name behavior as intentional
- Add class-level method overload test — verifies overload signatures
  + implementation are all extracted

* fix(ts,js): detect #private methods as visibility 'private'

ES2022 private class methods (#name) use private_property_identifier
as their name node type. Detect this and return 'private' visibility
instead of the default 'public'.

* fix(ts,js): address review findings + close ingestion gaps

- hasKeyword/findVisibility: skip name field child to prevent false
  positives on soft-keyword method names (e.g. `abstract()`, `static()`)
- extractTsJsParameters: filter TS `this` parameter (compile-time only)
- extractMethodSignature: mirror `this`-param skip in fallback path
- tree-sitter queries: capture abstract_method_signature,
  method_signature, and private_property_identifier for TS; add
  private_property_identifier for JS
- Remove dead childForFieldName('name') fallbacks and typeFromAnnotation
  fallback
- Add 10+ unit tests, 4 integration tests through query pipeline

* test(ts): update HAS_METHOD count for interface method_signature capture

The new method_signature query now captures ILogger.log() as a Method
node with a HAS_METHOD edge, increasing the expected count from 4 to 5.

* fix(ts,js): address second review — async generator test, declare module gap

- Add async generator method test (async *values() → isAsync: true)
- Document declare module/global augmentation as known gap
2026-04-01 14:09:59 +01:00
Abhigyan PatwariandClaude Opus 4.6 dcf40da3a8 fix: ensure import rewrites survive npm publish lifecycle
npm runs `prepare` after `prepack` during publish, so the previous
`prepare: tsc` overwrote the rewritten imports before packing.
Both `prepare` and `prepack` now run the full build script so the
tarball always contains rewritten relative imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:28:14 +05:30
Abhigyan PatwariandClaude Opus 4.6 c617350c58 chore: release v1.5.1 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:05:08 +05:30
71255a096c fix: bundle gitnexus-shared into CLI dist (#613)
* fix(ci): build gitnexus-shared before publish, use CHANGELOG for release notes

The publish workflow was missing the gitnexus-shared build step that
the setup-gitnexus composite action provides. Since PR #536 unified
the ingestion pipeline, gitnexus imports types from gitnexus-shared,
so it must be built first.

Also replaces generate_release_notes with CHANGELOG.md extraction so
GitHub Releases use the reviewed changelog entry instead of a flat
PR title list.

Made-with: Cursor

* fix: bundle gitnexus-shared into CLI dist to fix module resolution

gitnexus-shared was declared as a file: dependency but never published
to npm, causing ERR_MODULE_NOT_FOUND for users installing gitnexus
globally. The build script now copies gitnexus-shared/dist into
dist/_shared/ and rewrites bare specifiers to relative paths.

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

* fix: move gitnexus-shared to devDependencies, use tsc for prepare

gitnexus-shared must remain available for tsc to resolve imports during
development/CI, but is not needed at runtime since it's bundled into
dist/_shared/. Moving it to devDependencies keeps it out of production
installs while allowing compilation. The prepare script now runs plain
tsc (no shared bundling needed for local dev).

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

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:04:39 +05:30
Gergo Magyar 39322e37df fix(ci): build gitnexus-shared before npm ci in publish workflow
The publish job failed because npm ci triggers the prepare script (tsc)
before gitnexus-shared types are available. Add the same build step
that the setup-gitnexus composite action uses in CI.
2026-04-01 07:35:46 +01:00
Abhigyan PatwariandAbhigyan Patwari 0e6f8f1720 chore: release v1.5.0 — update CHANGELOG and package-lock (#611)
Made-with: Cursor

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
2026-04-01 11:24:38 +05:30
467 changed files with 43512 additions and 4322 deletions
+20 -1
View File
@@ -30,6 +30,10 @@ jobs:
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- run: npm ci
working-directory: gitnexus
@@ -63,7 +67,22 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Extract release notes from CHANGELOG
id: changelog
shell: bash
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
echo "fallback=true" >> "$GITHUB_OUTPUT"
else
echo "$NOTES" > /tmp/release-notes.md
echo "fallback=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
generate_release_notes: true
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
+1 -1
View File
@@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current symbol stats, run `npx gitnexus analyze` and inspect `.gitnexus/meta.json`.
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+57
View File
@@ -58,8 +58,65 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
## Known limitations
### Overloaded method resolution
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
disambiguate overloaded methods. Two overloads with different parameter counts
produce distinct graph nodes: `Method:file:Class.method#1` vs
`Method:file:Class.method#2`.
**Same-arity overload disambiguation:** When two overloads share the same
parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a
type-hash suffix `~type1,type2` is appended to produce distinct node IDs:
`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix
is only added when a same-arity collision is detected within a class and all
parameters have non-null type annotations. Languages without type info (Python,
Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures
are intentionally excluded from type-hashing because they are declaration-only
contracts that should collapse to the implementation body's node ID. See issue
\#651.
**C++ const-qualified overload disambiguation:** Methods overloaded by const
qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an
`isConst` property and a `$const` ID suffix appended to the const-qualified
variant when a non-const collision exists. The `$const` suffix appears after the
type-hash suffix: e.g. `Method:file:Container.begin#0$const`.
**Generic/template type preservation in type-hash:** The type-hash suffix uses
`rawType` (full AST text including generic/template args) rather than the
simplified `type` from `extractSimpleTypeName`. This means C++ template overloads
like `process(vector<int>)` vs `process(vector<string>)` produce distinct IDs:
`~vector<int>` vs `~vector<std::string>`. Java generic overloads like
`process(List<String>)` vs `process(List<Integer>)` are a compile error due to
type erasure, so this gap is theoretical for Java.
**ID stability on first overload:** Type and const tags are collision-only. When
a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag).
Adding `save(String)` changes the original to `save#1~int`. This is correct for
fresh analysis but means IDs are not stable across overload additions. Future
incremental re-analysis should account for this.
**Variadic method matching:** When one side is variadic (`parameterCount`
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
emitted with confidence 0.7 instead of 1.0. Variadic methods like
`foo(String... args)` may superficially match `foo(String s)` by type but
are not guaranteed to be interchangeable across all languages (Java/Kotlin
accept this via varargs sugar; TypeScript, C#, Rust do not).
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
| Match quality | Confidence | When |
|---|---|---|
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
## Related docs
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
- [TESTING.md](TESTING.md) — how to run tests.
+13
View File
@@ -10,6 +10,19 @@ All notable changes to GitNexus will be documented in this file.
- Added automatic cleanup of stale KuzuDB index files
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
## [1.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
### Fixed
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
- Ensure import rewrites survive npm publish lifecycle
## [1.4.0] - 2026-03-13
### Added
+203 -1
View File
@@ -49,4 +49,206 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## GitNexus rules
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
GitNexus MCP rules are in the `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+27
View File
@@ -0,0 +1,27 @@
# Migration Guide
## OVERRIDES → METHOD_OVERRIDES (PR #642)
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
consistency with the new `METHOD_IMPLEMENTS` edge type.
### Do I need to migrate?
**No.** Backward compatibility is handled automatically at runtime:
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
edges continue to return correct results without any manual intervention.
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
queries that reference either will work.
### What happens on re-index?
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
edges going forward. The old `OVERRIDES` edges are replaced as part of the
normal full re-index.
### When will the legacy alias be removed?
The `OVERRIDES` compat alias will remain until a future major version. Removal
will be announced in this file and in the changelog before it happens.
+17 -3
View File
@@ -52,7 +52,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Privacy** | Everything local, no network | Everything in-browser, no server |
@@ -119,7 +119,6 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **Codex** | Yes | — | — | MCP |
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
@@ -206,11 +205,21 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
### What Your AI Agent Gets
**7 tools** exposed via MCP:
**16 tools** exposed via MCP (11 per-repo + 5 group):
| Tool | What It Does | `repo` Param |
| ------------------ | ----------------------------------------------------------------- | -------------- |
@@ -221,6 +230,11 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
| `group_list` | List configured repository groups | — |
| `group_sync` | Extract contracts and match across repos/services | — |
| `group_contracts`| Inspect extracted contracts and cross-links | — |
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
@@ -0,0 +1,725 @@
# PR #626 HIGH-Priority Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
---
### Task 1: Path Traversal — Validate Group Name
**Files:**
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
- Test: `gitnexus/test/unit/group/storage.test.ts`
- [ ] **Step 1: Write failing tests for validateGroupName**
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
```typescript
describe('validateGroupName', () => {
it('test_validateGroupName_traversal_path_throws', () => {
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_slash_in_name_throws', () => {
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_empty_string_throws', () => {
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_dash_throws', () => {
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_underscore_throws', () => {
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_dots_throws', () => {
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_valid_alphanumeric_passes', () => {
expect(() => validateGroupName('my-group_01')).not.toThrow();
});
it('test_validateGroupName_single_char_passes', () => {
expect(() => validateGroupName('A')).not.toThrow();
});
it('test_validateGroupName_all_digits_passes', () => {
expect(() => validateGroupName('123')).not.toThrow();
});
});
describe('getGroupDir rejects invalid names', () => {
it('test_getGroupDir_traversal_throws', () => {
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
});
it('test_getGroupDir_valid_name_returns_path', () => {
const dir = getGroupDir(tmpDir, 'company');
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
});
});
describe('createGroupDir rejects invalid names', () => {
it('test_createGroupDir_traversal_throws', async () => {
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
```typescript
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
export function validateGroupName(name: string): void {
if (!GROUP_NAME_RE.test(name)) {
throw new Error(
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
);
}
}
export function getGroupDir(gitnexusDir: string, groupName: string): string {
validateGroupName(groupName);
return path.join(gitnexusDir, 'groups', groupName);
}
```
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
git commit -m "fix(group): validate group name to prevent path traversal
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
Called in getGroupDir (defense in depth) which covers all CLI entry
points: create, add, remove, status, sync.
Addresses PR #626 review item 1 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Directory Exclusions in Service Boundary Detector
**Files:**
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
- [ ] **Step 1: Write failing tests for excluded directories**
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
```typescript
it('test_detect_skips_vendor_directory', async () => {
writeFile('services/auth/package.json', '{}');
writeFile('services/auth/src/index.ts', '');
// vendor should be skipped — its contents should not create a boundary
writeFile('vendor/some-dep/package.json', '{}');
writeFile('vendor/some-dep/src/lib.go', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/auth');
expect(paths).not.toContain('vendor/some-dep');
});
it('test_detect_skips_target_directory', async () => {
writeFile('services/api/go.mod', 'module api');
writeFile('services/api/main.go', '');
writeFile('target/classes/Main.java', '');
writeFile('target/pom.xml', '<project/>');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('target');
});
it('test_detect_skips_pycache_directory', async () => {
writeFile('services/ml/pyproject.toml', '[project]');
writeFile('services/ml/model.py', '');
// __pycache__ with a marker + source files — would be detected as
// a boundary if not excluded, since it has package.json + .py file
writeFile('__pycache__/package.json', '{}');
writeFile('__pycache__/cached.py', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/ml');
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
});
it('test_detect_skips_dotfile_directories_regression', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
writeFile('.hidden/package.json', '{}');
writeFile('.hidden/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('.hidden');
});
it('test_detect_does_not_skip_regular_source_directories', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
expect(boundaries).toHaveLength(1);
expect(boundaries[0].serviceName).toBe('api');
});
```
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
In `gitnexus/src/core/group/service-boundary-detector.ts`:
After `SOURCE_EXTENSIONS` (after line 51), add:
```typescript
const EXCLUDED_DIRS = new Set([
'node_modules',
'vendor',
'target',
'build',
'dist',
'__pycache__',
'.venv',
'venv',
'.tox',
'.mypy_cache',
'.gradle',
'.mvn',
'out',
'bin',
]);
```
In `walkForBoundaries`, replace line 78:
```typescript
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
```
with:
```typescript
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
```
In `hasSourceFilesInSubdirs`, replace line 130:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
```
with:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
git commit -m "fix(group): add directory exclusions to service boundary detector
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
Replaces inline node_modules check.
Addresses PR #626 review item 3 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Remove Double-Close of LadybugDB Pools
**Files:**
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
```typescript
it('test_syncGroup_closes_only_opened_pools', async () => {
const config = makeConfig({
'app/backend': 'backend-repo',
'app/frontend': 'frontend-repo',
});
const closedIds: string[] = [];
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
const { vi } = await import('vitest');
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
if (id) closedIds.push(id);
});
try {
await syncGroup(config, {
resolveRepoHandle: async (_name, groupPath) => ({
id: groupPath.replace(/\//g, '-'),
path: groupPath,
repoPath: '/tmp/' + groupPath,
storagePath: '/tmp/' + groupPath + '/.gitnexus',
}),
skipWrite: true,
}).catch(() => {});
// Regardless of extraction errors, closeLbug should be called per id
// closeLbug should only receive specific pool ids, never undefined/empty
for (const id of closedIds) {
expect(id).toBeTruthy();
expect(typeof id).toBe('string');
}
// No blanket close (no-arg call)
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
expect(blanketCalls).toHaveLength(0);
} finally {
initSpy.mockRestore();
closeSpy.mockRestore();
}
});
```
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
```typescript
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
const source = fs.readFileSync(cliGroupPath, 'utf-8');
// closeLbug() without arguments (blanket close) must not appear.
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
// Match closeLbug() but not closeLbug(someArg)
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
expect(source).not.toMatch(blanketClosePattern);
});
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
In `gitnexus/src/cli/group.ts`:
Remove the `closeLbug` import at line 160:
```typescript
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
```
Replace the try/finally wrapper (lines 162-189):
```typescript
try {
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
} finally {
await closeLbug().catch(() => {});
}
```
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
```typescript
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
Expected: ALL PASS
- [ ] **Step 7: Commit**
```bash
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
sync.ts already closes pools per-id in its finally block.
The blanket closeLbug() in cli/group.ts tears down ALL active pools
including unrelated ones in MCP server context.
Addresses PR #626 review item 4 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: gRPC Proto Regex — Brace-Depth Counter
**Files:**
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
- [ ] **Step 1: Write failing tests for nested braces in proto services**
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
```typescript
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
writeFile(
'api/gateway.proto',
`syntax = "proto3";
package gateway.v1;
import "google/api/annotations.proto";
service GatewayService {
rpc GetUser (GetUserRequest) returns (UserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
);
expect(providers).toHaveLength(2);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::gateway.v1.GatewayService/CreateUser',
'grpc::gateway.v1.GatewayService/GetUser',
]);
});
it('test_extract_proto_with_multiple_services', async () => {
writeFile(
'api/multi.proto',
`syntax = "proto3";
package multi;
service ServiceA {
rpc MethodA (Req) returns (Res);
}
service ServiceB {
rpc MethodB1 (Req) returns (Res);
rpc MethodB2 (Req) returns (Res);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
);
expect(providers).toHaveLength(3);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::multi.ServiceA/MethodA',
'grpc::multi.ServiceB/MethodB1',
'grpc::multi.ServiceB/MethodB2',
]);
});
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
writeFile(
'api/nested.proto',
`syntax = "proto3";
package nested;
service DeepService {
rpc DeepMethod (Req) returns (Res) {
option (google.api.http) = {
post: "/v1/deep"
body: "*"
additional_bindings {
get: "/v1/deep/{id}"
}
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
);
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
});
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
writeFile(
'api/broken.proto',
`syntax = "proto3";
package broken;
service IncompleteService {
rpc SomeMethod (Req) returns (Res);
// Missing closing brace — EOF before depth returns to 0
`,
);
// Should not throw; incomplete service is silently skipped
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
);
// The old regex would find partial match; the new parser should skip it
expect(providers).toHaveLength(0);
});
```
- [ ] **Step 2: Run tests to verify the nested brace test fails**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
```typescript
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
```
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
```typescript
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
}
// If EOF before depth returns to 0, skip incomplete service
if (depth !== 0) continue;
// body is between opening { (consumed by regex) and closing } (pos is one past it)
const body = content.slice(bodyStart, pos - 1);
results.push({ name: serviceName, body });
}
return results;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: ALL PASS (including existing regression tests)
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
The serviceRe regex used [^}]* which stopped at the first '}'.
Proto services with google.api.http annotations contain nested {}
blocks, causing methods to be missed.
New extractServiceBlocks() uses a brace-depth counter (init depth=1
after opening {, scan char-by-char). Malformed protos with unclosed
braces are silently skipped.
Addresses PR #626 review item 2 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Run Full Test Suite
- [ ] **Step 1: Run all group-related tests**
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
Expected: ALL PASS
- [ ] **Step 2: Run full test suite to catch regressions**
Run: `cd gitnexus && npx vitest run`
Expected: ALL PASS, 0 failures
- [ ] **Step 3: Run typecheck**
Run: `cd gitnexus && npx tsc --noEmit`
Expected: No errors
---
### Task 6: CLI Integration Smoke Test
- [ ] **Step 1: Add CLI smoke test for path traversal**
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
```typescript
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Invalid group name');
});
```
- [ ] **Step 2: Run test**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: ALL PASS
- [ ] **Step 3: Commit**
```bash
cd gitnexus && git add test/integration/group/group-cli.test.ts
git commit -m "test(group): add CLI smoke test for path traversal rejection
Verifies that 'group create ../../evil' fails with Invalid group name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
@@ -0,0 +1,175 @@
# PR #626 HIGH-Priority Fixes Design
**Date:** 2026-04-02
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
---
## Fix 1: Path Traversal via Group Name
**File:** `gitnexus/src/core/group/storage.ts`
**Risk:** A group name like `../../etc` creates directories outside the intended path.
### Solution
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
- Call in `createGroupDir` (primary entry point)
- Call in `getGroupDir` (defense in depth)
- Throw descriptive error on invalid names
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
### Why regex over path.resolve + startsWith
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
- Stricter: disallows spaces, dots, Unicode edge cases
- Simpler to reason about
### Tests
- `../../evil` throws
- `foo/bar` throws
- Empty string throws
- `my-group_01` passes
- `A` (single char) passes
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
### CLI/API entry points accepting groupName
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
| Command | Entry | Calls |
|---------|-------|-------|
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
| `group add` | `cli/group.ts` action | `getGroupDir` |
| `group remove` | `cli/group.ts` action | `getGroupDir` |
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
| `group status` | `cli/group.ts` action | `getGroupDir` |
| `group sync` | `cli/group.ts` action | `getGroupDir` |
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
---
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
### Solution
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
2. Initialise depth to 1 immediately after the opening `{`
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
4. Stop when depth reaches 0 (the matching closing `}`)
5. Return name + body pairs
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
### Tests
- Proto with single service, no nesting (regression)
- Proto with `google.api.http` nested braces inside RPC options
- Proto with multiple services
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
- Malformed proto with unclosed brace (graceful handling)
---
## Fix 3: Directory Exclusions in Service Boundary Detector
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
### Solution
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
```text
node_modules, vendor, target, build, dist,
__pycache__, .venv, venv, .tox, .mypy_cache,
.gradle, .mvn, out, bin
```
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
Apply in both:
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
### Tests
- Directory named `vendor/` is skipped
- Directory named `target/` is skipped
- Directory named `__pycache__/` is skipped
- Regular source directories are NOT skipped
- Dotfile directories still skipped (regression)
---
## Fix 4: Double-Close of LadybugDB Pools
**Files:**
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
### Solution
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
```typescript
// sync.ts — KEEP: cleans up only pools opened by this sync
finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
}
}
```
```typescript
// cli/group.ts — REMOVE: blanket close that kills all pools
finally {
await closeLbug().catch(() => {}); // DELETE THIS
}
```
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
### Tests (unit level — mock pool adapter)
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
---
## Out of Scope
- JSON -> LadybugDB migration (tracked in #606)
- MEDIUM/LOW issues (items 5-10 from review summary)
- Test gap coverage beyond what's needed for these 4 fixes
- Any refactoring or architectural changes
## Execution Order
Fixes are independent — can be implemented in parallel or any order.
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
+2 -1
View File
@@ -97,7 +97,8 @@ export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'OVERRIDES'
| 'METHOD_OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
| 'DEFINES'
+1
View File
@@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js';
// Language support
export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
@@ -41,6 +41,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@@ -98,6 +99,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
+1
View File
@@ -19,6 +19,7 @@ export enum SupportedLanguages {
Kotlin = 'kotlin',
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
}
+3 -1
View File
@@ -55,7 +55,9 @@ export const REL_TYPES = [
'HAS_METHOD',
'HAS_PROPERTY',
'ACCESSES',
'OVERRIDES',
'METHOD_OVERRIDES',
'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated
'METHOD_IMPLEMENTS',
'MEMBER_OF',
'STEP_IN_PROCESS',
'HANDLES_ROUTE',
+23
View File
@@ -0,0 +1,23 @@
/**
* MRO (Method Resolution Order) strategy — shared between CLI and any
* future consumer that reasons about multiple-inheritance semantics.
*
* Lives in `gitnexus-shared` so the low-level resolution module
* (`core/ingestion/model/resolve.ts`) does not need to import from
* `languages/` — keeping the `model/` layer free of language-registry
* coupling.
*
* Strategy semantics:
* - `first-wins`: BFS ancestor walk, first match wins (default).
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
* - `c3`: C3-linearized ancestor order, first match wins (Python).
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
* interface-default ambiguity is handled at graph level.
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
*/
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for heartbeat disconnect/reconnect behavior.
*
* Verifies the key regression: when the heartbeat fails, the UI shows a
* "reconnecting" banner instead of resetting to the onboarding screen.
*
* Strategy: block /api/heartbeat via route interception BEFORE loading the
* graph. The heartbeat EventSource can never connect, so onReconnecting
* fires on the first retry attempt. This reliably tests the banner behavior
* without depending on setOffline timing (which varies across CI environments).
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Heartbeat Reconnect', () => {
test('shows reconnecting banner instead of onboarding reset when heartbeat is unavailable', async ({
page,
}) => {
// Block the heartbeat BEFORE navigating — the EventSource will fail
// immediately on every connection attempt, triggering onReconnecting.
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
// Load the app and connect to a repo (all other endpoints work normally)
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
// Wait for graph to load (heartbeat is blocked, but graph loads fine)
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// The reconnecting banner should appear (heartbeat is failing)
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// The graph canvas should STILL be visible — NOT reset to onboarding
await expect(page.locator('canvas').first()).toBeVisible();
});
test('banner clears when heartbeat becomes available', async ({ page }) => {
// Start with heartbeat blocked
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Verify banner appears
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// Unblock heartbeat — the real server is running, so reconnect will succeed
await page.unroute('**/api/heartbeat');
// Banner should disappear as heartbeat reconnects
await expect(banner).not.toBeVisible({ timeout: 30_000 });
// Graph should still be there
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for multi-repo scoping and URL persistence.
*
* Verifies that:
* - Connecting via ?server= loads data and sets ?project= in the URL
* - The repo name appears in the UI after connecting
* - F5 with ?server=&project= reconnects to the correct repo
*
* Runs against the single indexed repo in CI — validates the plumbing
* works end-to-end even with one repo.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
// Still need to fetch the repo name for assertions
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Multi-Repo Scoping', () => {
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
// Navigate with ?server= param (the bookmarkable shortcut)
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// Wait for graph to load
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should now contain ?project= with the repo name
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).toBe(firstRepoName);
});
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should still have ?server=
const url = new URL(page.url());
expect(url.searchParams.get('server')).toBeTruthy();
// F5 should reconnect (not show onboarding)
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
});
test('node count in status bar matches backend data', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Fetch expected node count from backend
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
const repoInfo = await res.json();
const expectedNodes = repoInfo.stats?.nodes;
if (expectedNodes) {
// Status bar shows node count — use the status-ready area to avoid
// matching multiple elements (file tree, header may also show counts)
const statusBar = page.locator('footer');
const nodeText = statusBar.getByText(/\d+ nodes/).first();
await expect(nodeText).toBeVisible({ timeout: 10_000 });
const text = await nodeText.textContent();
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
expect(displayedNodes).toBeGreaterThan(0);
}
});
});
+168
View File
@@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for the repo-switching and false-404 fixes.
*
* Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts).
* The 503 hold-queue test uses route interception to simulate a slow analysis.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
// ── 1. Hold-queue: 503 → descriptive user message ────────────────────────────
test.describe('Hold-queue timeout error', () => {
test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => {
// Intercept only /api/repo (singular) — not /api/repos — to return a 503
// regex: /api/repo followed by end, ?, or # — NOT /api/repos
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({
error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// UI should show the 503 error message
await expect(page.getByText(/taking longer than expected/i)).toBeVisible({
timeout: 20_000,
});
await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') });
});
});
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
test.describe('?project= URL persistence', () => {
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
// first repo returned by the live backend
if (firstRepoName) expect(project).toBe(firstRepoName);
});
test('?project= is still present after F5 reload', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// After connect, URL has ?server=&project= — F5 re-uses both params
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBeTruthy();
});
});
// ── 3. ?project= + ?server= combined auto-connect ────────────────────────────
test.describe('?project= auto-connect', () => {
test('navigating with ?server=&project= connects to the correct repo', async ({
page,
}, testInfo) => {
if (!firstRepoName) test.skip(true, 'no repo name available');
await page.goto(
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// ?project= in URL should match what we passed in
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBe(firstRepoName);
await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') });
});
});
// ── 4. Windows path normalization ─────────────────────────────────────────────
test.describe('Windows path normalization', () => {
test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({
page,
}) => {
const repoName = firstRepoName || 'test-repo';
const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`;
// Mock /api/repo to return a Windows backslash path while keeping name correct
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
// intentionally omit `name` to force path-based extraction
path: windowsPath,
repoPath: windowsPath,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL ?project= must be the short basename, NOT the full Windows path
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).not.toContain('\\');
expect(project).not.toContain('LENOVO');
expect(project).toBe(repoName);
});
});
+24 -26
View File
@@ -1,4 +1,4 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { test, expect } from '@playwright/test';
/**
* E2E tests for the GitNexus web UI — exploring view features.
@@ -58,36 +58,41 @@ test.beforeAll(async () => {
* For these tests we require at least one indexed repo, so pick the first
* landing card when present and then wait for the exploring view.
*/
async function waitForGraphLoaded(page: import('@playwright/test').Page, testInfo: TestInfo) {
async function waitForGraphLoaded(page: import('@playwright/test').Page) {
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
const landingCards = page.locator('[data-testid="landing-repo-card"]');
const preferredLandingCard = landingCards
.filter({ hasText: /GitNexus|local-integration/ })
.first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
const landingCard =
(await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first();
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('graph-loaded.png') });
const statusBar = page.getByRole('contentinfo');
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
await expect(statusBar).toContainText(/nodes/, {
timeout: 20_000,
});
}
test.describe('Server Connection & Graph Loading', () => {
test('selects a repo from landing and loads graph', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
await page.screenshot({ path: testInfo.outputPath('graph-loaded-full.png'), fullPage: true });
test('selects a repo from landing and loads graph', async ({ page }) => {
await waitForGraphLoaded(page);
});
});
test.describe('Nexus AI', () => {
test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
test('panel opens and agent initializes without error', async ({ page }) => {
await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
const errorBanner = page.getByText('Database not ready');
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
@@ -95,8 +100,8 @@ test.describe('Nexus AI', () => {
});
test.describe('Processes Panel', () => {
test('shows process list and View button works', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
test('shows process list and View button works', async ({ page }) => {
await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -104,7 +109,6 @@ test.describe('Processes Panel', () => {
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
@@ -114,14 +118,10 @@ test.describe('Processes Panel', () => {
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click();
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
await page.screenshot({
path: testInfo.outputPath('process-view-clicked.png'),
fullPage: true,
});
});
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
test('lightbulb highlights nodes in graph', async ({ page }) => {
await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -137,13 +137,12 @@ test.describe('Processes Panel', () => {
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
await lightbulb.click();
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
});
});
test.describe('Turn Off All Highlights', () => {
test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
test('selecting a node dims others, button clears it', async ({ page }) => {
await waitForGraphLoaded(page);
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
@@ -160,6 +159,5 @@ test.describe('Turn Off All Highlights', () => {
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
timeout: 5_000,
});
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
});
});
+84 -49
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppStateProvider, useAppState } from './hooks/useAppState';
import { DropZone } from './components/DropZone';
import { LoadingOverlay } from './components/LoadingOverlay';
@@ -36,7 +36,6 @@ const AppContent = () => {
refreshLLMSettings,
initializeAgent,
startEmbeddingsWithFallback,
embeddingStatus,
codeReferences,
selectedNode,
isCodePanelOpen,
@@ -45,17 +44,25 @@ const AppContent = () => {
availableRepos,
setAvailableRepos,
switchRepo,
setCurrentRepo,
} = useAppState();
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const [serverDisconnected, setServerDisconnected] = useState(false);
const handleServerConnect = useCallback(
async (result: ConnectResult): Promise<void> => {
// Extract project name from repoPath
// Use the canonical repo name from the server response so all subsequent
// backend calls (queries, search, grep, readFile) scope to this repo.
const repoName = result.repoInfo.name;
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
const parts = (repoPath || '').split('/').filter((p) => p && !p.startsWith('.'));
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
// Normalize both Windows (\) and Unix (/) path separators before splitting
const projectName =
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(projectName);
setCurrentRepo(projectName);
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
@@ -67,6 +74,11 @@ const AppContent = () => {
}
setGraph(graph);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', projectName);
window.history.replaceState(null, '', urlObj.toString());
// Transition directly to exploring view
setViewMode('exploring');
@@ -80,20 +92,26 @@ const AppContent = () => {
console.warn('Failed to initialize agent:', err);
}
},
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
[
setViewMode,
setGraph,
setProjectName,
setCurrentRepo,
initializeAgent,
startEmbeddingsWithFallback,
],
);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('server')) return;
autoConnectRan.current = true;
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
// Clean the URL so a refresh won't re-trigger
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
if (!serverUrlParam && !projectParam) return;
autoConnectRan.current = true;
setProgress({
phase: 'extracting',
@@ -103,36 +121,45 @@ const AppContent = () => {
});
setViewMode('loading');
const serverUrl = params.get('server') || window.location.origin;
const serverUrl = serverUrlParam || window.location.origin;
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(serverUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
})
const tryConnect = async () => {
return await connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam || undefined,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
};
tryConnect()
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);
@@ -169,21 +196,18 @@ const AppContent = () => {
// ── Server heartbeat: detect when server goes down while exploring ────────
// Uses SSE (EventSource) for instant detection — no polling delay.
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
// The heartbeat retries indefinitely with capped backoff and recovers automatically.
useEffect(() => {
if (viewMode !== 'exploring') return;
const cleanup = connectHeartbeat(
() => {}, // onConnect — already connected, no action needed
() => {
// Server went down — return to onboarding
setViewMode('onboarding');
setGraph(null);
setProgress(null);
},
() => setServerDisconnected(false),
() => setServerDisconnected(true),
);
return cleanup;
}, [viewMode, setViewMode, setGraph, setProgress]);
}, [viewMode]);
// Render based on view mode
if (viewMode === 'onboarding') {
@@ -196,7 +220,12 @@ const AppContent = () => {
await handleServerConnect(result);
setProgress(null);
if (serverUrl) {
setServerBaseUrl(normalizeServerUrl(serverUrl));
const base = normalizeServerUrl(serverUrl);
setServerBaseUrl(base);
// Add ?server= so F5 reconnects to this server
const url = new URL(window.location.href);
url.searchParams.set('server', base);
window.history.replaceState(null, '', url.toString());
}
}}
/>
@@ -268,6 +297,12 @@ const AppContent = () => {
<StatusBar />
{serverDisconnected && (
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
Server connection lost — reconnecting&hellip;
</div>
)}
{/* Settings Panel (modal) */}
<SettingsPanel
isOpen={isSettingsPanelOpen}
@@ -54,6 +54,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
clearCodeReferences,
setSelectedNode,
codeReferenceFocus,
projectName,
} = useAppState();
const nodeById = useMemo(() => {
@@ -174,7 +175,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
return () => {
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [codeReferenceFocus?.ts, aiReferences]);
}, [codeReferenceFocus, aiReferences]);
const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => {
@@ -223,13 +224,14 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const isWholeFile = selectedIsFile || startLine === undefined;
const options = isWholeFile
? undefined
? { repo: projectName }
: {
startLine: Math.max(0, startLine - CONTEXT_LINES),
endLine: (endLine ?? startLine) + CONTEXT_LINES,
repo: projectName,
};
readFile(selectedFilePath, options)
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
.then((result) => {
if (!cancelled) {
setFileResult(result);
@@ -251,6 +253,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
selectedNode?.properties?.startLine,
selectedNode?.properties?.endLine,
selectedIsFile,
projectName,
]);
// Scroll to the selected node's startLine after content loads
@@ -201,7 +201,7 @@ interface OnboardingGuideProps {
}
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
const primary = isDev ? 'cd gitnexus && npm run serve' : 'npx gitnexus@latest serve';
const primary = isDev ? 'npm run --prefix gitnexus serve' : 'npx gitnexus@latest serve';
const termLabel = isDev ? 'Start backend' : 'Terminal';
// Step states: step 1 = copy command, step 2 = run/wait, step 3 = auto-connect
@@ -277,7 +277,9 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
state={step2State}
number={2}
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
description={isPolling ? undefined : 'Open a new terminal window, paste, and hit Enter.'}
description={
isPolling ? undefined : 'Open a terminal at the project root, paste, and hit Enter.'
}
>
{isPolling && <PollingBar />}
</StepRow>
+1 -1
View File
@@ -64,7 +64,7 @@ export const StatusBar = () => {
</a>
{/* Right - Stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3" data-testid="graph-stats">
{graph && (
<>
<span>{nodeCount} nodes</span>
+59 -22
View File
@@ -145,6 +145,7 @@ interface AppState {
availableRepos: BackendRepo[];
setAvailableRepos: (repos: BackendRepo[]) => void;
switchRepo: (repoName: string) => Promise<void>;
setCurrentRepo: (repoName: string) => void;
// Worker API (shared across app)
runQuery: (cypher: string) => Promise<any[]>;
@@ -456,6 +457,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Backend client — direct HTTP calls (no Worker/Comlink)
const repoRef = useRef<string | undefined>(undefined);
const setCurrentRepo = useCallback((repoName: string) => {
repoRef.current = repoName;
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
return backendRunQuery(cypher, repoRef.current);
}, []);
@@ -574,6 +579,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
try {
const effectiveProjectName = overrideProjectName || projectName || 'project';
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
// never sets repoRef.current directly — without this, queries default to repo[0].
if (overrideProjectName) {
repoRef.current = overrideProjectName;
}
const repo = repoRef.current;
// Build backend interface for Graph RAG tools
@@ -605,7 +617,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsAgentInitializing(false);
}
},
[projectName],
// eslint-disable-next-line react-hooks/exhaustive-deps
[], // repoRef is a stable ref — we sync it explicitly on entry; no state deps needed
);
const sendChatMessage = useCallback(
@@ -1037,6 +1050,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodePanelOpen(false);
setCodeReferenceFocus(null);
let connectedRepo: BackendRepo | undefined;
let pNameStr = repoName || 'server-project';
try {
const result: ConnectResult = await connectToServer(
serverBaseUrl,
@@ -1068,39 +1084,28 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
undefined,
repoName,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
// Build graph for visualization
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Prefer the registry name, then normalize Windows \ and Unix / paths
const pName =
repoName || result.repoInfo.name || repoPath?.split('/').pop() || 'server-project';
repoName ||
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(pName);
repoRef.current = pName;
connectedRepo = result.repoInfo;
pNameStr = pName;
const newGraph = createKnowledgeGraph();
for (const node of result.nodes) newGraph.addNode(node);
for (const rel of result.relationships) newGraph.addRelationship(rel);
setGraph(newGraph);
// No fileContents needed — grep/read tools use backend HTTP
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
await initializeAgent(pName);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
} catch (err) {
} catch (err: unknown) {
console.error('Repo switch failed:', err);
setProgress({
phase: 'error',
@@ -1114,6 +1119,36 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setViewMode('exploring');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
return; // Abort the whole switchRepo process
}
if (pNameStr) {
// Persist the selected project in the URL so a refresh re-opens it
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', pNameStr);
window.history.replaceState(null, '', urlObj.toString());
}
// Reset the agent and clear chat history so the AI starts fresh for the new repo
agentRef.current = null;
setIsAgentReady(false);
setChatMessages([]);
// Re-initialize agent with the new repo's graph context
try {
if (getActiveProviderConfig()) {
await initializeAgent(pNameStr);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
},
[
@@ -1133,6 +1168,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodeReferences,
setCodePanelOpen,
setCodeReferenceFocus,
setChatMessages,
],
);
@@ -1219,6 +1255,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
availableRepos,
setAvailableRepos,
switchRepo,
setCurrentRepo,
runQuery,
isDatabaseReady,
// Embedding state and methods
+115 -19
View File
@@ -222,7 +222,7 @@ export function normalizeServerUrl(input: string): string {
// ── Internal Helpers ───────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_TIMEOUT_MS = 30_000;
const PROBE_TIMEOUT_MS = 2_000;
const fetchWithTimeout = async (
@@ -264,11 +264,13 @@ const fetchWithTimeout = async (
const assertOk = async (response: Response): Promise<void> => {
if (response.ok) return;
let message = `Backend returned ${response.status} ${response.statusText}`;
let message = response.statusText;
try {
const body = await response.json();
if (body && typeof body.error === 'string') {
message = body.error;
} else if (body && typeof body.message === 'string') {
message = body.message;
}
} catch {
// Response body was not JSON
@@ -302,16 +304,26 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
};
/**
* Connect an SSE heartbeat to the backend. Fires `onDisconnect` when the
* server goes down (after one retry to avoid false positives from transient
* network hiccups). Returns a cleanup function.
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
* exponential backoff so transient hiccups don't reset the UI.
*
* - `onConnect` fires on every successful (re)connection.
* - `onReconnecting` fires on the first retry after a drop — use it to show
* a "reconnecting" banner while keeping the current view intact.
*
* Returns a cleanup function that tears down the EventSource and timers.
*/
export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void): (() => void) => {
export const connectHeartbeat = (
onConnect: () => void,
onReconnecting: () => void,
): (() => void) => {
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
let attempt = 0;
const MAX_RETRIES = 3;
/** Whether we've already fired onReconnecting for the current drop. */
let notifiedReconnecting = false;
const MAX_BACKOFF_MS = 15_000;
const connect = () => {
if (closed) return;
@@ -319,6 +331,7 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
es.onopen = () => {
if (!closed) {
attempt = 0;
notifiedReconnecting = false;
onConnect();
}
};
@@ -326,13 +339,15 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
es?.close();
es = null;
if (closed) return;
if (attempt < MAX_RETRIES) {
const delay = 1_000 * Math.pow(2, attempt);
attempt++;
retryTimer = setTimeout(connect, delay);
} else {
onDisconnect();
if (!notifiedReconnecting) {
notifiedReconnecting = true;
onReconnecting();
}
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
attempt++;
retryTimer = setTimeout(connect, delay);
};
};
@@ -373,10 +388,22 @@ export const fetchRepos = async (): Promise<BackendRepo[]> => {
return response.json() as Promise<BackendRepo[]>;
};
/** Fetch repo metadata. */
export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
/** Fetch repo metadata.
* Pass `awaitAnalysis: true` when connecting to a repo that may still be cloning/analyzing —
* this enables the backend's hold-queue and uses a 5-minute timeout to match.
* Normal calls (e.g. repo switching between already-indexed repos) use the default 10s timeout.
*
* Must stay in sync with HOLD_QUEUE_TIMEOUT_SECS in gitnexus/src/server/api.ts.
*/
const HOLD_QUEUE_TIMEOUT_MS = 300_000; // 5 minutes — matches backend HOLD_QUEUE_TIMEOUT_SECS
export const fetchRepoInfo = async (
repo?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<BackendRepo> => {
const url = `${_backendUrl}/api/repo${repo ? `?${repoParam(repo)}` : ''}`;
const response = await fetchWithTimeout(url);
const timeout = opts?.awaitAnalysis ? HOLD_QUEUE_TIMEOUT_MS : undefined;
const response = await fetchWithTimeout(url, {}, timeout);
await assertOk(response);
const data = await response.json();
return { ...data, repoPath: data.repoPath ?? data.path };
@@ -391,13 +418,19 @@ export const fetchGraph = async (
onProgress?: (downloaded: number, total: number | null) => void;
},
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '']
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '', 'stream=true']
.filter(Boolean)
.join('&');
const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`;
const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000);
// Large repos can take a while to serialize the graph — use an elevated timeout
const response = await fetchWithTimeout(url, { signal: opts?.signal }, 120_000);
await assertOk(response);
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('application/x-ndjson')) {
return parseNdjsonGraphResponse(response, opts?.onProgress);
}
if (!opts?.onProgress || !response.body) {
return response.json() as Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }>;
}
@@ -426,6 +459,66 @@ export const fetchGraph = async (
return JSON.parse(new TextDecoder().decode(combined));
};
const parseNdjsonGraphResponse = async (
response: Response,
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
if (!response.body) {
throw new BackendError('No response body', response.status, 'server');
}
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : null;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const nodes: GraphNode[] = [];
const relationships: GraphRelationship[] = [];
let buffer = '';
let downloaded = 0;
const parseLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
const record = JSON.parse(trimmed) as
| { type: 'node'; data: GraphNode }
| { type: 'relationship'; data: GraphRelationship }
| { type: 'error'; error: string };
if (record.type === 'node') {
nodes.push(record.data);
return;
}
if (record.type === 'relationship') {
relationships.push(record.data);
return;
}
if (record.type === 'error') {
throw new BackendError(record.error, response.status || 500, 'server');
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
downloaded += value.length;
onProgress?.(downloaded, total);
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
parseLine(line);
}
}
buffer += decoder.decode();
parseLine(buffer);
return { nodes, relationships };
};
/** Execute a Cypher query. Returns rows. */
export const runQuery = async (
cypher: string,
@@ -657,18 +750,21 @@ export interface ConnectResult {
/**
* Connect to a server: validate, fetch repo info, download graph.
* Content is NOT included (use readFile/grep for file access).
* Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing —
* this enables the backend hold-queue and a 5-minute fetch timeout.
*/
export async function connectToServer(
url: string,
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<ConnectResult> {
const baseUrl = normalizeServerUrl(url);
setBackendUrl(baseUrl);
onProgress?.('validating', 0, null);
const repoInfo = await fetchRepoInfo(repoName);
const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis });
onProgress?.('downloading', 0, null);
const { nodes, relationships } = await fetchGraph(repoName, {
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { connectHeartbeat } from '../../src/services/backend-client';
// Mock EventSource to simulate SSE behavior
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close() {
this.closed = true;
}
}
let lastEventSource: MockEventSource | null = null;
beforeEach(() => {
lastEventSource = null;
vi.stubGlobal(
'EventSource',
vi.fn().mockImplementation(() => {
lastEventSource = new MockEventSource();
return lastEventSource;
}),
);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('connectHeartbeat', () => {
it('calls onConnect when EventSource opens', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
expect(onReconnecting).not.toHaveBeenCalled();
});
it('calls onReconnecting on first error, then retries', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate connection drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
expect(lastEventSource!.closed).toBe(true);
// Advance past first retry delay (1s)
vi.advanceTimersByTime(1_000);
// A new EventSource should have been created
expect(EventSource).toHaveBeenCalledTimes(2);
});
it('fires onReconnecting only once per disconnect', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// First error
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Second retry fires error again
vi.advanceTimersByTime(1_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
// Third retry fires error
vi.advanceTimersByTime(2_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
});
it('retries indefinitely instead of giving up after 3 attempts', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate 10 consecutive failures — should never stop retrying
for (let i = 0; i < 10; i++) {
lastEventSource!.onerror!();
// Advance past the max backoff (15s) to ensure the next retry fires
vi.advanceTimersByTime(16_000);
}
// Should have created 11 EventSources (1 initial + 10 retries)
expect(EventSource).toHaveBeenCalledTimes(11);
});
it('resets reconnecting state when connection recovers', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Retry succeeds
vi.advanceTimersByTime(1_000);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
// Drop again — should fire onReconnecting again (reset after recovery)
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledTimes(2);
});
it('caps backoff at 15 seconds', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Fail many times to push backoff past the cap
for (let i = 0; i < 6; i++) {
lastEventSource!.onerror!();
// The delay for attempt i is min(1000 * 2^i, 15000)
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
vi.advanceTimersByTime(16_000);
}
// All retries should have fired — 7 EventSources total
expect(EventSource).toHaveBeenCalledTimes(7);
});
it('stops retrying when cleanup is called', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onerror!();
cleanup();
// Advance time — no new EventSource should be created
vi.advanceTimersByTime(30_000);
expect(EventSource).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizeServerUrl } from '../../src/services/backend-client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
@@ -31,3 +31,137 @@ describe('normalizeServerUrl', () => {
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com');
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchGraph', () => {
it('requests streamed graph responses from the backend', async () => {
setBackendUrl('http://localhost:4747');
const fetchMock = vi.fn().mockResolvedValue(
new Response('{"nodes":[],"relationships":[]}', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
);
vi.stubGlobal('fetch', fetchMock);
await fetchGraph('big-repo');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/graph?repo=big-repo&stream=true'),
expect.any(Object),
);
});
it('parses NDJSON graph streams incrementally', async () => {
setBackendUrl('http://localhost:4747');
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
[
'{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts","filePath":"src/app.ts"}}}\n',
'{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
].join(''),
),
);
controller.close();
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
},
}),
),
);
const progress = vi.fn();
const result = await fetchGraph('big-repo', { onProgress: progress });
expect(result.nodes).toHaveLength(1);
expect(result.relationships).toHaveLength(1);
expect(result.nodes[0].id).toBe('File:src/app.ts');
expect(result.relationships[0].type).toBe('CONTAINS');
expect(progress).toHaveBeenCalled();
});
it('parses NDJSON graph lines split across chunks', async () => {
setBackendUrl('http://localhost:4747');
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
'{"type":"node","data":{"id":"File:src/app.ts","label":"File","properties":{"name":"app.ts"',
),
);
controller.enqueue(
encoder.encode(
',"filePath":"src/app.ts"}}}\n{"type":"relationship","data":{"id":"File:src/app.ts_CONTAINS_Function:src/app.ts:main","type":"CONTAINS","sourceId":"File:src/app.ts","targetId":"Function:src/app.ts:main"}}\n',
),
);
controller.close();
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
},
}),
),
);
const result = await fetchGraph('big-repo');
expect(result.nodes).toHaveLength(1);
expect(result.relationships).toHaveLength(1);
expect(result.nodes[0].properties.filePath).toBe('src/app.ts');
});
it('throws backend errors emitted in the NDJSON stream', async () => {
setBackendUrl('http://localhost:4747');
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('{"type":"error","error":"stream failed"}\n'));
controller.close();
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
},
}),
),
);
await expect(fetchGraph('big-repo')).rejects.toMatchObject({
message: 'stream failed',
});
});
});
+98
View File
@@ -2,6 +2,104 @@
All notable changes to GitNexus will be documented in this file.
## [1.6.0] - 2026-04-12
### Added
- **SemanticModel architecture refactor (SM-8 through SM-19)** — extracted registries into `model/` module with ISP-compliant interfaces: TypeRegistry, MethodRegistry, FieldRegistry, RegistrationTable, ResolutionContext (#786)
- HeritageMap built from accumulated `ExtractedHeritage[]` for MRO-aware resolution (#739)
- `lookupMethodByOwnerWithMRO` using HeritageMap for cross-class method dispatch (#740)
- MRO fast path before D2 fuzzy widening in call resolution (#741)
- BindingAccumulator for cross-file return type propagation (#743, #763)
- Restructured `resolveUncached` replacing `lookupFuzzy` data source for all tiers (#764)
- Deleted `lookupFuzzy`, `lookupFuzzyCallable`, `globalIndex`, `callableIndex` — replaced with structured lookups (#769)
- Deleted `resolveCallTarget` god-method — replaced with thin dispatcher delegating to `resolveMemberCall` (#744), `resolveStaticCall` (#754), `resolveFreeCall` (#756) (#770)
- **Service group infrastructure** — service boundary detection, contract extractors, sync pipeline, CLI/MCP tools, monorepo fixture; bridge.lbug storage and contract matching expansion (#795)
- **C# interface-to-interface heritage** capture (#789)
- **Vue SFC support** with destructured call result tracking (#604)
- **Java method reference** resolution — `obj::method` as call sites (#622)
- **C/C++ MethodExtractor** config with pure virtual detection (#617)
- **MethodExtractor configs** for Python, PHP, Swift, Dart, Rust, Ruby (#624)
- **METHOD_IMPLEMENTS edges** with overload disambiguation and MethodExtractor unification (#642)
- **Same-arity overload disambiguation** via type-hash suffix (#658)
- **`GITNEXUS_HOME` env var** to customize global directory (#746)
- **Verbose analyze output** prints skipped large file paths (#745)
- **Class name lookup index** for O(1) qualified lookups (#707, #716)
- **`lookupMethodByOwner` index** for O(1) cross-class chain resolution (#665)
- **Fuzzy lookup counters** for performance visibility (#708)
### Fixed
- **Stack overflow on large PHP files** — iterative AST traversal (#783)
- **Large repository graph loading** failure (#732)
- **Windows multi-repo switching** — false 404 errors and stale repo context (#633)
- **`detect_changes` diff mapping** — map diff hunks to symbol line ranges (#779)
- **HTTP client vs Express route detection** and Spring interface attribution (#780)
- **VECTOR extension** not loaded during DB init for semantic search (#782)
- **tree-sitter-swift** postinstall patch for macOS ARM64 (#788)
- **tree-sitter-c** peer dependency conflict pinned (#723)
- **Constructor indexing** in methodByOwner (#694, #753)
- **Named binding processor** — `lookupExact` replaced with `lookupExactAll` (#755)
- **`.gitnexusignore` negation patterns** now respected (#654)
- **MCP setup** prefers global gitnexus binary over npx (#653)
- **CORS rejection** returns clean error instead of 500 (#646)
- **Array.push stack overflow** — replaced spread with loop (#650)
- **MCP stdout silencing** prevents embedder/pool-adapter conflicts (#645)
- **Web heartbeat** — graceful reconnection replaces aggressive disconnect (#643)
- **Web repo scoping** — backend calls scoped to active repo (#644)
- **OpenCode config path** and FTS extension load order (#781)
- **OnboardingGuide** dev-mode serve command corrected (#725)
- **Security issues** and critical bugs from code review (#709)
### Changed
- Replaced class-type fuzzy lookups with structured indices in type-env (#733, #734, #736)
- Extracted `CLASS_LIKE_TYPES` constant (#693)
## [1.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor** config (#588)
### Fixed
- **Wiki Azure OpenAI** compat and HTML viewer script injection (#618)
## [1.5.2] - 2026-04-01
### Fixed
- **`gitnexus-shared` module not found** — `gitnexus-shared` was a `file:` workspace dependency never published to npm, causing `ERR_MODULE_NOT_FOUND` when installing `gitnexus` globally. The build now bundles shared code into `dist/_shared/` and rewrites imports to relative paths (#613)
- **v1.5.1 publish regression** — npm's `prepare` lifecycle ran `tsc` after `prepack`, overwriting the rewritten imports before packing; both scripts now run the full build so the final tarball is always correct
## [1.5.1] - 2026-04-01 [YANKED]
### Fixed
- Incomplete fix for `gitnexus-shared` bundling — `prepare` script overwrote rewritten imports during publish
## [1.5.0] - 2026-04-01
### Added
- **Repo landing screen** — when the backend detects indexed repositories, the web UI now shows a landing page with selectable repo cards (name, stats, indexed date) instead of auto-loading the first repo; users can also analyze new repos directly from the landing screen (#607)
- **Unified web & CLI ingestion pipeline** — complete architectural migration of the web app from a self-contained WASM browser app to a thin client backed by the CLI server; new `gitnexus-shared` package for cross-package type unification (#536)
- New server endpoints: `/api/heartbeat` (SSE liveness), `/api/info`, `/api/repos`, `/api/file`, `/api/grep`, `/api/analyze` (SSE progress), `/api/embed`, `/api/mcp` (MCP-over-StreamableHTTP)
- Onboarding flow: auto-detect server → connect → repo landing or analyze
- Header repo dropdown: switch, re-analyze, or delete repos
- **Azure OpenAI support for wiki command** — fixed broken Azure auth (`api-key` header), `api-version` URL parameter, reasoning model handling (`max_completion_tokens`, no `temperature`), content filter error messages; added interactive setup wizard, `--api-version` and `--reasoning-model` CLI flags (#562)
- **Java method references & interface dispatch** — `obj::method` treated as call sites, overload selection via typed variable args (not just literals), interface dispatch emits additional CALLS edges to implementing classes (#540)
- **MethodExtractor abstraction** — structured method metadata extraction (isAbstract, isFinal, annotations, visibility, parameter types) with config-driven factory pattern (#576)
- Java and Kotlin configs with overload-safe `methodInfoCache` keyed by `name:line`
- C# config with `sealed`, `params`/`out`/`ref`/optional parameters, `[Attribute]` syntax, `internal` visibility (#582)
- **`--skip-agents-md` CLI flag** — opt out of overwriting GitNexus-managed sections in AGENTS.md and CLAUDE.md during `gitnexus analyze` (#517)
- **Prettier** — monorepo-wide code formatter with lint-staged + Husky pre-commit hook, `.prettierrc` config, Tailwind CSS v4 plugin, `endOfLine: "lf"` + `.gitattributes` for Windows consistency (#563)
- **ESLint v9** — flat config with `unused-imports` auto-removal, `@typescript-eslint` rules, React hooks rules, CI `lint` job (#564)
### Fixed
- **OpenCode MCP configuration** — corrected README MCP setup for OpenCode which requires `command` as an array containing both executable and arguments (#363)
- **litellm security** — excluded vulnerable versions 1.82.7 and 1.82.8 in eval harness `pyproject.toml` (#580)
### Changed
- **Reduced explicit `any` types** — 128 `no-explicit-any` warnings eliminated (689 → 561, 19% reduction) across `NodeProperties` index signature, ~80 `SyntaxNode` substitutions, typed worker protocol, and graphology community detection (#566)
### Docs
- Added `gitnexus-shared` build step to web UI quick start instructions (#585)
- Added enterprise offering section to README (#579)
## [1.4.10] - 2026-03-27
### Fixed
+10
View File
@@ -164,6 +164,16 @@ gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
## Remote Embeddings
+40 -2
View File
@@ -1,17 +1,19 @@
{
"name": "gitnexus",
"version": "1.4.10",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.4.10",
"version": "1.6.0",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"cors": "^2.8.5",
@@ -22,6 +24,7 @@
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"onnxruntime-node": "^1.24.0",
@@ -47,9 +50,11 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
@@ -65,6 +70,7 @@
},
"../gitnexus-shared": {
"version": "1.0.0",
"dev": true,
"devDependencies": {
"typescript": "^6.0.2"
}
@@ -1875,6 +1881,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1992,6 +2005,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2285,6 +2305,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -3566,6 +3592,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+12 -6
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.5.0",
"version": "1.6.0",
"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",
@@ -38,7 +38,7 @@
"vendor"
],
"scripts": {
"build": "tsc",
"build": "node scripts/build.js",
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
@@ -46,23 +46,26 @@
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"prepack": "npm run build && chmod +x dist/cli/index.js"
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
},
"dependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"gitnexus-shared": "file:../gitnexus-shared",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"onnxruntime-node": "^1.24.0",
@@ -87,9 +90,11 @@
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
@@ -100,7 +105,8 @@
"overrides": {
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
}
},
"tree-sitter-c": "0.23.2"
},
"engines": {
"node": ">=20.0.0"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Build script that compiles gitnexus and inlines gitnexus-shared into the dist.
*
* Steps:
* 1. Build gitnexus-shared (tsc)
* 2. Build gitnexus (tsc)
* 3. Copy gitnexus-shared/dist → dist/_shared
* 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
const DIST = path.join(ROOT, 'dist');
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' });
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
fs.cpSync(path.join(SHARED_ROOT, 'dist'), SHARED_DEST, { recursive: true });
// ── 4. Rewrite imports ─────────────────────────────────────────────
console.log('[build] rewriting gitnexus-shared imports…');
let rewritten = 0;
function rewriteFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.includes('gitnexus-shared')) return;
const relDir = path.relative(path.dirname(filePath), SHARED_DEST);
// Always use posix separators and point to the package index
const relImport = relDir.split(path.sep).join('/') + '/index.js';
const updated = content
.replace(/from\s+['"]gitnexus-shared['"]/g, `from '${relImport}'`)
.replace(/import\(\s*['"]gitnexus-shared['"]\s*\)/g, `import('${relImport}')`);
if (updated !== content) {
fs.writeFileSync(filePath, updated);
rewritten++;
}
}
function walk(dir, extensions, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, extensions, cb);
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
cb(full);
}
}
}
walk(DIST, ['.js', '.d.ts'], rewriteFile);
// ── 5. Make CLI entry executable ────────────────────────────────────
const cliEntry = path.join(DIST, 'cli', 'index.js');
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
console.log(`[build] done — rewrote ${rewritten} files.`);
@@ -0,0 +1,78 @@
#!/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',
);
}
+38 -3
View File
@@ -26,6 +26,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@@ -42,10 +43,29 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
* - Exact tool commands with parameters — vague directives get ignored
* - Self-review checklist — forces model to verify its own work
*/
async function findGroupsContainingRegistryName(registryName: string): Promise<string[]> {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const names = await listGroups();
const hits: string[] = [];
for (const g of names) {
try {
const config = await loadGroupConfig(getGroupDir(getDefaultGitnexusDir(), g));
if (Object.values(config.repos).some((r) => r === registryName)) hits.push(config.name);
} catch {
// skip invalid or unreadable groups
}
}
return hits;
}
function generateGitNexusContent(
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@@ -69,7 +89,7 @@ function generateGitNexusContent(
return `${GITNEXUS_START_MARKER}
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
@@ -155,7 +175,15 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
## CLI
${
groupNames && groupNames.length > 0
? `## Cross-Repo Groups
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
`
: ''
}## CLI
${skillsTable}
@@ -305,7 +333,14 @@ export async function generateAIContextFiles(
generatedSkills?: GeneratedSkillInfo[],
options?: AIContextOptions,
): Promise<{ files: string[] }> {
const content = generateGitNexusContent(projectName, stats, generatedSkills);
const groupNames = await findGroupsContainingRegistryName(projectName);
const content = generateGitNexusContent(
projectName,
stats,
generatedSkills,
groupNames,
options?.noStats,
);
const createdFiles: string[] = [];
if (!options?.skipAgentsMd) {
+4 -1
View File
@@ -47,6 +47,8 @@ export interface AnalyzeOptions {
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
}
@@ -177,6 +179,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddings: options?.embeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
noStats: options?.noStats,
},
{
onProgress: (_phase, percent, message) => {
@@ -240,7 +243,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
{ skipAgentsMd: options?.skipAgentsMd },
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
);
}
} catch {
+298
View File
@@ -0,0 +1,298 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
export function registerGroupCommands(program: Command): void {
const group = program
.command('group')
.description('Manage repository groups for cross-index impact analysis');
group
.command('create <name>')
.description('Create a new group with template group.yaml')
.option('--force', 'Overwrite existing group')
.action(async (name: string, opts: { force?: boolean }) => {
const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force);
console.log(`Created group "${name}" at ${dir}`);
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
});
group
.command('add <group> <groupPath> <registryName>')
.description(
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = name from registry',
)
.action(async (groupName: string, groupPath: string, registryName: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
config.repos[groupPath] = registryName;
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`);
console.log(`Run: gitnexus group sync ${groupName}`);
});
group
.command('remove <group> <path>')
.description('Remove a repo from a group')
.action(async (groupName: string, repoPath: string) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const path = await import('node:path');
const fs = await import('node:fs/promises');
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
if (!(repoPath in config.repos)) {
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
process.exitCode = 1;
return;
}
delete config.repos[repoPath];
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
console.log(`Removed "${repoPath}" from group "${groupName}"`);
});
group
.command('list [name]')
.description('List all groups or details of one')
.action(async (name?: string) => {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
await import('../core/group/storage.js');
if (!name) {
const groups = await listGroups();
if (groups.length === 0) {
console.log('No groups configured. Create one with: gitnexus group create <name>');
return;
}
console.log('Groups:');
groups.forEach((g) => console.log(` ${g}`));
return;
}
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Group: ${config.name}`);
if (config.description) console.log(`Description: ${config.description}`);
console.log(`\nRepos (${Object.keys(config.repos).length}):`);
for (const [p, id] of Object.entries(config.repos)) {
console.log(` ${p} -> ${id}`);
}
if (config.links.length > 0) {
console.log(`\nManifest links (${config.links.length}):`);
for (const link of config.links) {
console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`);
}
}
});
group
.command('status <name>')
.description('Check staleness of group and repos')
.action(async (name: string) => {
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
await import('../core/group/storage.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
console.log(
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
);
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupStatus({ name });
const st = raw as {
repos?: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
>;
missingRepos?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
continue;
}
const idx = row.indexStale
? `STALE (${row.commitsBehind ?? '?'} commits behind)`
: 'OK ';
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
} finally {
await backend.dispose().catch(() => {});
}
});
group
.command('sync <name>')
.description('Sync Contract Registry — extract contracts and build cross-links')
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
.option('--exact-only', 'Exact match only')
.option('--allow-stale', 'Skip stale index warnings')
.option('--verbose', 'Show each cross-link detail')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
});
group
.command('query <name> <query>')
.description('Search execution flows across all repos in a group')
.option('--subgroup <path>', 'Limit search scope')
.option('--limit <n>', 'Max merged results', '5')
.option('--json', 'JSON output')
.action(
async (
name: string,
queryText: string,
opts: Record<string, string | boolean | undefined>,
) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const limit = parseInt(String(opts.limit ?? '5'), 10) || 5;
const subgroup = opts.subgroup as string | undefined;
const backend = new LocalBackend();
try {
await backend.init();
console.log(`Searching "${queryText}" across group "${name}"...\n`);
const raw = await backend.getGroupService().groupQuery({
name,
query: queryText,
limit,
subgroup,
});
const merged = raw as {
results: Array<Record<string, unknown>>;
per_repo: Array<{ repo: string; count: number }>;
};
if (opts.json) {
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Results (top ${merged.results.length}):\n`);
for (const p of merged.results) {
const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string;
console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`);
}
if (merged.results.length === 0) {
console.log(' No matching execution flows found.');
}
}
} finally {
await backend.dispose().catch(() => {});
}
},
);
group
.command('contracts <name>')
.description('Inspect Contract Registry')
.option('--type <type>', 'Filter by contract type')
.option('--repo <repo>', 'Filter by repo')
.option('--unmatched', 'Show only unmatched contracts')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const backend = new LocalBackend();
try {
await backend.init();
const raw = await backend.getGroupService().groupContracts({
name,
type: opts.type as string | undefined,
repo: opts.repo as string | undefined,
unmatchedOnly: Boolean(opts.unmatched),
});
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
const { contracts, crossLinks } = raw as {
contracts: Array<{
role: string;
contractId: string;
repo: string;
symbolRef: { name: string };
}>;
crossLinks: Array<{
from: { repo: string };
to: { repo: string };
matchType: string;
confidence: number;
contractId: string;
}>;
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`);
}
console.log(`\nCross-links (${crossLinks.length}):`);
for (const l of crossLinks) {
console.log(
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
}
} finally {
await backend.dispose().catch(() => {});
}
});
}
+4
View File
@@ -6,6 +6,7 @@
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction } from './lazy-action.js';
import { registerGroupCommands } from './group.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@@ -25,6 +26,7 @@ program
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.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('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.addHelpText(
@@ -148,4 +150,6 @@ program
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
registerGroupCommands(program);
program.parse(process.argv);
+4 -1
View File
@@ -14,7 +14,10 @@ process.on('unhandledRejection', (reason: any) => {
export const serveCommand = async (options?: { port?: string; host?: string }) => {
const port = Number(options?.port ?? 4747);
const host = options?.host ?? '127.0.0.1';
// Default to 'localhost' so the OS decides whether to bind to 127.0.0.1 or
// ::1 based on system configuration, avoiding spurious CORS errors when the
// hosted frontend at gitnexus.vercel.app connects to localhost.
const host = options?.host ?? 'localhost';
try {
await createServer(port, host);
+36 -3
View File
@@ -9,7 +9,7 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { execFile } from 'child_process';
import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
@@ -25,11 +25,44 @@ interface SetupResult {
errors: string[];
}
/**
* Resolve the absolute path to the `gitnexus` binary if it's installed
* globally (or via npm -g / yarn global). Returns null when not found.
*/
function resolveGitnexusBin(): string | null {
try {
const cmd = process.platform === 'win32' ? 'where' : 'which';
const resolved = execFileSync(cmd, ['gitnexus'], {
encoding: 'utf-8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
})
.split('\n')[0]
.trim();
return resolved || null;
} catch {
return null;
}
}
/**
* The MCP server entry for all editors.
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
*
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
* `npx -y gitnexus@latest` (cold-cache install of native deps can take
* >60 s, exceeding Claude Code's 30 s MCP connection timeout).
*
* Falls back to npx when the binary isn't on PATH — e.g. first-time
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
*/
function getMcpEntry() {
const bin = resolveGitnexusBin();
if (bin) {
return { command: bin, args: ['mcp'] };
}
// Fallback: npx (works without a global install, but slow cold-start)
if (process.platform === 'win32') {
return {
command: 'cmd',
@@ -232,7 +265,7 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
return;
}
const configPath = path.join(opencodeDir, 'config.json');
const configPath = path.join(opencodeDir, 'opencode.json');
try {
const existing = await readJsonFile(configPath);
const config = existing || {};
+18 -58
View File
@@ -232,65 +232,28 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
} else if (choice === '3') {
// Azure OpenAI guided setup
console.log('\n Azure OpenAI setup.');
console.log(
' You need: your resource name, deployment name, and API key from the Azure portal.\n',
);
// Azure OpenAI guided setup — minimal prompts
console.log('\n Azure OpenAI setup.\n');
const resourceName = (
await prompt(' Azure resource name (e.g. my-openai-resource): ')
).trim();
if (!resourceName) {
console.log('\n No resource name provided. Aborting.\n');
const endpoint = (
await prompt(' Endpoint URL (e.g. https://my-resource.openai.azure.com): ')
)
.trim()
.replace(/\/+$/, '');
if (!endpoint) {
console.log('\n No endpoint provided. Aborting.\n');
process.exitCode = 1;
return;
}
const deploymentName = (
await prompt(' Deployment name (the name you gave your model deployment): ')
).trim();
const deploymentName = (await prompt(' Deployment name: ')).trim();
if (!deploymentName) {
console.log('\n No deployment name provided. Aborting.\n');
process.exitCode = 1;
return;
}
// Offer v1 or legacy URL
console.log('\n API format:');
console.log(' [1] v1 API — recommended (no api-version needed)');
console.log(' [2] Legacy — uses api-version query param\n');
const apiFormat = await prompt(' Select format (1/2, default: 1): ');
let azureApiVersion: string | undefined;
let azureBaseUrl: string;
if (apiFormat === '2') {
const versionInput = await prompt(' api-version (default: 2024-10-21): ');
azureApiVersion = versionInput || '2024-10-21';
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/deployments/${deploymentName}`;
} else {
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/v1`;
azureApiVersion = undefined;
}
defaultModel = deploymentName;
// Ask if this is a reasoning model deployment
const reasoningAnswer = await prompt(
' Is this a reasoning model (o1, o3, o4-mini)? (y/N): ',
);
const isReasoningModelDeployment = ['y', 'yes'].includes(reasoningAnswer.toLowerCase());
if (isReasoningModelDeployment) {
console.log(
' Note: temperature and max_tokens will be omitted for this deployment (Azure reasoning model requirement).\n',
);
}
const modelInput = await prompt(` Model / deployment name (default: ${defaultModel}): `);
const model = modelInput || defaultModel;
// API key
// API key — use env var if available
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
let azureKey: string;
if (envKey) {
@@ -311,26 +274,23 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
return;
}
// Save Azure config including optional apiVersion and isReasoningModel
const azureConfig: Parameters<typeof saveCLIConfig>[0] = {
// Always use v1 API format — no need for api-version
const azureBaseUrl = `${endpoint}/openai/v1`;
await saveCLIConfig({
apiKey: azureKey,
baseUrl: azureBaseUrl,
model,
model: deploymentName,
provider: 'azure',
isReasoningModel: isReasoningModelDeployment,
};
if (azureApiVersion) azureConfig.apiVersion = azureApiVersion;
await saveCLIConfig(azureConfig);
});
console.log(' Config saved to ~/.gitnexus/config.json\n');
llmConfig = {
...llmConfig,
apiKey: azureKey,
baseUrl: azureBaseUrl,
model,
model: deploymentName,
provider: 'azure',
apiVersion: azureApiVersion,
isReasoningModel: isReasoningModelDeployment,
};
} else {
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
+8 -3
View File
@@ -398,11 +398,16 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
// defense-in-depth — do not remove `dot: false` assuming this covers it.
if (DEFAULT_IGNORE_LIST.has(p.name)) return true;
// Check against .gitignore / .gitnexusignore patterns.
// Test both bare path and path with trailing slash to handle
// bare-name patterns (e.g. `local`) and dir-only patterns (e.g. `local/`).
// Since childrenIgnored is only called for directories, always test with
// a trailing slash. This ensures directory-only negation patterns (e.g.
// `!iOS/`) are applied correctly — without the slash, `ig.ignores('iOS')`
// treats the path as a file and misses the negation.
// Bare-name patterns (e.g. `local`) still match `local/` per gitignore spec:
// the `ignore` package normalizes `dir` and `dir/` to match directories.
// See: https://github.com/kaelzhang/node-ignore#2-filenames-and-dirnames
if (ig) {
const rel = p.relative();
if (rel && (ig.ignores(rel) || ig.ignores(rel + '/'))) return true;
if (rel && ig.ignores(rel + '/')) return true;
}
return false;
},
+1 -1
View File
@@ -93,7 +93,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
if (!repo) return '';
// Lazy-load lbug adapter (skip unnecessary init)
const { initLbug, executeQuery, isLbugReady } = await import('../../mcp/core/lbug-adapter.js');
const { initLbug, executeQuery, isLbugReady } = await import('../lbug/pool-adapter.js');
const { searchFTSFromLbug } = await import('../search/bm25-index.js');
const repoId = repo.name.toLowerCase();
+39
View File
@@ -0,0 +1,39 @@
/**
* Git working tree vs index commit staleness (used by MCP resources, group status, etc.).
* Lives in core/ so application code does not depend on the MCP package layer.
*/
import { execFileSync } from 'node:child_process';
export interface StalenessInfo {
isStale: boolean;
commitsBehind: number;
hint?: string;
}
/**
* Check how many commits the index is behind HEAD (synchronous; uses git CLI).
*/
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
const commitsBehind = parseInt(result, 10) || 0;
if (commitsBehind > 0) {
return {
isStale: true,
commitsBehind,
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
};
}
return { isStale: false, commitsBehind: 0 };
} catch {
return { isStale: false, commitsBehind: 0 };
}
}
+588
View File
@@ -0,0 +1,588 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
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 { dedupeContracts, dedupeCrossLinks } from './normalization.js';
export function contractNodeId(
repo: string,
contractId: string,
role: string,
filePath: string,
): string {
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
}
/* ------------------------------------------------------------------ */
/* ContractLookupIndex — in-memory lookup for findContractNode */
/* ------------------------------------------------------------------ */
/**
* In-memory index of contract node IDs keyed three ways, mirroring the
* three-tier fallback lookup in {@link findContractNode}. Built once per
* `writeBridge` call after all contracts are successfully inserted, then
* consulted for every cross-link — which eliminates the former N+1 query
* pattern (up to `6 × cross-links` DB round-trips) and turns cross-link
* resolution into constant-time per link.
*
* Keys are deliberately flat strings (not tuples) so `Map<string, ...>`
* works; the separator `\0` can't occur in any legal repo path / file
* path / symbol identifier, which makes the encoding injection-safe.
*/
export interface ContractLookupIndex {
/** tier 1: `repo + role + symbolUid` → contract node id */
byUid: Map<string, string>;
/** tier 2: `repo + role + filePath + symbolName` → contract node id */
byRef: Map<string, string>;
/** tier 3: `repo + role + filePath` → list of contract node ids in that file */
byFile: Map<string, string[]>;
}
export function createContractLookupIndex(): ContractLookupIndex {
return {
byUid: new Map(),
byRef: new Map(),
byFile: new Map(),
};
}
function uidKey(repo: string, role: string, symbolUid: string): string {
return `${repo}\0${role}\0${symbolUid}`;
}
function refKey(repo: string, role: string, filePath: string, symbolName: string): string {
return `${repo}\0${role}\0${filePath}\0${symbolName}`;
}
function fileKey(repo: string, role: string, filePath: string): string {
return `${repo}\0${role}\0${filePath}`;
}
/**
* Add a successfully-inserted contract to the lookup index. Must be called
* AFTER the DB insert succeeds (not before) so failed inserts don't poison
* the index and cause cross-links to point at non-existent rows.
*/
export function indexContract(
index: ContractLookupIndex,
contract: StoredContract,
nodeId: string,
): void {
if (contract.symbolUid) {
index.byUid.set(uidKey(contract.repo, contract.role, contract.symbolUid), nodeId);
}
index.byRef.set(
refKey(contract.repo, contract.role, contract.symbolRef.filePath, contract.symbolRef.name),
nodeId,
);
const fk = fileKey(contract.repo, contract.role, contract.symbolRef.filePath);
const existing = index.byFile.get(fk);
if (existing) {
existing.push(nodeId);
} else {
index.byFile.set(fk, [nodeId]);
}
}
/**
* Resolve a cross-link endpoint (consumer or provider reference) to an
* already-inserted contract node id. Returns `null` if no match — the
* caller is expected to count that as a dropped link in `WriteBridgeReport`.
*
* The resolution order matches the pre-cache DB-query behavior:
* 1. exact `symbolUid` match in the same `(repo, role)` scope
* 2. exact `(filePath, symbolName)` match
* 3. if exactly one contract lives in the file → that one (fallback for
* legacy graph-assisted extractors that couldn't resolve a symbol name)
*
* This is a pure function — no I/O, no DB — so it's trivial to unit-test
* in isolation (which was the reviewer's main clean-code concern on the
* original 35-line inner closure in `writeBridge`).
*/
export function findContractNode(
index: ContractLookupIndex,
repo: string,
role: 'consumer' | 'provider',
symbolUid: string,
filePath: string,
symbolName: string,
): string | null {
if (symbolUid) {
const uidHit = index.byUid.get(uidKey(repo, role, symbolUid));
if (uidHit !== undefined) return uidHit;
}
const refHit = index.byRef.get(refKey(repo, role, filePath, symbolName));
if (refHit !== undefined) return refHit;
const fileCandidates = index.byFile.get(fileKey(repo, role, filePath));
if (fileCandidates && fileCandidates.length === 1) return fileCandidates[0];
return null;
}
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);
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
}
/**
* LadybugDB returns an error whose message contains this substring when a
* CREATE NODE TABLE or CREATE REL TABLE statement hits an already-existing
* table. LadybugDB DDL doesn't support IF NOT EXISTS, and its JS driver
* doesn't expose typed error codes, so we match on the message substring —
* the same pattern used by `core/lbug/lbug-adapter.ts`. If a future
* LadybugDB release changes the wording, update this constant.
*/
const LBUG_ALREADY_EXISTS_MSG = 'already exists';
export async function ensureBridgeSchema(handle: BridgeHandle): Promise<void> {
const conn = handle._conn as lbug.Connection;
for (const q of BRIDGE_SCHEMA_QUERIES) {
try {
await conn.query(q);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes(LBUG_ALREADY_EXISTS_MSG)) throw err;
}
}
}
export async function queryBridge<T>(
handle: BridgeHandle,
cypher: string,
params?: Record<string, LbugValue>,
): Promise<T[]> {
const conn = handle._conn as lbug.Connection;
if (params && Object.keys(params).length > 0) {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Bridge query prepare failed: ${errMsg}`);
}
const queryResult = await conn.execute(stmt, params);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
const queryResult = await conn.query(cypher);
const result = unwrapQueryResult(queryResult);
return (await result.getAll()) as T[];
}
/**
* LadybugDB's `conn.query` / `conn.execute` can return either a single
* `QueryResult` (for a single statement) or an array of them (when a
* multi-statement script is dispatched). We always pass a single statement,
* so the array form is a wrapper we unwrap here — but an empty top-level
* array would cause `.getAll()` on `undefined` and crash with a confusing
* stack. Throwing an explicit error makes a driver-contract regression
* visible immediately instead of masking it.
*/
function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]): lbug.QueryResult {
if (Array.isArray(queryResult)) {
if (queryResult.length === 0) {
throw new Error('Bridge query returned an empty QueryResult array');
}
return queryResult[0];
}
return queryResult;
}
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
try {
await (handle._conn as lbug.Connection).close();
} catch {
/* ignore */
}
try {
await (handle._db as lbug.Database).close();
} catch {
/* ignore */
}
}
/* ------------------------------------------------------------------ */
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
/* ------------------------------------------------------------------ */
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
export async function retryRename(src: string, dst: string, attempts = 3): Promise<void> {
for (let i = 1; i <= attempts; i++) {
try {
await fsp.rename(src, dst);
return;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (!code || !RETRY_CODES.has(code) || i === attempts) throw err;
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1)));
}
}
}
/* ------------------------------------------------------------------ */
/* writeBridgeMeta / readBridgeMeta */
/* ------------------------------------------------------------------ */
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
const target = path.join(groupDir, 'meta.json');
const tmp = `${target}.tmp.${Date.now()}`;
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
// Use retryRename for consistency with writeBridge's atomic swap — on
// Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
// meta.json, and we don't want meta write to be less robust than the
// bridge.lbug swap it accompanies.
await retryRename(tmp, target);
}
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
try {
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
return JSON.parse(content) as BridgeMeta;
} catch {
return { version: 0, generatedAt: '', missingRepos: [] };
}
}
/* ------------------------------------------------------------------ */
/* writeBridge — atomic write-to-temp-then-rename */
/* ------------------------------------------------------------------ */
export interface WriteBridgeInput {
contracts: StoredContract[];
crossLinks: CrossLink[];
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
}
/**
* Non-fatal issues encountered during writeBridge. Callers can log these to
* surface partial-success state without aborting the whole sync.
* `sampleErrors` is capped at MAX_SAMPLE_ERRORS per category to bound memory.
*/
export interface WriteBridgeReport {
contractsInserted: number;
contractsFailed: number;
snapshotsInserted: number;
snapshotsFailed: number;
linksInserted: number;
linksFailed: number;
/** Cross-links skipped because their from/to contract nodes weren't found. */
linksDroppedMissingNode: number;
sampleErrors: Array<{
kind: 'contract' | 'snapshot' | 'link';
id: string;
message: string;
}>;
}
const MAX_SAMPLE_ERRORS = 10;
function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
try {
return String(err);
} catch {
return 'unknown error';
}
}
export async function writeBridge(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
await fsp.mkdir(groupDir, { recursive: true });
const contracts = dedupeContracts(input.contracts);
const crossLinks = dedupeCrossLinks(input.crossLinks);
const finalPath = path.join(groupDir, 'bridge.lbug');
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
const report: WriteBridgeReport = {
contractsInserted: 0,
contractsFailed: 0,
snapshotsInserted: 0,
snapshotsFailed: 0,
linksInserted: 0,
linksFailed: 0,
linksDroppedMissingNode: 0,
sampleErrors: [],
};
const recordError = (kind: 'contract' | 'snapshot' | 'link', id: string, err: unknown) => {
if (report.sampleErrors.length < MAX_SAMPLE_ERRORS) {
report.sampleErrors.push({ kind, id, message: errMessage(err) });
}
};
// Clean up any leftover tmp
try {
await fsp.rm(tmpPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 1. Create temp DB, insert all data.
//
// Everything after `openBridgeDb` must run inside a try/finally so that
// if ANY step before the explicit `closeBridgeDb` throws — schema
// creation, a contract insert loop that rethrows, a snapshot write, the
// cross-link loop, or anything else — the handle is still released. A
// leaked handle holds the native LadybugDB file lock on tmpPath, which
// (a) leaks a FD and (b) prevents the next writeBridge call from
// reusing the same tmp slot.
const handle = await openBridgeDb(tmpPath);
let handleClosed = false;
try {
await ensureBridgeSchema(handle);
// Build the lookup index incrementally as contracts are inserted, so
// failed inserts are never in the index (and therefore never resolved
// by the cross-link loop below). This replaces a previous N+1 query
// pattern where each link made up to 6 DB round-trips to find its
// endpoints — see ContractLookupIndex.
const lookupIndex = createContractLookupIndex();
// Insert contracts — tolerate individual failures (e.g., a corrupt meta
// that can't be serialized). The whole sync must not fail because one
// contract is broken.
for (const c of contracts) {
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
try {
await queryBridge(
handle,
`CREATE (n:Contract {
id: $id,
contractId: $contractId,
type: $type,
role: $role,
repo: $repo,
service: $service,
symbolUid: $symbolUid,
filePath: $filePath,
symbolName: $symbolName,
confidence: $confidence,
meta: $meta
})`,
{
id,
contractId: c.contractId,
type: c.type,
role: c.role,
repo: c.repo,
service: c.service ?? '',
symbolUid: c.symbolUid,
filePath: c.symbolRef.filePath,
symbolName: c.symbolName,
confidence: c.confidence,
meta: JSON.stringify(c.meta),
},
);
report.contractsInserted++;
// Only index on successful insert — the cross-link loop must never
// resolve to a row that isn't actually in the DB.
indexContract(lookupIndex, c, id);
} catch (err) {
report.contractsFailed++;
recordError('contract', id, err);
}
}
// Insert repo snapshots
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
try {
await queryBridge(
handle,
`CREATE (s:RepoSnapshot {
id: $id,
indexedAt: $indexedAt,
lastCommit: $lastCommit
})`,
{
id: repoId,
indexedAt: snap.indexedAt,
lastCommit: snap.lastCommit,
},
);
report.snapshotsInserted++;
} catch (err) {
report.snapshotsFailed++;
recordError('snapshot', repoId, err);
}
}
// Insert cross-links (tolerating missing nodes).
//
// `findContractNode` consults the in-memory lookup index built above,
// not the DB — that's an O(1) pure-function lookup per endpoint instead
// of the previous 2-3 DB queries. For M cross-links, the previous code
// issued up to 6M round-trips; this version issues zero.
//
// `link.contractId` may differ between the consumer and provider sides
// (e.g. wildcard consumer `grpc::Service/*` → method-level provider
// `grpc::Service/Method`) — that's why we resolve each endpoint
// independently via its own `(repo, role, symbolUid, filePath, symbolName)`
// tuple rather than matching on contractId.
for (const link of crossLinks) {
const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
try {
const fromId = findContractNode(
lookupIndex,
link.from.repo,
'consumer',
link.from.symbolUid,
link.from.symbolRef.filePath,
link.from.symbolRef.name,
);
const toId = findContractNode(
lookupIndex,
link.to.repo,
'provider',
link.to.symbolUid,
link.to.symbolRef.filePath,
link.to.symbolRef.name,
);
if (!fromId || !toId) {
report.linksDroppedMissingNode++;
continue;
}
await queryBridge(
handle,
`
MATCH (a:Contract), (b:Contract)
WHERE a.id = $fromId AND b.id = $toId
CREATE (a)-[:ContractLink {
matchType: $matchType,
confidence: $confidence,
contractId: $contractId,
fromRepo: $fromRepo,
toRepo: $toRepo
}]->(b)
`,
{
fromId,
toId,
matchType: link.matchType,
confidence: link.confidence,
contractId: link.contractId,
fromRepo: link.from.repo,
toRepo: link.to.repo,
},
);
report.linksInserted++;
} catch (err) {
report.linksFailed++;
recordError('link', linkId, err);
}
}
// 2. Close temp DB (happy path). The finally block also calls
// closeBridgeDb if we threw above; `handleClosed` prevents a
// double-close on the native handle.
await closeBridgeDb(handle);
handleClosed = true;
} finally {
if (!handleClosed) {
await closeBridgeDb(handle).catch(() => {
/* ignore: cleanup path, best effort */
});
}
}
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
try {
await fsp.access(finalPath);
await retryRename(finalPath, bakPath);
} catch {
/* no existing db */
}
await retryRename(tmpPath, finalPath);
try {
await fsp.rm(bakPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 4. Write meta.json
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
missingRepos: input.missingRepos,
});
return report;
}
/* ------------------------------------------------------------------ */
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
const dbPath = path.join(groupDir, 'bridge.lbug');
try {
await fsp.access(dbPath);
} catch {
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
// exact same reason the rest of this file does: the scenario that
// triggers bak recovery is an interrupted writer, which on Windows may
// still be holding an open handle on `.bak` for a few milliseconds when
// a reader races in. EBUSY/EPERM retries recover that case silently.
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
try {
await fsp.access(bakPath);
await retryRename(bakPath, dbPath);
} catch {
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 */
}
}
if (db) {
try {
await db.close();
} catch {
/* ignore */
}
}
return null;
}
}
/* ------------------------------------------------------------------ */
/* bridgeExists */
/* ------------------------------------------------------------------ */
export async function bridgeExists(groupDir: string): Promise<boolean> {
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) return false;
await closeBridgeDb(handle);
return true;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Bridge LadybugDB schema for cross-repo Contract Registry.
* Separate from per-repo schema in lbug/schema.ts.
*/
/**
* Version of the bridge.lbug schema below. `openBridgeDbReadOnly` compares
* this against `meta.json`'s version field and returns `null` on mismatch,
* which trips the caller into either the JSON fallback path or a fresh
* `group sync` that rebuilds `bridge.lbug` from scratch.
*
* Migration contract for contributors bumping this constant:
* 1. Bump the number (e.g. `1` → `2`).
* 2. Update the DDL below to match the new schema.
* 3. DO NOT attempt an online migration in this file — the version gate
* is intentionally a "discard and re-sync" strategy for V1. An old
* bridge.lbug whose version doesn't match is treated as opaque and
* rebuilt by the next `group sync`.
* 4. If online migration becomes necessary (e.g. when groups accumulate
* large amounts of embedding data), add a migration path as a
* separate `bridge-migrations.ts` module rather than bloating this
* file — keep schema and migration concerns separate.
*/
export const BRIDGE_SCHEMA_VERSION = 1;
export const CONTRACT_SCHEMA = `
CREATE NODE TABLE Contract (
id STRING,
contractId STRING,
type STRING,
role STRING,
repo STRING,
service STRING DEFAULT '',
symbolUid STRING DEFAULT '',
filePath STRING DEFAULT '',
symbolName STRING DEFAULT '',
confidence DOUBLE DEFAULT 0.0,
meta STRING DEFAULT '{}',
PRIMARY KEY (id)
)`;
export const REPO_SNAPSHOT_SCHEMA = `
CREATE NODE TABLE RepoSnapshot (
id STRING,
indexedAt STRING DEFAULT '',
lastCommit STRING DEFAULT '',
PRIMARY KEY (id)
)`;
export const CONTRACT_LINK_SCHEMA = `
CREATE REL TABLE ContractLink (
FROM Contract TO Contract,
matchType STRING,
confidence DOUBLE,
contractId STRING,
fromRepo STRING,
toRepo STRING
)`;
export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA];
+98
View File
@@ -0,0 +1,98 @@
import { createRequire } from 'node:module';
import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom'];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
const DEFAULT_DETECT = {
http: true,
grpc: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
};
export function parseGroupConfig(yamlContent: string): GroupConfig {
const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Invalid YAML: expected an object');
}
if (raw.version === undefined) throw new Error('version is required in group.yaml');
if (raw.version !== 1) {
throw new Error(`Unsupported group.yaml version: ${raw.version}. Expected 1.`);
}
if (!raw.name || typeof raw.name !== 'string') throw new Error('name is required in group.yaml');
if (!raw.repos || typeof raw.repos !== 'object' || Array.isArray(raw.repos)) {
throw new Error('repos is required in group.yaml (must be a mapping)');
}
const repos = raw.repos as Record<string, string>;
const repoPaths = new Set(Object.keys(repos));
const rawLinks = (raw.links as unknown[]) || [];
const links: GroupManifestLink[] = rawLinks.map((l: unknown, i: number) => {
const link = l as Record<string, unknown>;
if (!link.from || !repoPaths.has(link.from as string)) {
throw new Error(`links[${i}].from "${link.from}" does not match any repo path in group`);
}
if (!link.to || !repoPaths.has(link.to as string)) {
throw new Error(`links[${i}].to "${link.to}" does not match any repo path in group`);
}
if (!VALID_CONTRACT_TYPES.includes(link.type as ContractType)) {
throw new Error(
`links[${i}].type "${link.type}" is invalid. Expected: ${VALID_CONTRACT_TYPES.join(', ')}`,
);
}
if (!VALID_ROLES.includes(link.role as ContractRole)) {
throw new Error(`links[${i}].role "${link.role}" is invalid. Expected: provider | consumer`);
}
if (
link.contract === undefined ||
link.contract === null ||
String(link.contract).trim() === ''
) {
throw new Error(`links[${i}].contract is required`);
}
return {
from: link.from as string,
to: link.to as string,
type: link.type as ContractType,
contract: String(link.contract),
role: link.role as ContractRole,
};
});
const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) };
const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) };
const packages = (raw.packages as Record<string, Record<string, string>>) || {};
return {
version: 1,
name: raw.name as string,
description: (raw.description as string) || '',
repos,
links,
packages,
detect,
matching,
};
}
export async function loadGroupConfig(groupDir: string): Promise<GroupConfig> {
const fsp = await import('node:fs/promises');
const path = await import('node:path');
const yamlPath = path.join(groupDir, 'group.yaml');
const content = await fsp.readFile(yamlPath, 'utf-8');
return parseGroupConfig(content);
}
@@ -0,0 +1,16 @@
import type { ContractType, ExtractedContract, RepoHandle } from './types.js';
export interface ContractExtractor {
type: ContractType;
canExtract(repo: RepoHandle): Promise<boolean>;
extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]>;
}
export type CypherExecutor = (
query: string,
params?: Record<string, unknown>,
) => Promise<Record<string, unknown>[]>;
@@ -0,0 +1,357 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function contractId(pkg: string, service: string, method: string): string {
const prefix = pkg ? `${pkg}.${service}` : service;
return `grpc::${prefix}/${method}`;
}
function serviceOnlyContractId(serviceName: string): string {
return `grpc::${serviceName}/*`;
}
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
// v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2)
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
}
// If EOF before depth returns to 0, skip incomplete service
if (depth !== 0) continue;
// body is between opening { (consumed by regex) and closing } (pos is one past it)
const body = content.slice(bodyStart, pos - 1);
results.push({ name: serviceName, body });
}
return results;
}
function makeContract(
cid: string,
role: 'provider' | 'consumer',
filePath: string,
symbolName: string,
confidence: number,
meta: Record<string, unknown>,
): ExtractedContract {
return {
contractId: cid,
type: 'grpc',
role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
symbolName,
confidence,
meta: { ...meta, extractionStrategy: 'source_scan' },
};
}
export class GrpcExtractor implements ContractExtractor {
type = 'grpc' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
// Proto files — definitive provider source
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
nodir: true,
});
for (const rel of protoFiles) {
const content = readSafe(repoPath, rel);
if (content) out.push(...this.parseProtoFile(content, rel));
}
// Source files — server/client detection
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
nodir: true,
});
for (const rel of sourceFiles) {
const content = readSafe(repoPath, rel);
if (!content) continue;
const ext = path.extname(rel).toLowerCase();
if (ext === '.go') {
out.push(...this.scanGoProviders(content, rel));
out.push(...this.scanGoConsumers(content, rel));
} else if (ext === '.java') {
out.push(...this.scanJavaProviders(content, rel));
out.push(...this.scanJavaConsumers(content, rel));
} else if (ext === '.py') {
out.push(...this.scanPythonProviders(content, rel));
out.push(...this.scanPythonConsumers(content, rel));
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
out.push(...this.scanTsProviders(content, rel));
}
}
return this.dedupe(out);
}
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// pb.RegisterXxxServer(
const registerRe = /\w+\.Register(\w+)Server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = registerRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Register${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_register' },
),
);
}
// pb.UnimplementedXxxServer
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
while ((m = unimplRe.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Unimplemented${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_unimplemented' },
),
);
}
return out;
}
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /\w+\.New(\w+)Client\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`New${serviceName}Client`,
0.7,
{ service: serviceName, source: 'go_client' },
),
);
}
return out;
}
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcService
if (content.includes('@GrpcService')) {
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
const m = content.match(implBaseRe);
if (m) {
out.push(
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
service: m[1],
source: 'java_grpc_service',
}),
);
} else {
// Try extracting service name from class name
const classRe =
/class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/;
const cm = content.match(classRe);
if (cm) {
const svcName = cm[2].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
service: svcName,
source: 'java_grpc_service',
}),
);
}
}
}
// extends XxxImplBase (without @GrpcService)
if (!content.includes('@GrpcService')) {
const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/;
const m = content.match(implRe);
if (m) {
const svcName = m[2] || m[1].replace(/Grpc$/, '');
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
service: svcName,
source: 'java_impl_base',
}),
);
}
}
return out;
}
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`${serviceName}Stub`,
0.7,
{ service: serviceName, source: 'java_stub' },
),
);
}
return out;
}
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// add_XxxServicer_to_server(
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`add_${serviceName}Servicer_to_server`,
0.8,
{ service: serviceName, source: 'python_servicer' },
),
);
}
return out;
}
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxStub(
const re = /(\w+)Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const name = m[1];
// Filter out common false positives
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
out.push(
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
service: name,
source: 'python_stub',
}),
);
}
return out;
}
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcMethod('ServiceName', 'MethodName')
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const methodName = m[2];
const cid = contractId('', serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
service: serviceName,
method: methodName,
source: 'ts_grpc_method',
}),
);
}
return out;
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}
@@ -0,0 +1,487 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
const HANDLES_ROUTE_QUERY = `
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
route.responseKeys AS responseKeys,
r.reason AS routeSource`;
const FETCHES_QUERY = `
MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route)
RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
r.reason AS fetchReason`;
const CONTAINS_QUERY = `
MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
WHERE sym.startLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
ORDER BY sym.startLine`;
export function normalizeHttpPath(p: string): string {
let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, '');
s = s.replace(/:\w+/g, '{param}');
s = s.replace(/\{[^}]+\}/g, '{param}');
s = s.replace(/\[[^\]]+\]/g, '{param}');
return s;
}
function methodFromRouteReason(reason: string): string | null {
const r = reason || '';
if (/GetMapping|decorator-Get/i.test(r)) return 'GET';
if (/PostMapping|decorator-Post/i.test(r)) return 'POST';
if (/PutMapping|decorator-Put/i.test(r)) return 'PUT';
if (/DeleteMapping|decorator-Delete/i.test(r)) return 'DELETE';
if (/PatchMapping|decorator-Patch/i.test(r)) return 'PATCH';
return null;
}
function contractIdFor(method: string, pathNorm: string): string {
return `http::${method.toUpperCase()}::${pathNorm}`;
}
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function pickJavaHandlerName(
content: string,
routePath: string,
httpMethod: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
const ann = mapNames[httpMethod] || 'GetMapping';
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes(`@${ann}`)) continue;
if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail))
continue;
for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
if (m) return m[1];
}
}
return null;
}
function pickSymbolUid(
rows: Record<string, unknown>[],
preferredName: string | null,
): { uid: string; name: string; filePath: string } {
const norm = (x: unknown) => String(x ?? '');
const labeled = rows.filter((r) => {
const labels = r.labels ?? r[3];
const s = JSON.stringify(labels);
return s.includes('Method') || s.includes('Function');
});
const pool = labeled.length > 0 ? labeled : rows;
if (preferredName) {
const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName);
if (hit) {
return {
uid: norm(hit.uid ?? hit[0]),
name: norm(hit.name ?? hit[1]),
filePath: norm(hit.filePath ?? hit[2]),
};
}
}
const first = pool[0] || rows[0];
return {
uid: norm(first?.uid ?? first?.[0]),
name: norm(first?.name ?? first?.[1]),
filePath: norm(first?.filePath ?? first?.[2]),
};
}
export class HttpRouteExtractor implements ContractExtractor {
type = 'http' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]> {
const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : [];
const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath);
const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : [];
const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath);
return [...providers, ...consumers];
}
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
try {
rows = await db(HANDLES_ROUTE_QUERY);
} catch {
return [];
}
for (const row of rows) {
const filePath = String(row.filePath ?? '');
const routePath = String(row.routePath ?? '');
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
let method = methodFromRouteReason(routeSource);
const content = readSafe(repoPath, filePath);
if (!method && content) {
method = this.inferMethodFromFileScan(content, routePath, 'provider');
}
if (!method) method = 'GET';
const pathNorm = normalizeHttpPath(routePath);
const cid = contractIdFor(method, pathNorm);
const handlerName =
content && routePath ? pickJavaHandlerName(content, routePath, method) : null;
let symbolUid = '';
let symbolName = path.basename(filePath) || 'handler';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, handlerName);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
}
} catch {
/* ignore */
}
}
out.push({
contractId: cid,
type: 'http',
role: 'provider',
symbolUid,
symbolRef: { filePath: symPath, name: symbolName },
symbolName,
confidence: 0.9,
meta: {
method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'graph_assisted',
routeSource,
},
});
}
return out;
}
private inferMethodFromFileScan(
content: string,
routePath: string,
_role: string,
): string | null {
const tail = routePath.split('/').filter(Boolean).pop() || '';
for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) {
const mapNames: Record<string, string> = {
GET: 'GetMapping',
POST: 'PostMapping',
PUT: 'PutMapping',
DELETE: 'DeleteMapping',
PATCH: 'PatchMapping',
};
if (
content.includes(`@${mapNames[m]}`) &&
(content.includes(tail) || routePath.includes(tail))
) {
return m;
}
}
return null;
}
private async extractProvidersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanSpringProviders(content, rel));
out.push(...this.scanExpressProviders(content, rel));
out.push(...this.scanLaravelProviders(content, rel));
out.push(...this.scanFastApiProviders(content, rel));
}
return this.dedupeContracts(out);
}
private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
private scanSpringProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
// Skip Feign/client interfaces — annotated methods in interfaces are
// consumers (Feign, JAX-RS proxies), not provider endpoints.
// Anchored to line start (with optional access modifier) so we do not
// match "interface" inside comments or string literals.
if (
/^\s*(?:public\s+)?interface\s+\w+/m.test(content) &&
!/@(?:Rest)?Controller\b/.test(content)
) {
return out;
}
let classPrefix = '';
const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/);
if (classRm) classPrefix = classRm[1].replace(/\/+$/, '');
const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
let p = m[2];
if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`;
const pathNorm = normalizeHttpPath(p);
const sub = content.slice(m.index);
const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
const name = nameM ? nameM[1] : m[0];
out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
}
return out;
}
private scanExpressProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8));
}
return out;
}
private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(m[2]);
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
}
return out;
}
private makeProvider(
filePath: string,
method: string,
pathNorm: string,
name: string,
confidence: number,
): ExtractedContract {
const cid = contractIdFor(method, pathNorm);
return {
contractId: cid,
type: 'http',
role: 'provider',
symbolUid: '',
symbolRef: { filePath, name },
symbolName: name,
confidence,
meta: {
method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'source_scan',
},
};
}
private async extractConsumersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
try {
rows = await db(FETCHES_QUERY);
} catch {
return [];
}
for (const row of rows) {
const filePath = String(row.filePath ?? '');
const routePath = String(row.routePath ?? '');
const pathNorm = normalizeHttpPath(routePath);
let method = 'GET';
const content = readSafe(repoPath, filePath);
if (content) {
const inferred = this.inferFetchMethod(content, pathNorm);
if (inferred) method = inferred;
}
const cid = contractIdFor(method, pathNorm);
let symbolUid = '';
let symbolName = 'fetch';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, null);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
}
} catch {
/* ignore */
}
}
out.push({
contractId: cid,
type: 'http',
role: 'consumer',
symbolUid,
symbolRef: { filePath: symPath, name: symbolName },
symbolName,
confidence: 0.9,
meta: {
method,
path: pathNorm,
extractionStrategy: 'graph_assisted',
fetchReason: String(row.fetchReason ?? ''),
},
});
}
return out;
}
private inferFetchMethod(content: string, pathNorm: string): string | null {
const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fetchRe = new RegExp(
`fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`,
'i',
);
const m = content.match(fetchRe);
if (m) return m[2].toUpperCase();
return null;
}
private async extractConsumersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFetchConsumers(content, rel));
out.push(...this.scanAxiosConsumers(content, rel));
}
return this.dedupeContracts(out);
}
private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re =
/fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const pathNorm = normalizeHttpPath(this.templateToPattern(m[1]));
const method = (m[2] || 'GET').toUpperCase();
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private templateToPattern(url: string): string {
return url.replace(/\$\{[^}]+\}/g, '{param}');
}
private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private makeConsumer(
filePath: string,
method: string,
pathNorm: string,
confidence: number,
): ExtractedContract {
return {
contractId: contractIdFor(method, pathNorm),
type: 'http',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath, name: 'fetch' },
symbolName: 'fetch',
confidence,
meta: {
method,
path: pathNorm,
extractionStrategy: 'source_scan',
},
};
}
}
@@ -0,0 +1,277 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
type Broker = 'kafka' | 'rabbitmq' | 'nats';
function readSafe(repoPath: string, rel: string): string | null {
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function makeContract(
topicName: string,
role: 'provider' | 'consumer',
filePath: string,
symbolName: string,
confidence: number,
broker: Broker,
): ExtractedContract {
return {
contractId: `topic::${topicName}`,
type: 'topic',
role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
symbolName,
confidence,
meta: {
broker,
topicName,
extractionStrategy: 'source_scan',
},
};
}
interface PatternDef {
regex: RegExp;
role: 'provider' | 'consumer';
broker: Broker;
confidence: number;
topicGroup: number;
symbolName: string;
}
// --- Kafka patterns ---
const KAFKA_PATTERNS: PatternDef[] = [
// Java: @KafkaListener(topics = "xxx")
{
regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaListener',
},
// Java: kafkaTemplate.send("xxx"
{
regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'kafkaTemplate.send',
},
// Node: producer.send({ topic: 'xxx'
{
regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'producer.send',
},
// Node: consumer.subscribe({ topic: 'xxx'
{
regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.8,
topicGroup: 1,
symbolName: 'consumer.subscribe',
},
// Go: consumer.ConsumePartition("xxx"
{
regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'ConsumePartition',
},
// Python: KafkaConsumer('xxx'
{
regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'KafkaConsumer',
},
// Python: producer.send('xxx' or producer.produce('xxx'
{
regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'kafka',
confidence: 0.7,
topicGroup: 1,
symbolName: 'producer.send',
},
];
// --- RabbitMQ patterns ---
const RABBITMQ_PATTERNS: PatternDef[] = [
// Java: @RabbitListener(queues = "xxx")
{
regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitListener',
},
// Java: rabbitTemplate.convertAndSend("xxx"
{
regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'rabbitTemplate.convertAndSend',
},
// Node: channel.consume("xxx"
{
regex: /channel\.consume\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.consume',
},
// Node: channel.publish("xxx"
{
regex: /channel\.publish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.publish',
},
// Node: channel.sendToQueue("xxx"
{
regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.8,
topicGroup: 1,
symbolName: 'channel.sendToQueue',
},
// Python: channel.basic_consume(queue='xxx'
{
regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g,
role: 'consumer',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_consume',
},
// Python: channel.basic_publish(exchange='xxx'
{
regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g,
role: 'provider',
broker: 'rabbitmq',
confidence: 0.7,
topicGroup: 1,
symbolName: 'basic_publish',
},
];
// --- NATS patterns ---
const NATS_PATTERNS: PatternDef[] = [
// Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx"
{
regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g,
role: 'consumer',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Subscribe',
},
// Go/Node: nc.Publish("xxx" or nc.publish("xxx"
{
regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g,
role: 'provider',
broker: 'nats',
confidence: 0.8,
topicGroup: 1,
symbolName: 'nc.Publish',
},
];
const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS];
export class TopicExtractor implements ContractExtractor {
type = 'topic' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanFile(content, rel));
}
return this.dedupe(out);
}
private scanFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
for (const pattern of ALL_PATTERNS) {
// Reset regex state for each file
const re = new RegExp(pattern.regex.source, pattern.regex.flags);
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const topicName = m[pattern.topicGroup];
if (!topicName) continue;
out.push(
makeContract(
topicName,
pattern.role,
filePath,
pattern.symbolName,
pattern.confidence,
pattern.broker,
),
);
}
}
return out;
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}
+237
View File
@@ -0,0 +1,237 @@
import type { StoredContract, CrossLink } from './types.js';
export interface MatchResult {
matched: CrossLink[];
unmatched: StoredContract[];
}
export interface WildcardMatchResult {
matched: CrossLink[];
remaining: StoredContract[];
}
function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
}
export function normalizeContractId(id: string): string {
const colonIdx = id.indexOf('::');
if (colonIdx === -1) return id;
const type = id.substring(0, colonIdx);
const rest = id.substring(colonIdx + 2);
switch (type) {
case 'http': {
const parts = rest.split('::');
if (parts.length >= 2) {
const method = parts[0].toUpperCase();
let pathPart = parts.slice(1).join('::');
pathPart = pathPart.replace(/\/+$/, '');
return `http::${method}::${pathPart}`;
}
return id;
}
case 'grpc': {
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
//
// The package/service segment is lowercased because gRPC package
// names are effectively case-insensitive across language bindings
// (`auth.AuthService`, `auth.authservice`, `AUTH.AUTHSERVICE` all
// describe the same wire protocol service). The RPC method segment
// is preserved as-is because the HTTP/2 path used on the wire is
// case-sensitive per the gRPC spec (`/Service/MethodName`), and
// method names in generated clients match the proto source exactly.
//
// A package-only id (no slash) and a package/method id are treated
// as DISTINCT canonical forms: `grpc::userservice` does not match
// `grpc::userservice/Login`. That's by design — callers that want
// service-level manifest matching against method-level providers
// should use the gRPC wildcard form `grpc::UserService/*` which is
// handled by runWildcardMatch below.
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const pkg = rest.substring(0, slashIdx).toLowerCase();
const method = rest.substring(slashIdx);
return `grpc::${pkg}${method}`;
}
if (slashIdx === 0) {
// Malformed "/method" with leading slash — keep as-is so two
// equally malformed ids can still match each other.
return `grpc::${rest}`;
}
// No slash: package/service only. Lowercase to match the package
// segment produced by the pkg/method branch above.
return `grpc::${rest.toLowerCase()}`;
}
case 'topic':
return `topic::${rest.trim().toLowerCase()}`;
case 'lib':
return `lib::${rest.toLowerCase()}`;
default:
return id;
}
}
function findMatchingKeys(contractId: string, index: Map<string, StoredContract[]>): string[] {
const normalized = normalizeContractId(contractId);
if (index.has(normalized)) return [normalized];
if (normalized.startsWith('http::*::')) {
const pathPart = normalized.substring('http::*::'.length);
const matches: string[] = [];
for (const key of index.keys()) {
if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) {
matches.push(key);
}
}
return matches;
}
return [];
}
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
const list = index.get(key) || [];
list.push(p);
index.set(key, list);
}
return index;
}
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
): MatchResult {
const index = providerIndex ?? buildProviderIndex(contracts);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
const matchedProviderIds = new Set<string>();
for (const consumer of consumers) {
const matchingKeys = findMatchingKeys(consumer.contractId, index);
if (matchingKeys.length === 0) continue;
const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []);
for (const provider of allMatchingProviders) {
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId,
matchType: 'exact',
confidence: 1.0,
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
matchedProviderIds.add(`${provider.repo}::${provider.contractId}`);
}
}
// 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
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
// Re-add gRPC wildcard contracts — they were never in exact matching
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...grpcWildcards];
return { matched, unmatched };
}
export function runWildcardMatch(
unmatched: StoredContract[],
providerIndex: Map<string, StoredContract[]>,
): WildcardMatchResult {
const wildcardConsumers = unmatched.filter(
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
for (const consumer of wildcardConsumers) {
const normalized = normalizeContractId(consumer.contractId);
// "grpc::com.example.userservice/*" → "com.example.userservice"
// "grpc::userservice/*" → "userservice"
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
for (const [key, providers] of providerIndex) {
// Only match against non-wildcard gRPC providers (method-level IDs)
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
const afterPrefix = key.slice(6); // strip "grpc::"
const slashIdx = afterPrefix.indexOf('/');
if (slashIdx < 0) continue;
const providerFqService = afterPrefix.slice(0, slashIdx);
// Match: exact FQ service, or bare-name match when consumer has no package
const isMatch =
providerFqService === fqService ||
(!fqService.includes('.') && providerFqService.endsWith('.' + fqService));
if (!isMatch) continue;
for (const provider of providers) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
}
}
}
const remaining = unmatched.filter((c) => {
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
});
return { matched, remaining };
}
+124
View File
@@ -0,0 +1,124 @@
import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js';
function contractKey(contract: StoredContract): string {
return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join(
'\0',
);
}
function endpointKey(endpoint: CrossLinkEndpoint): string {
return [
endpoint.repo,
endpoint.service ?? '',
endpoint.symbolRef.filePath,
endpoint.symbolRef.name,
].join('\0');
}
/**
* Score a contract by how much information it carries, so `dedupeContracts`
* can prefer the "richer" record when two contracts collide on the same
* `(repo, contractId, role, filePath)` key.
*
* Weights express a priority ordering, not calibrated probabilities:
* +3 — `symbolUid` resolved (tier 1 of the downstream lookup — highest
* signal because it's the strongest anchor for cross-impact traversal
* and the only one that's robust to renames)
* +2 — any of `filePath`, `symbolRef.name`, or `symbolName` that's more
* specific than the contractId itself (tier 2 signal — resolves
* uniquely in most cases and survives across syncs)
* +1 — `service` tag (monorepo attribution — useful but not sufficient
* on its own) or non-manifest origin (auto-extracted contracts are
* preferred over manifest-declared synthetic ones because the former
* are grounded in real source code)
*
* The absolute numbers don't matter, only their relative ordering.
*/
function contractRichness(contract: StoredContract): number {
let score = 0;
if (contract.symbolUid) score += 3;
if (contract.symbolRef.filePath) score += 2;
if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2;
if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2;
if (contract.service) score += 1;
if (contract.meta.source !== 'manifest') score += 1;
return score;
}
function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract {
const [primary, secondary] =
contractRichness(incoming) > contractRichness(existing)
? [incoming, existing]
: [existing, incoming];
const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name;
return {
...secondary,
...primary,
symbolUid: primary.symbolUid || secondary.symbolUid,
symbolRef: {
filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath,
name: symbolRefName,
},
symbolName: primary.symbolName || secondary.symbolName || symbolRefName,
confidence: Math.max(existing.confidence, incoming.confidence),
service: primary.service ?? secondary.service,
meta: { ...secondary.meta, ...primary.meta },
};
}
function mergeEndpoints(
existing: CrossLinkEndpoint,
incoming: CrossLinkEndpoint,
): CrossLinkEndpoint {
return {
repo: existing.repo,
service: existing.service ?? incoming.service,
symbolUid: existing.symbolUid || incoming.symbolUid,
symbolRef: {
filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath,
name: existing.symbolRef.name || incoming.symbolRef.name,
},
};
}
function crossLinkKey(link: CrossLink): string {
return [
link.type,
link.contractId,
link.matchType,
endpointKey(link.from),
endpointKey(link.to),
].join('\0');
}
export function dedupeContracts(items: StoredContract[]): StoredContract[] {
const deduped = new Map<string, StoredContract>();
for (const contract of items) {
const key = contractKey(contract);
const existing = deduped.get(key);
deduped.set(key, existing ? mergeContracts(existing, contract) : contract);
}
return [...deduped.values()];
}
export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] {
const deduped = new Map<string, CrossLink>();
for (const link of items) {
const key = crossLinkKey(link);
const existing = deduped.get(key);
if (!existing) {
deduped.set(key, link);
continue;
}
const keepIncoming = link.confidence > existing.confidence;
const primary = keepIncoming ? link : existing;
const secondary = keepIncoming ? existing : link;
deduped.set(key, {
...primary,
confidence: Math.max(existing.confidence, link.confidence),
from: mergeEndpoints(primary.from, secondary.from),
to: mergeEndpoints(primary.to, secondary.to),
});
}
return [...deduped.values()];
}
@@ -0,0 +1,177 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export interface ServiceBoundary {
servicePath: string;
serviceName: string;
markers: string[];
confidence: number;
}
const SERVICE_MARKERS = [
'package.json',
'go.mod',
'Dockerfile',
'pom.xml',
'build.gradle',
'build.gradle.kts',
'Cargo.toml',
'pyproject.toml',
'requirements.txt',
'mix.exs',
] as const;
const SOURCE_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.go',
'.java',
'.kt',
'.kts',
'.py',
'.pyi',
'.rs',
'.c',
'.cpp',
'.h',
'.hpp',
'.cs',
'.rb',
'.php',
'.swift',
'.dart',
'.ex',
'.exs',
'.erl',
'.proto',
]);
const EXCLUDED_DIRS = new Set([
'node_modules',
'vendor',
'target',
'build',
'dist',
'__pycache__',
'.venv',
'venv',
'.tox',
'.mypy_cache',
'.gradle',
'.mvn',
'out',
'bin',
]);
export async function detectServiceBoundaries(repoPath: string): Promise<ServiceBoundary[]> {
const boundaries: ServiceBoundary[] = [];
await walkForBoundaries(repoPath, repoPath, boundaries);
return boundaries;
}
async function walkForBoundaries(
dir: string,
repoRoot: string,
results: ServiceBoundary[],
): Promise<void> {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
const isRoot = path.resolve(dir) === path.resolve(repoRoot);
const foundMarkers: string[] = [];
let hasSourceFiles = false;
const subdirs: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.isDirectory()) {
subdirs.push(path.join(dir, entry.name));
} else if (entry.isFile()) {
if (SERVICE_MARKERS.includes(entry.name as (typeof SERVICE_MARKERS)[number])) {
foundMarkers.push(entry.name);
}
const ext = path.extname(entry.name).toLowerCase();
if (SOURCE_EXTENSIONS.has(ext)) {
hasSourceFiles = true;
}
}
}
// Check subdirectories for source files if not found at this level
if (!hasSourceFiles && foundMarkers.length > 0) {
hasSourceFiles = await hasSourceFilesInSubdirs(subdirs);
}
if (!isRoot && foundMarkers.length >= 1 && hasSourceFiles) {
const relativePath = path.relative(repoRoot, dir).replace(/\\/g, '/');
const serviceName = path.basename(dir);
const confidence = computeConfidence(foundMarkers.length);
results.push({
servicePath: relativePath,
serviceName,
markers: foundMarkers,
confidence,
});
}
// Recurse into subdirectories
for (const subdir of subdirs) {
await walkForBoundaries(subdir, repoRoot, results);
}
}
async function hasSourceFilesInSubdirs(subdirs: string[]): Promise<boolean> {
for (const subdir of subdirs) {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(subdir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (SOURCE_EXTENSIONS.has(ext)) return true;
}
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]);
if (deeper) return true;
}
}
}
return false;
}
function computeConfidence(markerCount: number): number {
if (markerCount >= 3) return 1.0;
if (markerCount === 2) return 0.9;
return 0.75;
}
export function assignService(filePath: string, boundaries: ServiceBoundary[]): string | undefined {
const normalized = filePath.replace(/\\/g, '/');
let bestMatch: ServiceBoundary | undefined;
let bestLength = 0;
for (const boundary of boundaries) {
const prefix = boundary.servicePath + '/';
if (normalized.startsWith(prefix) && boundary.servicePath.length > bestLength) {
bestMatch = boundary;
bestLength = boundary.servicePath.length;
}
}
return bestMatch?.servicePath;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Group orchestration shared by MCP (LocalBackend) and CLI.
* DB access is injected via GroupToolPort so this module stays free of LocalBackend private API.
*/
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { syncGroup } from './sync.js';
export interface GroupRepoHandle {
id: string;
name: string;
repoPath: string;
storagePath: string;
indexedAt?: string;
lastCommit?: string;
}
export interface GroupToolPort {
resolveRepo(repoParam?: string): Promise<GroupRepoHandle>;
impact(
repo: GroupRepoHandle,
params: {
target: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
},
): Promise<unknown>;
query(
repo: GroupRepoHandle,
params: {
query: string;
task_context?: string;
goal?: string;
limit?: number;
max_symbols?: number;
include_content?: boolean;
},
): Promise<unknown>;
impactByUid(
repoId: string,
uid: string,
direction: string,
opts: {
maxDepth: number;
relationTypes: string[];
minConfidence: number;
includeTests: boolean;
},
): Promise<unknown | null>;
}
function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
if (!subgroup?.trim()) return true;
const s = subgroup.replace(/\/+$/, '');
return repoPath === s || repoPath.startsWith(`${s}/`);
}
export class GroupService {
constructor(private readonly port: GroupToolPort) {}
async groupList(params: Record<string, unknown>): Promise<unknown> {
const name = typeof params.name === 'string' ? params.name.trim() : '';
if (!name) {
const groups = await listGroups();
return { groups };
}
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
return {
name: config.name,
description: config.description,
repos: config.repos,
links: config.links,
};
}
async groupSync(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
});
return {
contracts: result.contracts.length,
crossLinks: result.crossLinks.length,
unmatched: result.unmatched.length,
missingRepos: result.missingRepos,
};
}
async groupContracts(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
if (!registry) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
}
let contracts = registry.contracts;
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
if (params.unmatchedOnly) {
const matchedIds = new Set(
registry.crossLinks.flatMap((l) => [
`${l.from.repo}::${l.contractId}`,
`${l.to.repo}::${l.contractId}`,
]),
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
return { contracts, crossLinks: registry.crossLinks };
}
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
const queryText = String(params.query ?? '').trim();
if (!name || !queryText) return { error: 'name and query are required' };
const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5;
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const perRepo: Array<{ repo: string; score: number; processes: unknown[] }> = [];
for (const [repoPath, registryName] of Object.entries(config.repos)) {
if (!repoInSubgroup(repoPath, subgroup)) continue;
try {
const repoObj = await this.port.resolveRepo(registryName);
const queryResult = (await this.port.query(repoObj, {
query: queryText,
limit,
max_symbols: 10,
include_content: false,
})) as { processes?: Array<Record<string, unknown>> };
const processes = queryResult.processes || [];
const scored = processes.map((p, idx) => ({
...p,
_rrf_score: 1 / (idx + 1 + 60),
_repo: repoPath,
}));
perRepo.push({ repo: repoPath, score: 0, processes: scored });
} catch {
perRepo.push({ repo: repoPath, score: 0, processes: [] });
}
}
const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>);
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
const topN = allProcesses.slice(0, limit);
return {
group: name,
query: queryText,
results: topN,
per_repo: perRepo.map((r) => ({ repo: r.repo, count: r.processes.length })),
};
}
async groupStatus(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const registry = await readContractRegistry(groupDir);
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
> = {};
const fsp = await import('node:fs/promises');
const pathMod = await import('node:path');
for (const [repoPath, registryName] of Object.entries(config.repos)) {
try {
const repoObj = await this.port.resolveRepo(registryName);
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
const staleness = meta.lastCommit
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
const snapshot = registry?.repoSnapshots[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
repoStatuses[repoPath] = {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
}
}
return {
group: name,
lastSync: registry?.generatedAt || null,
missingRepos: registry?.missingRepos || [],
repos: repoStatuses,
};
}
}
+109
View File
@@ -0,0 +1,109 @@
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
}
export function getGroupsBaseDir(gitnexusDir?: string): string {
return path.join(gitnexusDir || getDefaultGitnexusDir(), 'groups');
}
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
export function validateGroupName(name: string): void {
if (!GROUP_NAME_RE.test(name)) {
throw new Error(
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
);
}
}
export function getGroupDir(gitnexusDir: string, groupName: string): string {
validateGroupName(groupName);
return path.join(gitnexusDir, 'groups', groupName);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,
): Promise<void> {
const targetPath = path.join(groupDir, CONTRACTS_FILE);
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
await fsp.rename(tmpPath, targetPath);
}
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
const filePath = path.join(groupDir, CONTRACTS_FILE);
try {
const content = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(content) as ContractRegistry;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
export async function listGroups(gitnexusDir?: string): Promise<string[]> {
const groupsDir = getGroupsBaseDir(gitnexusDir);
try {
const entries = await fsp.readdir(groupsDir, { withFileTypes: true });
const names: string[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const yamlPath = path.join(groupsDir, entry.name, 'group.yaml');
if (fs.existsSync(yamlPath)) {
names.push(entry.name);
}
}
}
return names;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}
export async function createGroupDir(
gitnexusDir: string,
groupName: string,
force: boolean = false,
): Promise<string> {
const groupDir = getGroupDir(gitnexusDir, groupName);
if (fs.existsSync(path.join(groupDir, 'group.yaml')) && !force) {
throw new Error(`Group "${groupName}" already exists. Use --force to overwrite.`);
}
await fsp.mkdir(groupDir, { recursive: true });
const template = `version: 1
name: ${groupName}
description: ""
repos: {}
links: []
packages: {}
detect:
http: true
grpc: true
topics: true
shared_libs: true
embedding_fallback: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
return groupDir;
}
+185
View File
@@ -0,0 +1,185 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { initLbug, closeLbug, executeParameterized } from '../lbug/pool-adapter.js';
import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js';
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import type { ContractRegistry } from './types.js';
export interface SyncOptions {
extractorOverride?:
| ((repo: RepoHandle) => Promise<StoredContract[]>)
| (() => Promise<StoredContract[]>);
resolveRepoHandle?: (registryName: string, groupPath: string) => Promise<RepoHandle | null>;
skipWrite?: boolean;
groupDir?: string;
allowStale?: boolean;
verbose?: boolean;
exactOnly?: boolean;
skipEmbeddings?: boolean;
}
export interface SyncResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
unmatched: StoredContract[];
missingRepos: string[];
repoSnapshots: Record<string, RepoSnapshot>;
}
export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry[]): string {
const base = entry.name.toLowerCase();
const resolved = path.resolve(entry.path);
for (const other of allEntries) {
if (other.name.toLowerCase() === base && path.resolve(other.path) !== resolved) {
const hash = Buffer.from(entry.path).toString('base64url').slice(0, 6);
return `${base}-${hash}`;
}
}
return base;
}
function defaultResolveHandle(allEntries: RegistryEntry[]) {
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
const e = allEntries.find((en) => en.name === registryName);
if (!e) return null;
const poolId = stableRepoPoolId(e, allEntries);
return {
id: poolId,
path: groupPath,
repoPath: e.path,
storagePath: e.storagePath,
};
};
}
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
const missingRepos: string[] = [];
const repoSnapshots: Record<string, RepoSnapshot> = {};
let autoContracts: StoredContract[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
const entries = await readRegistry();
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
const topicEx = new TopicExtractor();
dbExecutors = new Map<string, CypherExecutor>();
const openPoolIds: string[] = [];
try {
for (const [groupPath, regName] of Object.entries(config.repos)) {
const handle = await resolve(regName, groupPath);
if (!handle) {
missingRepos.push(groupPath);
continue;
}
const poolId = handle.id;
const lbugPath = path.join(handle.storagePath, 'lbug');
try {
await initLbug(poolId, lbugPath);
openPoolIds.push(poolId);
const executor: CypherExecutor = (query, params) =>
executeParameterized(poolId, query, params ?? {});
dbExecutors.set(groupPath, executor);
const boundaries = await detectServiceBoundaries(handle.repoPath);
if (config.detect.http) {
const extracted = await httpEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.grpc) {
const extracted = await grpcEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.topics) {
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
const metaPath = path.join(handle.storagePath, 'meta.json');
try {
const raw = await fs.readFile(metaPath, 'utf-8');
const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string };
repoSnapshots[groupPath] = {
indexedAt: m.indexedAt || '',
lastCommit: m.lastCommit || '',
};
} catch {
const e = entries.find((en) => en.name === regName);
repoSnapshots[groupPath] = {
indexedAt: e?.indexedAt || '',
lastCommit: e?.lastCommit || '',
};
}
} catch {
missingRepos.push(groupPath);
}
}
} finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
}
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const crossLinks: CrossLink[] = matched;
const allContracts: StoredContract[] = autoContracts;
const registry: ContractRegistry = {
version: 1,
generatedAt: new Date().toISOString(),
repoSnapshots,
missingRepos,
contracts: allContracts,
crossLinks,
};
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
}
return {
contracts: allContracts,
crossLinks,
unmatched,
missingRepos,
repoSnapshots,
};
}
+147
View File
@@ -0,0 +1,147 @@
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
version: number;
name: string;
description: string;
repos: Record<string, string>;
links: GroupManifestLink[];
packages: Record<string, Record<string, string>>;
detect: DetectConfig;
matching: MatchingConfig;
}
export interface GroupManifestLink {
from: string;
to: string;
type: ContractType;
contract: string;
role: ContractRole;
}
export interface DetectConfig {
http: boolean;
grpc: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
}
export interface SymbolRef {
filePath: string;
name: string;
}
export interface ExtractedContract {
contractId: string;
type: ContractType;
role: ContractRole;
symbolUid: string;
symbolRef: SymbolRef;
symbolName: string;
confidence: number;
meta: Record<string, unknown>;
/** Service boundary within a monorepo (relative path from repo root, e.g. "services/auth"). */
service?: string;
}
export interface CrossLinkEndpoint {
repo: string;
/** Service boundary within a monorepo (relative path from repo root). */
service?: string;
symbolUid: string;
symbolRef: SymbolRef;
}
export interface CrossLink {
from: CrossLinkEndpoint;
to: CrossLinkEndpoint;
type: ContractType;
contractId: string;
matchType: MatchType;
confidence: number;
}
export interface RepoSnapshot {
indexedAt: string;
lastCommit: string;
}
export interface ContractRegistry {
version: number;
generatedAt: string;
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
contracts: StoredContract[];
crossLinks: CrossLink[];
}
export interface StoredContract extends ExtractedContract {
repo: string;
}
/** Repo within a group (group path + paths; name collision with MCP RepoHandle — import from group/types only). */
export interface RepoHandle {
id: string;
path: string;
repoPath: string;
storagePath: string;
}
export interface GroupImpactResult {
local: unknown;
group: string;
cross: CrossRepoImpact[];
outOfScope: OutOfScopeLink[];
truncated: boolean;
truncatedRepos: string[];
summary: {
direct: number;
processes_affected: number;
modules_affected: number;
cross_repo_hits: number;
};
risk: string;
}
export interface CrossRepoImpact {
repo: string;
repo_path: string;
contract: {
id: string;
type: ContractType;
match_type: MatchType;
confidence: number;
};
by_depth: Record<string, unknown[]>;
affected_processes: string[];
}
export interface OutOfScopeLink {
from: string;
to: string;
contractId: string;
confidence: number;
}
/** Opaque handle to an open bridge LadybugDB. */
export interface BridgeHandle {
/** Internal — do not access directly. */
readonly _db: unknown;
readonly _conn: unknown;
readonly groupDir: string;
}
export interface BridgeMeta {
version: number;
generatedAt: string;
missingRepos: string[];
}
@@ -0,0 +1,387 @@
/**
* BindingAccumulator — read-append-only accumulator that collects TypeEnv
* bindings across files in the GitNexus analyzer pipeline.
*
* **Current behavior (both execution paths):** The accumulator carries only
* file-scope (`scope = ''`) entries. Function-scope bindings are stripped
* at both write sites:
*
* - **Worker path**: `parse-worker.ts` serializes only
* `typeEnv.fileScope()` entries across the IPC boundary.
* - **Sequential path**: `type-env.ts::flush()` iterates only the FILE_SCOPE
* entry of the env map and writes `BindingEntry` records with
* `scope: ''` hardcoded.
*
* The narrowing exists because function-scope bindings have zero downstream
* consumers today and were previously costing ~4.9 MB of heap + IPC on
* every pipeline run. See `type-env.ts::flush()` and the `FileScopeBindings`
* JSDoc in `parse-worker.ts` for the paired Phase 9 reversion checklist.
*
* **Historical quality asymmetry (Phase 9 consideration):** Even though
* both paths now carry only file-scope data, the two paths were built
* under different resolution capabilities, and a future Phase 9 reverter
* that widens them back to all scopes will inherit that asymmetry:
*
* - **Sequential path** had (and would regain) access to the full
* `SymbolTable` and `importedBindings`, so its bindings benefit from
* Tier 2 cross-file propagation.
* - **Worker path** runs without `SymbolTable` / `importedBindings` and
* can only produce Tier 0 (annotation-declared) and local Tier 1
* (same-file constructor inference) bindings.
*
* Phase 9 consumers that trust every entry equally will silently produce
* worse results for large repos (worker-dominant) than small ones
* (sequential-dominant). If Phase 9 needs homogeneous quality, either
* (a) tag entries with their tier at insert time so consumers can filter,
* or (b) post-process worker-path entries through a follow-up resolution
* pass after the main-thread `SymbolTable` is complete.
*
* **Lifecycle contract**: `append → finalize → consume → dispose`. See
* `finalize()` and `dispose()` for the state machine. Disposal is
* orthogonal to finalization: either order is legal.
*/
export interface BindingEntry {
readonly scope: string; // '' for file-level, 'funcName@startIndex' for function-local
readonly varName: string;
readonly typeName: string;
}
/**
* Minimal graph-node shape required by `enrichExportedTypeMap()`. Intentionally
* narrower than the full `GraphNode` type in `graph/types.ts` so tests can
* construct a minimal mock without depending on the full graph module, and
* so the enrichment logic is a pure function over this contract.
*
* Matches the shape of the real `KnowledgeGraph` node's `properties.isExported`
* access path — tests that use a different shape silently pass while
* production fails.
*/
export interface EnrichmentGraphNode {
readonly id: string;
readonly properties?: { readonly isExported?: boolean } | undefined;
}
/**
* Minimal graph lookup interface used by `enrichExportedTypeMap()`.
* Consumes only the method the enrichment loop actually calls.
*/
export interface EnrichmentGraphLookup {
getNode(id: string): EnrichmentGraphNode | undefined;
}
/**
* Merge file-scope bindings from a (finalized) `BindingAccumulator` into an
* `exportedTypeMap` for symbols whose graph nodes are marked as exported.
*
* This is the single source of truth for the worker-path ExportedTypeMap
* enrichment loop. Previously the logic lived inline in `pipeline.ts` and
* the test suite reimplemented it as a `runEnrichmentLoop` helper — a
* drift-prone pattern that meant tests could pass while production regressed.
* Extracting it here makes the production code call the same function the
* tests call.
*
* **Node ID candidate order**: `Function:{filePath}:{name}` →
* `Variable:{filePath}:{name}` → `Const:{filePath}:{name}`. First match wins.
*
* **Tier 0 priority**: if `exportedTypeMap` already has an entry for a
* `(filePath, name)` pair, the accumulator entry does NOT overwrite it —
* the SymbolTable tier-0 pass is authoritative. Without this guard, a
* worker-path binding could clobber a higher-quality type from SymbolTable.
*
* **Finalize precondition**: the accumulator should be finalized before
* calling this function. The lifecycle contract is
* `append → finalize → enrich → dispose`. Finalization is not asserted
* here (the test suite and pipeline both honor it separately), but any
* append happening concurrently with this enrichment would be a lifecycle
* bug at the caller level.
*
* @returns The number of new entries written into `exportedTypeMap`
* (0 on empty accumulator or when every candidate was filtered
* out by the export check or the Tier 0 guard).
*/
export function enrichExportedTypeMap(
bindingAccumulator: BindingAccumulator,
graph: EnrichmentGraphLookup,
exportedTypeMap: Map<string, Map<string, string>>,
): number {
if (bindingAccumulator.fileCount === 0) return 0;
let enriched = 0;
for (const filePath of bindingAccumulator.files()) {
for (const [name, type] of bindingAccumulator.fileScopeEntries(filePath)) {
// Three-candidate-ID lookup mirrors the sequential-path export check
// in `collectExportedBindings()` (call-processor.ts).
const functionNodeId = `Function:${filePath}:${name}`;
const variableNodeId = `Variable:${filePath}:${name}`;
const constNodeId = `Const:${filePath}:${name}`;
const node =
graph.getNode(functionNodeId) ??
graph.getNode(variableNodeId) ??
graph.getNode(constNodeId);
if (!node?.properties?.isExported) continue;
let fileExports = exportedTypeMap.get(filePath);
if (!fileExports) {
fileExports = new Map();
exportedTypeMap.set(filePath, fileExports);
}
// Tier 0 priority: SymbolTable-populated entries are authoritative.
if (!fileExports.has(name)) {
fileExports.set(name, type);
enriched++;
}
}
}
return enriched;
}
const ENTRY_OVERHEAD = 64; // bytes per entry (object overhead + property refs)
const MAP_ENTRY_OVERHEAD = 80; // bytes per file entry in the map
export class BindingAccumulator {
// Storage is split into two parallel maps so file-scope reads are fast.
// - _allByFile holds every BindingEntry (used by getFile, memory estimate).
// - _fileScopeByFile is a nested Map<filePath, Map<varName, typeName>> for
// O(1) point-lookup via fileScopeGet(). For iteration-based consumers
// (enrichExportedTypeMap), fileScopeEntries() iterates the inner Map.
// Both maps carry the same key set modulo the `scope === ''` precondition:
// _allByFile has a key as soon as any entry is appended; _fileScopeByFile
// only has a key once a file-scope entry arrives. Code that iterates via
// files() uses _allByFile so files with only function-scope entries
// remain visible.
//
// Note: Map.set semantics mean a duplicate varName for the same file
// overwrites the previous value (last-write-wins). This is the correct
// behavior — duplicate top-level bindings in the same file shouldn't
// happen in well-formed source, and if they do the last declaration
// is typically the one the compiler sees.
private readonly _allByFile = new Map<string, BindingEntry[]>();
private readonly _fileScopeByFile = new Map<string, Map<string, string>>();
private _totalBindings = 0;
private _finalized = false;
private _disposed = false;
/**
* Append bindings for a file. Safe to call multiple times for the same file.
* Throws if the accumulator has been finalized. Skips if entries is empty.
*
* The `entries` parameter is `readonly` — this method never mutates the
* caller's array. Internally, the first `appendFile` call per filePath
* makes a defensive copy (`slice()`), and subsequent calls push into the
* accumulator's own storage.
*/
appendFile(filePath: string, entries: readonly BindingEntry[]): void {
if (this._finalized) {
throw new Error(
'[BindingAccumulator] appendFile after finalize — no further appends allowed',
);
}
if (entries.length === 0) {
return;
}
// Contract consistency: if this accumulator was previously disposed
// without being finalized, `dispose()` is documented to leave it
// "behaving like a fresh one" for subsequent appends. Clear the
// `_disposed` flag here so the `disposed` getter tracks the actual
// live state, not a stale signal from the prior lifecycle cycle.
if (this._disposed) {
this._disposed = false;
}
// Note on the file-scope-only invariant:
// The accumulator does NOT reject function-scope entries at this
// boundary. The narrowing contract is enforced by the two production
// write sites — `parse-worker.ts` (which uses `typeEnv.fileScope()`
// and hardcodes `scope: ''` in the pipeline adapter) and
// `type-env.ts::flush()` (which iterates only `env.get(FILE_SCOPE)`).
// The class JSDoc documents the invariant and the Phase 9 reversion
// path. Making `appendFile` runtime-reject non-file-scope entries
// would break the accumulator's own storage-split tests which
// legitimately exercise mixed-scope entries. If a future write path
// violates the invariant, tests should fail via missing exports in
// the enrichment loop, not via an assertion here.
// All-scope store.
const existingAll = this._allByFile.get(filePath);
if (existingAll !== undefined) {
for (const e of entries) {
existingAll.push(e);
}
} else {
this._allByFile.set(filePath, entries.slice());
}
// File-scope fast-path store (nested Map for O(1) point-lookup via fileScopeGet).
// Populated lazily on first file-scope entry per file.
let fileScopeMap = this._fileScopeByFile.get(filePath);
for (const e of entries) {
if (e.scope === '') {
if (fileScopeMap === undefined) {
fileScopeMap = new Map();
this._fileScopeByFile.set(filePath, fileScopeMap);
}
fileScopeMap.set(e.varName, e.typeName);
}
}
this._totalBindings += entries.length;
}
/** Lock the accumulator — no further appends. Idempotent. */
finalize(): void {
// Dev-mode invariant: verify the parallel storage split is consistent.
// `_fileScopeByFile` must be a proper projection of `_allByFile`
// where the outer key is a subset and the inner entries are exactly
// the `scope === ''` subset of `_allByFile[key]`. A drift would
// indicate a bug in `appendFile()` where one map was updated but
// not the other.
if (process.env.NODE_ENV !== 'production' && !this._finalized) {
for (const [filePath, fileScopeMap] of this._fileScopeByFile) {
const allEntries = this._allByFile.get(filePath);
if (allEntries === undefined) {
throw new Error(
`[BindingAccumulator] storage split drift: file ${filePath} has file-scope entries ` +
`but no _allByFile entry`,
);
}
// Count unique file-scope varNames in _allByFile (to match Map dedup
// semantics in _fileScopeByFile where Map.set deduplicates same-name).
const projectedNames = new Set(
allEntries.filter((e) => e.scope === '').map((e) => e.varName),
);
if (projectedNames.size !== fileScopeMap.size) {
throw new Error(
`[BindingAccumulator] storage split drift: file ${filePath} has ` +
`${fileScopeMap.size} file-scope names in Map but ${projectedNames.size} unique ` +
`file-scope varNames in _allByFile`,
);
}
}
}
this._finalized = true;
}
/**
* Release the accumulator's heap footprint. Clears both internal storage
* maps and resets `_totalBindings` to zero. Idempotent and orthogonal to
* `finalize()` — calling `dispose()` does not change the finalized state.
*
* Post-dispose contract: all read methods return empty/undefined state
* matching a never-appended-to accumulator. Specifically:
* - `fileCount === 0`
* - `totalBindings === 0`
* - `files()` yields an empty iterator
* - `getFile(x)` returns `undefined` for all `x`
* - `fileScopeEntries(x)` returns `[]` for all `x`
* - `estimateMemoryBytes()` returns `0`
*
* If `dispose()` is called **before** `finalize()`, subsequent `appendFile`
* calls succeed — the accumulator behaves like a fresh one. If called
* **after** `finalize()`, subsequent `appendFile` calls throw the existing
* "finalized" error.
*
* Lifecycle note: the pipeline disposes the accumulator after both Phase 9
* consumers (`processCallsFromExtracted`, `processAssignmentsFromExtracted`)
* and the ExportedTypeMap enrichment loop have completed, so the heap is
* released before Phase 14 (`runCrossFileBindingPropagation`) and
* `runGraphAnalysisPhases` begin their long-running work.
*/
dispose(): void {
this._allByFile.clear();
this._fileScopeByFile.clear();
this._totalBindings = 0;
this._disposed = true;
}
/** Get all bindings for a file, or undefined if the file is unknown. */
getFile(filePath: string): readonly BindingEntry[] | undefined {
return this._allByFile.get(filePath);
}
/**
* Get only scope='' (file-level) entries as [varName, typeName] tuples.
* For iteration-based consumers (e.g., `enrichExportedTypeMap`).
* Returns an empty array for an unknown file.
*
* O(1) map lookup + O(n_file_scope) tuple reconstruction from the inner
* Map. Does NOT walk function-scope entries.
*
* For point-lookup consumers (e.g., Phase 9 fallback), prefer
* `fileScopeGet(filePath, name)` — O(1) with no allocation.
*/
fileScopeEntries(filePath: string): readonly (readonly [string, string])[] {
const map = this._fileScopeByFile.get(filePath);
return map ? [...map.entries()] : [];
}
/**
* O(1) point-lookup for a single file-scope binding by (filePath, name).
* Returns the typeName if found, `undefined` otherwise.
*
* This is the preferred lookup path for Phase 9 consumers that resolve
* a single callee's return type — avoids the O(n_file_scope) iteration
* and defensive-copy allocation of `fileScopeEntries()`.
*/
fileScopeGet(filePath: string, name: string): string | undefined {
return this._fileScopeByFile.get(filePath)?.get(name);
}
/** Iterate over all file paths in insertion order. */
files(): IterableIterator<string> {
return this._allByFile.keys();
}
/** Number of distinct files with at least one binding. */
get fileCount(): number {
return this._allByFile.size;
}
/** Total number of binding entries across all files. */
get totalBindings(): number {
return this._totalBindings;
}
/** Whether the accumulator has been finalized. */
get finalized(): boolean {
return this._finalized;
}
/**
* Whether the accumulator has been disposed. Exposed for symmetry with
* `finalized` so debug tooling and future Phase 9 consumers can detect a
* disposed accumulator without inspecting empty state heuristically.
*
* Disposal and finalization are orthogonal: a disposed accumulator may or
* may not be finalized, and vice versa. See `dispose()` for the full
* lifecycle contract.
*/
get disposed(): boolean {
return this._disposed;
}
/**
* Rough memory estimate in bytes (intentionally pessimistic).
* Formula: sum of (ENTRY_OVERHEAD + char bytes of scope+varName+typeName) per entry
* + MAP_ENTRY_OVERHEAD + char bytes of filePath per file.
*
* Note: V8 stores all-ASCII strings as Latin-1 (1 byte/char) and only upgrades
* to UCS-2 (2 bytes/char) for non-Latin-1 code points. Source paths and type names
* are typically all-ASCII, so actual heap cost is roughly half what this returns.
* The pessimistic factor is intentional — better to over-budget than under-budget.
*
* **⚠ Cost profile**: O(totalBindings) — iterates every entry in
* `_allByFile` and reads three string `.length` properties per entry.
* At a typical repo scale (10k files × ~20 file-scope bindings) this is
* ~200k property reads per call. Call at most once per pipeline run,
* NOT per file, per chunk, or per progress tick. The current single
* call site is the dev-mode telemetry log at the pipeline finalize
* seam. Adding a per-file-progress caller would silently make it
* quadratic in repo size.
*/
estimateMemoryBytes(): number {
let total = 0;
for (const [filePath, entries] of this._allByFile) {
total += MAP_ENTRY_OVERHEAD + filePath.length * 2;
for (const e of entries) {
total += ENTRY_OVERHEAD + (e.scope.length + e.varName.length + e.typeName.length) * 2;
}
}
return total;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type {
ClassExtractionConfig,
ClassExtractor,
ClassLikeNodeLabel,
ExtractedClassSymbol,
} from '../class-types.js';
const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([
'nested_namespace_specifier',
'scoped_identifier',
'scoped_type_identifier',
'qualified_name',
'namespace_name',
'namespace_identifier',
'package_identifier',
'type_identifier',
'identifier',
'name',
'constant',
]);
const DEFAULT_TYPE_NAME_NODE_TYPES = new Set([
'type_identifier',
'identifier',
'simple_identifier',
'namespace_identifier',
'constant',
'name',
]);
const DEFAULT_LABEL_BY_NODE_TYPE: Record<string, ClassLikeNodeLabel> = {
class_declaration: 'Class',
abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
struct_declaration: 'Struct',
record_declaration: 'Record',
enum_declaration: 'Enum',
class_definition: 'Class',
struct_specifier: 'Struct',
class_specifier: 'Class',
enum_specifier: 'Enum',
struct_item: 'Struct',
enum_item: 'Enum',
class: 'Class',
object_declaration: 'Class',
companion_object: 'Class',
protocol_declaration: 'Interface',
extension_declaration: 'Class',
};
const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
'Class',
'Struct',
'Interface',
'Enum',
'Record',
]);
const normalizeQualifiedName = (value: string): string =>
value
.replace(/\s+/g, '')
.replace(/^::/, '')
.replace(/::/g, '.')
.replace(/\\/g, '.')
.replace(/\.+/g, '.')
.replace(/^\.+|\.+$/g, '');
const splitQualifiedName = (value: string): string[] => {
const normalized = normalizeQualifiedName(value);
return normalized ? normalized.split('.').filter(Boolean) : [];
};
const extractScopeSegmentsFromNode = (
scopeNode: SyntaxNode,
scopeNameNodeTypes: ReadonlySet<string>,
): string[] => {
const nameNode =
scopeNode.childForFieldName?.('name') ??
scopeNode.namedChildren?.find((child) => scopeNameNodeTypes.has(child.type));
return nameNode ? splitQualifiedName(nameNode.text) : [];
};
const extractTypeNameFromNode = (node: SyntaxNode): string | undefined => {
const nameField = node.childForFieldName?.('name');
if (nameField) return nameField.text;
const nameChild = node.namedChildren?.find((child) =>
DEFAULT_TYPE_NAME_NODE_TYPES.has(child.type),
);
return nameChild?.text;
};
const isClassLikeLabel = (label: NodeLabel | null | undefined): label is ClassLikeNodeLabel =>
label !== undefined && label !== null && CLASS_LIKE_LABELS.has(label as ClassLikeNodeLabel);
export function createClassExtractor(config: ClassExtractionConfig): ClassExtractor {
const typeDeclarationSet = new Set(config.typeDeclarationNodes);
const fileScopeSet = new Set(config.fileScopeNodeTypes ?? []);
const ancestorScopeSet = new Set(config.ancestorScopeNodeTypes ?? []);
const scopeNameNodeTypes = new Set([
...DEFAULT_SCOPE_NAME_NODE_TYPES,
...(config.scopeNameNodeTypes ?? []),
]);
const buildQualifiedName = (node: SyntaxNode, simpleName: string): string => {
let root = node;
while (root.parent) root = root.parent;
const readScopeSegments = (scopeNode: SyntaxNode): string[] =>
config.extractScopeSegments?.(scopeNode) ??
extractScopeSegmentsFromNode(scopeNode, scopeNameNodeTypes);
const fileScopeSegments: string[] = [];
for (const child of root.namedChildren ?? []) {
if (fileScopeSet.has(child.type)) {
fileScopeSegments.push(...readScopeSegments(child));
}
}
const ancestorScopes: string[][] = [];
let current = node.parent;
while (current) {
if (ancestorScopeSet.has(current.type)) {
const segments = readScopeSegments(current);
if (segments.length > 0) ancestorScopes.push(segments);
}
current = current.parent;
}
return [
...fileScopeSegments,
...ancestorScopes.reverse().flat(),
...splitQualifiedName(simpleName),
]
.filter(Boolean)
.join('.');
};
const extract = (
node: SyntaxNode,
fallback?: {
name?: string;
type?: NodeLabel | null;
},
): ExtractedClassSymbol | null => {
if (!typeDeclarationSet.has(node.type)) return null;
const name = config.extractName?.(node) ?? extractTypeNameFromNode(node) ?? fallback?.name;
const type =
config.extractType?.(node) ??
DEFAULT_LABEL_BY_NODE_TYPE[node.type] ??
(isClassLikeLabel(fallback?.type) ? fallback.type : undefined);
if (!name || !type) return null;
return {
name,
type,
qualifiedName: buildQualifiedName(node, name) || name,
};
};
return {
language: config.language,
isTypeDeclaration(node: SyntaxNode): boolean {
return typeDeclarationSet.has(node.type);
},
extract,
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null {
return extract(node, { name: simpleName })?.qualifiedName ?? null;
},
};
}
@@ -0,0 +1,44 @@
import type { NodeLabel, SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from './utils/ast-helpers.js';
export type ClassLikeNodeLabel = Extract<
NodeLabel,
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record'
>;
export interface ExtractedClassSymbol {
name: string;
type: ClassLikeNodeLabel;
qualifiedName: string;
}
/**
* Cross-language qualified type names are normalized to dot-separated scope
* segments:
* - file/package scope contributes leading segments when the language has one
* - lexical namespace/module/type scope contributes enclosing segments
* - the simple type name is always the trailing segment
*/
export interface ClassExtractor {
language: SupportedLanguages;
isTypeDeclaration(node: SyntaxNode): boolean;
extract(
node: SyntaxNode,
fallback?: {
name?: string;
type?: NodeLabel | null;
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
}
export interface ClassExtractionConfig {
language: SupportedLanguages;
typeDeclarationNodes: string[];
fileScopeNodeTypes?: string[];
ancestorScopeNodeTypes?: string[];
scopeNameNodeTypes?: string[];
extractName?: (node: SyntaxNode) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
}
@@ -226,6 +226,7 @@ export const ENTRY_POINT_PATTERNS = {
/^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[]>;
@@ -15,12 +15,17 @@ import type { FieldVisibility } from '../../field-types.js';
/**
* Check whether any child of `node` (named or unnamed) has .text matching
* one of the given `keywords`.
* the given `keyword`.
*
* Skips the `name` field child to avoid false positives when a method is
* named after a contextual keyword (e.g. `abstract()` in TypeScript).
*/
export function hasKeyword(node: SyntaxNode, keyword: string): boolean {
const nameNode = node.childForFieldName('name');
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.text.trim() === keyword) return true;
if (!child || child === nameNode) continue;
if (child.text.trim() === keyword) return true;
}
return false;
}
@@ -46,6 +51,7 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str
/**
* Return the first matching visibility keyword found either as a direct keyword
* child or inside a modifier wrapper node.
* Skips the `name` field child (same rationale as hasKeyword).
*/
export function findVisibility(
node: SyntaxNode,
@@ -53,10 +59,12 @@ export function findVisibility(
defaultVis: FieldVisibility,
modifierNodeType?: string,
): FieldVisibility {
const nameNode = node.childForFieldName('name');
// Direct keyword children
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
const text = child?.text.trim() as FieldVisibility | undefined;
if (!child || child === nameNode) continue;
const text = child.text.trim() as FieldVisibility | undefined;
if (text && (keywords as ReadonlySet<string>).has(text)) return text;
}
// Modifier wrapper
+2 -2
View File
@@ -1,7 +1,7 @@
// gitnexus/src/core/ingestion/field-types.ts
import type { TypeEnvironment } from './type-env.js';
import type { SymbolTable } from './symbol-table.js';
import type { SymbolTableReader } from './model/symbol-table.js';
import { SupportedLanguages } from 'gitnexus-shared';
/**
@@ -57,7 +57,7 @@ export interface FieldExtractorContext {
/** Type environment for resolution */
typeEnv: TypeEnvironment;
/** Symbol table for FQN lookups */
symbolTable: SymbolTable;
symbolTable: SymbolTableReader;
/** Current file path */
filePath: string;
/** Language ID */
@@ -1,3 +1,4 @@
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
@@ -43,6 +44,7 @@ export const walkRepositoryPaths = async (
const entries: ScannedFile[] = [];
let processed = 0;
let skippedLarge = 0;
const skippedLargePaths: string[] = [];
for (let start = 0; start < filtered.length; start += READ_CONCURRENCY) {
const batch = filtered.slice(start, start + READ_CONCURRENCY);
@@ -52,6 +54,7 @@ export const walkRepositoryPaths = async (
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) {
skippedLarge++;
skippedLargePaths.push(relativePath.replace(/\\/g, '/'));
return null;
}
return { path: relativePath.replace(/\\/g, '/'), size: stat.size };
@@ -73,6 +76,11 @@ export const walkRepositoryPaths = async (
console.warn(
` Skipped ${skippedLarge} large files (>${MAX_FILE_SIZE / 1024}KB, likely generated/vendored)`,
);
if (isVerboseIngestionEnabled()) {
for (const p of skippedLargePaths) {
console.warn(` - ${p}`);
}
}
}
return entries;
@@ -891,6 +891,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
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[]>;
@@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE } from './resolution-context.js';
import type {
ExtractedHeritage,
HeritageResolutionStrategy,
HeritageStrategyLookup,
} from './model/heritage-map.js';
import { resolveExtendsType } from './model/heritage-map.js';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE } from './model/resolution-context.js';
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship.
* Uses the symbol table first (authoritative — Tier 1); falls back to provider-defined
* heuristics for external symbols not present in the graph:
* - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java)
* - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS
* - All others: default EXTENDS
* Derive the heritage-resolution strategy for a language from its
* `LanguageProvider`. This is the production wiring that `buildHeritageMap`
* and the standalone `resolveExtendsType` call site use — the model layer
* itself stays unaware of the provider registry.
*/
/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
language: SupportedLanguages,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to provider-defined heuristics
const provider = getProvider(language);
if (provider.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (provider.heritageDefaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
export const getHeritageStrategyForLanguage: HeritageStrategyLookup = (
lang: SupportedLanguages,
): HeritageResolutionStrategy => {
const provider = getProvider(lang);
return {
interfaceNamePattern: provider.interfaceNamePattern,
defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS',
};
};
/**
@@ -180,7 +167,7 @@ export const processHeritage = async (
parentClassName,
file.path,
ctx,
language,
getHeritageStrategyForLanguage(language),
);
const child = resolveHeritageId(
@@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async (
h.parentName,
h.filePath,
ctx,
fileLanguage,
getHeritageStrategyForLanguage(fileLanguage),
);
const child = resolveHeritageId(
@@ -372,7 +359,7 @@ export const processHeritageFromExtracted = async (
/**
* Walk source files with the same heritage captures as parse-worker, producing
* {@link ExtractedHeritage} rows without mutating the graph. Used on the
* sequential pipeline path so `buildImplementorMap(..., ctx)` can run before
* sequential pipeline path so `buildHeritageMap(..., ctx)` can run before
* `processCalls` (worker path defers calls until heritage from all chunks exists).
*/
export async function extractExtractedHeritageFromFiles(
@@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
import { buildSuffixIndex } from './import-resolvers/utils.js';
import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js';
import type {
ResolutionContext,
ModuleAliasMap,
NamedImportMap,
} from './model/resolution-context.js';
import type {
ImportResult,
ResolveCtx,
@@ -61,30 +65,6 @@ function wireImplicitImports(
// Avoids expanding every Go package import into N individual ImportMap edges.
export type PackageMap = Map<string, Set<string>>;
// Type: Map<ImportingFilePath, Map<LocalName, {sourcePath, exportedName}>>
// Tracks which specific names a file imports from which sources (TS/Python only).
// Used to tighten Tier 2a resolution: `import { User } from './models'`
// means only `User` (not `Repo`) is visible from models.ts via this import.
// Stores both the resolved source path and the original exported name so that
// aliased imports (`import { User as U }`) can resolve U → User in the source file.
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by its suffix.
* Used by the symbol resolver for Go and C# directory-level import matching.
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
// ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers.
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {
@@ -11,6 +11,7 @@ export const EXTENSIONS = [
'.ts',
'.jsx',
'.js',
'.vue',
'/index.tsx',
'/index.ts',
'/index.jsx',
@@ -0,0 +1,13 @@
/**
* Vue import resolver — delegates to TypeScript's standard resolver.
*
* Vue <script> blocks use the same import syntax as TypeScript (including
* tsconfig path aliases like `@/`), so no custom resolution logic is needed.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveStandard } from './standard.js';
import type { ImportResolverFn } from './types.js';
export const resolveVueImport: ImportResolverFn = (raw, fp, ctx) =>
resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript);
@@ -9,9 +9,10 @@
* so adding a language to the enum without creating a provider is a compiler error.
*/
import type { SupportedLanguages } from 'gitnexus-shared';
import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
import type { ClassExtractor } from './class-types.js';
import type { ExportChecker } from './export-detection.js';
import type { FieldExtractor } from './field-extractor.js';
import type { MethodExtractor } from './method-types.js';
@@ -25,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared';
export type CaptureMap = Record<string, SyntaxNode | undefined>;
// ── Strategy tag types ─────────────────────────────────────────────────────
/** MRO strategy for multiple inheritance resolution. */
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
// so `core/ingestion/model/resolve.ts` can consume it without importing from
// this file (which would pull in the full language-registry dependency graph).
/** How a language handles imports — determines wildcard synthesis behavior. */
export type ImportSemantics = 'named' | 'wildcard' | 'namespace';
@@ -131,6 +129,10 @@ interface LanguageProviderConfig {
* declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract,
* isFinal, annotations metadata. Default: undefined (no method extraction). */
readonly methodExtractor?: MethodExtractor;
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
* Uses the same provider-driven strategy pattern as method/field extraction so
* namespace/package/module rules stay language-specific. */
readonly classExtractor?: ClassExtractor;
/** Extract a semantic description for a definition node (e.g., PHP Eloquent
* property arrays, relation method descriptions).
* Default: undefined (no description extraction). */
+185 -1
View File
@@ -9,19 +9,34 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
import { cCppExportChecker } from '../export-detection.js';
import { resolveCImport, resolveCppImport } from '../import-resolvers/standard.js';
import { C_QUERIES, CPP_QUERIES } from '../tree-sitter-queries.js';
import { isCppInsideClassOrStruct } from '../utils/ast-helpers.js';
/**
* Node types for standard function declarations that need C/C++ declarator handling.
* Used by cCppExtractFunctionName to determine how to extract the function name.
*/
const FUNCTION_DECLARATION_TYPES = new Set([
'function_declaration',
'function_definition',
'async_function_declaration',
'generator_function_declaration',
'function_item',
]);
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import type { LanguageProvider } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import {
cConfig as cFieldConfig,
cppConfig as cppFieldConfig,
} from '../field-extractors/configs/c-cpp.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@@ -130,6 +145,165 @@ const C_BUILT_INS: ReadonlySet<string> = new Set([
'put',
]);
const cClassExtractor = createClassExtractor({
language: SupportedLanguages.C,
typeDeclarationNodes: ['struct_specifier', 'enum_specifier'],
});
const cppClassExtractor = createClassExtractor({
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
});
/**
* C/C++ function name extraction — unwraps pointer_declarator / reference_declarator /
* function_declarator / qualified_identifier chains to find the actual function name.
* Handles field_identifier (method inside class body) and parenthesized_declarator.
*/
const cCppExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (!FUNCTION_DECLARATION_TYPES.has(node.type)) return null;
let funcName: string | null = null;
let label: NodeLabel = 'Function';
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
let declarator = node.childForFieldName?.('declarator');
if (!declarator) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') {
declarator = c;
break;
}
}
}
while (
declarator &&
(declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')
) {
let nextDeclarator = declarator.childForFieldName?.('declarator');
if (!nextDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'function_declarator' ||
c?.type === 'pointer_declarator' ||
c?.type === 'reference_declarator'
) {
nextDeclarator = c;
break;
}
}
}
declarator = nextDeclarator;
}
if (declarator) {
let innerDeclarator = declarator.childForFieldName?.('declarator');
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (
c?.type === 'qualified_identifier' ||
c?.type === 'identifier' ||
c?.type === 'field_identifier' ||
c?.type === 'parenthesized_declarator'
) {
innerDeclarator = c;
break;
}
}
}
if (innerDeclarator?.type === 'qualified_identifier') {
let nameNode = innerDeclarator.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (
innerDeclarator?.type === 'identifier' ||
innerDeclarator?.type === 'field_identifier'
) {
// field_identifier is used for method names inside C++ class bodies
funcName = innerDeclarator.text;
if (innerDeclarator.type === 'field_identifier') label = 'Method';
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') {
nestedId = c;
break;
}
}
if (nestedId?.type === 'qualified_identifier') {
let nameNode = nestedId.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < nestedId.childCount; i++) {
const c = nestedId.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
}
} else if (nestedId?.type === 'identifier') {
funcName = nestedId.text;
}
}
}
// Fallback for other node types in FUNCTION_DECLARATION_TYPES (e.g. function_item for Rust in C++ tree)
if (!funcName) {
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (
c?.type === 'identifier' ||
c?.type === 'property_identifier' ||
c?.type === 'simple_identifier'
) {
nameNode = c;
break;
}
}
}
funcName = nameNode?.text ?? null;
}
return { funcName, label };
};
/** Check if a C/C++ function_definition is inside a class or struct body.
* Used by cppLabelOverride to skip duplicate function captures
* that are already covered by definition.method queries. */
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
return false;
}
/** Label override shared by C and C++: skip function_definition captures inside class/struct
* bodies (they're duplicates of definition.method captures). */
const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
@@ -149,6 +323,11 @@ export const cProvider = defineLanguage({
importResolver: resolveCImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(cFieldConfig),
methodExtractor: createMethodExtractor({
...cMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
classExtractor: cClassExtractor,
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});
@@ -163,6 +342,11 @@ export const cppProvider = defineLanguage({
importSemantics: 'wildcard',
mroStrategy: 'leftmost-base',
fieldExtractor: createFieldExtractor(cppFieldConfig),
methodExtractor: createMethodExtractor({
...cppMethodConfig,
extractFunctionName: cCppExtractFunctionName,
}),
classExtractor: cppClassExtractor,
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
});
@@ -7,6 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as csharpConfig } from '../type-extractors/csharp.js';
import { csharpExportChecker } from '../export-detection.js';
@@ -125,5 +126,24 @@ export const csharpProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(csharpFieldConfig),
methodExtractor: createMethodExtractor(csharpMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.CSharp,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'struct_declaration',
'enum_declaration',
'record_declaration',
],
fileScopeNodeTypes: ['file_scoped_namespace_declaration'],
ancestorScopeNodeTypes: [
'namespace_declaration',
'class_declaration',
'interface_declaration',
'struct_declaration',
'enum_declaration',
'record_declaration',
],
}),
builtInNames: BUILT_INS,
});
+27 -4
View File
@@ -12,8 +12,9 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { NodeLabel } from 'gitnexus-shared';
import { FUNCTION_NODE_TYPES, extractFunctionName } from '../utils/ast-helpers.js';
import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
import { dartExportChecker } from '../export-detection.js';
@@ -21,6 +22,8 @@ import { resolveDartImport } from '../import-resolvers/dart.js';
import { DART_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { dartMethodConfig } from '../method-extractors/configs/dart.js';
/**
* Resolve the enclosing function from a `function_body` node by looking at its
@@ -28,8 +31,8 @@ import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.
* function_body are siblings under program or class_body, unlike most languages
* where the function declaration wraps both.
*
* Delegates name extraction to the shared `extractFunctionName` which already
* handles Dart's function_signature and method_signature node types.
* Extracts the function name inline — Dart uses function_signature and
* method_signature (which wraps function_signature) as its FUNCTION_NODE_TYPES.
*/
const dartEnclosingFunctionFinder = (
node: SyntaxNode,
@@ -37,7 +40,21 @@ const dartEnclosingFunctionFinder = (
if (node.type !== 'function_body') return null;
const prev = node.previousSibling;
if (!prev || !FUNCTION_NODE_TYPES.has(prev.type)) return null;
const { funcName, label } = extractFunctionName(prev);
// method_signature wraps function_signature — unwrap to reach the name
let target = prev;
let label: NodeLabel = 'Function';
if (prev.type === 'method_signature') {
label = 'Method';
for (let i = 0; i < prev.childCount; i++) {
const c = prev.child(i);
if (c?.type === 'function_signature') {
target = c;
break;
}
}
}
const funcName = target.childForFieldName?.('name')?.text ?? null;
return funcName ? { funcName, label } : null;
};
@@ -75,6 +92,12 @@ export const dartProvider = defineLanguage({
importResolver: resolveDartImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(dartFieldConfig),
methodExtractor: createMethodExtractor(dartMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Dart,
typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'],
ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'],
}),
enclosingFunctionFinder: dartEnclosingFunctionFinder,
builtInNames: BUILT_INS,
});
@@ -10,6 +10,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as goConfig } from '../type-extractors/go.js';
import { goExportChecker } from '../export-detection.js';
@@ -17,6 +18,8 @@ import { resolveGoImport } from '../import-resolvers/go.js';
import { GO_QUERIES } from '../tree-sitter-queries.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';
import { goMethodConfig } from '../method-extractors/configs/go.js';
export const goProvider = defineLanguage({
id: SupportedLanguages.Go,
@@ -27,4 +30,21 @@ export const goProvider = defineLanguage({
importResolver: resolveGoImport,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(goFieldConfig),
methodExtractor: createMethodExtractor(goMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Go,
typeDeclarationNodes: ['type_declaration'],
fileScopeNodeTypes: ['package_clause'],
extractName(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
return typeSpec?.childForFieldName('name')?.text;
},
extractType(node) {
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
const typeNode = typeSpec?.childForFieldName('type');
if (typeNode?.type === 'struct_type') return 'Struct';
if (typeNode?.type === 'interface_type') return 'Interface';
return undefined;
},
}),
});
@@ -23,6 +23,7 @@ import { phpProvider } from './php.js';
import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
export const providers = {
@@ -40,6 +41,7 @@ export const providers = {
[SupportedLanguages.Ruby]: rubyProvider,
[SupportedLanguages.Swift]: swiftProvider,
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
@@ -8,6 +8,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { javaTypeConfig } from '../type-extractors/jvm.js';
import { javaExportChecker } from '../export-detection.js';
@@ -31,4 +32,20 @@ export const javaProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(javaConfig),
methodExtractor: createMethodExtractor(javaMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Java,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
],
fileScopeNodeTypes: ['package_declaration'],
ancestorScopeNodeTypes: [
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
],
}),
});
@@ -8,6 +8,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
import { kotlinExportChecker } from '../export-detection.js';
@@ -15,12 +16,26 @@ import { resolveKotlinImport } from '../import-resolvers/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 { isKotlinClassMethod } from '../utils/ast-helpers.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
* Returns true when the captured definition node has a class_body ancestor. */
function isKotlinClassMethod(
captureNode: { parent?: SyntaxNode | null } | null | undefined,
): boolean {
let ancestor = captureNode?.parent;
while (ancestor) {
if (ancestor.type === 'class_body') return true;
ancestor = ancestor.parent;
}
return false;
}
const BUILT_INS: ReadonlySet<string> = new Set([
'println',
'print',
@@ -92,6 +107,16 @@ export const kotlinProvider = defineLanguage({
mroStrategy: 'implements-split',
fieldExtractor: createFieldExtractor(kotlinConfig),
methodExtractor: createMethodExtractor(kotlinMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Kotlin,
typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'],
fileScopeNodeTypes: ['package_header'],
ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'],
extractType(node) {
if (node.type !== 'class_declaration') return undefined;
return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class';
},
}),
builtInNames: BUILT_INS,
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;
+24 -11
View File
@@ -7,6 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
@@ -17,6 +18,8 @@ import { findDescendant, extractStringContent, type SyntaxNode } from '../utils/
import type { NodeLabel } from 'gitnexus-shared';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { phpMethodConfig } from '../method-extractors/configs/php.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'echo',
@@ -157,18 +160,22 @@ function extractPhpPropertyDescription(propName: string, propDeclNode: SyntaxNod
* Returns description like "hasMany(Post)" or null.
*/
function extractEloquentRelationDescription(methodNode: SyntaxNode): string | null {
function findRelationCall(node: SyntaxNode): SyntaxNode | null {
if (node.type === 'member_call_expression') {
function findRelationCall(root: SyntaxNode): SyntaxNode | null {
const stack: SyntaxNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'member_call_expression') {
const children = node.children ?? [];
const objectNode = children.find(
(c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this',
);
const nameNode = children.find((c: SyntaxNode) => c.type === 'name');
if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node;
}
const children = node.children ?? [];
const objectNode = children.find(
(c: SyntaxNode) => c.type === 'variable_name' && c.text === '$this',
);
const nameNode = children.find((c: SyntaxNode) => c.type === 'name');
if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node;
}
for (const child of node.children ?? []) {
const found = findRelationCall(child);
if (found) return found;
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i]);
}
}
return null;
}
@@ -231,6 +238,12 @@ export const phpProvider = defineLanguage({
importResolver: resolvePhpImport,
namedBindingExtractor: extractPhpNamedBindings,
fieldExtractor: createFieldExtractor(phpFieldConfig),
methodExtractor: createMethodExtractor(phpMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.PHP,
typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'],
ancestorScopeNodeTypes: ['namespace_definition'],
}),
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,
@@ -11,6 +11,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
import { pythonExportChecker } from '../export-detection.js';
@@ -19,6 +20,8 @@ import { extractPythonNamedBindings } from '../named-bindings/python.js';
import { PYTHON_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { pythonMethodConfig } from '../method-extractors/configs/python.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
@@ -61,5 +64,11 @@ export const pythonProvider = defineLanguage({
importSemantics: 'namespace',
mroStrategy: 'c3',
fieldExtractor: createFieldExtractor(pythonFieldConfig),
methodExtractor: createMethodExtractor(pythonMethodConfig),
classExtractor: createClassExtractor({
language: SupportedLanguages.Python,
typeDeclarationNodes: ['class_definition'],
ancestorScopeNodeTypes: ['class_definition'],
}),
builtInNames: BUILT_INS,
});
@@ -8,7 +8,10 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } 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';
import { rubyExportChecker } from '../export-detection.js';
@@ -16,6 +19,27 @@ import { resolveRubyImport } from '../import-resolvers/ruby.js';
import { RUBY_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { rubyMethodConfig } from '../method-extractors/configs/ruby.js';
/** Ruby method/singleton_method: extract name from 'name' field, label as Method. */
const rubyExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'method' && node.type !== 'singleton_method') return null;
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Method' };
};
const BUILT_INS: ReadonlySet<string> = new Set([
'puts',
@@ -85,5 +109,14 @@ export const rubyProvider = defineLanguage({
callRouter: routeRubyCall,
importSemantics: 'wildcard',
fieldExtractor: createFieldExtractor(rubyFieldConfig),
methodExtractor: createMethodExtractor({
...rubyMethodConfig,
extractFunctionName: rubyExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class'],
ancestorScopeNodeTypes: ['module', 'class'],
}),
builtInNames: BUILT_INS,
});
@@ -11,7 +11,10 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rustConfig } from '../type-extractors/rust.js';
import { rustExportChecker } from '../export-detection.js';
import { resolveRustImport } from '../import-resolvers/rust.js';
@@ -19,6 +22,37 @@ import { extractRustNamedBindings } from '../named-bindings/rust.js';
import { RUST_QUERIES } from '../tree-sitter-queries.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';
import { rustMethodConfig } from '../method-extractors/configs/rust.js';
/** Rust impl_item: find the function_item child and extract its name as a Method. */
const rustExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'impl_item') return null;
let funcItem: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_item') {
funcItem = c;
break;
}
}
if (!funcItem) return null;
let nameNode = funcItem.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcItem.childCount; i++) {
const c = funcItem.child(i);
if (c?.type === 'identifier') {
nameNode = c;
break;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Method' };
};
const BUILT_INS: ReadonlySet<string> = new Set([
'unwrap',
@@ -87,5 +121,14 @@ export const rustProvider = defineLanguage({
namedBindingExtractor: extractRustNamedBindings,
mroStrategy: 'qualified-syntax',
fieldExtractor: createFieldExtractor(rustFieldConfig),
methodExtractor: createMethodExtractor({
...rustMethodConfig,
extractFunctionName: rustExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Rust,
typeDeclarationNodes: ['struct_item', 'enum_item'],
ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'],
}),
builtInNames: BUILT_INS,
});
@@ -11,14 +11,19 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
import { swiftExportChecker } from '../export-detection.js';
import { resolveSwiftImport } from '../import-resolvers/swift.js';
import { SWIFT_QUERIES } from '../tree-sitter-queries.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { swiftMethodConfig } from '../method-extractors/configs/swift.js';
/**
* Group Swift files by SPM target for implicit module visibility.
@@ -107,6 +112,15 @@ function wireSwiftImplicitImports(
}
}
/** Swift init/deinit declarations have special names and Constructor label. */
const swiftExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type === 'init_declaration') return { funcName: 'init', label: 'Constructor' };
if (node.type === 'deinit_declaration') return { funcName: 'deinit', label: 'Constructor' };
return null; // fall through to generic
};
const BUILT_INS: ReadonlySet<string> = new Set([
'print',
'debugPrint',
@@ -227,6 +241,22 @@ export const swiftProvider = defineLanguage({
importSemantics: 'wildcard',
heritageDefaultEdge: 'IMPLEMENTS',
fieldExtractor: createFieldExtractor(swiftFieldConfig),
methodExtractor: createMethodExtractor({
...swiftMethodConfig,
extractFunctionName: swiftExtractFunctionName,
}),
classExtractor: createClassExtractor({
language: SupportedLanguages.Swift,
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'],
extractType(node) {
if (node.type === 'protocol_declaration') return 'Interface';
if (node.type !== 'class_declaration') return undefined;
if (node.children.some((child) => child?.text === 'struct')) return 'Struct';
if (node.children.some((child) => child?.text === 'enum')) return 'Enum';
return 'Class';
},
}),
implicitImportWirer: wireSwiftImplicitImports,
builtInNames: BUILT_INS,
});
@@ -8,7 +8,11 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import type { ClassExtractionConfig } from '../class-types.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { resolveTypescriptImport, resolveJavascriptImport } from '../import-resolvers/standard.js';
@@ -17,8 +21,38 @@ import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.j
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import {
typescriptMethodConfig,
javascriptMethodConfig,
} from '../method-extractors/configs/typescript-javascript.js';
const BUILT_INS: ReadonlySet<string> = new Set([
/**
* TypeScript/JavaScript: arrow_function and function_expression get their name
* from the parent variable_declarator (e.g. `const foo = () => {}`).
*/
const tsExtractFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: NodeLabel } | null => {
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
const parent = node.parent;
if (parent?.type !== 'variable_declarator') 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;
}
}
}
return { funcName: nameNode?.text ?? null, label: 'Function' };
};
export const BUILT_INS: ReadonlySet<string> = new Set([
'console',
'log',
'warn',
@@ -115,6 +149,22 @@ const BUILT_INS: ReadonlySet<string> = new Set([
'valueOf',
]);
const tsJsClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.TypeScript,
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
ancestorScopeNodeTypes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
};
export const typescriptProvider = defineLanguage({
id: SupportedLanguages.TypeScript,
extensions: ['.ts', '.tsx'],
@@ -124,6 +174,11 @@ export const typescriptProvider = defineLanguage({
importResolver: resolveTypescriptImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: typescriptFieldExtractor,
methodExtractor: createMethodExtractor({
...typescriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
classExtractor: createClassExtractor(tsJsClassConfig),
builtInNames: BUILT_INS,
});
@@ -136,5 +191,13 @@ export const javascriptProvider = defineLanguage({
importResolver: resolveJavascriptImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: createFieldExtractor(javascriptConfig),
methodExtractor: createMethodExtractor({
...javascriptMethodConfig,
extractFunctionName: tsExtractFunctionName,
}),
classExtractor: createClassExtractor({
...tsJsClassConfig,
language: SupportedLanguages.JavaScript,
}),
builtInNames: BUILT_INS,
});
@@ -0,0 +1,86 @@
/**
* Vue language provider.
*
* Vue SFCs are preprocessed by extracting the <script> / <script setup>
* block content, which is then parsed as TypeScript. This provider reuses
* nearly all TypeScript infrastructure — queries, type config, field
* extraction, and named binding extraction.
*
* Export detection for <script setup> is handled directly in the parse
* worker (all top-level bindings are implicitly exported). The export
* checker here is used as fallback for non-setup <script> blocks.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { resolveVueImport } from '../import-resolvers/vue.js';
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js';
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
import { BUILT_INS as TS_BUILT_INS } from './typescript.js';
const VUE_SPECIFIC_BUILT_INS = [
'ref',
'reactive',
'computed',
'watch',
'watchEffect',
'onMounted',
'onUnmounted',
'onBeforeMount',
'onBeforeUnmount',
'onUpdated',
'onBeforeUpdate',
'nextTick',
'defineProps',
'defineEmits',
'defineExpose',
'defineOptions',
'defineSlots',
'defineModel',
'withDefaults',
'toRef',
'toRefs',
'unref',
'isRef',
'shallowRef',
'triggerRef',
'provide',
'inject',
'useSlots',
'useAttrs',
] as const;
const VUE_BUILT_INS: ReadonlySet<string> = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]);
const vueClassExtractor = createClassExtractor({
language: SupportedLanguages.Vue,
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
ancestorScopeNodeTypes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'enum_declaration',
],
});
export const vueProvider = defineLanguage({
id: SupportedLanguages.Vue,
extensions: ['.vue'],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
importResolver: resolveVueImport,
namedBindingExtractor: extractTsNamedBindings,
fieldExtractor: typescriptFieldExtractor,
classExtractor: vueClassExtractor,
builtInNames: VUE_BUILT_INS,
});
@@ -0,0 +1,409 @@
// gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts
// Verified against tree-sitter-cpp ^0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// C/C++ helpers
// ---------------------------------------------------------------------------
/**
* Find the function_declarator inside a method node, handling pointer/reference
* return types where the function_declarator is nested inside a pointer_declarator
* or reference_declarator.
*/
function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null {
const declarator = node.childForFieldName('declarator');
if (!declarator) return null;
if (declarator.type === 'function_declarator') return declarator;
// Recursively unwrap pointer_declarator / reference_declarator chains
// (e.g. int** (*pfn)() has pointer_declarator → pointer_declarator → function_declarator)
let current: SyntaxNode | null = declarator;
while (current) {
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child?.type === 'function_declarator') return child;
}
// Go deeper into nested pointer/reference declarators
const next = current.namedChildren.find(
(c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator',
);
current = next ?? null;
}
return null;
}
/**
* Detect `= delete` and `= default` special member function declarations.
* These are not callable methods and should be suppressed from extraction.
* tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause`
* as named children of the function_definition node.
*/
function isDeletedOrDefaulted(node: SyntaxNode): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') {
return true;
}
}
return false;
}
/**
* Extract method name from a function_declarator.
* The name is the `declarator` field of the function_declarator — typically a
* field_identifier, but can be a destructor_name (~ClassName) or operator name.
*/
function extractCppMethodName(node: SyntaxNode): string | undefined {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return undefined;
// Suppress `= delete` and `= default` special members — these are not callable
// methods and should not appear in HAS_METHOD edges.
if (isDeletedOrDefaulted(node)) return undefined;
const nameNode = funcDecl.childForFieldName('declarator');
if (!nameNode) return undefined;
// destructor_name: ~ClassName
if (nameNode.type === 'destructor_name') return nameNode.text;
// operator_name: operator==, operator+, etc.
if (nameNode.type === 'operator_name') return nameNode.text;
return nameNode.text;
}
/**
* Extract return type from the `type` field of the method node.
* tree-sitter-cpp puts the return type as the `type` field on field_declaration
* and function_definition nodes.
*/
function extractCppReturnType(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('type');
if (typeNode) {
const typeText = typeNode.text?.trim();
// C++11 trailing return type: `auto foo() -> ReturnType`
// When the declared type is `auto`, check for a trailing_return_type on the
// function_declarator which holds the actual return type.
if (typeText === 'auto') {
const funcDecl = findFunctionDeclarator(node);
if (funcDecl) {
for (let i = 0; i < funcDecl.namedChildCount; i++) {
const child = funcDecl.namedChild(i);
if (child?.type === 'trailing_return_type') {
// trailing_return_type contains a type_descriptor with the real type
const typeDesc = child.firstNamedChild;
if (typeDesc) return typeDesc.text?.trim();
}
}
}
}
return typeText;
}
// Fallback: first type-like named child (for declarations without type field)
const first = node.firstNamedChild;
if (
first &&
(first.type === 'primitive_type' ||
first.type === 'type_identifier' ||
first.type === 'sized_type_specifier' ||
first.type === 'template_type')
) {
return first.text?.trim();
}
return undefined;
}
/**
* Extract parameters from the parameter_list inside the function_declarator.
*
* C/C++ uses parameter_declaration (required) and optional_parameter_declaration
* (with default value). Variadic `...` appears as a variadic_parameter_declaration.
*/
function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return [];
const paramList = funcDecl.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'parameter_declaration': {
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
// Extract name — may be wrapped in pointer_declarator or reference_declarator
const name = extractParamName(declNode);
params.push({
name: name ?? typeNode?.text?.trim() ?? '?',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'optional_parameter_declaration': {
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
const name = extractParamName(declNode);
params.push({
name: name ?? typeNode?.text?.trim() ?? '?',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: true,
isVariadic: false,
});
break;
}
case 'variadic_parameter_declaration': {
// C-style `...` or typed variadic `T... args`
const typeNode = param.childForFieldName('type');
const declNode = param.childForFieldName('declarator');
const name = extractParamName(declNode);
params.push({
name: name ?? '...',
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: true,
});
break;
}
case 'variadic_parameter': {
// Bare `...` (C-style)
params.push({
name: '...',
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
break;
}
}
}
// C/C++: bare `...` token in parameter list is an unnamed child (not a named node).
// Check all children for the unnamed `...` token when no variadic was detected above.
if (!params.some((p) => p.isVariadic)) {
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child && !child.isNamed && child.text === '...') {
params.push({
name: '...',
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
break;
}
}
}
return params;
}
/** Extract parameter name, recursively unwrapping pointer/reference declarators. */
function extractParamName(declNode: SyntaxNode | null): string | undefined {
if (!declNode) return undefined;
if (declNode.type === 'identifier') return declNode.text;
// Recursively unwrap pointer_declarator / reference_declarator chains (e.g. int** ptr)
for (let i = 0; i < declNode.namedChildCount; i++) {
const child = declNode.namedChild(i);
if (!child) continue;
if (child.type === 'identifier') return child.text;
if (child.type === 'pointer_declarator' || child.type === 'reference_declarator') {
return extractParamName(child);
}
}
return undefined;
}
/**
* Detect C++ access specifier by walking backwards through siblings.
* Mirrors the field extractor pattern in c-cpp.ts.
*/
function extractCppVisibility(node: SyntaxNode): MethodVisibility {
// If this node was unwrapped from a template_declaration, the access_specifier
// is a sibling of the template_declaration in field_declaration_list, not of
// this node — climb up one level before walking backward.
const startNode = node.parent?.type === 'template_declaration' ? node.parent : node;
let sibling = startNode.previousNamedSibling;
while (sibling) {
if (sibling.type === 'access_specifier') {
const text = sibling.text.replace(':', '').trim();
if (text === 'public' || text === 'private' || text === 'protected') return text;
}
sibling = sibling.previousNamedSibling;
}
// Default: struct/union = public, class = private
const parent = startNode.parent?.parent;
return parent?.type === 'struct_specifier' || parent?.type === 'union_specifier'
? 'public'
: 'private';
}
/**
* Detect pure virtual methods (`= 0`).
* tree-sitter-cpp emits `=` (unnamed) followed by `number_literal` with text `0`.
*/
function isPureVirtual(node: SyntaxNode): boolean {
let foundEquals = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.text === '=') {
foundEquals = true;
} else if (foundEquals && child.type === 'number_literal' && child.text === '0') {
return true;
} else if (foundEquals) {
foundEquals = false; // Reset if something else follows `=`
}
}
return false;
}
/**
* Check for a virtual_specifier ('final' or 'override') inside the function_declarator.
* In tree-sitter-cpp, these are named children of the function_declarator, not the
* method node itself.
*/
function hasVirtualSpecifier(node: SyntaxNode, keyword: string): boolean {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return false;
for (let i = 0; i < funcDecl.namedChildCount; i++) {
const child = funcDecl.namedChild(i);
if (child?.type === 'virtual_specifier' && child.text === keyword) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// C++ config
// ---------------------------------------------------------------------------
// C++ methods appear as field_declaration (declarations) or function_definition
// (inline definitions) inside field_declaration_list. The generic extractor
// iterates bodyNodeTypes children and matches against methodNodeTypes.
//
// Key difference from TS/JVM/C#: C++ has no dedicated method_declaration node.
// A field_declaration is a method if it contains a function_declarator.
// The generic extractor calls extractName() on every methodNodeType node — if
// extractName returns undefined (no function_declarator), the method is skipped.
//
// Known gaps:
// - Out-of-class method definitions (void Foo::bar() {}) are not linked as
// HAS_METHOD — they appear as top-level function_definition nodes.
// This includes namespace-wrapped and nested classes.
// - Friend declarations are not extracted.
// - Template method declarations with explicit specialization.
// - const-qualified method overloads (e.g. begin() vs begin() const) are
// disambiguated via isConst flag and $const ID suffix.
export const cppMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'union_specifier'],
// declaration covers constructors/destructors; field_declaration covers method
// declarations; function_definition covers inline method definitions.
// Non-method declarations (variables, typedefs) are filtered by extractName
// returning undefined when no function_declarator is found.
methodNodeTypes: ['field_declaration', 'function_definition', 'declaration'],
bodyNodeTypes: ['field_declaration_list'],
extractName: extractCppMethodName,
extractReturnType: extractCppReturnType,
extractParameters: extractCppParameters,
extractVisibility: extractCppVisibility,
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract(node) {
return isPureVirtual(node);
},
isFinal(node) {
return hasVirtualSpecifier(node, 'final');
},
isVirtual(node) {
// In C++, override and method-level final are only legal on virtual functions,
// so they imply virtual even without the explicit keyword.
return (
hasKeyword(node, 'virtual') ||
hasVirtualSpecifier(node, 'override') ||
hasVirtualSpecifier(node, 'final')
);
},
isOverride(node) {
return hasVirtualSpecifier(node, 'override');
},
isConst(node) {
// const qualifier appears as a type_qualifier child of function_declarator,
// after the parameter_list: e.g. `int size() const` → funcDecl has
// type_qualifier child with text "const". Not to be confused with return-type
// const (e.g. `const int& begin()`) which is at a different AST level.
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return false;
for (let i = 0; i < funcDecl.namedChildCount; i++) {
const child = funcDecl.namedChild(i);
if (child?.type === 'type_qualifier' && child.text === 'const') return true;
}
return false;
},
};
// ---------------------------------------------------------------------------
// C config (minimal — C has no classes/methods, only struct function pointers)
// Verified against tree-sitter-c 0.23.2
// ---------------------------------------------------------------------------
// C does not have methods in the OOP sense. Structs with function pointer fields
// are handled by the field extractor. This config exists for completeness but
// will rarely match since C structs don't contain function_definition nodes.
export const cMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.C,
typeDeclarationNodes: ['struct_specifier'],
methodNodeTypes: ['function_definition'],
bodyNodeTypes: ['field_declaration_list'],
extractName: extractCppMethodName,
extractReturnType: extractCppReturnType,
extractParameters: extractCppParameters,
extractVisibility() {
return 'public'; // C has no access control
},
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract() {
return false; // C has no virtual/abstract
},
isFinal() {
return false;
},
};
@@ -1,4 +1,5 @@
// gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts
// Verified against tree-sitter-c-sharp 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
@@ -85,6 +86,7 @@ function extractParametersFromList(paramList: SyntaxNode): ParameterInfo[] {
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: true,
});
@@ -126,6 +128,7 @@ function extractParametersFromList(paramList: SyntaxNode): ParameterInfo[] {
params.push({
name: nameNode.text,
type: typeName,
rawType: typeNode?.text?.trim() ?? null,
isOptional,
isVariadic: false,
});
@@ -186,6 +189,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
],
bodyNodeTypes: ['declaration_list'],
@@ -221,7 +225,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
// Constructors and destructors have no return type
// operator_declaration and conversion_operator_declaration use 'type' field, not 'returns'
const returnsNode = node.childForFieldName('returns');
if (returnsNode) return extractSimpleTypeName(returnsNode) ?? returnsNode.text?.trim();
if (returnsNode) return returnsNode.text?.trim();
// Fallback for operator/conversion declarations that use 'type' as return type field
if (node.type === 'operator_declaration' || node.type === 'conversion_operator_declaration') {
const typeNode = node.childForFieldName('type');
@@ -0,0 +1,409 @@
// gitnexus/src/core/ingestion/method-extractors/configs/dart.ts
// Verified against tree-sitter-dart 1.0.0 (80e23c07)
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Dart helpers
// ---------------------------------------------------------------------------
/** Type node types that represent a return type in function/getter/setter signatures. */
const TYPE_NODE_TYPES = new Set([
'type_identifier',
'generic_type',
'function_type',
'nullable_type',
'void_type',
'record_type',
]);
/**
* Dart method_signature is a WRAPPER node containing one inner signature:
* function_signature, constructor_signature, getter_signature, setter_signature,
* operator_signature, or factory_constructor_signature.
*
* Name, parameters, and return type live on the INNER signature, not on
* method_signature itself.
*/
function getInnerSignature(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child &&
(child.type === 'function_signature' ||
child.type === 'constructor_signature' ||
child.type === 'getter_signature' ||
child.type === 'setter_signature' ||
child.type === 'operator_signature' ||
child.type === 'factory_constructor_signature')
) {
return child;
}
}
// `declaration` nodes (abstract methods) also wrap function_signature as a
// named child — handled by the loop above.
return null;
}
/**
* Extract the method name from a method_signature node.
*
* Descends into the inner signature to find the name field/identifier.
*/
function extractDartName(node: SyntaxNode): string | undefined {
const inner = getInnerSignature(node);
if (!inner) return undefined;
// constructor_signature name field may include "ClassName.namedCtor" via multiple children.
// getter_signature, setter_signature, function_signature all have a 'name' field.
if (inner.type === 'operator_signature') {
// operator_signature has no 'name' field; name is 'operator' + the operator symbol
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'binary_operator') {
return `operator ${child.text.trim()}`;
}
}
// Check for unnamed operator tokens like []= or ~
for (let i = 0; i < inner.childCount; i++) {
const child = inner.child(i);
if (child && !child.isNamed && child.text.trim() !== 'operator') {
const text = child.text.trim();
if (text && !TYPE_NODE_TYPES.has(child.type)) {
return `operator ${text}`;
}
}
}
return undefined;
}
if (inner.type === 'getter_signature') {
const nameNode = inner.childForFieldName('name');
return nameNode?.text;
}
if (inner.type === 'setter_signature') {
const nameNode = inner.childForFieldName('name');
return nameNode ? `set ${nameNode.text}` : undefined;
}
if (inner.type === 'factory_constructor_signature') {
// Collect all identifier children to form "ClassName" or "ClassName.named"
const parts: string[] = [];
for (let i = 0; i < inner.childCount; i++) {
const child = inner.child(i);
if (child?.isNamed && child.type === 'identifier') {
parts.push(child.text);
}
}
return parts.length > 0 ? parts.join('.') : undefined;
}
// function_signature and constructor_signature both have a 'name' field
const nameNode = inner.childForFieldName('name');
if (nameNode) {
// constructor_signature: name field may be multiple identifiers joined by '.'
return nameNode.text;
}
return undefined;
}
/**
* Extract the return type from the inner signature.
*
* function_signature children include type nodes before the name.
* getter_signature children include type nodes before 'get' keyword.
* constructor/setter signatures have no return type.
*/
function extractDartReturnType(node: SyntaxNode): string | undefined {
const inner = getInnerSignature(node);
if (!inner) return undefined;
// Constructors and setters have no return type
if (
inner.type === 'constructor_signature' ||
inner.type === 'setter_signature' ||
inner.type === 'factory_constructor_signature'
) {
return undefined;
}
// For function_signature, getter_signature, operator_signature:
// The type node is a named child before the name/operator
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child && TYPE_NODE_TYPES.has(child.type)) {
return child.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from the inner signature's formal_parameter_list.
*
* Dart parameters can be:
* - Positional required: `int x`
* - Optional positional: `[int? x]` — wrapped in optional_formal_parameters with '['
* - Optional named: `{int? x}` or `{required int x}` — wrapped in optional_formal_parameters with '{'
*/
function extractDartParameters(node: SyntaxNode): ParameterInfo[] {
const inner = getInnerSignature(node);
if (!inner) return [];
// getter_signature has no parameters
if (inner.type === 'getter_signature') return [];
// Find formal_parameter_list — it's a child, not a field in function_signature
let paramList: SyntaxNode | null = null;
if (inner.type === 'constructor_signature' || inner.type === 'factory_constructor_signature') {
paramList = inner.childForFieldName('parameters');
}
if (!paramList) {
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'formal_parameter_list') {
paramList = child;
break;
}
}
}
if (!paramList) return [];
return extractParamsFromList(paramList, false);
}
/**
* Extract ParameterInfo entries from a formal_parameter_list or optional_formal_parameters node.
*/
function extractParamsFromList(listNode: SyntaxNode, isOptionalBlock: boolean): ParameterInfo[] {
const params: ParameterInfo[] = [];
for (let i = 0; i < listNode.namedChildCount; i++) {
const child = listNode.namedChild(i);
if (!child) continue;
if (child.type === 'formal_parameter') {
params.push(extractSingleParam(child, isOptionalBlock));
} else if (child.type === 'optional_formal_parameters') {
// Determine if these are named ({}) or positional ([]) optional params
// by checking the surrounding delimiters
params.push(...extractParamsFromList(child, true));
}
}
return params;
}
/**
* Extract a single ParameterInfo from a formal_parameter node.
*/
function extractSingleParam(param: SyntaxNode, isOptionalBlock: boolean): ParameterInfo {
const nameNode = param.childForFieldName('name');
const name = nameNode?.text ?? '<unknown>';
// Find the type node
let typeName: string | null = null;
let rawTypeName: string | null = null;
for (let i = 0; i < param.namedChildCount; i++) {
const child = param.namedChild(i);
if (child && TYPE_NODE_TYPES.has(child.type)) {
rawTypeName = child.text?.trim() ?? null;
typeName = extractSimpleTypeName(child) ?? rawTypeName;
break;
}
// Also check type_identifier
if (child?.type === 'type_identifier') {
rawTypeName = child.text?.trim() ?? null;
typeName = rawTypeName;
break;
}
}
// Check for 'required' keyword:
// 1. Among children of the param node itself
let hasRequired = false;
for (let i = 0; i < param.childCount; i++) {
const child = param.child(i);
if (child && child.text.trim() === 'required') {
hasRequired = true;
break;
}
}
// 2. In tree-sitter-dart, `required` may be an anonymous sibling token
// immediately preceding the formal_parameter inside optional_formal_parameters.
if (!hasRequired) {
let prev = param.previousSibling;
// Skip comma separators
while (prev && !prev.isNamed && prev.text.trim() === ',') {
prev = prev.previousSibling;
}
if (prev && !prev.isNamed && prev.text.trim() === 'required') {
hasRequired = true;
}
}
// A parameter is optional if it's inside an optional_formal_parameters block
// and does NOT have the 'required' keyword
const isOptional = isOptionalBlock && !hasRequired;
return {
name,
type: typeName,
rawType: rawTypeName,
isOptional,
isVariadic: false, // Dart has no variadic params
};
}
/**
* Dart visibility: underscore prefix = private, else public.
*
* We resolve the name by descending into the inner signature.
*/
function extractDartVisibility(node: SyntaxNode): MethodVisibility {
const name = extractDartName(node);
if (!name) return 'public';
// Strip 'set ' or 'operator ' prefix to get the raw name
const rawName = name.startsWith('set ')
? name.slice(4)
: name.startsWith('operator ')
? name.slice(9)
: name;
return rawName.startsWith('_') ? 'private' : 'public';
}
/**
* In tree-sitter-dart, `static` is an anonymous child token of
* `method_signature` (or `declaration`), not a previous sibling.
*
* We check children first, then fall back to previous siblings for
* grammar variants.
*/
function isDartStatic(node: SyntaxNode): boolean {
// In tree-sitter-dart, `static` is an anonymous child token of method_signature
// (or declaration), not a previous sibling.
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && !child.isNamed && child.text.trim() === 'static') return true;
// Stop once we hit the inner signature — static always precedes it
if (child?.isNamed) break;
}
// Also check previous siblings (fallback for grammar variants)
let sibling = node.previousSibling;
while (sibling) {
if (sibling.isNamed && sibling.type !== 'annotation') break;
if (!sibling.isNamed && sibling.text.trim() === 'static') return true;
sibling = sibling.previousSibling;
}
return false;
}
/**
* A Dart method is abstract if it has no function_body sibling following it.
* In the tree-sitter grammar, function_body is a sibling of method_signature
* in class_body.
*/
function isDartAbstract(node: SyntaxNode, _ownerNode: SyntaxNode): boolean {
// `declaration` nodes in class_body represent abstract methods (no body, followed by ';').
// Note: extension bodies cannot have abstract members in Dart, but `declaration` nodes
// do not appear in extension_body in practice since extensions must provide implementations.
if (node.type === 'declaration') return true;
// For method_signature nodes, check if the next named sibling is a function_body
const next = node.nextNamedSibling;
return !next || next.type !== 'function_body';
}
/**
* Check for `async`, `async*`, or `sync*` keyword in the function_body sibling.
* The keyword appears as an unnamed child of function_body, or
* as a sibling keyword before function_body.
*
* Dart has three async-like forms: `async` (Future), `async*` (Stream), `sync*` (Iterable).
* All three are treated as async for graph purposes.
*/
function isDartAsync(node: SyntaxNode): boolean {
let sibling: SyntaxNode | null = node.nextSibling;
let limit = 3;
while (sibling && limit > 0) {
if (!sibling.isNamed) {
const text = sibling.text.trim();
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
}
if (sibling.isNamed && sibling.type === 'function_body') {
// Check first child of function_body for async/async*/sync*
for (let i = 0; i < sibling.childCount; i++) {
const child = sibling.child(i);
if (child) {
const text = child.text.trim();
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
}
// Stop at first substantial child
if (child?.isNamed) break;
}
break;
}
sibling = sibling.nextSibling;
limit--;
}
return false;
}
/**
* Extract annotations that appear as sibling nodes before the method_signature
* in class_body. Each annotation node is prefixed with '@'.
*/
function extractDartAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
let sibling = node.previousNamedSibling;
while (sibling && sibling.type === 'annotation') {
// annotation node text already includes '@', e.g. "@override"
const text = sibling.text?.trim();
if (text) {
// Normalize: strip arguments from annotation if present, keep just the name
// e.g. "@deprecated" -> "@deprecated", "@JsonKey(name: 'id')" -> "@JsonKey"
const match = text.match(/^@(\w+)/);
if (match) {
annotations.unshift('@' + match[1]);
} else {
annotations.unshift(text);
}
}
sibling = sibling.previousNamedSibling;
}
return annotations;
}
// ---------------------------------------------------------------------------
// Dart config
// ---------------------------------------------------------------------------
export const dartMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Dart,
typeDeclarationNodes: ['class_definition', 'mixin_declaration', 'extension_declaration'],
methodNodeTypes: ['method_signature', 'declaration'],
bodyNodeTypes: ['class_body', 'extension_body'],
extractName: extractDartName,
extractReturnType: extractDartReturnType,
extractParameters: extractDartParameters,
extractVisibility: extractDartVisibility,
isStatic: isDartStatic,
isAbstract: isDartAbstract,
isFinal: () => false, // Dart methods cannot be 'final'
isAsync: isDartAsync,
extractAnnotations: extractDartAnnotations,
};
@@ -0,0 +1,195 @@
// gitnexus/src/core/ingestion/method-extractors/configs/go.ts
// Verified against tree-sitter-go 0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Go helpers
// ---------------------------------------------------------------------------
/**
* Extract the method/function name.
* - method_declaration: name is a `field_identifier`
* - function_declaration: name is an `identifier`
*/
function extractGoName(node: SyntaxNode): string | undefined {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
}
/**
* Extract return type from the `result` field.
*
* Go supports single return (`int`) and multi-return (`(User, error)`).
* Multi-return appears as a `parameter_list` — extract the first type.
*/
function extractGoReturnType(node: SyntaxNode): string | undefined {
const result = node.childForFieldName('result');
if (!result) return undefined;
// Single return type (type_identifier, pointer_type, etc.)
if (result.type !== 'parameter_list') {
return result.text?.trim();
}
// Multi-return: (Type, error) — extract first parameter's type
for (let i = 0; i < result.namedChildCount; i++) {
const param = result.namedChild(i);
if (param?.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
if (typeNode) return typeNode.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from the `parameters` field.
*
* Go parameter_list contains parameter_declaration nodes with optional
* `name` and required `type` fields. Go allows multiple names for one type:
* `func(a, b int)` — each name shares the type.
*
* Handles variadic_parameter_declaration (`...string`).
*/
function extractGoParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
const typeName = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
// Go allows multiple names for one type: func(a, b int)
const names: string[] = [];
for (let j = 0; j < param.namedChildCount; j++) {
const child = param.namedChild(j);
if (child?.type === 'identifier') {
names.push(child.text);
}
}
const rawType = typeNode?.text?.trim() ?? null;
if (names.length === 0) {
// Unnamed parameter: func(int, string)
params.push({
name: `_${i}`,
type: typeName,
rawType,
isOptional: false,
isVariadic: false,
});
} else {
for (const name of names) {
params.push({ name, type: typeName, rawType, isOptional: false, isVariadic: false });
}
}
} else if (param.type === 'variadic_parameter_declaration') {
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
const typeName = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
params.push({
name: nameNode?.text ?? `_${i}`,
type: typeName,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: true,
});
}
}
return params;
}
/**
* Go visibility: uppercase first character = exported (public), lowercase = unexported (private).
*/
function extractGoVisibility(node: SyntaxNode): MethodVisibility {
const name = extractGoName(node);
if (!name || name.length === 0) return 'private';
const first = name[0];
return first === first.toUpperCase() && first !== first.toLowerCase() ? 'public' : 'private';
}
/**
* Extract receiver type from the `receiver` field.
*
* The receiver is a parameter_list with one parameter_declaration:
* (r *Repo) → pointer_type → type_identifier "Repo"
* (r Repo) → type_identifier "Repo"
*/
function extractGoReceiverType(node: SyntaxNode): string | undefined {
const receiver = node.childForFieldName('receiver');
if (!receiver) return undefined;
for (let i = 0; i < receiver.namedChildCount; i++) {
const param = receiver.namedChild(i);
if (param?.type === 'parameter_declaration') {
const typeNode = param.childForFieldName('type');
if (!typeNode) continue;
// Unwrap pointer_type: *User → User
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
return inner?.text;
}
}
return undefined;
}
/**
* Resolve owner name from the receiver type.
* For function_declaration (no receiver), returns undefined.
*/
function extractGoOwnerName(node: SyntaxNode): string | undefined {
return extractGoReceiverType(node);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const goMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Go,
// Each method_declaration/function_declaration is treated as its own "container"
// for extractFromNode() — not used with extract() in the traditional sense.
// method_elem covers interface method signatures (abstract methods).
typeDeclarationNodes: ['method_declaration', 'function_declaration', 'method_elem'],
methodNodeTypes: ['method_declaration', 'function_declaration', 'method_elem'],
bodyNodeTypes: [],
extractName: extractGoName,
extractReturnType: extractGoReturnType,
extractParameters: extractGoParameters,
extractVisibility: extractGoVisibility,
extractReceiverType: extractGoReceiverType,
extractOwnerName: extractGoOwnerName,
isStatic(node) {
// Go functions (no receiver) are effectively static
return node.type === 'function_declaration';
},
isAbstract(node, _ownerNode) {
// Go interface method signatures (method_elem) are abstract — no body
return node.type === 'method_elem';
},
isFinal(_node) {
return false; // Go has no final methods
},
};
@@ -19,7 +19,9 @@ const INTERFACE_OWNER_TYPES = new Set(['interface_declaration', 'annotation_type
function extractReturnTypeFromField(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
// Use .text to preserve full generic types (e.g. List<User>, Stream<T>)
// needed by the call resolver for return-type inference.
return typeNode.text?.trim();
}
function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
@@ -68,6 +70,7 @@ function extractJavaParameters(node: SyntaxNode): ParameterInfo[] {
params.push({
name: nameNode.text,
type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: false,
});
@@ -76,6 +79,7 @@ function extractJavaParameters(node: SyntaxNode): ParameterInfo[] {
// Varargs: type_identifier + "..." + variable_declarator
let paramName: string | undefined;
let paramType: string | null = null;
let paramRawType: string | null = null;
for (let j = 0; j < param.namedChildCount; j++) {
const c = param.namedChild(j);
if (!c) continue;
@@ -90,13 +94,15 @@ function extractJavaParameters(node: SyntaxNode): ParameterInfo[] {
c.type === 'floating_point_type' ||
c.type === 'boolean_type'
) {
paramType = extractSimpleTypeName(c) ?? c.text?.trim();
paramRawType = c.text?.trim() ?? null;
paramType = extractSimpleTypeName(c) ?? paramRawType;
}
}
if (paramName) {
params.push({
name: paramName,
type: paramType,
rawType: paramRawType,
isOptional: false,
isVariadic: true,
});
@@ -192,6 +198,7 @@ function extractKotlinParameters(node: SyntaxNode): ParameterInfo[] {
let paramName: string | undefined;
let paramType: string | null = null;
let paramRawType: string | null = null;
let hasDefault = false;
const isVariadic = nextIsVariadic;
nextIsVariadic = false;
@@ -206,7 +213,8 @@ function extractKotlinParameters(node: SyntaxNode): ParameterInfo[] {
part.type === 'nullable_type' ||
part.type === 'function_type'
) {
paramType = extractSimpleTypeName(part) ?? part.text?.trim();
paramRawType = part.text?.trim() ?? null;
paramType = extractSimpleTypeName(part) ?? paramRawType;
}
}
@@ -223,6 +231,7 @@ function extractKotlinParameters(node: SyntaxNode): ParameterInfo[] {
params.push({
name: paramName,
type: paramType,
rawType: paramRawType,
isOptional: hasDefault,
isVariadic: isVariadic,
});
@@ -252,7 +261,7 @@ function extractKotlinReturnType(node: SyntaxNode): string | undefined {
child.type === 'nullable_type' ||
child.type === 'function_type')
) {
return extractSimpleTypeName(child) ?? child.text?.trim();
return child.text?.trim();
}
if (child.type === 'function_body') break;
}
@@ -0,0 +1,326 @@
// gitnexus/src/core/ingestion/method-extractors/configs/php.ts
// Verified against tree-sitter-php 0.23.12
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// PHP helpers
// ---------------------------------------------------------------------------
/** Regex to extract PHPDoc @return annotations: `@return User` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/** Node types to skip when walking backwards through siblings for PHPDoc. */
const PHPDOC_SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribute']);
/**
* Normalize a PHPDoc return type for the MethodExtractor.
* Strips nullable prefix, null/false/void unions, namespace prefixes, and
* rejects uninformative types (mixed, void, self, static, object, array).
*/
function normalizePhpReturnType(raw: string): string | undefined {
let type = raw.startsWith('?') ? raw.slice(1) : raw;
const parts = type
.split('|')
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
if (parts.length !== 1) return undefined;
type = parts[0];
const segments = type.split('\\');
type = segments[segments.length - 1];
if (
type === 'mixed' ||
type === 'void' ||
type === 'self' ||
type === 'static' ||
type === 'object' ||
type === 'array'
)
return undefined;
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
return undefined;
}
/**
* Walk backwards through preceding siblings of `node` to find a PHPDoc
* `@return Type` annotation. Skips `attribute_list` nodes (PHP 8 attributes).
*/
function extractPhpDocReturnType(node: SyntaxNode): string | undefined {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizePhpReturnType(match[1]);
} else if (sibling.isNamed && !PHPDOC_SKIP_NODE_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
}
const PHP_VIS = new Set<MethodVisibility>(['public', 'private', 'protected']);
/**
* Find the visibility keyword from a visibility_modifier named child.
* PHP tree-sitter emits `visibility_modifier` as a named node with text
* "public", "private", or "protected".
*/
function findPhpVisibility(node: SyntaxNode): MethodVisibility {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'visibility_modifier') {
const text = child.text.trim() as MethodVisibility;
if (PHP_VIS.has(text)) return text;
}
}
return 'public'; // PHP methods are public by default
}
/**
* Check for a named modifier child of a specific type.
* PHP tree-sitter uses distinct node types: abstract_modifier, final_modifier,
* static_modifier — rather than a wrapper `modifiers` node with keyword children.
*/
function hasModifierNode(node: SyntaxNode, modifierType: string): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === modifierType) return true;
}
return false;
}
/**
* Extract the return type from a PHP method_declaration node.
*
* In tree-sitter-php, the return type is not exposed via a named field.
* It appears as a type node (primitive_type, named_type, union_type,
* optional_type, nullable_type, intersection_type) after the formal_parameters
* and a `:` token separator.
*
* When the AST return type is missing or uninformative (`array` / `iterable`),
* falls back to parsing PHPDoc `@return Type` from preceding doc comments.
*/
function extractPhpReturnType(node: SyntaxNode): string | undefined {
const TYPE_NODE_TYPES = new Set([
'primitive_type',
'named_type',
'union_type',
'optional_type',
'nullable_type',
'intersection_type',
]);
let astType: string | undefined;
let seenParams = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === 'formal_parameters') {
seenParams = true;
continue;
}
// After the parameters node, look for the colon and then the type
if (seenParams && child.isNamed && TYPE_NODE_TYPES.has(child.type)) {
astType = child.text?.trim();
break;
}
// Stop at body or semicolon
if (child.type === 'compound_statement' || (!child.isNamed && child.text === ';')) {
break;
}
}
// If AST type is missing or uninformative, try PHPDoc @return fallback
if (!astType || astType === 'array' || astType === 'iterable') {
const docType = extractPhpDocReturnType(node);
if (docType) return docType;
}
return astType;
}
/**
* Extract parameters from a PHP method_declaration node.
*
* PHP parameter types in tree-sitter-php:
* - `simple_parameter`: regular parameter with optional type and default
* - `variadic_parameter`: `...$param` with optional type
* - `property_promotion_parameter`: constructor promotion `private string $name`
* (may also be variadic via an ERROR node containing `...`)
*/
function extractPhpParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'simple_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
// Detect optional: '=' token among children indicates a default value
let isOptional = false;
for (let j = 0; j < param.childCount; j++) {
const c = param.child(j);
if (c && !c.isNamed && c.text === '=') {
isOptional = true;
break;
}
}
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
rawType: typeNode?.text?.trim() ?? null,
isOptional,
isVariadic: false,
});
} else if (param.type === 'variadic_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: true,
});
} else if (param.type === 'property_promotion_parameter') {
const nameNode = param.childForFieldName('name');
if (!nameNode) continue;
const typeNode = param.childForFieldName('type');
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
// Detect variadic: an ERROR child containing "..." indicates variadic promotion
let isVariadic = false;
for (let j = 0; j < param.childCount; j++) {
const c = param.child(j);
if (c && (c.text === '...' || (c.type === 'ERROR' && c.text === '...'))) {
isVariadic = true;
break;
}
}
params.push({
name: stripDollar(nameNode.text),
type: typeName ?? null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic,
});
}
}
return params;
}
/** Strip leading $ from PHP variable names. */
function stripDollar(name: string): string {
return name.startsWith('$') ? name.slice(1) : name;
}
/**
* Extract PHP 8 attributes (#[...]) from a method_declaration node.
*
* AST structure: attribute_list → attribute_group → attribute → name child.
* Names are prefixed with '#' to distinguish from Java/Kotlin @ annotations.
*/
function extractPhpAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child || child.type !== 'attribute_list') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const group = child.namedChild(j);
if (!group || group.type !== 'attribute_group') continue;
for (let k = 0; k < group.namedChildCount; k++) {
const attr = group.namedChild(k);
if (!attr || attr.type !== 'attribute') continue;
const nameNode = attr.firstNamedChild;
if (nameNode && nameNode.type === 'name') {
annotations.push('#' + nameNode.text);
}
}
}
}
return annotations;
}
// ---------------------------------------------------------------------------
// PHP config
// ---------------------------------------------------------------------------
export const phpMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.PHP,
typeDeclarationNodes: [
'class_declaration',
'interface_declaration',
'trait_declaration',
'enum_declaration',
],
methodNodeTypes: ['method_declaration', 'function_definition'],
bodyNodeTypes: ['declaration_list'],
extractName(node) {
return node.childForFieldName('name')?.text;
},
extractReturnType: extractPhpReturnType,
extractParameters: extractPhpParameters,
extractVisibility: findPhpVisibility,
isStatic(node) {
return hasModifierNode(node, 'static_modifier');
},
isAbstract(node, ownerNode) {
if (hasModifierNode(node, 'abstract_modifier')) return true;
// Interface methods are implicitly abstract when they have no body.
// Check ownerNode first, then fall back to walking the parent chain
// (needed when called from extractFromNode where ownerNode === node).
let isInterface = ownerNode.type === 'interface_declaration';
if (!isInterface) {
let p = node.parent;
while (p) {
if (p.type === 'interface_declaration') {
isInterface = true;
break;
}
p = p.parent;
}
}
if (isInterface) {
const body = node.childForFieldName('body');
if (body) return false;
for (let i = 0; i < node.namedChildCount; i++) {
if (node.namedChild(i)?.type === 'compound_statement') return false;
}
return true;
}
return false;
},
isFinal(node) {
return hasModifierNode(node, 'final_modifier');
},
extractAnnotations: extractPhpAnnotations,
};
@@ -0,0 +1,325 @@
// gitnexus/src/core/ingestion/method-extractors/configs/python.ts
// Verified against tree-sitter-python 0.23.4
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Python helpers
// ---------------------------------------------------------------------------
/** Names that represent the instance/class receiver — not real parameters. */
const SELF_NAMES = new Set(['self', 'cls']);
/**
* Unwrap a decorated_definition to its inner function_definition.
*
* tree-sitter-python wraps decorated functions/methods in a `decorated_definition`
* node that contains the decorators as children followed by the function_definition.
* This is different from TS/JS where decorators are siblings.
*/
function unwrapDecorated(node: SyntaxNode): SyntaxNode {
if (node.type === 'decorated_definition') {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child.type === 'function_definition') return child;
}
}
return node;
}
/**
* Collect decorator names from a decorated_definition wrapper.
*
* Returns decorator names prefixed with '@'. If the node is a plain
* function_definition (no decorators), check if its parent is a
* decorated_definition and collect from there.
*/
function collectDecorators(node: SyntaxNode): SyntaxNode[] {
let wrapper: SyntaxNode | null = null;
if (node.type === 'decorated_definition') {
wrapper = node;
} else if (node.parent?.type === 'decorated_definition') {
wrapper = node.parent;
}
if (!wrapper) return [];
const decorators: SyntaxNode[] = [];
for (let i = 0; i < wrapper.namedChildCount; i++) {
const child = wrapper.namedChild(i);
if (child && child.type === 'decorator') {
decorators.push(child);
}
}
return decorators;
}
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
// decorator > identifier (simple)
// decorator > call > identifier (call-style, e.g. @lru_cache())
// decorator > attribute (dotted, e.g. @abc.abstractmethod)
const expr = decorator.firstNamedChild;
if (!expr) return undefined;
if (expr.type === 'identifier') return '@' + expr.text;
if (expr.type === 'attribute') return '@' + expr.text;
if (expr.type === 'call') {
const fn = expr.childForFieldName('function');
return fn ? '@' + fn.text : undefined;
}
return undefined;
}
function hasDecorator(node: SyntaxNode, name: string): boolean {
const decorators = collectDecorators(node);
for (const dec of decorators) {
const decName = extractDecoratorName(dec);
if (decName === '@' + name || decName?.endsWith('.' + name)) return true;
}
return false;
}
/**
* Extract parameters from a Python function_definition.
*
* Handles: identifier, default_parameter, typed_parameter, typed_default_parameter,
* list_splat_pattern (*args), dictionary_splat_pattern (**kwargs), and typed variants.
* Skips `self` and `cls` first parameters.
*/
function extractPythonParameters(node: SyntaxNode): ParameterInfo[] {
const funcNode = unwrapDecorated(node);
const paramList = funcNode.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
let isFirst = true;
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'identifier': {
// Bare parameter: `self`, `cls`, or untyped `x`
if (isFirst && SELF_NAMES.has(param.text)) {
isFirst = false;
continue;
}
isFirst = false;
params.push({
name: param.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'default_parameter': {
// `x = value` — untyped with default
isFirst = false;
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: true,
isVariadic: false,
});
}
break;
}
case 'typed_parameter': {
// `x: int` or `*args: str` or `**kwargs: int`
// The first named child can be identifier, list_splat_pattern, or dictionary_splat_pattern
const inner = param.firstNamedChild;
if (!inner) break;
if (isFirst && inner.type === 'identifier' && SELF_NAMES.has(inner.text)) {
isFirst = false;
continue;
}
isFirst = false;
const typeNode = param.childForFieldName('type');
const typeText = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
const rawTypeText = typeNode?.text?.trim() ?? null;
if (inner.type === 'list_splat_pattern') {
const nameId = inner.firstNamedChild;
if (nameId) {
params.push({
name: nameId.text,
type: typeText,
rawType: rawTypeText,
isOptional: false,
isVariadic: true,
});
}
} else if (inner.type === 'dictionary_splat_pattern') {
const nameId = inner.firstNamedChild;
if (nameId) {
params.push({
name: nameId.text,
type: typeText,
rawType: rawTypeText,
isOptional: false,
isVariadic: true,
});
}
} else {
params.push({
name: inner.text,
type: typeText,
rawType: rawTypeText,
isOptional: false,
isVariadic: false,
});
}
break;
}
case 'typed_default_parameter': {
// `x: int = 5` — typed with default
isFirst = false;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
if (nameNode) {
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: true,
isVariadic: false,
});
}
break;
}
case 'list_splat_pattern': {
// `*args` (untyped)
isFirst = false;
const nameId = param.firstNamedChild;
if (nameId) {
params.push({
name: nameId.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
case 'dictionary_splat_pattern': {
// `**kwargs` (untyped)
isFirst = false;
const nameId = param.firstNamedChild;
if (nameId) {
params.push({
name: nameId.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
default:
isFirst = false;
break;
}
}
return params;
}
/**
* Extract return type from the `return_type` field.
*
* tree-sitter-python uses a `type` field on function_definition for the return
* type annotation (e.g. `-> str`). The field contains a type node.
*/
function extractPythonReturnType(node: SyntaxNode): string | undefined {
const funcNode = unwrapDecorated(node);
const returnType = funcNode.childForFieldName('return_type');
if (!returnType) return undefined;
// Use .text to preserve full generic types (e.g. list[User], Dict[str, User])
// that the call resolver needs for for-loop iterable and return-type inference.
return returnType.text?.trim();
}
/**
* Extract visibility based on Python name-mangling convention.
* `__name` (not dunder) = private, `_name` = protected, else public.
*/
function extractPythonVisibility(node: SyntaxNode): MethodVisibility {
const funcNode = unwrapDecorated(node);
const nameNode = funcNode.childForFieldName('name');
const name = nameNode?.text;
if (!name) return 'public';
if (name.startsWith('__') && !name.endsWith('__')) return 'private';
if (name.startsWith('_') && !(name.startsWith('__') && name.endsWith('__'))) return 'protected';
return 'public';
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const pythonMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Python,
typeDeclarationNodes: ['class_definition'],
// Both function_definition and decorated_definition must be listed:
// decorated methods appear as decorated_definition in the class body block,
// while undecorated methods appear as function_definition directly.
methodNodeTypes: ['function_definition', 'decorated_definition'],
bodyNodeTypes: ['block'],
extractName(node) {
const funcNode = unwrapDecorated(node);
const nameNode = funcNode.childForFieldName('name');
return nameNode?.text;
},
extractReturnType: extractPythonReturnType,
extractParameters: extractPythonParameters,
extractVisibility: extractPythonVisibility,
isStatic(node) {
return hasDecorator(node, 'staticmethod') || hasDecorator(node, 'classmethod');
},
isAbstract(node, _ownerNode) {
return hasDecorator(node, 'abstractmethod');
},
isFinal(_node) {
return false; // @typing.final (PEP 591) is captured in annotations; isFinal not modeled
},
extractAnnotations(node) {
const decorators = collectDecorators(node);
const annotations: string[] = [];
for (const dec of decorators) {
const name = extractDecoratorName(dec);
if (name) annotations.push(name);
}
return annotations;
},
isAsync(node) {
const funcNode = unwrapDecorated(node);
return hasKeyword(funcNode, 'async');
},
};
@@ -0,0 +1,303 @@
// gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts
// Verified against tree-sitter-ruby 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Ruby helpers
// ---------------------------------------------------------------------------
const VISIBILITY_MODIFIERS = new Set(['private', 'protected', 'public']);
/** Regex to extract YARD `@return [Type]` annotations from comments. */
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
/**
* Extract the simple type name from a YARD type string.
* Handles qualified types ("Models::User" -> "User"), generics ("Array<User>"
* -> "Array"), nullable ("String, nil" -> "String"), and rejects ambiguous
* unions ("String, Integer" -> undefined).
*/
function extractYardTypeName(yardType: string): string | undefined {
const trimmed = yardType.trim();
// Bracket-balanced split on commas to handle generics like Hash<Symbol, User>
const parts: string[] = [];
let depth = 0,
start = 0;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed[i] === '<') depth++;
else if (trimmed[i] === '>') depth--;
else if (trimmed[i] === ',' && depth === 0) {
parts.push(trimmed.slice(start, i).trim());
start = i + 1;
}
}
parts.push(trimmed.slice(start).trim());
const filtered = parts.filter((p) => p !== '' && p !== 'nil');
if (filtered.length !== 1) return undefined; // ambiguous union
const typePart = filtered[0];
// Qualified: "Models::User" -> "User"
const segments = typePart.split('::');
const last = segments[segments.length - 1];
// Generic: "Array<User>" -> "Array"
const genericMatch = last.match(/^(\w+)\s*[<{(]/);
if (genericMatch) return genericMatch[1];
// Simple identifier
if (/^\w+$/.test(last)) return last;
return undefined;
}
/**
* Extract visibility for a Ruby method by walking backwards through the
* parent body_statement's named children from the method node's position.
*
* Ruby visibility modifiers (private, protected, public) appear as bare
* `identifier` nodes in the body_statement. The most recent modifier
* before the method determines its visibility. Default is public.
*
* Example AST for:
* class Foo
* private
* def secret; end
* end
*
* body_statement
* identifier "private" ← index 0
* method "def secret" ← index 1
*/
function extractRubyVisibility(node: SyntaxNode): MethodVisibility {
const parent = node.parent;
if (!parent) return 'public';
// Find the index of this method node in the parent's named children
let methodIndex = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
if (parent.namedChild(i) === node) {
methodIndex = i;
break;
}
}
if (methodIndex < 0) return 'public';
// Walk backwards from the method node looking for a visibility modifier
for (let i = methodIndex - 1; i >= 0; i--) {
const sibling = parent.namedChild(i);
if (!sibling) continue;
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) {
return sibling.text as MethodVisibility;
}
// module_function makes instance methods private
if (sibling.type === 'identifier' && sibling.text === 'module_function') {
return 'private';
}
}
return 'public';
}
/**
* Extract parameters from a Ruby method's method_parameters node.
*
* Handles: identifier, optional_parameter (default), splat_parameter (*args),
* hash_splat_parameter (**kwargs), block_parameter (&block), keyword_parameter.
*/
function extractRubyParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'identifier': {
// Plain parameter: def foo(x)
params.push({
name: param.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'optional_parameter': {
// Default parameter: def foo(x = 10)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: true,
isVariadic: false,
});
}
break;
}
case 'splat_parameter': {
// Splat: def foo(*args)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
case 'hash_splat_parameter': {
// Double splat: def foo(**kwargs)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
case 'block_parameter': {
// Block: def foo(&block)
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
}
break;
}
case 'keyword_parameter': {
// Keyword: def foo(name:) or def foo(name: "default")
const nameNode = param.childForFieldName('name');
const valueNode = param.childForFieldName('value');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
rawType: null,
isOptional: !!valueNode,
isVariadic: false,
});
}
break;
}
}
}
return params;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export const rubyMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class', 'module', 'singleton_class'],
methodNodeTypes: ['method', 'singleton_method'],
bodyNodeTypes: ['body_statement'],
extractOwnerName(node) {
// singleton_class (class << self) inherits the enclosing class/module name
if (node.type === 'singleton_class') {
let ancestor = node.parent;
while (ancestor) {
if (ancestor.type === 'class' || ancestor.type === 'module') {
const nameNode = ancestor.childForFieldName('name');
return nameNode?.text;
}
ancestor = ancestor.parent;
}
return undefined;
}
return undefined; // use default resolution for class/module
},
extractName(node) {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
},
extractReturnType(node) {
// Walk backwards through preceding siblings looking for YARD @return [Type].
// Try direct siblings first, then fall back to parent (body_statement) siblings
// for class methods where the comment may be a sibling of the body_statement.
const search = (startNode: SyntaxNode): string | undefined => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = YARD_RETURN_RE.exec(sibling.text);
if (match) return extractYardTypeName(match[1]);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
};
const result = search(node);
if (result) return result;
if (node.parent?.type === 'body_statement') {
return search(node.parent);
}
return undefined;
},
extractParameters: extractRubyParameters,
extractVisibility: extractRubyVisibility,
isStatic(node) {
if (node.type === 'singleton_method') return true;
// module_function makes following methods callable at module level (static)
const parent = node.parent;
if (!parent) return false;
let methodIndex = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
if (parent.namedChild(i) === node) {
methodIndex = i;
break;
}
}
for (let i = methodIndex - 1; i >= 0; i--) {
const sibling = parent.namedChild(i);
if (!sibling) continue;
if (sibling.type === 'identifier' && sibling.text === 'module_function') return true;
// Other visibility modifiers override module_function
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) return false;
}
return false;
},
isAbstract(_node, _ownerNode) {
return false; // Ruby has no abstract methods
},
isFinal(_node) {
return false; // Ruby has no final methods
},
};
@@ -0,0 +1,211 @@
// gitnexus/src/core/ingestion/method-extractors/configs/rust.ts
// Verified against tree-sitter-rust 0.23.1
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Rust helpers
// ---------------------------------------------------------------------------
/**
* Extract method name from function_item or function_signature_item.
* Both use a `name` field containing an identifier.
*/
function extractRustMethodName(node: SyntaxNode): string | undefined {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
}
/**
* Extract return type from the `return_type` field.
* tree-sitter-rust puts the return type (after `->`) as the `return_type` field.
*/
function extractRustReturnType(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('return_type');
if (!typeNode) return undefined;
return typeNode.text?.trim();
}
/**
* Extract parameters, skipping the self_parameter (handled by extractReceiverType).
*
* Rust parameters use `pattern` and `type` fields:
* parameter { pattern: identifier, type: primitive_type }
*/
function extractRustParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
// Skip self_parameter — it is the receiver, not a regular parameter
if (param.type === 'self_parameter') continue;
if (param.type === 'parameter') {
const patternNode = param.childForFieldName('pattern');
const typeNode = param.childForFieldName('type');
params.push({
name: patternNode?.text ?? '?',
type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: false,
});
}
}
return params;
}
/**
* Detect visibility from visibility_modifier named child.
* `pub`, `pub(crate)`, `pub(super)`, `pub(in path)` → public.
* Absence → private (Rust default).
*/
function extractRustVisibility(node: SyntaxNode): MethodVisibility {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'visibility_modifier') return 'public';
}
return 'private';
}
/**
* Detect receiver type from the first parameter if it is a self_parameter.
*
* Variants:
* - `self` → "self"
* - `&self` → "&self"
* - `&mut self` → "&mut self"
* - `mut self` → "mut self"
* - `self: Box<Self>` → "Box<Self>" (explicit self type)
*/
function extractRustReceiverType(node: SyntaxNode): string | undefined {
const paramList = node.childForFieldName('parameters');
if (!paramList) return undefined;
const first = paramList.namedChild(0);
if (!first || first.type !== 'self_parameter') return undefined;
return first.text;
}
/**
* Check whether a function_item has the `async` keyword.
* tree-sitter-rust wraps it in a `function_modifiers` named child.
*/
function isRustAsync(node: SyntaxNode): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'function_modifiers' && child.text.includes('async')) return true;
}
return false;
}
/**
* Extract attributes from preceding sibling attribute_item nodes.
*
* In tree-sitter-rust, `#[inline]` is an `attribute_item` sibling that precedes
* the function_item in the declaration_list, not a child of the function_item.
*/
function extractRustAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
let sibling = node.previousNamedSibling;
while (sibling) {
if (sibling.type === 'attribute_item') {
annotations.unshift(sibling.text);
} else {
// Stop at the first non-attribute sibling — attributes are contiguous
break;
}
sibling = sibling.previousNamedSibling;
}
return annotations;
}
// ---------------------------------------------------------------------------
// Rust config
// ---------------------------------------------------------------------------
// Rust methods live inside `impl` blocks (concrete implementations) or
// `trait` blocks (trait definitions with required/default methods).
//
// `impl_item` body contains `function_item` nodes for concrete methods.
// `trait_item` body contains `function_item` (default methods) and
// `function_signature_item` (required/abstract methods without a body).
//
// ownerName resolution: `impl_item` has no `name` field — the generic
// extractor falls back to the first `type_identifier` child, which is the
// implementing type (e.g. `impl MyStruct { ... }` → "MyStruct").
// `trait_item` uses the standard `name` field.
//
// Known gaps:
// - Macro-generated methods (e.g. derive) are not visible in the AST.
// - Unsafe methods are not distinguished (no isUnsafe field in schema).
export const rustMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Rust,
typeDeclarationNodes: ['impl_item', 'trait_item'],
methodNodeTypes: ['function_item', 'function_signature_item'],
bodyNodeTypes: ['declaration_list'],
// For `impl Trait for Struct`, resolve owner to the concrete Struct (after `for`).
// For plain `impl Struct`, resolve to Struct (first type_identifier).
// For `trait Foo`, let the default name-field resolution handle it.
extractOwnerName(node) {
if (node.type !== 'impl_item') return undefined;
const children = node.children ?? [];
const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for');
if (forIdx !== -1) {
// impl Trait for Struct — pick the type after `for`
const typeNode = children
.slice(forIdx + 1)
.find(
(c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier',
);
if (typeNode) return typeNode.text;
}
// Plain `impl Struct` — pick the first type_identifier
const first = children.find((c: SyntaxNode) => c.type === 'type_identifier');
return first?.text;
},
extractName: extractRustMethodName,
extractReturnType: extractRustReturnType,
extractParameters: extractRustParameters,
extractVisibility: extractRustVisibility,
isStatic(node) {
// A Rust method is an "associated function" (static) if it lacks a
// self_parameter as first parameter.
const paramList = node.childForFieldName('parameters');
if (!paramList) return true;
const first = paramList.namedChild(0);
return !first || first.type !== 'self_parameter';
},
isAbstract(node, ownerNode) {
// Only trait methods without a body (function_signature_item) are abstract.
// function_signature_item never has a body field.
if (ownerNode.type === 'trait_item' && node.type === 'function_signature_item') {
return true;
}
return false;
},
isFinal() {
// Rust has no `final` concept — all methods are effectively sealed
// (traits cannot be "overridden" the way Java methods can).
return false;
},
extractAnnotations: extractRustAnnotations,
extractReceiverType: extractRustReceiverType,
isAsync: isRustAsync,
};
@@ -0,0 +1,311 @@
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
// Verified against tree-sitter-swift 0.6.0
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { findVisibility, hasKeyword, hasModifier } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Swift helpers
// ---------------------------------------------------------------------------
const SWIFT_VIS = new Set<MethodVisibility>([
'public',
'private',
'fileprivate',
'internal',
'open',
]);
/**
* Extract the method name from a function_declaration or protocol_function_declaration.
*
* In tree-sitter-swift, the name is stored in a `simple_identifier` child
* (not a 'name' field) on both function_declaration and protocol_function_declaration.
*/
function extractSwiftName(node: SyntaxNode): string | undefined {
// Try field-based name first
const nameField = node.childForFieldName('name');
if (nameField) return nameField.text;
// Walk named children for simple_identifier (the function name)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'simple_identifier') return child.text;
}
return undefined;
}
/**
* Extract the return type from a Swift function declaration.
*
* In tree-sitter-swift, the return type appears in a `type_annotation` child
* that follows the parameter list (after `->` in source). It may also appear
* as a direct type child (user_type, optional_type, tuple_type, array_type).
*/
function extractSwiftReturnType(node: SyntaxNode): string | undefined {
// Look for the return type — typically the last type_annotation or a type node
// that appears after the parameter list.
// tree-sitter-swift places the return type inside a type child after '->'
let seenParams = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'parameter') {
seenParams = true;
continue;
}
// The parameter list may be unnamed children; track when we pass ')'
if (seenParams || child.type === 'type_annotation') {
if (child.type === 'type_annotation') {
const inner = child.firstNamedChild;
if (inner) return inner.text?.trim();
}
if (
child.type === 'user_type' ||
child.type === 'optional_type' ||
child.type === 'tuple_type' ||
child.type === 'array_type' ||
child.type === 'dictionary_type' ||
child.type === 'function_type'
) {
return child.text?.trim();
}
}
}
// Fallback: scan all children (named + unnamed) for '->' then grab the next named child
let seenArrow = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (!child.isNamed && child.text.trim() === '->') {
seenArrow = true;
continue;
}
if (seenArrow && child.isNamed) {
return child.text?.trim();
}
}
return undefined;
}
/**
* Extract parameters from a Swift function declaration.
*
* In tree-sitter-swift, parameters are `parameter` named children directly on
* the function_declaration node. Each parameter has:
* - An external name (label) and/or internal name as simple_identifier children
* - A type_annotation child containing the type
* - An optional default value after '='
* - A possible `...` for variadic parameters
*/
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
// In tree-sitter-swift 0.6.0, 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++) {
const child = node.child(i);
if (!child?.isNamed || child.type !== 'parameter') continue;
// Extract parameter name — the last simple_identifier is the internal name
let paramName: string | undefined;
for (let j = 0; j < child.namedChildCount; j++) {
const part = child.namedChild(j);
if (part?.type === 'simple_identifier') {
paramName = part.text;
}
}
if (!paramName) continue;
// Extract type — tree-sitter-swift uses user_type (not type_annotation)
let typeName: string | null = null;
let rawTypeName: string | null = null;
for (let j = 0; j < child.namedChildCount; j++) {
const part = child.namedChild(j);
if (part?.type === 'user_type' || part?.type === 'type_annotation') {
rawTypeName = part.text?.trim() ?? null;
const inner = part.firstNamedChild;
if (inner) {
typeName = extractSimpleTypeName(inner) ?? inner.text?.trim() ?? null;
} else {
typeName = rawTypeName;
}
break;
}
// Handle built-in types (array_type, dictionary_type, optional_type, tuple_type)
if (part?.type.endsWith('_type') && part.type !== 'simple_identifier') {
rawTypeName = part.text?.trim() ?? null;
typeName = extractSimpleTypeName(part) ?? rawTypeName;
break;
}
}
// Check for default value: '=' token appears as a sibling after the parameter node
let isOptional = false;
const nextSibling = node.child(i + 1);
if (nextSibling && !nextSibling.isNamed && nextSibling.text.trim() === '=') {
isOptional = true;
}
// Check for variadic: '...' token among parameter children
let isVariadic = false;
for (let j = 0; j < child.childCount; j++) {
const c = child.child(j);
if (c && c.text.trim() === '...') {
isVariadic = true;
break;
}
}
params.push({
name: paramName,
type: typeName,
rawType: rawTypeName,
isOptional,
isVariadic,
});
}
return params;
}
/**
* Check if a method is inside a protocol.
*
* A protocol_function_declaration is always abstract. For function_declaration
* inside a protocol_body (if it appears there), it's also abstract when it has
* no body.
*/
function isSwiftAbstract(node: SyntaxNode, ownerNode: SyntaxNode): boolean {
// protocol_function_declaration nodes are inherently abstract
if (node.type === 'protocol_function_declaration') return true;
// function_declaration inside a protocol is abstract if it has no body
if (ownerNode.type === 'protocol_declaration') {
const body = node.childForFieldName('body');
if (!body) {
// Also check for function_body named child
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'function_body') return false;
}
return true;
}
return false;
}
return false;
}
/**
* Collect attribute nodes from a Swift function declaration.
*
* In tree-sitter-swift, attributes appear as `attribute` named children
* directly on the function_declaration node, or inside a `modifiers` wrapper.
* Each attribute node text starts with '@'.
*/
function extractSwiftAnnotations(node: SyntaxNode): string[] {
const annotations: string[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'attribute') {
const text = child.text?.trim();
if (text) {
// Normalize: strip arguments, keep just the name
// e.g. "@objc(myMethod)" -> "@objc", "@available(iOS 13, *)" -> "@available"
const match = text.match(/^@(\w+)/);
if (match) {
annotations.push('@' + match[1]);
} else {
annotations.push(text);
}
}
}
// Also check inside modifiers wrapper
if (child.type === 'modifiers') {
for (let j = 0; j < child.namedChildCount; j++) {
const mod = child.namedChild(j);
if (mod?.type === 'attribute') {
const text = mod.text?.trim();
if (text) {
const match = text.match(/^@(\w+)/);
if (match) {
annotations.push('@' + match[1]);
} else {
annotations.push(text);
}
}
}
}
}
}
return annotations;
}
// ---------------------------------------------------------------------------
// Swift config
// ---------------------------------------------------------------------------
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+.
// 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.
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
// function_declaration for class/struct methods, protocol_function_declaration for protocol methods
methodNodeTypes: ['function_declaration', 'protocol_function_declaration'],
bodyNodeTypes: ['class_body', 'protocol_body'],
extractName: extractSwiftName,
extractReturnType: extractSwiftReturnType,
extractParameters: extractSwiftParameters,
extractVisibility(node) {
return findVisibility(node, SWIFT_VIS, 'internal', 'modifiers');
},
isStatic(node) {
return (
hasKeyword(node, 'static') ||
hasKeyword(node, 'class') ||
hasModifier(node, 'modifiers', 'static') ||
hasModifier(node, 'modifiers', 'class')
);
},
isAbstract: isSwiftAbstract,
isFinal(node) {
return hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final');
},
isAsync(node) {
return hasKeyword(node, 'async') || hasModifier(node, 'modifiers', 'async');
},
isOverride(node) {
return hasKeyword(node, 'override') || hasModifier(node, 'modifiers', 'override');
},
extractAnnotations: extractSwiftAnnotations,
};
@@ -0,0 +1,351 @@
// gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts
// Verified against tree-sitter-typescript ^0.23.2, tree-sitter-javascript ^0.23.0
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractionConfig,
ParameterInfo,
MethodVisibility,
} from '../../method-types.js';
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// TS/JS helpers
// ---------------------------------------------------------------------------
const VISIBILITY_KEYWORDS = new Set<MethodVisibility>(['public', 'private', 'protected']);
/**
* Extract parameters from formal_parameters.
*
* Handles both TS node types (required_parameter, optional_parameter, rest_parameter)
* and JS node types (identifier, assignment_pattern, rest_pattern), plus destructured
* parameters (object_pattern, array_pattern) in both grammars.
*/
function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] {
const paramList = node.childForFieldName('parameters');
if (!paramList) return [];
const params: ParameterInfo[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
switch (param.type) {
case 'required_parameter': {
const patternNode = param.childForFieldName('pattern');
if (!patternNode) break;
// Skip TS `this` parameter — it's a compile-time type constraint, not a real param
if (patternNode.type === 'this') break;
// Rest parameter: pattern is a rest_pattern (...args) — extract inner identifier
const isRest = patternNode.type === 'rest_pattern';
const nameNode = isRest ? patternNode.firstNamedChild : patternNode;
if (!nameNode) break;
// type field is a type_annotation — unwrap to get the inner type node
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
// Default value: presence of a 'value' field means isOptional
const hasDefault = !!param.childForFieldName('value');
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: hasDefault,
isVariadic: isRest,
});
break;
}
case 'optional_parameter': {
const nameNode = param.childForFieldName('pattern');
if (!nameNode) break;
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: true,
isVariadic: false,
});
break;
}
case 'rest_parameter': {
const nameNode = param.childForFieldName('pattern');
if (!nameNode) break;
const typeAnnotation = param.childForFieldName('type');
const typeNode = typeAnnotation?.firstNamedChild;
params.push({
name: nameNode.text,
type: typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: true,
});
break;
}
case 'identifier': {
// JS: bare parameter name, no type info
params.push({
name: param.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
break;
}
case 'assignment_pattern': {
// JS: param = defaultValue — the left side is the name, isOptional = true
const left = param.childForFieldName('left');
if (left) {
params.push({
name: left.text,
type: null,
rawType: null,
isOptional: true,
isVariadic: false,
});
}
break;
}
case 'rest_pattern': {
// JS: ...args
const inner = param.firstNamedChild;
if (inner) {
params.push({
name: inner.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: true,
});
}
break;
}
case 'object_pattern':
case 'array_pattern': {
// Destructured parameter — use full text as name
params.push({
name: param.text,
type: null,
rawType: null,
isOptional: false,
isVariadic: false,
});
break;
}
}
}
return params;
}
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
/**
* Minimal sanitization for JSDoc return types — preserves generic wrappers
* (e.g. `Promise<User>`) so that extractReturnTypeName in call-processor
* can apply WRAPPER_GENERICS unwrapping. Only strips JSDoc-specific syntax markers.
*/
function sanitizeJsDocReturnType(raw: string): string | undefined {
let type = raw.trim();
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
// Strip module: prefix — module:models.User → models.User
if (type.startsWith('module:')) type = type.slice(7);
// Reject unions (ambiguous)
if (type.includes('|')) return undefined;
if (!type) return undefined;
return type;
}
/**
* Walk backwards through preceding siblings looking for a JSDoc comment containing
* `@returns {Type}` or `@return {Type}`. Stops at the first non-comment named node
* (excluding decorators, which precede methods in TS/JS).
*/
function extractJsDocReturnType(node: SyntaxNode): string | undefined {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = JSDOC_RETURN_RE.exec(sibling.text);
if (match) return sanitizeJsDocReturnType(match[1]);
} else if (sibling.isNamed && sibling.type !== 'decorator') break;
sibling = sibling.previousSibling;
}
return undefined;
}
/**
* Extract return type from return_type field, unwrapping type_annotation.
* Falls back to JSDoc `@returns {Type}` when the AST has no return type annotation.
*
* tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM).
* The return_type field points to a type_annotation node that must be unwrapped.
*/
function extractTsJsReturnType(node: SyntaxNode): string | undefined {
const returnType = node.childForFieldName('return_type');
if (returnType) {
if (returnType.type === 'type_annotation') {
const inner = returnType.firstNamedChild;
if (inner) return inner.text?.trim();
}
return returnType.text?.trim();
}
// AST has no return type annotation — try JSDoc fallback
return extractJsDocReturnType(node);
}
/**
* Extract visibility from accessibility_modifier or #private name.
*
* tree-sitter-typescript emits accessibility_modifier as a named child of method nodes
* (not as a modifiers wrapper like JVM). Pass 1 scans for that child; pass 2 checks for
* ES2022 private_property_identifier (#name). Default: public.
*/
function extractTsJsVisibility(node: SyntaxNode): MethodVisibility {
// Pass 1: check for accessibility_modifier named child (TS-specific)
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child.type === 'accessibility_modifier') {
const t = child.text.trim();
if (VISIBILITY_KEYWORDS.has(t as MethodVisibility)) return t as MethodVisibility;
}
}
// Pass 2: ES2022 private methods (#name) are inherently private
const nameNode = node.childForFieldName('name');
if (nameNode && nameNode.type === 'private_property_identifier') return 'private';
// No accessibility_modifier found — default to public.
// Note: tree-sitter-typescript does not wrap modifiers in a 'modifiers' node
// (unlike JVM), so there is no wrapper to scan.
return 'public';
}
/**
* Extract decorator names, prefixed with '@'.
*
* In tree-sitter-typescript, decorators are **siblings** of the method_definition in the
* class_body — they are NOT children of the method node. We find them by walking backwards
* from the method node through its preceding siblings in the parent body.
*/
function extractTsJsDecorators(node: SyntaxNode): string[] {
const decorators: string[] = [];
// Walk backwards via previousNamedSibling to collect consecutive decorator siblings.
// This avoids the O(N) index-finding scan through the parent's children.
let sibling = node.previousNamedSibling;
while (sibling && sibling.type === 'decorator') {
const name = extractDecoratorName(sibling);
if (name) decorators.unshift(name);
sibling = sibling.previousNamedSibling;
}
return decorators;
}
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
const expr = decorator.firstNamedChild;
if (!expr) return undefined;
if (expr.type === 'call_expression') {
const fn = expr.childForFieldName('function');
return fn ? '@' + fn.text : undefined;
}
if (expr.type === 'identifier') return '@' + expr.text;
if (expr.type === 'member_expression') return '@' + expr.text;
return undefined;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
// TS and JS share the same config base. TS-only node types (abstract_class_declaration,
// interface_declaration, abstract_method_signature, method_signature, interface_body) are
// included because the JS grammar never produces these nodes — they are harmless no-ops.
// This mirrors the field extractor's typescript-javascript.ts shared pattern.
//
// Note: TS and JS share a method config but NOT a field extractor because the TS field
// extractor needs a hand-written class for type_alias_declaration object literals and
// nested type discovery. Methods have no such requirement.
const shared: Omit<MethodExtractionConfig, 'language'> = {
typeDeclarationNodes: [
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
],
// Note: TS constructors are method_definition nodes (name = 'constructor'), so no
// explicit constructor_declaration entry is needed (unlike JVM/C# configs).
// Known gaps:
// - call_signature and construct_signature (e.g., interface Fn { (x: string): void; })
// are not extracted — they have no name field and are uncommon in practice.
// - class_expression (const Foo = class { ... }) — methods inside class expressions
// are not discovered because class_expression is not in typeDeclarationNodes.
// - declare module / declare global augmentations — methods inside ambient_module_declaration
// wrappers are not surfaced because the top-level walker doesn't descend into them.
methodNodeTypes: [
'method_definition',
'method_signature',
'abstract_method_signature',
'function_declaration',
'generator_function_declaration',
'function_signature',
],
bodyNodeTypes: ['class_body', 'interface_body'],
extractName(node) {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
},
extractReturnType: extractTsJsReturnType,
extractParameters: extractTsJsParameters,
extractVisibility: extractTsJsVisibility,
isStatic(node) {
return hasKeyword(node, 'static');
},
isAbstract(node, ownerNode) {
// Explicit abstract keyword on the method itself
if (hasKeyword(node, 'abstract')) return true;
// Interface methods are implicitly abstract — TS interfaces never have method bodies
// (unlike Java default methods), so no !body check needed
if (ownerNode.type === 'interface_declaration') return true;
return false;
},
isFinal(_node) {
return false; // TS/JS has no final/sealed methods
},
extractAnnotations: extractTsJsDecorators,
isAsync(node) {
return hasKeyword(node, 'async');
},
isOverride(node) {
return hasKeyword(node, 'override');
},
};
export const typescriptMethodConfig: MethodExtractionConfig = {
...shared,
language: SupportedLanguages.TypeScript,
};
export const javascriptMethodConfig: MethodExtractionConfig = {
...shared,
language: SupportedLanguages.JavaScript,
};
@@ -16,8 +16,8 @@ import type {
MethodInfo,
} from '../method-types.js';
/** Owner node types where member functions are effectively static (JVM semantics). */
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration']);
/** Owner node types where member functions are effectively static (JVM/Ruby semantics). */
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration', 'singleton_class']);
/**
* Create a MethodExtractor from a declarative config.
@@ -37,17 +37,27 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
if (!typeDeclarationSet.has(node.type)) return null;
// Resolve owner name: field-based → type_identifier → simple_identifier → "Companion"
// Resolve owner name: config hook → field-based → type_identifier → simple_identifier → "Companion"
let ownerName: string | undefined;
const nameField = node.childForFieldName('name');
if (nameField) {
ownerName = nameField.text;
} else {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && (child.type === 'type_identifier' || child.type === 'simple_identifier')) {
ownerName = child.text;
break;
if (config.extractOwnerName) {
ownerName = config.extractOwnerName(node);
}
if (!ownerName) {
const nameField = node.childForFieldName('name');
if (nameField) {
ownerName = nameField.text;
} else {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child &&
(child.type === 'type_identifier' ||
child.type === 'simple_identifier' ||
child.type === 'identifier')
) {
ownerName = child.text;
break;
}
}
}
}
@@ -71,6 +81,13 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
return { ownerName, methods };
},
extractFromNode(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null {
if (!methodNodeSet.has(node.type)) return null;
return buildMethod(node, node, context, config);
},
...(config.extractFunctionName ? { extractFunctionName: config.extractFunctionName } : {}),
};
}
@@ -102,10 +119,17 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
return result;
}
function addNestedBodies(parent: SyntaxNode, bodyNodeSet: Set<string>, out: SyntaxNode[]): void {
function addNestedBodies(
parent: SyntaxNode,
bodyNodeSet: Set<string>,
out: SyntaxNode[],
seen?: Set<SyntaxNode>,
): void {
const visited = seen ?? new Set(out);
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && bodyNodeSet.has(child.type) && !out.includes(child)) {
if (child && bodyNodeSet.has(child.type) && !visited.has(child)) {
visited.add(child);
out.push(child);
}
}
@@ -120,9 +144,15 @@ function extractMethodsFromBody(
out: MethodInfo[],
): void {
for (let i = 0; i < body.namedChildCount; i++) {
const child = body.namedChild(i);
let child = body.namedChild(i);
if (!child) continue;
// C++ template methods are wrapped in template_declaration — unwrap to the inner node
if (child.type === 'template_declaration') {
const inner = child.namedChildren.find((c) => methodNodeSet.has(c.type));
if (inner) child = inner;
}
if (methodNodeSet.has(child.type)) {
const method = buildMethod(child, ownerNode, context, config);
if (method) out.push(method);
@@ -170,6 +200,7 @@ function buildMethod(
...(config.isOverride?.(node) ? { isOverride: true } : {}),
...(config.isAsync?.(node) ? { isAsync: true } : {}),
...(config.isPartial?.(node) ? { isPartial: true } : {}),
...(config.isConst?.(node) ? { isConst: true } : {}),
annotations: config.extractAnnotations?.(node) ?? [],
sourceFile: context.filePath,
line: node.startPosition.row + 1,
@@ -10,6 +10,10 @@ export type MethodVisibility = FieldVisibility;
export interface ParameterInfo {
name: string;
type: string | null;
/** Full type text including generic/template args (e.g. 'vector<int>', 'List<String>').
* Used by typeTagForId for overload disambiguation where generic args matter.
* Falls back to `type` when not set. */
rawType?: string | null;
isOptional: boolean;
isVariadic: boolean;
}
@@ -27,6 +31,7 @@ export interface MethodInfo {
isOverride?: boolean;
isAsync?: boolean;
isPartial?: boolean;
isConst?: boolean;
annotations: string[];
sourceFile: string;
line: number;
@@ -46,6 +51,16 @@ export interface MethodExtractor {
language: SupportedLanguages;
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null;
isTypeDeclaration(node: SyntaxNode): boolean;
/** Extract method info from a standalone method node (e.g. Go top-level method_declaration). */
extractFromNode?(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null;
/** Extract function name + label from an AST node during parent-walk.
* Languages with non-standard AST structures (e.g. C/C++ declarator
* unwrapping, Swift init/deinit, Rust impl_item) provide this hook
* to replace the generic name-field lookup.
* Return null to fall through to the generic extractor. */
extractFunctionName?(
node: SyntaxNode,
): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
export interface MethodExtractionConfig {
@@ -66,9 +81,17 @@ export interface MethodExtractionConfig {
isOverride?: (node: SyntaxNode) => boolean;
isAsync?: (node: SyntaxNode) => boolean;
isPartial?: (node: SyntaxNode) => boolean;
isConst?: (node: SyntaxNode) => boolean;
/** Resolve the owner name from a standalone method node (e.g. Go receiver type). */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
/** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */
extractPrimaryConstructor?: (
ownerNode: SyntaxNode,
context: MethodExtractorContext,
) => MethodInfo | null;
/** Extract function name + label from an AST node during parent-walk.
* Passed through to the MethodExtractor by createMethodExtractor. */
extractFunctionName?: (
node: SyntaxNode,
) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
}
@@ -0,0 +1,53 @@
/**
* Field Registry
*
* Owner-scoped field/property index extracted from SymbolTable.
* Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface FieldRegistry {
/** Look up a field/property by its owning class nodeId and field name. */
lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined;
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableFieldRegistry extends FieldRegistry {
/** Register a field/property under its owner. */
register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createFieldRegistry = (): MutableFieldRegistry => {
const fieldByOwner = new Map<string, SymbolDefinition>();
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => {
fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def);
};
const clear = (): void => {
fieldByOwner.clear();
};
return { lookupFieldByOwner, register, clear };
};
@@ -0,0 +1,234 @@
/**
* Heritage Map
*
* Unified inheritance data structure built from accumulated
* {@link ExtractedHeritage} records **after all chunks complete** (between
* chunk processing and call resolution). Consumes `ExtractedHeritage[]` and
* resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge
* queries.
*
* Combines two concerns:
* 1. **Parent/ancestor lookup** (MRO-aware method resolution)
* 2. **Implementor lookup** (interface dispatch — which files contain
* classes implementing a given interface)
*/
import type { ResolutionContext } from './resolution-context.js';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// ExtractedHeritage — the shape produced by the parse worker / heritage
// extractor. Defined here so `model/` has no upward imports; consumers
// import this type from the model module.
// ---------------------------------------------------------------------------
export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
// ---------------------------------------------------------------------------
// Heritage resolution strategy (the per-language knobs that drive
// `resolveExtendsType` below). Pulled out as an explicit strategy object so
// the model layer depends on a plain data shape rather than on the language
// provider registry.
// ---------------------------------------------------------------------------
export interface HeritageResolutionStrategy {
/** If set and the parent name matches, force IMPLEMENTS even when the
* symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */
readonly interfaceNamePattern?: RegExp;
/** Fallback edge for unresolved parents when the name pattern doesn't
* match (Swift uses 'IMPLEMENTS' for protocol conformance). */
readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS';
}
/** Callback used by `buildHeritageMap` to look up the resolution strategy
* for a given language. Injected by callers so the model module doesn't
* depend on `../languages/index.js`. */
export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy;
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS
* relationship. Consults the symbol table first (authoritative — Tier 1 /
* Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy}
* heuristics for external symbols not present in the graph.
*/
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
strategy: HeritageResolutionStrategy,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to strategy heuristics.
if (strategy.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (strategy.defaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
};
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** Maximum ancestor chain depth to prevent runaway traversal. */
const MAX_ANCESTOR_DEPTH = 32;
export interface HeritageMap {
/** Direct parents of `childNodeId` (extends + implements + trait-impl). */
getParents(childNodeId: string): string[];
/** Full ancestor chain (BFS, bounded depth, cycle-safe). */
getAncestors(childNodeId: string): string[];
/**
* File paths of classes that directly implement or extend-as-interface the
* given interface/abstract-class **name**. Replaces the standalone
* `ImplementorMap` — used by interface-dispatch in call resolution.
*/
getImplementorFiles(interfaceName: string): ReadonlySet<string>;
}
/** Shared empty set returned when no implementors are found. */
const EMPTY_SET: ReadonlySet<string> = new Set();
/** Default strategy used when `buildHeritageMap` is called without an
* explicit `getHeritageStrategy` callback — the fallback for a language
* whose provider sets no interface-name pattern and no non-default
* `heritageDefaultEdge`. */
const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' };
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
/**
* Build a HeritageMap from accumulated ExtractedHeritage records.
*
* Resolves class/interface/struct/trait names to nodeIds via
* `ctx.model.types.lookupClassByName`. When a name resolves to multiple
* candidates, all are recorded (partial-class / cross-file scenario).
* Unresolvable names are silently skipped — a missing parent is better
* than a wrong edge.
*
* Also builds the implementor index (interface name → implementing file
* paths) used by interface-dispatch in call resolution.
*/
export const buildHeritageMap = (
heritage: readonly ExtractedHeritage[],
ctx: ResolutionContext,
getHeritageStrategy?: HeritageStrategyLookup,
): HeritageMap => {
// childNodeId → Set<parentNodeId> (Set to deduplicate cross-chunk duplicates)
const directParents = new Map<string, Set<string>>();
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
const implementorFiles = new Map<string, Set<string>>();
for (const h of heritage) {
// ── Parent lookup (nodeId-based) ────────────────────────────────
const childDefs = ctx.model.types.lookupClassByName(h.className);
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
if (childDefs.length > 0 && parentDefs.length > 0) {
for (const child of childDefs) {
for (const parent of parentDefs) {
// Skip self-references
if (child.nodeId === parent.nodeId) continue;
let parents = directParents.get(child.nodeId);
if (!parents) {
parents = new Set();
directParents.set(child.nodeId, parents);
}
parents.add(parent.nodeId);
}
}
}
// ── Implementor index (name-based) ──────────────────────────────
//
// Known limitation: Rust `kind: 'trait-impl'` entries are intentionally NOT
// added to the implementor index. Interface dispatch resolution currently
// does not traverse Rust trait objects, so recording them here would
// inflate the index without a consumer. Revisit if/when trait-object
// dispatch is added.
//
// Known limitation: `getImplementorFiles` is keyed by interface **name**
// (string), so two interfaces with the same unqualified name in different
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide.
let isImpl = false;
if (h.kind === 'implements') {
isImpl = true;
} else if (h.kind === 'extends') {
const lang = getLanguageFromFilename(h.filePath);
if (lang) {
const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY;
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy);
isImpl = type === 'IMPLEMENTS';
}
}
if (isImpl) {
let files = implementorFiles.get(h.parentName);
if (!files) {
files = new Set();
implementorFiles.set(h.parentName, files);
}
files.add(h.filePath);
}
}
// --- Public API ---------------------------------------------------
const getParents = (childNodeId: string): string[] => {
const parents = directParents.get(childNodeId);
return parents ? [...parents] : [];
};
const getAncestors = (childNodeId: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
visited.add(childNodeId); // prevent cycles through the start node
// BFS with bounded depth
let frontier = getParents(childNodeId);
let depth = 0;
while (frontier.length > 0 && depth < MAX_ANCESTOR_DEPTH) {
const nextFrontier: string[] = [];
for (const parentId of frontier) {
if (visited.has(parentId)) continue;
visited.add(parentId);
result.push(parentId);
// Expand parent's own parents for next level
const grandparents = directParents.get(parentId);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) nextFrontier.push(gp);
}
}
}
frontier = nextFrontier;
depth++;
}
return result;
};
const getImplementorFiles = (interfaceName: string): ReadonlySet<string> => {
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
};
return { getParents, getAncestors, getImplementorFiles };
};

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