Compare commits

...
15 Commits
Author SHA1 Message Date
github-actions[bot] 90b8c9f34d release: v1.6.3-rc.10 2026-04-18 18:03:45 +00:00
Gergő Magyar e944f90879 chore(shared): apply Ring 2 SHARED review follow-ups in one diff (#964)
* chore(shared): apply Ring 2 SHARED review follow-ups in one diff

Aggregates all actionable follow-ups from the 9 Ring 2 SHARED PRs
(#949–#963) before proceeding to Ring 2 PKG. No behavior changes;
docstring edits, test refinements, and one structural cleanup.

## #913 (DefIndex / ModuleScopeIndex / QualifiedNameIndex)
  - Rename `freezeIndex` → `wrapIndex` across all three index builders.
    The old name implied `Object.freeze` on the wrapper, which we never
    applied; `wrapIndex` more accurately describes the lightweight
    readonly-interface wrap. Safety surface (frozen bucket arrays,
    frozen miss-empty array, readonly Maps) is unchanged.
  - Document in `buildModuleScopeIndex` JSDoc that callers must
    pre-normalize `filePath` keys (no path-separator canonicalization
    happens here). Prevents silent cross-platform misses.
  - Add an explicit hit-path freeze assertion in
    `qualified-name-index.test.ts` (the existing test covered only the
    miss-path `EMPTY` array).

## #914 (MethodDispatchIndex)
  - Differentiate the C3 and BFS test cases: both tests now use
    distinct MRO orderings so they prove the materializer stores
    whatever order the `computeMro` callback produces (not that C3 and
    BFS yield identical output).
  - Add `implementsOfCalls` counter in the first-write-wins test, and
    document the call-count contract in `MethodDispatchInput.implementsOf`
    JSDoc: `implementsOf` fires **per occurrence** in `input.owners`
    (not per unique owner); `computeMro` fires at most once per unique
    owner. Callers with expensive `implementsOf` implementations should
    pre-dedupe `owners`.

## #916 (resolveTypeRef)
  - Document the deliberate exclusion of `'Type'` from `TYPE_KINDS`
    (verified no extractor in `gitnexus/src/core/ingestion/` emits
    `type: 'Type'` for annotation-relevant symbols).
  - Rename the namespace-origin test from `'resolves ...'` to
    `'returns null for a namespace-origin binding whose def is not a
    type kind'`, matching the failure-case intent.

## #918 (shadow diff + aggregate)
  - Remove the partial re-export `export type { ShadowAgreement, ShadowDiff };`
    from `aggregate.ts` — it omitted `ShadowCallsite` and diverged
    from the top-level barrel. Consumers import all three from the
    `gitnexus-shared` entry point.
  - Fix the invalid `'wildcard'` evidence kind in `diff.test.ts` fixture
    (that kind is not a valid `ResolutionEvidence.kind`). Replaced with
    `'global-name'`, a real kind the test treats identically.

## #912 (ScopeTree / PositionIndex / makeScopeId)
  - Document the touching-boundary semantics on `PositionIndex.atPosition`:
    when siblings share a boundary point, the right (later-start) sibling
    wins per the existing innermost-wins sort contract.
  - Resolve the layer-inversion flagged by review: move `ScopeLookup`
    from `resolve-type-ref.ts` to `types.ts` (its natural home in the
    data-model layer). `scope-tree.ts` now imports `ScopeLookup` from
    `types.js` directly; the old re-export from `resolve-type-ref.ts`
    is removed per repo convention (`feedback_no_reexport`). Barrel
    export moved alongside.

## #917 (ClassRegistry / MethodRegistry / FieldRegistry)
  - Replace the dangling "try a name-match among class-like defs"
    comment in `lookupReceiverType` with explicit prose that callers
    must pre-resolve via `resolveTypeRef` if they want richer semantics.
    No behavior change — the function already returned `undefined` on
    ambiguous/missing qnames.
  - Fix `tieBreakKey.origin` default for pure Step-2 candidates.
    Type-binding-only hits no longer falsely inherit `'local'` from
    `ensureCandidate`'s neutral default; they now demote to `'import'`
    on their first type-binding hit, and only a later Step-1 lexical
    hit can upgrade them back to `'local'`. Keeps the Appendix B
    cascade faithful to the true origin.
  - Document `'global-name'` in `evidence.ts`: currently reserved for
    Ring 3's byName global index; `lookupCore` never emits it today.
    The weight stays live so `composeEvidence` remains exhaustive over
    the origin union.
  - Rename the mislabeled Step-7 test from `'confidence DESC is the
    primary key'` (which actually tested hard-shadow baseline) to
    `'inner scope shadows outer, yielding single result'`, and add a
    separate test that actually exercises multi-candidate confidence
    ordering (local vs wildcard at the same scope).

## Verification
  - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
  - `gitnexus-shared` build clean
  - Combined scope-resolution / model / shadow suite: **260/260 pass**
    (+1 from the new multi-candidate ordering test in #917)

## Not addressed (non-actionable)
  - #949 CI "failure with zero failing tests": pre-existing Swift Node 22
    grammar flake unrelated to #910 scope.
  - #950: the two non-blocking findings were already addressed in
    follow-up commit `cbac32ba` (ParsedImport discriminated union +
    `ScopeId | null` on the two hooks).
  - #915: the five in-scope findings were already addressed in
    follow-up commit `54515a7e` (dead code, unused params, multi-hop
    docs, cap-hit test, stats granularity).
  - #915 LanguageProvider.resolveImportTarget signature divergence +
    `findDefById` O(F×D) perf: tracked separately as follow-up issues
    for the Ring 3 migration window.

* chore(shared): address ce:review findings on the follow-up diff

ce:review (interactive) on PR #964 surfaced two P2s and several P3s. This
commit applies all `safe_auto` fixes + both manual tests in-line so the
PR ships with a cleaner review trail.

## P2 fixes

- **Complete `freezeIndex` → `wrapIndex` rename.** The prior commit renamed
  3 of 5 sibling index files; `method-dispatch-index.ts` and
  `position-index.ts` still carried the old name. Now all 5 helpers use
  the consistent `wrapIndex` naming.
  (maintainability + project-standards reviewers both flagged this.)

- **Add regression tests for the `recordTypeBindingHit` origin demotion.**
  The prior commit introduced the `tieBreakKey.origin = 'import'`
  demotion for Step-2-only candidates without a direct test. Added:
    - `registries.test.ts`: two Step-2-only siblings under the same
      interface, asserting deterministic DefId.localeCompare tie-break
      AND the stronger invariant that composeEvidence never emits a
      where-found signal for Step-2-only candidates (no `signals.origin`).
    - `position-index.test.ts`: touching-boundary test proving the
      right-sibling-wins rule documented in the new JSDoc.
  (testing + kieran-typescript + api-contract reviewers all flagged these gaps.)

## P3 fixes

- Fix wrong comment in `recordTypeBindingHit` that claimed Step 1 could
  later upgrade a demoted origin. Step 1 runs BEFORE Step 2 — the actual
  upgrade path is Step 3 (`seedFromOwnerScopedContributor`). Comment now
  describes execution order correctly.

- Fix inaccurate "re-exported there" comment in `index.ts`. `types.ts`
  *defines* ScopeLookup natively; it's not a re-export. Phrasing now
  says "defined in types.ts and exported from the type-export block
  above — not from this module."

- Update stale `scope-tree.ts` file-header prose that still referenced
  `ScopeLookup` as living in #916/resolve-type-ref.ts. Now points to
  `./types.js` with a cross-ref to both #916 and #917 consumers.

- Expand `atPosition` touching-boundary JSDoc to name the mechanism
  (backward scan through start-sorted array) so readers can trace the
  binary-search code to the claim.

- Add breadcrumb to `aggregate.ts` module header pointing future readers
  to `./diff.ts` / the top-level barrel for `ShadowAgreement`,
  `ShadowCallsite`, and `ShadowDiff`.

- Remove unnecessary non-null assertion in `recordTypeBindingHit`. Local
  `const existingMroDepth = ...` lets TS narrow to `number` in the
  else-branch, eliminating the `!` without behavior change.

## Verification

- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- Combined scope-resolution / model / shadow suite: **262/262 pass** (+2
  from the new origin-demotion + touching-boundary regression tests)
2026-04-18 18:40:29 +01:00
Gergő Magyar 1bf9fb4ef1 feat(shared): ClassRegistry / MethodRegistry / FieldRegistry + 7-step lookup (#917, RFC #909 Ring 2 SHARED) (#963)
Capstone of Ring 2 SHARED. Implements RFC §4 — the shared, scope-aware
resolution surface the rest of the semantic model feeds into.

## Modules (`gitnexus-shared/src/scope-resolution/registries/`)

  - `context.ts`         — `RegistryContext` bundling ScopeTree / DefIndex
                           / QualifiedNameIndex / ModuleScopeIndex /
                           MethodDispatchIndex + provider hooks.
                           Narrows Ring 1's opaque `RegistryContributor`
                           to concrete `OwnerScopedContributor`.
  - `tie-breaks.ts`      — `compareByConfidenceWithTiebreaks`, the RFC
                           Appendix B cascade: confidence DESC → scope
                           depth ASC → MRO depth ASC → ORIGIN_PRIORITY
                           ASC → DefId.localeCompare.
  - `evidence.ts`        — `composeEvidence(signals)` / `confidenceFromEvidence`.
                           Translates raw walk signals into the typed
                           `ResolutionEvidence[]` using authoritative
                           `EvidenceWeights`. No magic numbers.
  - `lookup-qualified.ts`— RFC §4.5. Qualified-name fast path consumed
                           by `resolveTypeRef` dotted fallback and by
                           Step 6 of lookup-core.
  - `lookup-core.ts`     — The 7-step canonical algorithm. Pure. Param-
                           eterized by `CoreLookupParams`.
  - `{class,method,field}-registry.ts`
                         — Thin wrappers over `lookupCore` that fix
                           `acceptedKinds` + `useReceiverTypeBinding` per
                           kind. `buildClassRegistry` / `buildMethodRegistry`
                           / `buildFieldRegistry` factory functions.

## RFC §4.2 algorithm contract (honored verbatim)

  1. Lexical scope-chain walk. Hard shadow on any `scope.bindings.has(name)`
     regardless of kind survivorship.
  2. Type-binding resolution (methods/fields only, opt-in via
     `useReceiverTypeBinding`). MRO walk via `MethodDispatchIndex.mroFor`.
     MRO-depth-decayed weight via `typeBindingWeightAtDepth`.
  3. Owner-scoped contributor — when the caller knows the receiver owner,
     its direct members merge in as `origin: 'local'`.
  4. Kind filter — `acceptedKinds` per registry; `kind-match` evidence
     at weight 0 is always emitted for debuggability.
  5. Arity filter — `provider.arityCompatibility` per candidate. When at
     least one compatible candidate exists, incompatibles are dropped;
     otherwise the −0.15 penalty alone disambiguates (they stay in the
     result, just ranked lower).
  6. Global fallback — fires only when Steps 1-3 produced NO candidates
     AND the name is dotted. Delegates to `lookupQualified`.
  7. Rank + tie-break — evidence list sorted by the Appendix B cascade.

## §4.7 invariants asserted in tests

  - No tier vocabulary in the return type (`Resolution`, not `TierXResult`).
  - Confidence is per-candidate (not per-tier).
  - Shadowing is a HARD filter; globals are consulted ONLY when lexically
    empty.
  - Caller can read `[0]` for one-shot answers.
  - `Resolution.confidence` is capped at 1.0.
  - `kind-match` is always emitted (weight 0).

## Unresolved-import + dynamic-unresolved evidence shape

  - `BindingRef.via.linkStatus === 'unresolved'` applies the
    `unlinkedImportMultiplier` (0.5×) to the where-found signal only.
    Corroborators (`arity-match`, `owner-match`, `type-binding`) remain
    unaffected — the RFC §4v2 capped-signal rule applies per-signal, not
    per-candidate.
  - `BindingRef.via.kind === 'dynamic-unresolved'` adds a degraded
    `dynamic-import-unresolved` evidence signal at weight 0.02.

## Tests (28 in registries.test.ts, 259/259 combined)

Organized per RFC §4.2 step so a regression localizes to the step it broke:

  - Step 1: local + walk-to-parent + hard-shadow + origin=import
  - Step 2: explicit receiver type-binding + MRO depth decay on ancestor
  - Step 3: owner-scoped contributor + owner-match
  - Step 5: drop-incompatible-when-compatible-exists + soft-penalty-when-all-
            incompatible + unknown-when-no-provider
  - Step 6: global-qualified fires only when lexically empty + never for
            non-dotted names + not consulted when lexical hit exists
  - Step 7: tie-break cascade (inner shadows outer; defId.localeCompare
            final)
  - Corroborators: unresolved-import 0.5× cap per-signal + dynamic-
            unresolved 0.02 degraded signal
  - §4.5: lookupQualified kind filter + empty on miss + deterministic defId
          order for partial classes
  - §4.7: invariants — confidence per-candidate, capped at 1.0, kind-match
          always present, [0]-for-one-shot

## Known follow-up optimizations

`collectOwnedMembers` in `lookup-core.ts` iterates `defs.byId.values()`
for each MRO hop — O(D) per call. Acceptable for Ring 2 fixtures; a
by-owner index should land before Ring 3 migrates large-workspace
languages. Tracked alongside the existing `findDefById` follow-up from
#915 review.

## Module placement

All under `gitnexus-shared/src/scope-resolution/registries/` — consistent
with the Ring 2 SHARED folder layout (#912/#913/#914/#915/#916/#918).
Slight deviation from the issue's `gitnexus-shared/src/registries/`
suggestion for consistency with siblings.

## Part of

- Parent: #909
- Depends on (code): #910, #911, #912, #913, #914, #915, #916, #918.
- Closes the Ring 2 SHARED delivery band. Unblocks Ring 2 PKG (#919–#925
  bridges to the gitnexus/ CLI package) and Ring 3 language migrations.
2026-04-18 17:58:26 +01:00
Gergő Magyar a9a5e1c388 feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED) (#962)
* feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED)

Implements RFC §3.2 Phase 2 as pure logic in `gitnexus-shared`. Takes
per-file parse output and returns linked `ImportEdge[]` + materialized
module-scope bindings, fully language-agnostic (target resolution,
wildcard expansion, and binding precedence all go through caller hooks).

Three-phase algorithm:

  1. Tarjan SCC over the file-level import graph (iterative, deterministic
     node order, O(V+E)). Returns SCCs in reverse-topological order so
     leaves finalize before dependents — and so disjoint SCCs are
     explicitly surfaced for parallel-processing callers.

  2. Per-SCC bounded fixpoint. For each SCC in topo order, iterate up to
     `N = |intra-SCC edges|`; each pass tries to resolve every still-
     unlinked edge by looking up the imported name in the target file's
     local defs. Stops early when no progress. Edges still unlinked after
     the cap get `linkStatus: 'unresolved'` — keeps malformed inputs
     bounded and preserves the RFC §4v2 capped-signal contract for
     unresolved markers.

  3. Wildcard expansion + module-scope binding materialization. For each
     `wildcard` ParsedImport that linked to a module, expand via
     `expandsWildcardTo` into one `wildcard-expanded` ImportEdge per
     exported name. Bindings per module scope are the merge of local defs
     (`origin: 'local'`), named / alias / reexport imports
     (`origin: 'import' | 'reexport'`), namespace imports (`origin:
     'namespace'`), and wildcard expansions (`origin: 'wildcard'`), with
     precedence delegated to `provider.mergeBindings`.

Dynamic imports rule: `kind: 'dynamic-unresolved'` passes through as an
ImportEdge with `targetFile: null` and no BindingRef.

Re-export flattening: reexport edges land with `transitiveVia: [targetFile]`.
Multi-hop chains settle iteratively across the fixpoint.

Types:
  - Adds `'wildcard'` variant to ParsedImport (parse-time signal for
    `import * from M`). The finalize-only `'wildcard-expanded'` ImportEdge
    kind is unchanged and remains finalize output only, as documented.
  - Exports `finalize` + `FinalizeFile` / `FinalizeInput` / `FinalizeHooks`
    / `FinalizeOutput` / `FinalizedScc` / `FinalizeStats`.

Simple-name derivation: `deriveSimpleName` uses `def.qualifiedName` as the
authoritative source (tail after the last `.`). Defs without a
qualifiedName are not name-resolvable by this algorithm — an explicit
design choice that trades strictness for predictability (no heuristic
nodeId parsing).

Tests (20, all passing):
  - Trivial: empty workspace · acyclic resolution · unresolvable target
    (file + name) · dynamic-unresolved passthrough.
  - Cycles: A↔B two-file cycle linked · cycles packed into SCC with
    isCycle=true · disjoint cycles produce disjoint SCCs · mixed
    linked/unresolved edges reported correctly in stats.
  - Wildcards: one ImportEdge per exported name · unresolved wildcards
    survive as single edges · expanded bindings carry origin='wildcard'.
  - Reexports: transitiveVia carries the intermediate file path.
  - Aliased + namespace: alias preserves targetExportedName under its
    local name · namespace links to module scope even without a module-def.
  - Bindings: locals land as origin='local' · imports layer on via
    mergeBindings · mergeBindings can drop existing (last-write-wins
    precedence honored).
  - SCC-DAG: reverse-topological ordering verified (leaf first).

Combined scope-resolution / model / shadow suite: 229/229 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.

Closes part of #909. Unblocks #917 (Registry.lookup's import-chain fast
path consumes finalized ImportEdges); unblocks Ring 3 language migrations
(per-language providers supply FinalizeHooks implementations).

* chore(shared): address #915 review findings — dead code, docs, tests

Review thread on PR #962.

Code changes:
  - Remove dead `resolvedTargets` map + `keyFor` + `ParsedImportKey`
    type alias. The map was populated but never read; originally intended
    to cache / dedup resolutions for later phases but that path was never
    wired (finding 1.1).
  - Drop unused params (`_edgeIndex`, `_hooks`, `_workspace`) from
    `tryFinalize`. No planned fixpoint-state consultation; no reason to
    keep them reserved (finding 2.1).

Documentation:
  - `FinalizeFile.localDefs` now documents the multi-hop re-export
    contract explicitly: `finalize` looks names up in the target's
    static `localDefs`; if B only re-exports from C and doesn't surface
    the name in its own localDefs, A's import of that name from B will
    hit the cap and be marked unresolved. Parsers that want multi-hop
    chains to settle end-to-end must include re-exported names in the
    intermediate file's localDefs (finding 1.2).
  - `FinalizeStats` now documents its counting granularity: all edge
    counters are per-`ParsedImport`, not per-materialized-`ImportEdge`.
    A wildcard expanding to N exports counts as one linked edge;
    dynamic-unresolved pass-throughs count as linked. The bindings map
    is the authoritative "has a BindingRef" source (finding 3.2).

Tests (2 added, 22 total in finalize-algorithm.test.ts, 231/231 combined):
  - Explicit cap-hit → `linkStatus: 'unresolved'` assertion for a cycle
    where the name-level lookup never succeeds (distinct from
    `targetFile: null`; cap exhaustion path) (finding 3.1).
  - Multi-hop re-export contract test: demonstrates both variants —
    intermediate B WITHOUT X in localDefs → unresolved; B WITH X in
    localDefs → resolved to the original source DefId (finding 1.2).

Not addressed (filed as follow-up issues):
  - LanguageProvider.resolveImportTarget vs FinalizeHooks signature
    divergence (finding 1.3) — pre-Ring-3 concern.
  - findDefById O(F×D) scan in Phase 5 (finding 4.1) — acceptable for
    Ring 2; optimize before large-workspace Ring 3 migrations.
2026-04-18 17:26:07 +01:00
Gergő Magyar 8cf9ae0e0d feat(shared): ScopeTree + PositionIndex + makeScopeId (#912, RFC #909 Ring 2 SHARED) (#961)
Implements the scope-tree spine and position-indexed lookup as pure logic
in `gitnexus-shared`. Generalizes the `enclosingFunctions` pattern from
closed PR #902 to arbitrary `ScopeKind`s.

Three modules under `gitnexus-shared/src/scope-resolution/`:

1. `scope-id.ts` — `makeScopeId({filePath, range, kind})` builds the
   canonical RFC §2.2 shape
     `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
   and interns the result through a process-local pool so repeated calls
   with structurally identical inputs return the same string reference.
   `clearScopeIdInternPool()` exported for test isolation.

2. `scope-tree.ts` — `buildScopeTree(scopes)` validates invariants and
   returns an immutable `ScopeTree`:
     - `getScope(id)` / `getParent(id)` / `getChildren(id)` / `getAncestors(id)`
     - Implements the `ScopeLookup` contract from #916, so `resolveTypeRef`
       can consume a `ScopeTree` directly (test included).
   Invariants enforced (throw `ScopeTreeInvariantError` on violation):
     - Non-Module scopes must have a parent.
     - Parent must exist in the supplied set.
     - Parent range STRICTLY contains child range (equal ranges rejected).
     - Sibling ranges under the same parent do not overlap. Ranges that
       merely touch at the boundary (`a.end == b.start`) are accepted.
     - Parent and child live in the same filePath.
     - Duplicate scope ids are rejected.

3. `position-index.ts` — `buildPositionIndex(scopes)` produces a
   `PositionIndex` with `atPosition(filePath, line, col)`. Per-file sorted
   array; binary-search the upper bound of `start ≤ query`, scan backward
   through the prefix, return the first containing hit.
   Complexity: `O(log N_file + D)` typical (D = lexical depth ≤ ~10);
   degrades to `O(N_file)` only under pathological inputs (many scopes
   starting at the same position). "Innermost wins" falls out of the sort
   + backward-scan contract because `ScopeTree`'s invariants guarantee
   that scopes containing a point form an ancestor chain.

Types:
  - `ScopeTree` now exported from `scope-tree.ts`. The Ring 1 opaque
    placeholder in `types.ts` has been removed; LanguageProvider hooks
    that previously took `ScopeTree = unknown` now receive the concrete
    interface (CLI `tsc --noEmit` passes — no existing callers rely on
    the opaque shape).

Tests (39, all passing):
  - scope-id: canonical shape · all six ScopeKinds encoded · identity
    equality (same inputs → same reference) · distinguished by
    filePath / range / kind · purity under repeated calls · intern-pool
    clear preserves canonical shape.
  - scope-tree: empty tree · single module · nested Module→Class→Function
    · multiple siblings input-order preserved · ScopeLookup integration
    with resolveTypeRef · frozen children and ancestor arrays · all six
    invariant violations (non-Module orphan, parent-not-found, parent
    doesn't contain, parent == child, siblings overlap, cross-file parent,
    duplicate id) · boundary-touching siblings accepted.
  - position-index: empty · unindexed filePath · before/after-file
    queries · start/end inclusivity · innermost-wins for nested / co-
    starting / co-ending / same-line scopes · sibling dispatch · multi-
    file isolation · size · id-dedup.

Combined scope-resolution / model / shadow suite: 190/190 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.

Closes part of #909. Unblocks #917 (`Registry.lookup` needs the scope
spine); makes `ScopeLookup` in #916 concrete without API churn.
2026-04-18 16:41:38 +01:00
azizur100389 ac148612ab feat(search): per-phase timing instrumentation for the query pipeline (#953)
* feat(search): per-phase timing instrumentation for the query pipeline

The eval harness already measures search-pipeline latency per phase,
but the *product* query() tool has no timing visibility. That leaves
production latency opaque:

 - Is BM25 the tail, or vector search?
 - How much Promise.all overlap do concurrent searches actually save?
 - Does symbol_lookup dominate when per-symbol Cypher round-trips pile up?

None of this is answerable from the outside, which blocks the
latency-quality Pareto work tracked in #546 / #553.

Changes:

* New PhaseTimer class at src/core/search/phase-timer.ts.
  Supports three APIs:
    - start(phase) / stop() for sequential phases (per issue spec)
    - mark(phase, durationMs) for pre-measured durations
    - time(phase, promise) to wrap a promise inside Promise.all

  The issue's original spec was sequential-only, which doesn't work
  for BM25 + vector inside Promise.all — the second start() would
  auto-stop the first and only one phase would get timed. The mark()
  and time() variants resolve that without changing the sequential
  API for the other phases.

* local-backend.ts query() instrumented across seven phase markers:
    bm25, vector   (concurrent via timer.time inside Promise.all)
    merge          (RRF reciprocal-rank-fusion)
    symbol_lookup  (per-symbol process + cohesion + content Cypher)
    ranking        (in-memory priority sort)
    formatting     (response object construction + dedup)
    wall           (end-to-end; separate mark so callers can compare
                   sum(phases) vs wall and see Promise.all savings)

* logQueryTiming() helper next to logQueryError(), same console-based
  pattern (repo has no structured logger). Emits
    GitNexus [query:timing] query="..." totalMs=N phases={...}
  to stdout — greppable prefix, JSON-parseable payload, no new deps.

* timing: Record<string, number> added as a top-level field on the
  query() response. Strict superset of the previous shape — existing
  tests only assert field presence, so no regression. Other MCP tools
  use the same top-level-metadata convention (status, row_count,
  warning) rather than a nested _meta wrapper.

Tests:

 - 6 new unit tests for PhaseTimer covering start/stop, implicit
   stop-on-start, additive mark(), Promise.all-safe time(),
   negative/NaN rejection, and totalMs auto-stop.
 - 3 new assertions on the existing query integration test verifying
   timing.wall is a non-negative number and at least one of
   bm25/vector fired.

Verification:
  npx vitest run test/unit/phase-timer.test.ts       -> 6 pass
  npx vitest run test/unit/calltool-dispatch.test.ts -> 65 pass
  npx vitest run test/integration/local-backend-calltool.test.ts -> 18 pass
  npm run test:unit                                   -> 3777 pass
    (4 pre-existing env failures unchanged: skip-git-cli needs
     built dist/, git-utils tmpdir on Windows worktree)
  npx tsc --noEmit                                    -> clean

Scope declined for v1:

 - In-process histogram aggregation — the log line is enough for
   external tooling
 - Pareto curve generation — issue asks to enable it, not generate it
 - Sub-phases of symbol_lookup (process vs cohesion vs content) —
   issue lists them under one bucket; can split later if demand surfaces

Closes #553

* fix(search): route query:timing log to stderr to preserve stdio MCP contract

CI (#953) failed the `query: JSON appears on stdout, not stderr`
e2e test in test/integration/cli-e2e.test.ts with:

  SyntaxError: Unexpected token 'G', "GitNexus [..." is not valid JSON

Root cause: my initial logQueryTiming() in 63fbdc4 used console.log,
which writes to stdout. The MCP stdio transport uses stdout
exclusively for JSON-RPC responses (#324), and the CLI e2e test
guards that contract by asserting stdout parses as JSON on every
tool invocation. The "GitNexus [query:timing] ..." line was
interleaving with the response JSON and breaking the parse.

Fix: route logQueryTiming through console.error instead. stderr is
the correct channel for human-readable diagnostics and it is what
the sibling logQueryError already uses for the same reason. The log
line format is otherwise unchanged -- still greppable, still
JSON-parseable payload.

Verification (local, with dist built):
  npx vitest run test/integration/cli-e2e.test.ts -t "query: JSON"
    -> now passes (was failing across ubuntu/windows/macos in CI)
  npx tsc --noEmit                                  -> clean
  Two unrelated pre-existing failures on non-git
  directory handling remain (same on upstream/main).

Closes the CI regression introduced in 63fbdc4.
2026-04-18 16:30:07 +01:00
Gergő Magyar 5d76dbcfa2 feat(shared): MethodDispatchIndex materialized view over HeritageMap (#914, RFC #909 Ring 2 SHARED) (#960)
Implements RFC §3.1 `MethodDispatchIndex`: a two-way materialized view
keyed by `DefId` for O(1) method-dispatch resolution:

  - `mroByOwnerDefId`       — owner class → full MRO ancestor chain
                              (excludes self, per-language strategy order)
  - `implsByInterfaceDefId` — interface/trait → classes that implement it

**Not an MRO implementation.** `buildMethodDispatchIndex` is a pure
aggregator that calls back into caller-provided `computeMro` and
`implementsOf` functions. The five existing strategies (Python C3, Ruby
kind-aware, Java/Kotlin linear, Rust qualified-syntax, COBOL none) stay
where they are today (`model/resolve.ts`, `languages/ruby.ts`); this index
does not reimplement them.

Why callbacks rather than a shared registry: the strategies depend on the
CLI's `HeritageMap` + `SemanticModel`. Migrating both to `gitnexus-shared`
is out of scope for #914; callbacks let the shared build stay pure.

Module placement: `gitnexus-shared/src/scope-resolution/method-dispatch-index.ts`
for consistency with the other RFC §3.1 indexes (#913 DefIndex /
ModuleScopeIndex / QualifiedNameIndex; #916 resolveTypeRef).

Safety surface mirrors sibling indexes:
  - First-write-wins on duplicate owners.
  - Repeated (interface, owner) pairs deduplicated.
  - Stored arrays are `Object.freeze`d; caller mutation of the source
    array does not leak into the index.
  - Miss returns a shared frozen empty array.

Tests (19, all passing): empty input, single-inheritance chain, Python
C3 diamond, Java BFS, Ruby kind-aware mixin, Rust qualified-syntax empty,
interface inversion (single, multiple, ordered), dedup within and across
callback calls, frozen miss + bucket arrays, callback-array isolation,
readonly Map iteration.

Closes part of #909.
2026-04-18 16:28:46 +01:00
Gergő Magyar 56e32b310b feat(shared): resolveTypeRef strict single-return type resolver (#916, RFC #909 Ring 2 SHARED) (#959)
Implements RFC §4.6: a strict, pure resolver for `TypeRef`s used by
`Registry.lookup` Step 2 (type-binding propagation) and by any caller that
wants the single best type-target for an annotation without paying for the
full evidence pipeline.

Algorithm (strict):

  1. Walk the scope chain from `ref.declaredAtScope`:
     - Return the first binding for `rawName` whose origin is in
       `{'local','import','namespace','reexport'}` AND whose `def.type` is a
       type-kind (class-like, interface-like, enum-like, alias-like).
     - If bindings exist but none qualify (non-type shadow, wildcard-only
       origin), return null immediately — do NOT fall through to the global
       qualified-name index.
  2. If `rawName` is dotted and the scope walk produced no match, consult
     `QualifiedNameIndex.byQualifiedName`. Only accept a UNIQUE type-kind
     hit; ambiguous or non-type results return null.

`'wildcard'` is deliberately excluded from strict origins — a
wildcard-expanded name is too loose to anchor type resolution.

Module placement: `gitnexus-shared/src/scope-resolution/resolve-type-ref.ts`
(alongside sibling indexes) rather than the issue's suggested
`gitnexus-shared/src/resolve-type-ref.ts`, for consistency with the rest of
the RFC §2/§3 surface.

A minimal `ScopeLookup` interface is declared inline so #916 ships
standalone; #912's `ScopeTree` will satisfy this contract without change.

Closes part of #909.
2026-04-18 16:09:54 +01:00
Gergő Magyar ac2012e5ed feat(shared): DefIndex / ModuleScopeIndex / QualifiedNameIndex (#913, RFC #909 Ring 2 SHARED) (#958)
Three flat O(1) indexes + pure build functions over per-file artifacts.
Contract-only; no runtime behavior change yet — consumers (#917 Registry
lookups, #915 SCC finalize, #919 ScopeExtractor) wire in later.

Each index follows the same shape:
  - build function: flat input list → frozen immutable index
  - public interface: readonly Map + get/has/size accessors
  - first-write-wins on id/filePath collisions (upstream bug signal)
  - pure, side-effect-free, safe to call repeatedly

DefIndex — the global "what is this id?" lookup
  gitnexus-shared/src/scope-resolution/def-index.ts
  buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex
    byId: ReadonlyMap<DefId, SymbolDefinition>
  Consumed by Registry.lookup (#917) to materialize DefId[] hits back to
  full SymbolDefinition records.

ModuleScopeIndex — `filePath → moduleScopeId` for cross-file hops
  gitnexus-shared/src/scope-resolution/module-scope-index.ts
  buildModuleScopeIndex(entries): ModuleScopeIndex
    byFilePath: ReadonlyMap<string, ScopeId>
  Consumed by the SCC finalize link pass (#915) to resolve
  ImportEdge.targetFile to a concrete module scope in constant time.

QualifiedNameIndex — cross-kind qualified-name fast path
  gitnexus-shared/src/scope-resolution/qualified-name-index.ts
  buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex
    byQualifiedName: ReadonlyMap<string, readonly DefId[]>
  Returns DefId[] (not a single DefId) because partial classes, method
  overloads, and cross-kind collisions can legitimately share a
  qualifiedName. Callers filter by acceptedKinds at the lookup site.
  Consumed by Registry.lookup qualified fast path + resolveTypeRef
  dotted fallback (#916, #917).

Barrel re-exports added to gitnexus-shared/src/index.ts so consumers
import from 'gitnexus-shared' rather than deep paths.

Tests (gitnexus/test/unit/scope-resolution/, 23 total):
  def-index.test.ts (6):
    empty, single def, multiple distinct, first-write-wins collision,
    missing id returns undefined, byId direct iteration
  module-scope-index.test.ts (6):
    empty, single entry, multiple files, first-write-wins on duplicate
    filePath, missing returns undefined, byFilePath direct iteration
  qualified-name-index.test.ts (11):
    empty, single qnamed def, partial classes accumulate, input-order
    preservation, qname separation, skip undefined/empty qname, pair
    dedup, cross-kind indexing, frozen-empty-array on miss, direct
    iteration

Verification:
  - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
  - test/unit/scope-resolution: 23/23 pass
  - model + shadow + scope-resolution combined: 129/129 pass
  - No runtime consumer wiring yet — indexes are standalone library
    functions that #915, #917, #919 will import when ready

Depends on #910 (SymbolDefinition, DefId, ScopeId types — already on main).
Unblocks #915 (finalize algorithm), #917 (Registry.lookup), #919
(ScopeExtractor materialization).
2026-04-18 15:59:34 +01:00
Copilotandmagyargergo f73389eac3 fix: ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync (#957)
* Initial plan

* Fix ENOBUFS in detect_changes by setting maxBuffer on git/rg execFileSync

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bb241ed0-3b39-431f-a242-b0c7ced9707b

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-18 15:58:31 +01:00
Gergő Magyar 22f0beb057 feat(shared): shadow-mode diff + aggregate — full implementation (#918, RFC #909 Ring 2 SHARED) (#951)
Replaces the scaffold stubs with working pure-logic implementations plus
unit-test coverage for both functions. Unblocks Ring 2 PKG #923 (shadow
harness) to consume a concrete library instead of throwing scaffolds.

gitnexus-shared/src/scope-resolution/shadow/diff.ts
  `diffResolutions(callsite, legacy, newResult): ShadowDiff`
    - [0] on each side is the top match
    - both empty         → 'both-empty',   delta []
    - legacy empty only  → 'only-new',     delta = new top evidence
    - new empty only     → 'only-legacy',  delta = legacy top evidence
    - same top nodeId    → 'both-agree',   delta []
    - different nodeIds  → 'both-disagree',
                           delta = symmetric difference of evidence kinds
                           (legacy-only first in input order, then new-only)
  Evidence identity is `ResolutionEvidence.kind` — weight/note differences
  for the same kind do NOT produce delta entries. Rationale: the aggregator
  wants to know which *signals* explain a disagreement, not fluctuations
  in calibration values.

gitnexus-shared/src/scope-resolution/shadow/aggregate.ts
  `aggregateDiffs(diffs, now?): ShadowParityReport`
    - buckets by `SupportedLanguages`
    - tallies agreements, evidence-breakdown (divergences only — agree and
      empty rows do not contribute)
    - parity = bothAgree / (totalCalls - bothEmpty), yields 0 (not NaN)
      when the denominator is 0
    - perLanguage sorted alphabetically by enum value for stable output
    - evidenceBreakdown internally sorted by kind for stable output
    - overall = column-wise sum across languages
    - `now` parameter makes generatedAt deterministic in tests

gitnexus-shared/src/index.ts
  Re-exports the full shadow API: diffResolutions, aggregateDiffs, and all
  their types (ShadowAgreement, ShadowCallsite, ShadowDiff,
  LanguageParityRow, ShadowParityReport).

gitnexus/test/unit/shadow/diff.test.ts (13 tests)
  - 5 agreement outcomes
  - symmetric-by-kind evidence delta (disjoint, overlapping, fully-overlapping)
  - weight-only differences produce no delta
  - top-match only (ignores indices beyond [0])
  - callsite passthrough
  - delta ordering (legacy-only first, input order preserved)

gitnexus/test/unit/shadow/aggregate.test.ts (9 tests)
  - empty input
  - single language, all agree / mixed / all empty
  - multi-language bucketing + overall sum
  - alphabetical language sort
  - evidence breakdown scope
  - determinism via injected `now` + JSON round-trip identity

Verification:
  - gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
  - test/unit/shadow: 22/22 pass
  - test/unit/model + test/unit/shadow combined: 106/106 pass
  - No runtime behavior changes (shadow is invoked by #923, not yet wired)

Stacked on main (af1d278a). Depends on types from #910 (merged).
Unblocks: #923 (Ring 2 PKG — shadow harness wiring) — concrete library
to consume instead of scaffold stubs.

Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
is about #911; #918's scope is the scaffold+fill-in described in the PR
description of #951.
2026-04-18 15:32:51 +01:00
Gergő Magyar af1d278a7e feat(shared,ingestion): extend LanguageProvider with scope-resolution hooks (#911, RFC #909 Ring 1) (#950)
Adds the 14 optional scope-resolution hooks from RFC #909 §5.2 to
`LanguageProviderConfig` plus the supporting input/output types in
`gitnexus-shared`. Contract-only; no runtime behavior changes.

Review-driven refinements (addresses two non-blocking review comments on #950):

1. `ParsedImport` is now a 5-variant discriminated union, not a flat
   record. Each variant carries only its legal fields so invalid shapes
   are compile errors:
     - 'named', 'alias', 'namespace', 'reexport', 'dynamic-unresolved'
   'wildcard-expanded' is deliberately excluded — finalize materializes
   that kind; a provider must never emit it at parse time.
   'reexport' is a first-class parse-phase variant so syntactically-
   detectable re-exports (TS `export { X } from './y'`, Rust
   `pub use foo::bar`) keep their parse-time signal through to finalize
   rather than being re-derived by the SCC pass.
   `namespace` gains an `importedName` field so `import numpy as np`
   can carry both `localName: 'np'` and `importedName: 'numpy'`.
   `dynamic-unresolved.targetRaw` is `string | null` (was mandatory
   null) so providers can emit the unresolvable expression text for
   diagnostics when available.

2. `bindingScopeFor` and `importOwningScope` return type changed from
   `ScopeId` to `ScopeId | null`, aligning with the X | null convention
   used by the 12 sibling optional hooks (receiverBinding,
   resolveScopeKind, interpretTypeBinding, …). `null` = delegate to the
   central default. Enables partial overrides — a JS provider can
   return a hoisted scope for `var` and `null` for `let`/`const`
   without re-implementing the default lookup.
   Both hooks also gain a purity JSDoc contract: same inputs yield the
   same ScopeId (or null) across invocations; no closure over mutable
   state. Required to keep scope-tree construction deterministic.

   A richer callable-defaults pattern (typed BindingScopeDefaults /
   ImportOwningDefaults helper interfaces on a `defaults` parameter)
   was considered and deferred to Ring 2 PKG #919, where the concrete
   ScopeExtractor will exist to inform the helper shape. Designing that
   pattern before the first consumer would set cross-hook precedent
   based on a single motivating example.

Supporting types added to gitnexus-shared/src/scope-resolution/types.ts:
  - CaptureMatch, ParsedImport, ParsedTypeBinding
  - WorkspaceIndex, ScopeTree (opaque placeholders until Ring 2)
  - Callsite

14 hooks added to LanguageProviderConfig (all optional):
  Parse phase: emitScopeCaptures, interpretImport, receiverBinding,
    interpretTypeBinding, resolveScopeKind, shouldCreateScope,
    bindingScopeFor
  Finalize phase: resolveImportTarget, expandsWildcardTo,
    importOwningScope, mergeBindings
  Reference-extraction phase: classifyCallForm
  Resolution phase: shouldShadow, arityCompatibility

Verification:
  - gitnexus-shared builds clean (tsc)
  - gitnexus builds clean (scripts/build.js)
  - test/unit/model: 84/84 pass — no regressions
  - No provider needs updating (all hooks optional)
  - No BindingScopeDefaults/ImportOwningDefaults/defaults parameter
    introduced (deferred to #919)

Stacked on #910 (merged as afc0a8b6); rebased on main.
Tracking: #909 (meta). Unblocks Ring 2 PKG (#919 ScopeExtractor,
#922 import adapters) and all Ring 3 per-language migrations.

Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
2026-04-18 14:54:51 +01:00
Gergő Magyar afc0a8b6c5 feat(shared): add scope-resolution types + constants (#910, RFC #909 Ring 1) (#949)
Lands the authoritative data model and constants for the pure scope-based
resolution RFC (#909) as Ring 1, part 1. No runtime behavior changes —
types + constants only.

New in gitnexus-shared/src/scope-resolution/:
  - types.ts — Scope, ScopeKind, ScopeId, DefId, Range, Capture,
    BindingRef, ImportEdge, TypeRef, Resolution, ResolutionEvidence,
    Reference, ReferenceIndex, LookupParams, RegistryContributor
  - evidence-weights.ts — EvidenceWeights constant map + typeBindingWeightAtDepth
    (RFC Appendix A)
  - origin-priority.ts — ORIGIN_PRIORITY constant map for deterministic
    tie-breaks (RFC Appendix B)
  - language-classification.ts — LanguageClassification type +
    LanguageClassifications map (production × 14, experimental × 2
    for vue/cobol; governs Ring 4 DAG-retirement gate)
  - symbol-definition.ts — SymbolDefinition moved from
    gitnexus/src/core/ingestion/model/symbol-table.ts so scope-resolution
    types can reference it from the shared package

Consumer updates:
  - symbol-table.ts: removes local SymbolDefinition declaration; imports
    from gitnexus-shared
  - model/index.ts: drops SymbolDefinition from barrel re-export per
    "direct imports from gitnexus-shared" convention (see
    gitnexus-shared feedback in project memory)
  - 9 source files + 5 test files: import SymbolDefinition directly
    from 'gitnexus-shared'

Verification:
  - gitnexus-shared builds clean (tsc)
  - gitnexus builds clean (scripts/build.js)
  - 131/132 unit test files pass; 3767 tests green
  - Zero behavior changes; SymbolDefinition shape unchanged

Blocks: #911 (LanguageProvider hook interface extensions) and all of
Ring 2 (#912-#925). Closes part of #909.
2026-04-18 12:55:09 +01:00
Gergő Magyar d9da7d6692 fix(test): isolate cli-e2e from shared mini-repo fixture (#954)
Deterministic fix for the Windows-flaky pipeline-graph-golden test.

Root cause
  cli-e2e.test.ts wrote into the SHARED fixture directory
  (test/fixtures/mini-repo/) — git init, analyze run that creates
  AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden
  ran in parallel, its `cpSync` of the source directory could capture
  the mid-flight pollution before cli-e2e's afterAll cleanup fired.
  macOS/Ubuntu won the race often enough that the flake presented as
  Windows-only.

Fix
  cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose
  basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by
  basename), runs git-init there, and rm's the whole tmpdir in afterAll.
  The shared fixture source is never touched.

  Fallout from the cwd change: bare `--import tsx` specifiers (2
  spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where
  there is no node_modules. Switched them to the already-existing
  `tsxImportUrl` (absolute file:// URL to the tsx loader), matching
  the `runCliOutsideProject` pattern that was already set up for this
  exact case.

  Updated the "MINI_REPO is inside the project tree" comment in the
  `status on non-indexed repo` test — MINI_REPO is now in os.tmpdir,
  so the rationale for using a separate throwaway tmp git repo is
  different (but still valid: previous tests in the suite create
  MINI_REPO/.gitnexus, which findRepo() would pick up).

  Also updated pipeline-graph-golden's comment explaining WHY it
  copies to tmp — it's now defense-in-depth rather than a necessity,
  so a future test that adds files to the source can't silently
  regress the golden.

Verification
  - 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass
    (deterministic)
  - 3x full suite including pipeline.test: 27/27 pass
  - test/fixtures/mini-repo/ post-run contents: only `src/` —
    zero pollution from any test
  - macOS/Ubuntu behavior unchanged (they were passing; tmpdir
    isolation is purely additive)
2026-04-18 12:54:59 +01:00
azizur100389 131d411ae4 feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints (#888)
* feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints

The `context` MCP tool already returned `{ status: 'ambiguous', candidates }`
when a name hit multiple symbols, but the candidates were returned in
arbitrary DB order and the only hint it accepted was file_path. The
`impact` tool was worse: when its name resolver found multiple viable
matches it silently picked the first one from a priority UNION, with no
signal back to the caller that a different symbol might have been
intended.

Both failure modes were flagged in issue #470 and reconfirmed in the
comments by a second user who described impact as returning "incorrect
parsing results and meaningless tool calls" in the multi-match case.

Changes:

* Add `resolveSymbolCandidates(repo, query, hints)` private helper on
  LocalBackend. Single place that:
   - Short-circuits on direct uid (zero-ambiguity)
   - Runs the same name-or-qualified-id match as before, with LIMIT 20
     (was 10) so the ranker has headroom instead of arbitrary truncation
   - Preserves the #480 Class/Constructor preference -- when the only
     ambiguity is a Class and its own Constructor, the Class wins
     silently
   - Scores each candidate (pure TS, no extra DB round-trip): base 0.50,
     +0.40 for file_path match, +0.20 for kind match, plus a small
     kind-priority tiebreaker (Class > Interface > Function > Method >
     Constructor) when no explicit kind hint is given
   - Sorts desc by score with stable tiebreakers (shorter filePath,
     then lex uid)
   - Promotes to a single confident resolve when the top score is
     >= 0.95 AND beats the runner-up by >= 0.10 -- lets a strong hint
     cut through without forcing the caller through a disambiguation
     round-trip

* Rewire `context()` to use the shared helper. Response shape is a
  strict superset of today's: candidates gain a `score` field, the
  existing `{ uid, name, kind, filePath, line }` keys are preserved so
  every downstream consumer (rename, eval-server formatter, etc.) keeps
  working. New `kind` input hint accepted.

* Rewire `impact()` to use the shared helper. Now emits the same
  `{ status: 'ambiguous', candidates, impactedCount: 0, risk: 'UNKNOWN' }`
  shape instead of silent first-pick. New inputs accepted:
  `target_uid`, `file_path`, `kind`.

* Update tool schemas in mcp/tools.ts to advertise the new inputs and
  describe ranked disambiguation.

Backward compatibility:

The #480 Class/Constructor collapse is preserved and covered by the
existing java-class-impact integration test (still green). The
ambiguous response shape is a strict superset -- `eval-formatters`
unit test that parses the old shape is unchanged and still passes.
`impact` going from silent-first-pick to structured ambiguous is a
semantic improvement that is the entire point of the issue; callers
relying on silent first-pick now get an actionable response.

Scope declined for v1:

module/community hint -- the issue lists it as one of several hints,
but kind + file_path cover the vast majority of disambiguation needs
in practice, and a community-label filter requires an extra graph
query per candidate. Natural v2 follow-up.

Tests: calltool-dispatch.test.ts gains 5 new cases covering file_path
boost, kind hint boost, impact ambiguous shape, impact target_uid
short-circuit, and score field presence on the existing ambiguous
test. Plus the extended assertions on the existing
`context tool returns disambiguation for multiple matches`.

Verification:
  npx vitest run test/unit/calltool-dispatch.test.ts       -> 64 pass
  npx vitest run test/integration/java-class-impact.test.ts -> pass
  npm run test:unit                                         -> 3642 pass
    (4 pre-existing env failures unchanged: skip-git-cli needs built
    dist/, git-utils tmpdir on Windows worktree -- same on main)
  npx tsc --noEmit                                          -> clean

Closes #470

* fix(mcp): enrich labels from UNION when labels(n)[0] is empty; address review findings

CI on PR #888 caught 13 integration-test failures I did not cover locally:
my resolver refactor collected candidates via `labels(n)[0] AS type`, but
LadybugDB returns an empty string for that projection on certain node
types (most importantly Class). With an empty `type`, impact's downstream
`_runImpactBFS` no longer recognised `symType === 'Class' | 'Interface'`
and stopped seeding Constructor + File nodes into the frontier, so the
"impact(upstream) surfaces the file importer" assertion broke across 11
language fixtures plus 2 OVERRIDES filter tests.

The original impact resolver worked around this by running a prioritised
UNION across Class/Interface/Function/Method/Constructor and picking the
first hit. My refactor dropped that. Fix: keep the simple candidate MATCH
but enrich types afterward via a single scoped UNION query, so every
candidate carries an accurate label for both scoring and downstream
BFS seeding. The UID direct-lookup path is patched the same way.

Also addresses the findings from the senior reviewer on PR #888:

* MIGRATION.md: document the `impact` behavioural change (silent first-
  pick → structured `{ status: 'ambiguous', candidates }`) so downstream
  callers know to branch on `result.status` before reading byDepth/
  summary. `context` is unchanged shape-wise (strict superset).

* New test: `context tool promotes top candidate via scoring when
  multiple rows survive DB pre-filter`. The review flagged that the
  existing file_path test works only because the mock ignores WHERE
  parameters -- the scored-promotion path (top ≥ 0.95 AND gap > 0.09)
  wasn't directly exercised. The new test uses two candidates both in
  App.tsx-containing paths plus a kind hint so promotion is decided by
  scoring, not DB pre-filtering. Also tightened the comment on the
  earlier file_path test to describe the mock vs production divergence
  honestly.

* NIT: added a paragraph explaining why `scored.length >= 2` is kept as
  a defensive guard even though the `normalized.length === 1` early
  return already covers the single-candidate path.

* Integration: two tests in `local-backend-calltool.test.ts` targeted
  `'authenticate'`, which now correctly resolves as ambiguous (two
  Method nodes: AuthService.authenticate and BaseService.authenticate).
  Updated both to pass `file_path: 'src/auth.ts'` so they exercise the
  new disambiguation API and still assert the METHOD_OVERRIDES filtering
  they were originally about.

Edge case fix in the promotion gap check: IEEE754 makes 0.50 + 0.40 +
0.20 - 0.90 = 0.09999999999999998 instead of exactly 0.10, which would
otherwise break the "winner clearly dominates" intent for legitimate
1.00 vs 0.90 cases. Changed `>= 0.10` to `> 0.09`; same user-facing
intent, no floating-point sensitivity.

Verification (all from gitnexus/):
  npx vitest run test/integration/class-impact-all-languages.test.ts
    -> 52 pass (was 11 FAIL on CI before this fix)
  npx vitest run test/integration/local-backend-calltool.test.ts
    -> 18 pass (was 2 FAIL on CI before this fix)
  npx vitest run test/integration/java-class-impact.test.ts
    -> 10 pass (regression guard for #480 preserved)
  npx vitest run test/unit/calltool-dispatch.test.ts
    -> 65 pass (1 new test + 4 from original #470 PR)
  npm run test:unit
    -> 3626 pass, 4 pre-existing env failures unchanged
  npx tsc --noEmit
    -> clean
2026-04-18 12:52:42 +01:00
65 changed files with 8071 additions and 244 deletions
+44
View File
@@ -1,5 +1,49 @@
# Migration Guide
## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470)
Before this change the `impact` MCP tool silently picked the first match
when the `target` name hit multiple symbols (Class → Interface → Function
→ Method → Constructor priority UNION). This often produced analysis for
the wrong symbol with no signal back to the caller.
After this change, when the resolver finds more than one viable match
and the caller supplied none of `target_uid` / `file_path` / `kind`,
`impact` returns a disambiguation response shaped like:
```json
{
"status": "ambiguous",
"message": "Found N symbols matching '<target>'. Use target_uid, file_path, or kind to disambiguate.",
"target": { "name": "<target>" },
"direction": "upstream",
"impactedCount": 0,
"risk": "UNKNOWN",
"candidates": [
{ "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 }
]
}
```
### Do I need to migrate?
**Probably not, but check for assumptions.** Callers that unconditionally
read `result.byDepth` / `result.summary` / `result.affected_processes`
without first checking `result.status` will now see `undefined` in the
ambiguous case. The fix is to branch on `result.status === 'ambiguous'`
first and follow up with `target_uid` (preferred) or `file_path` / `kind`.
The `context` tool's ambiguous response is a strict superset of the
existing shape — every candidate gains a `score` field, no existing field
has changed. No migration required for `context` callers.
### What happens on re-index?
Nothing — this is an MCP-surface change only. The graph schema, indexer,
and stored data are untouched.
---
## OVERRIDES → METHOD_OVERRIDES (PR #642)
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
+121
View File
@@ -23,3 +23,124 @@ export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
// Data model (RFC §2)
export type { SymbolDefinition } from './scope-resolution/symbol-definition.js';
export type {
ScopeId,
DefId,
ScopeKind,
Range,
Capture,
CaptureMatch,
BindingRef,
ImportEdge,
TypeRef,
Scope,
ResolutionEvidence,
Resolution,
Reference,
ReferenceIndex,
LookupParams,
RegistryContributor,
ParsedImport,
ParsedTypeBinding,
WorkspaceIndex,
Callsite,
ScopeLookup,
} from './scope-resolution/types.js';
// Evidence + tie-break constants (RFC Appendix A, Appendix B)
export { EvidenceWeights, typeBindingWeightAtDepth } from './scope-resolution/evidence-weights.js';
export { ORIGIN_PRIORITY } from './scope-resolution/origin-priority.js';
export type { OriginForTieBreak } from './scope-resolution/origin-priority.js';
// Language classification (RFC §6.1 Ring 3/4 governance)
export {
LanguageClassifications,
isProductionLanguage,
} from './scope-resolution/language-classification.js';
export type { LanguageClassification } from './scope-resolution/language-classification.js';
// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913)
export { buildDefIndex } from './scope-resolution/def-index.js';
export type { DefIndex } from './scope-resolution/def-index.js';
export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js';
export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js';
export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js';
// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916)
// `ScopeLookup` is defined in `./scope-resolution/types.js` and exported
// from the type-export block above — not from this module.
export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js';
export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref.js';
// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914)
export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js';
export type {
MethodDispatchIndex,
MethodDispatchInput,
} from './scope-resolution/method-dispatch-index.js';
// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915)
export { finalize } from './scope-resolution/finalize-algorithm.js';
export type {
FinalizeInput,
FinalizeFile,
FinalizeHooks,
FinalizeOutput,
FinalizedScc,
FinalizeStats,
} from './scope-resolution/finalize-algorithm.js';
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
export { buildClassRegistry } from './scope-resolution/registries/class-registry.js';
export type { ClassRegistry } from './scope-resolution/registries/class-registry.js';
export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js';
export type {
MethodRegistry,
MethodLookupOptions,
} from './scope-resolution/registries/method-registry.js';
export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js';
export type {
FieldRegistry,
FieldLookupOptions,
} from './scope-resolution/registries/field-registry.js';
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js';
export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js';
export type { RawSignals } from './scope-resolution/registries/evidence.js';
export {
compareByConfidenceWithTiebreaks,
CONFIDENCE_EPSILON,
} from './scope-resolution/registries/tie-breaks.js';
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
export type {
RegistryContext,
RegistryProviders,
OwnerScopedContributor,
ArityVerdict,
} from './scope-resolution/registries/context.js';
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js';
export type { ScopeIdInput } from './scope-resolution/scope-id.js';
export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js';
export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {
ShadowAgreement,
ShadowCallsite,
ShadowDiff,
} from './scope-resolution/shadow/diff.js';
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
@@ -0,0 +1,62 @@
/**
* `DefIndex` — O(1) `DefId → SymbolDefinition` materialization.
*
* The global "what is this id?" lookup. Every per-kind registry (ClassRegistry,
* MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to
* full `SymbolDefinition` records through this index — one central hash map,
* one allocation per def.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface DefIndex {
readonly byId: ReadonlyMap<DefId, SymbolDefinition>;
readonly size: number;
get(id: DefId): SymbolDefinition | undefined;
has(id: DefId): boolean;
}
/**
* Build a `DefIndex` from a flat list of `SymbolDefinition` records.
*
* **Collision policy: first-write-wins.** `DefId` is meant to be unique
* (`nodeId` is the stable graph identifier), so a collision indicates an
* upstream bug — most likely the same symbol parsed twice or a duplicate
* commit into the pipeline. Rather than silently overwriting with a later
* definition that may be partial or wrong, the first record wins and
* subsequent records for the same id are dropped. Pipeline bugs surface
* later as `has(id) === true` but the def looking older than expected,
* which is easier to debug than a silent overwrite.
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex {
const byId = new Map<DefId, SymbolDefinition>();
for (const def of defs) {
if (byId.has(def.nodeId)) continue; // first-write-wins
byId.set(def.nodeId, def);
}
return wrapIndex(byId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function wrapIndex(byId: Map<DefId, SymbolDefinition>): DefIndex {
return {
byId,
get size() {
return byId.size;
},
get(id: DefId): SymbolDefinition | undefined {
return byId.get(id);
},
has(id: DefId): boolean {
return byId.has(id);
},
};
}
@@ -0,0 +1,90 @@
/**
* `EvidenceWeights` — RFC Appendix A (authoritative values).
*
* Starting calibration for scope-based resolution. Shadow-first rollout
* tunes these against legacy DAG parity. Every `ResolutionEvidence.weight`
* value in the codebase MUST reference this map; inline magic numbers are a
* lint violation. Extends issue #429 (centralize hardcoded confidence values).
*
* Evidence composes additively inside `composeEvidence`; the sum is capped
* at 1.0 in `Resolution.confidence`.
*/
/**
* Authoritative weight map. Keys are a mix of `ResolutionEvidence.kind`
* values and special modifiers (scope-chain depth, MRO depth decay,
* unlinked-import multiplicative cap).
*/
export const EvidenceWeights = {
// ─── Where-found signals (visibility) ─────────────────────────────────────
/** `BindingRef.origin === 'local'` */
local: 0.55,
/** `BindingRef.origin === 'import'` */
import: 0.45,
/** `BindingRef.origin === 'reexport'` */
reexport: 0.4,
/** `BindingRef.origin === 'namespace'` */
namespace: 0.4,
/** `BindingRef.origin === 'wildcard'` */
wildcard: 0.3,
// ─── Scope-chain deduction (per-hop) ──────────────────────────────────────
/** Deducted per parent-hop taken (depth-0 = 0, depth-1 = −0.02, …). */
scopeChainPerDepth: -0.02,
// ─── Receiver-type-binding signal (decays by MRO depth) ───────────────────
/**
* Weight applied when the receiver's type binding resolves to a class that
* declares the candidate as a method/field. Decays by MRO depth: direct
* class = index 0; 1 parent hop = index 1; etc. Falls back to the last
* value for depths beyond the table.
*/
typeBindingByMroDepth: [0.5, 0.42, 0.36, 0.32, 0.3] as const,
// ─── Corroborating signals ────────────────────────────────────────────────
/** `def.ownerId === resolvedReceiver.def.id` (exact owner match). */
ownerMatch: 0.2,
/** Explanatory only — retained for debuggability. Never discriminates
* because surviving candidates already passed `acceptedKinds`. */
kindMatch: 0.0,
// ─── Arity compatibility (from `provider.arityCompatibility`) ─────────────
/** `provider.arityCompatibility(...) === 'compatible'` */
arityMatchCompatible: 0.1,
/** `provider.arityCompatibility(...) === 'unknown'` */
arityMatchUnknown: 0.0,
/** `provider.arityCompatibility(...) === 'incompatible'` — penalizes;
* candidates filtered only when a compatible candidate exists. */
arityMatchIncompatible: -0.15,
// ─── Global fallback (only when nothing lexically visible) ────────────────
/** Hit via `QualifiedNameIndex.byQualifiedName`. */
globalQualified: 0.35,
/** Fallback hit in a `byName` index (and nothing was lexically visible). */
globalName: 0.1,
// ─── Degraded signals ─────────────────────────────────────────────────────
/** Call/reference flowing through a `dynamic-unresolved` edge. */
dynamicImportUnresolved: 0.02,
// ─── Unresolved-import cap (multiplicative, applied per-signal) ───────────
/**
* Multiplicative cap on the edge-derived evidence signal
* (`import`/`wildcard`/`reexport`/`namespace`) when
* `ImportEdge.linkStatus === 'unresolved'`. Independent corroborating
* signals on the same candidate (`owner-match`, `arity-match`,
* `type-binding`) are NOT penalized.
*/
unlinkedImportMultiplier: 0.5,
} as const;
/**
* Look up the `type-binding` signal weight for a given MRO depth, falling
* back to the last tabulated value for depths beyond the table.
*/
export function typeBindingWeightAtDepth(mroDepth: number): number {
const table = EvidenceWeights.typeBindingByMroDepth;
if (mroDepth < 0) return table[0];
if (mroDepth >= table.length) return table[table.length - 1];
return table[mroDepth];
}
@@ -0,0 +1,663 @@
/**
* `finalize` — cross-file finalize algorithm for the SemanticModel
* (RFC §3.2 Phase 2; Ring 2 SHARED #915).
*
* Pure logic that takes per-file parse output (`ParsedImport[]` +
* `SymbolDefinition[]`) and returns:
*
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
* `targetDefId` filled where resolvable; edges that could not be
* resolved within the hard fixpoint cap are marked
* `linkStatus: 'unresolved'`.
* - Materialized `bindings` per module scope — local defs merged with
* imported / wildcard-expanded / re-exported names via the provider's
* `mergeBindings` precedence.
* - The SCC condensation of the import graph, exposed so disjoint SCCs
* can be processed in parallel by callers that want that.
*
* The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level
* import graph, processes SCCs in reverse-topological order (leaves
* first), and within each SCC runs a bounded fixpoint link pass capped at
* `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed
* inputs are bounded by the cap.
*
* **No language-specific logic.** Target resolution, wildcard expansion,
* and binding precedence all go through caller-supplied hooks
* (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that
* match the LanguageProvider surface from #911.
*
* **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through
* as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }`
* with no `BindingRef`. They are parse-time signals, not linkable targets.
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js';
// ─── Public contracts ───────────────────────────────────────────────────────
/** Per-file input for the finalize pass. */
export interface FinalizeFile {
readonly filePath: string;
/** The module scope id for this file; owns the finalized imports + bindings. */
readonly moduleScope: ScopeId;
readonly parsedImports: readonly ParsedImport[];
/**
* Defs exported from this file — the "what other files can import by name"
* surface. Typically those with `isExported: true` (the module's own
* declarations) plus, for multi-hop re-export chains, the re-exported
* names the parser chose to surface here.
*
* **Multi-hop re-export contract.** `finalize` resolves an edge
* `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B
* only has `export { X } from './C'` and the parser *does not* include
* `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked
* `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs`
* across iterations — it is static input.
*
* Parsers that want multi-hop re-export chains to settle end-to-end must
* include re-exported names in the intermediate file's `localDefs` (with
* the original `DefId` of the source symbol). This keeps the algorithm
* O(1) per lookup and avoids graph-crawl during finalize.
*/
readonly localDefs: readonly SymbolDefinition[];
}
/** Input to `finalize`. */
export interface FinalizeInput {
readonly files: readonly FinalizeFile[];
/** Opaque workspace context forwarded to provider hooks. */
readonly workspaceIndex: WorkspaceIndex;
}
/**
* Provider-supplied hooks. Mirror the optional LanguageProvider scope-
* resolution hooks declared in #911; `finalize` calls them pure-ly and
* expects pure answers.
*/
export interface FinalizeHooks {
/**
* Resolve a raw import target to the concrete file path that owns it.
* Return `null` when no target file is resolvable (e.g., `np.foo` when
* `numpy` is external to the workspace).
*/
resolveImportTarget(
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
): string | null;
/**
* For a wildcard `import * from M`, return the names visible in the
* exporting module scope `M`. The finalize pass looks each name up in
* `M`'s local defs to produce a concrete `BindingRef`; names with no
* matching export are dropped.
*/
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
/**
* Merge `incoming` bindings into `existing` for a given name. Called
* once per name at each scope. Typical rules:
* - Python: local > imported > wildcard (last-write-wins within tier).
* - Rust: explicit `use` > glob; `pub use` overrides.
* Return value replaces the bucket entirely — no implicit append.
*/
mergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
scope: ScopeId,
): readonly BindingRef[];
}
/** One SCC in the file-level import graph. */
export interface FinalizedScc {
readonly files: readonly string[];
/** True iff this SCC has ≥ 2 files OR a single file that self-imports. */
readonly isCycle: boolean;
}
/**
* Counters reported by `finalize`.
*
* **Counting granularity** — all edge counters are **per-`ParsedImport`**,
* not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
* expands to N exports counts as one linked edge in these stats; the
* materialized output (`FinalizeOutput.imports`) will have N edges for
* that input. `dynamic-unresolved` ParsedImports count as linked (they
* pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
* BindingRef" — use the `bindings` map for that.
*
* In other words: `totalEdges === input.parsedImports.length` summed
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
*/
export interface FinalizeStats {
readonly totalFiles: number;
/** Total `ParsedImport` records seen across all files. */
readonly totalEdges: number;
/**
* `ParsedImport`s whose finalized edge does NOT carry
* `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs.
*/
readonly linkedEdges: number;
/** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */
readonly unresolvedEdges: number;
readonly sccCount: number;
readonly largestSccSize: number;
}
export interface FinalizeOutput {
/** Linked `ImportEdge[]` per module scope, in original input order. */
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
/** Materialized bindings per module scope. */
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
/** SCCs in reverse-topological order (leaves first). */
readonly sccs: readonly FinalizedScc[];
readonly stats: FinalizeStats;
}
// ─── Entry point ───────────────────────────────────────────────────────────
export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput {
const byFilePath = new Map<string, FinalizeFile>();
for (const f of input.files) byFilePath.set(f.filePath, f);
// ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per
// (file, parsedImport)). Edges with no resolvable target become
// `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through
// with `targetFile: null`.
const edgeIndex = new Map<string, ImportEdgeDraft[]>(); // filePath → drafts
let totalEdges = 0;
for (const file of input.files) {
const drafts: ImportEdgeDraft[] = [];
for (const parsed of file.parsedImports) {
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
drafts.push(draft);
totalEdges++;
}
edgeIndex.set(file.filePath, drafts);
}
// ── Phase 1: build file-level import graph (only resolvable edges form
// graph edges; unresolvable ones are terminal and contribute no
// fixpoint obligation).
const graph = new Map<string, Set<string>>();
for (const file of input.files) {
graph.set(file.filePath, new Set());
}
for (const [fromFile, drafts] of edgeIndex) {
const edges = graph.get(fromFile)!;
for (const d of drafts) {
if (d.targetFile !== null && byFilePath.has(d.targetFile)) {
edges.add(d.targetFile);
}
}
}
// ── Phase 2: Tarjan SCC → reverse-topological list of SCCs.
const sccs = tarjanSccs(graph);
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
// Edges leaving the SCC are already resolved (their target SCC is
// already finalized); edges inside the SCC may need multiple passes.
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
let linkedEdges = 0;
for (const scc of sccs) {
const sccFiles = new Set(scc.files);
const capacity = countEdgesWithin(edgeIndex, sccFiles);
// Run the fixpoint up to `capacity` iterations. Each iteration tries to
// resolve every still-unlinked edge in the SCC; stops early if a pass
// makes no progress.
let progressed = true;
let iterations = 0;
while (progressed && iterations < capacity) {
progressed = false;
iterations++;
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
const finalized = tryFinalize(draft, byFilePath);
if (finalized !== null) {
draft.finalized = finalized;
progressed = true;
}
}
}
}
// Any drafts still not finalized within this SCC hit the cap → unresolved.
for (const filePath of scc.files) {
const drafts = edgeIndex.get(filePath)!;
for (const draft of drafts) {
if (draft.finalized !== null) continue;
draft.finalized = {
...draft.base,
linkStatus: 'unresolved' as const,
};
}
}
}
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
// input order within each file, and wildcard-expand where applicable.
for (const file of input.files) {
const drafts = edgeIndex.get(file.filePath)!;
const finalized: ImportEdge[] = [];
for (const d of drafts) {
const edge = d.finalized!;
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
// Produce one `wildcard-expanded` ImportEdge per exported name.
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
for (const e of expanded) finalized.push(e);
} else {
finalized.push(edge);
}
if (edge.linkStatus !== 'unresolved') linkedEdges++;
}
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
}
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
// delegating precedence to `provider.mergeBindings`.
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
// ── Stats.
const sccCount = sccs.length;
let largestSccSize = 0;
for (const scc of sccs) {
if (scc.files.length > largestSccSize) largestSccSize = scc.files.length;
}
const stats: FinalizeStats = {
totalFiles: input.files.length,
totalEdges,
linkedEdges,
unresolvedEdges: totalEdges - linkedEdges,
sccCount,
largestSccSize,
};
return Object.freeze({
imports: linkedByScope,
bindings: bindingsByScope,
sccs,
stats,
});
}
// ─── Internal: edge drafting (phase 0) ──────────────────────────────────────
interface ImportEdgeDraft {
readonly source: ParsedImport;
readonly fromFile: string;
readonly fromScope: ScopeId;
readonly targetFile: string | null;
readonly base: ImportEdge;
finalized: ImportEdge | null;
}
function makeEdgeDraft(
parsed: ParsedImport,
file: FinalizeFile,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
): ImportEdgeDraft {
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
if (parsed.kind === 'dynamic-unresolved') {
const base: ImportEdge = {
localName: parsed.localName,
targetFile: null,
targetExportedName: '',
kind: 'dynamic-unresolved',
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile: null,
base,
finalized: base, // already fully finalized
};
}
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
// Edge is unresolvable at the file level — mark unresolved now.
if (targetFile === null) {
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
const base: ImportEdge = {
localName,
targetFile: null,
targetExportedName,
kind: edgeKind,
linkStatus: 'unresolved',
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile: null,
base,
finalized: base,
};
}
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
// in `targetDefId` (e.g., symbol not exported from target).
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
const targetExportedName = extractExportedName(parsed);
const base: ImportEdge = {
localName,
targetFile,
targetExportedName,
kind: edgeKind,
};
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile,
base,
finalized: null,
};
}
function extractExportedName(parsed: ParsedImport): string {
switch (parsed.kind) {
case 'named':
case 'alias':
case 'namespace':
case 'reexport':
return parsed.importedName;
case 'wildcard':
case 'dynamic-unresolved':
return '';
}
}
// ─── Internal: per-edge finalization (phase 3) ─────────────────────────────
function tryFinalize(
draft: ImportEdgeDraft,
byFilePath: Map<string, FinalizeFile>,
): ImportEdge | null {
const targetFile = draft.targetFile;
if (targetFile === null) return draft.base; // already terminal
const targetModule = byFilePath.get(targetFile);
if (targetModule === undefined) return draft.base; // external target — leave as-is
// Wildcards finalize at the file level; their per-name expansion happens
// in phase 4. At this stage we just record the target module scope.
if (draft.source.kind === 'wildcard') {
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
};
}
// Namespace imports alias the target *module*; they don't name a
// specific export. Link the module scope unconditionally. If the target
// also exposes a def whose simple name matches `importedName` (some
// languages emit a synthetic module-def), pick it up as the `targetDefId`
// so consumers can reach the module as a symbol — but its absence is not
// a failure.
if (draft.source.kind === 'namespace') {
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}),
};
}
// named / alias / reexport: look up the imported name in the target's
// local defs. Multi-hop re-export chains settle iteratively — each hop
// resolves once its prior hop is finalized.
const importedName = extractExportedName(draft.source);
const exported = findExportByName(targetModule.localDefs, importedName);
if (exported === undefined) {
// Target resolvable but the name isn't exported — keep trying in case a
// re-export inside the target's SCC surfaces it in a later iteration.
return null;
}
const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined;
return {
...draft.base,
targetModuleScope: targetModule.moduleScope,
targetDefId: exported.nodeId,
...(transitiveVia !== undefined ? { transitiveVia } : {}),
};
}
/**
* The "simple" (unqualified) name of a def, for import-name matching.
*
* Canonical source: `def.qualifiedName` — the tail after the last `.` (or
* the whole string if no dot). Defs without a qualifiedName can't be
* resolved by name here and return `null`; callers treat that as "name
* not exported" and either retry in a later fixpoint iteration or mark
* the edge unresolved.
*/
function deriveSimpleName(def: SymbolDefinition): string | null {
const q = def.qualifiedName;
if (q === undefined || q.length === 0) return null;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
function findExportByName(
defs: readonly SymbolDefinition[],
name: string,
): SymbolDefinition | undefined {
for (const d of defs) {
if (deriveSimpleName(d) === name) return d;
}
return undefined;
}
function countEdgesWithin(edgeIndex: Map<string, ImportEdgeDraft[]>, files: Set<string>): number {
let n = 0;
for (const filePath of files) {
const drafts = edgeIndex.get(filePath);
if (drafts === undefined) continue;
for (const d of drafts) {
if (d.targetFile !== null && files.has(d.targetFile)) n++;
}
}
// Guarantee at least one pass even for a trivial SCC (ensures deterministic
// fixpoint termination even when a single-file SCC has zero intra-SCC edges
// but still needs one settle pass).
return Math.max(n, 1);
}
// ─── Internal: wildcard expansion (phase 4) ────────────────────────────────
function expandWildcard(
edge: ImportEdge,
byFilePath: Map<string, FinalizeFile>,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
): readonly ImportEdge[] {
if (edge.targetModuleScope === undefined || edge.targetFile === null) {
return [edge]; // unresolvable wildcard survives as a single unlinked edge
}
const target = byFilePath.get(edge.targetFile);
if (target === undefined) return [edge];
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
if (names.length === 0) return [];
const expanded: ImportEdge[] = [];
for (const name of names) {
const def = findExportByName(target.localDefs, name);
if (def === undefined) continue;
expanded.push({
localName: name,
targetFile: edge.targetFile,
targetExportedName: name,
kind: 'wildcard-expanded',
targetModuleScope: edge.targetModuleScope,
targetDefId: def.nodeId,
});
}
return expanded;
}
// ─── Internal: bindings materialization (phase 5) ───────────────────────────
function materializeBindings(
files: readonly FinalizeFile[],
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
hooks: FinalizeHooks,
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
for (const file of files) {
const scopeBindings = new Map<string, readonly BindingRef[]>();
// Start with local defs as `origin: 'local'` bindings.
for (const def of file.localDefs) {
const name = deriveSimpleName(def);
if (name === null) continue;
const incoming: BindingRef[] = [{ def, origin: 'local' }];
const existing = scopeBindings.get(name) ?? [];
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
}
// Layer in finalized imports.
const imports = linkedByScope.get(file.moduleScope) ?? [];
for (const edge of imports) {
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
// Every def the importing file needs to reach is in some other file's
// `localDefs`; walk all files to find it. In practice we could index
// this, but at finalize-time N(files) is small per workspace pass.
const def = findDefById(files, edge.targetDefId);
if (def === undefined) continue;
const origin: BindingRef['origin'] =
edge.kind === 'namespace'
? 'namespace'
: edge.kind === 'wildcard-expanded'
? 'wildcard'
: edge.kind === 'reexport'
? 'reexport'
: 'import';
const fallback = deriveSimpleName(def);
const name = edge.localName.length > 0 ? edge.localName : fallback;
if (name === null) continue;
const incoming: BindingRef[] = [{ def, origin, via: edge }];
const existing = scopeBindings.get(name) ?? [];
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
}
// Freeze nested buckets for immutability.
const frozen = new Map<string, readonly BindingRef[]>();
for (const [name, refs] of scopeBindings) {
frozen.set(name, Object.freeze(refs.slice()));
}
out.set(file.moduleScope, frozen);
}
return out;
}
function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined {
for (const f of files) {
for (const d of f.localDefs) {
if (d.nodeId === defId) return d;
}
}
return undefined;
}
// ─── Internal: Tarjan SCC ──────────────────────────────────────────────────
/**
* Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order
* (leaves first — a property Tarjan gives for free, and the order
* `finalize` wants so leaves are fully resolved before their dependents).
*/
function tarjanSccs(graph: ReadonlyMap<string, ReadonlySet<string>>): FinalizedScc[] {
const index = new Map<string, number>();
const lowlink = new Map<string, number>();
const onStack = new Set<string>();
const stack: string[] = [];
const sccs: FinalizedScc[] = [];
let idx = 0;
// Iterative DFS to avoid stack overflow on deep import chains.
const allNodes = Array.from(graph.keys()).sort(); // deterministic order
const iterStack: Array<{ node: string; children: Iterator<string>; entered: boolean }> = [];
for (const root of allNodes) {
if (index.has(root)) continue;
iterStack.push({
node: root,
children: (graph.get(root) ?? new Set<string>()).values(),
entered: false,
});
while (iterStack.length > 0) {
const frame = iterStack[iterStack.length - 1]!;
if (!frame.entered) {
frame.entered = true;
index.set(frame.node, idx);
lowlink.set(frame.node, idx);
idx++;
stack.push(frame.node);
onStack.add(frame.node);
}
const nextChild = frame.children.next();
if (nextChild.done) {
// Post-visit: compute SCC membership if frame.node is a root.
if (lowlink.get(frame.node) === index.get(frame.node)) {
const scc: string[] = [];
let selfInCycle = false;
while (true) {
const w = stack.pop()!;
onStack.delete(w);
scc.push(w);
// A single-file self-loop counts as a cycle.
if (w === frame.node) {
selfInCycle = (graph.get(w) ?? new Set()).has(w);
break;
}
}
const isCycle = scc.length > 1 || selfInCycle;
sccs.push({ files: Object.freeze(scc), isCycle });
}
iterStack.pop();
// Propagate lowlink to parent.
if (iterStack.length > 0) {
const parent = iterStack[iterStack.length - 1]!;
lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!));
}
continue;
}
const child = nextChild.value;
if (!index.has(child)) {
iterStack.push({
node: child,
children: (graph.get(child) ?? new Set<string>()).values(),
entered: false,
});
} else if (onStack.has(child)) {
lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!));
}
}
}
return sccs;
}
@@ -0,0 +1,49 @@
/**
* `LanguageClassification` — RFC §6.1 Ring 3 / Ring 4 governance.
*
* Classifies each `SupportedLanguages` member for the rollout. Ring 4 (DAG
* retirement) is gated on *all production languages* being registry-primary
* and stable for one release cycle; `experimental` and `quarantined`
* languages do not block.
*
* Initial classification (locked in Ring 1 #910):
* - production: javascript, typescript, python, java, c, cpp, csharp, go,
* ruby, rust, php, kotlin, swift, dart
* - experimental: vue (embedded-language / SFC complexity),
* cobol (regex-provider path)
* - quarantined: (none)
*/
import { SupportedLanguages } from '../languages.js';
export type LanguageClassification = 'production' | 'experimental' | 'quarantined';
/**
* The canonical classification for each supported language. Governance
* changes (promote `experimental` → `production`, quarantine a language, …)
* update this map in a dedicated PR.
*/
export const LanguageClassifications: Readonly<Record<SupportedLanguages, LanguageClassification>> =
{
[SupportedLanguages.JavaScript]: 'production',
[SupportedLanguages.TypeScript]: 'production',
[SupportedLanguages.Python]: 'production',
[SupportedLanguages.Java]: 'production',
[SupportedLanguages.C]: 'production',
[SupportedLanguages.CPlusPlus]: 'production',
[SupportedLanguages.CSharp]: 'production',
[SupportedLanguages.Go]: 'production',
[SupportedLanguages.Ruby]: 'production',
[SupportedLanguages.Rust]: 'production',
[SupportedLanguages.PHP]: 'production',
[SupportedLanguages.Kotlin]: 'production',
[SupportedLanguages.Swift]: 'production',
[SupportedLanguages.Dart]: 'production',
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */
export function isProductionLanguage(lang: SupportedLanguages): boolean {
return LanguageClassifications[lang] === 'production';
}
@@ -0,0 +1,145 @@
/**
* `MethodDispatchIndex` — materialized view of class hierarchies keyed by
* `DefId` (RFC §3.1; Ring 2 SHARED #914).
*
* Two O(1)-access maps used by `Registry.lookupMethod` and interface-
* dispatch callers:
*
* - `mroByOwnerDefId` : owner class → full MRO ancestor chain
* (excludes the owner itself, in per-language
* strategy order).
* - `implsByInterfaceDefId` : interface/trait → classes that implement it.
*
* **Not an MRO implementation.** The build function is a pure aggregator: it
* asks the caller (via `computeMro` and `implementsOf` callbacks) for the
* per-language answers and materializes the two-way index. MRO strategies
* live where they already do today (`model/resolve.ts § c3Linearize`,
* `languages/ruby.ts § selectDispatch`, etc.) — this index does not
* reimplement them.
*
* Why callbacks and not a shared strategy registry: the five strategies
* (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax,
* COBOL none) already exist in the CLI package and depend on the CLI's
* `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would
* require migrating both — out of scope for #914. Callbacks let the shared
* build stay pure while honoring existing strategies verbatim.
*
* Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface
* dispatch resolver).
*/
import type { DefId } from './types.js';
// ─── Public contracts ───────────────────────────────────────────────────────
export interface MethodDispatchIndex {
/**
* Full MRO ancestor chain per owner class (excludes the owner itself).
* Order reflects the per-language strategy used by `computeMro`.
*/
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
mroFor(ownerDefId: DefId): readonly DefId[];
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
implementorsOf(interfaceDefId: DefId): readonly DefId[];
}
export interface MethodDispatchInput {
/**
* Owner defs to index (classes, structs, traits, interfaces — any kind
* that can appear on the owner side of a method-dispatch graph).
*/
readonly owners: readonly DefId[];
/**
* Return the full MRO ancestor chain for `ownerDefId`, **excluding the
* owner itself**, in the order dictated by the owner's language-specific
* MRO strategy.
*
* Contract:
* - Pure (no side effects).
* - Deterministic per input.
* - `undefined` not allowed — return `[]` when the owner has no parents.
*/
readonly computeMro: (ownerDefId: DefId) => readonly DefId[];
/**
* Return the set of interface/trait defs that `ownerDefId` implements.
* Transitive inclusion (e.g., `implements` on a parent class) is the
* caller's choice — the build function simply inverts whatever is
* returned.
*
* Repeated IDs in the output are deduplicated automatically.
*
* **Call-count contract.** `implementsOf` is invoked **once per
* occurrence** of an owner in `input.owners`, not once per unique
* owner. Duplicate owners therefore re-invoke it; dedup happens at
* the bucket layer (after the callback returns). Callers with
* expensive `implementsOf` implementations should pass a deduplicated
* `owners` list. `computeMro`, by contrast, is memoized by the first-
* write-wins policy and fires at most once per unique owner.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
}
// ─── Builder ────────────────────────────────────────────────────────────────
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
const implsBuilding = new Map<DefId, DefId[]>();
const implsSeen = new Map<DefId, Set<DefId>>();
for (const ownerId of input.owners) {
// First-write-wins on duplicate owner ids: a stable policy consistent
// with sibling indexes (#913 DefIndex / ModuleScopeIndex).
if (!mroByOwnerDefId.has(ownerId)) {
const chain = input.computeMro(ownerId);
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
}
for (const ifaceId of input.implementsOf(ownerId)) {
let seen = implsSeen.get(ifaceId);
if (seen === undefined) {
seen = new Set<DefId>();
implsSeen.set(ifaceId, seen);
}
if (seen.has(ownerId)) continue;
seen.add(ownerId);
let bucket = implsBuilding.get(ifaceId);
if (bucket === undefined) {
bucket = [];
implsBuilding.set(ifaceId, bucket);
}
bucket.push(ownerId);
}
}
const implsByInterfaceDefId = new Map<DefId, readonly DefId[]>();
for (const [ifaceId, owners] of implsBuilding) {
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
const EMPTY: readonly DefId[] = Object.freeze([]);
function wrapIndex(
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
): MethodDispatchIndex {
return {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
return mroByOwnerDefId.get(ownerDefId) ?? EMPTY;
},
implementorsOf(interfaceDefId: DefId): readonly DefId[] {
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
}
@@ -0,0 +1,73 @@
/**
* `ModuleScopeIndex` — O(1) `filePath → moduleScopeId` lookup.
*
* Every file parsed produces exactly one `Module` scope at its root. The
* finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete
* module scope id in constant time during the link pass; this index is that
* mapping.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
* resolving callsite file → enclosing module).
*/
import type { ScopeId } from './types.js';
export interface ModuleScopeIndex {
readonly byFilePath: ReadonlyMap<string, ScopeId>;
readonly size: number;
get(filePath: string): ScopeId | undefined;
has(filePath: string): boolean;
}
export interface ModuleScopeEntry {
readonly filePath: string;
readonly moduleScopeId: ScopeId;
}
/**
* Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }`
* pairs.
*
* **Collision policy: first-write-wins.** A file should appear exactly once
* in a single ingestion run; collisions indicate the same file was parsed
* twice or a `filePath` normalization bug upstream. Dropping the later
* entry preserves the first-stable id the rest of the pipeline may already
* have registered against.
*
* **Caller contract: filePath keys must be pre-normalized.** This index
* keys on the raw `filePath` string and does NOT canonicalize separators,
* case, or trailing slashes. Callers upstream of this function must agree
* on a canonical form (typically repo-root-relative, POSIX separators,
* no trailing slash) before constructing entries — otherwise `C:\foo\bar.ts`,
* `C:/foo/bar.ts`, and `foo/bar.ts` will all hash to distinct buckets and
* `get()` will miss.
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex {
const byFilePath = new Map<string, ScopeId>();
for (const { filePath, moduleScopeId } of entries) {
if (byFilePath.has(filePath)) continue; // first-write-wins
byFilePath.set(filePath, moduleScopeId);
}
return wrapIndex(byFilePath);
}
// ─── Internal ───────────────────────────────────────────────────────────────
function wrapIndex(byFilePath: Map<string, ScopeId>): ModuleScopeIndex {
return {
byFilePath,
get size() {
return byFilePath.size;
},
get(filePath: string): ScopeId | undefined {
return byFilePath.get(filePath);
},
has(filePath: string): boolean {
return byFilePath.has(filePath);
},
};
}
@@ -0,0 +1,30 @@
/**
* `ORIGIN_PRIORITY` — RFC Appendix B (authoritative values).
*
* Tie-break ordering applied inside `Registry.lookup` Step 7 when
* `|Δconfidence| < 0.001` between two `Resolution` candidates. Lower number
* = stronger (wins the tie).
*
* Full tie-break order (§4.2 Step 7):
* confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC
* → DefId.localeCompare
*/
export type OriginForTieBreak =
| 'local'
| 'import'
| 'reexport'
| 'namespace'
| 'wildcard'
| 'global-qualified'
| 'global-name';
export const ORIGIN_PRIORITY: Readonly<Record<OriginForTieBreak, number>> = {
local: 0,
import: 1,
reexport: 2,
namespace: 3,
wildcard: 4,
'global-qualified': 5,
'global-name': 6,
};
@@ -0,0 +1,166 @@
/**
* `PositionIndex` — O(log N_file) scope-at-position lookup
* (RFC §3.1; Ring 2 SHARED #912).
*
* Per-file sorted array of `(range, scopeId)` entries, sorted by start
* position ASC (`startLine`, then `startCol`). `atPosition(filePath, line,
* col)` binary-searches for the last entry whose start ≤ (line, col), then
* scans backward through the sorted prefix and returns the first entry
* whose range contains the query position.
*
* **Why this works.** `ScopeTree`'s invariants (parent strictly contains
* child; siblings don't overlap) guarantee that the scopes containing a
* given point form an **ancestor chain**. When scanning backward through
* entries sorted by start position ASC, the first scope we find that
* contains the query is the innermost one — any deeper-starting scope
* that also contained the query would appear *later* in the sorted array,
* but we're only scanning entries with start ≤ query, so anything later
* necessarily starts after the query and can't contain it.
*
* Expected complexity: `O(log N_file + D)` where `D` is the lexical depth
* at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)`
* only under pathological inputs (many scopes starting at the same line).
*
* **Line/column conventions.** Matches `Range` in `types.ts`: lines are
* 1-based, columns are 0-based. Ranges are **inclusive on both ends** —
* a scope whose `endLine:endCol` equals the query position still contains
* it. That matches how tree-sitter captures bodies (closing brace
* included) and how closed PR #902's `enclosingFunctions` behaved.
*/
import type { Range, Scope, ScopeId } from './types.js';
export interface PositionIndex {
/** Total scope entries indexed across all files. */
readonly size: number;
/**
* Innermost scope containing `(line, col)` in `filePath`, or `undefined`
* when nothing contains it (position before file start, after file end,
* or filePath not indexed).
*
* **Touching-boundary semantics.** Ranges are inclusive on both ends.
* When two sibling scopes share a boundary point — e.g.
* `[5:0, 10:0]` and `[10:0, 15:0]`, which is legal under `ScopeTree`'s
* non-overlap invariant — a query at the shared point `(10, 0)` is
* contained by **both**. The innermost-wins tie-break rule applies as
* usual: since neither is nested inside the other, the one that
* **starts latest** wins, i.e. the **right** sibling. The mechanism
* is the backward scan through the start-position-sorted array (see
* `findLastStartLteIndex` below) — both siblings land before the
* upper-bound cursor, and the right sibling is scanned first. Queries at non-boundary positions between them naturally
* fall to the unique containing scope.
*/
atPosition(filePath: string, line: number, col: number): ScopeId | undefined;
}
/**
* Build a `PositionIndex` from a flat list of `Scope` records.
*
* Duplicate `id`s are tolerated and deduplicated — the caller's
* `ScopeTree.buildScopeTree` is the authoritative validator of scope
* identity, and the position index does not need to re-check that
* invariant.
*/
export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex {
const entriesByFile = new Map<string, Entry[]>();
const seen = new Set<ScopeId>();
for (const scope of scopes) {
if (seen.has(scope.id)) continue;
seen.add(scope.id);
let bucket = entriesByFile.get(scope.filePath);
if (bucket === undefined) {
bucket = [];
entriesByFile.set(scope.filePath, bucket);
}
bucket.push({ id: scope.id, range: scope.range });
}
for (const bucket of entriesByFile.values()) {
bucket.sort(compareEntry);
}
return wrapIndex(entriesByFile, seen.size);
}
// ─── Internals ──────────────────────────────────────────────────────────────
interface Entry {
readonly id: ScopeId;
readonly range: Range;
}
/**
* Sort by start position ASC, breaking ties by end position DESC so that
* larger (outer) scopes appear before their smaller (inner) co-starting
* siblings in the array. Makes the backward-scan contract crisp: the
* first containing hit from the end of the scanned prefix is the
* innermost scope.
*/
function compareEntry(a: Entry, b: Entry): number {
if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine;
if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol;
if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine;
return b.range.endCol - a.range.endCol;
}
/** Whether `(line, col)` is at or after `range`'s start. */
function startIsAtOrBefore(range: Range, line: number, col: number): boolean {
if (range.startLine < line) return true;
if (range.startLine > line) return false;
return range.startCol <= col;
}
/** Whether `(line, col)` is at or before `range`'s end (inclusive). */
function endIsAtOrAfter(range: Range, line: number, col: number): boolean {
if (range.endLine > line) return true;
if (range.endLine < line) return false;
return range.endCol >= col;
}
/**
* Return the largest index `i` in `arr` where `arr[i].range` starts at or
* before `(line, col)`. Returns `-1` if no entry starts ≤ the query.
*
* Classic "upper bound - 1" binary search: find the first entry that
* starts *after* the query, then step back one.
*/
function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number {
let lo = 0;
let hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (startIsAtOrBefore(arr[mid]!.range, line, col)) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo - 1;
}
function wrapIndex(entriesByFile: Map<string, Entry[]>, size: number): PositionIndex {
return {
get size() {
return size;
},
atPosition(filePath: string, line: number, col: number): ScopeId | undefined {
const bucket = entriesByFile.get(filePath);
if (bucket === undefined || bucket.length === 0) return undefined;
const endIdx = findLastStartLteIndex(bucket, line, col);
if (endIdx < 0) return undefined;
// Scan backward; first containing hit is innermost (see file header).
for (let i = endIdx; i >= 0; i--) {
const entry = bucket[i]!;
if (endIsAtOrAfter(entry.range, line, col)) {
// `startIsAtOrBefore` is guaranteed true by the binary search.
return entry.id;
}
}
return undefined;
},
};
}
@@ -0,0 +1,92 @@
/**
* `QualifiedNameIndex` — O(1) `qualifiedName → DefId[]` lookup across all kinds.
*
* Cross-kind fast path for qualified-name resolution
* (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method,
* field, and namespace defs all contribute to a single index here; consumers
* filter the returned `DefId[]` by `p.acceptedKinds` at the call site.
*
* Returns `DefId[]` (not a single `DefId`) because multiple defs can legally
* share a qualified name — partial classes in C#, method overloads, or
* accidental cross-kind collisions. The lookup caller filters to the expected
* kind(s) and ranks the survivors.
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef`
* dotted fallback via #916).
*/
import type { SymbolDefinition } from './symbol-definition.js';
import type { DefId } from './types.js';
export interface QualifiedNameIndex {
readonly byQualifiedName: ReadonlyMap<string, readonly DefId[]>;
readonly size: number;
/** Returns all `DefId`s registered under this qualified name; empty frozen
* array on miss so callers can iterate without null checks. */
get(qualifiedName: string): readonly DefId[];
has(qualifiedName: string): boolean;
}
/**
* Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records.
*
* Only defs with a non-empty `qualifiedName` contribute; defs without one are
* silently skipped (not every kind carries a qualified name — anonymous or
* top-level symbols, dynamic-unresolved imports, etc.).
*
* **Duplicate policy: appended in input order.** Each unique `(qname, DefId)`
* pair contributes at most once — repeated entries for the same pair are
* deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion
* order (stable output for deterministic lookup ranking at the call site).
*
* Pure function — safe to call repeatedly; no side effects.
*/
export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex {
const byQualifiedName = new Map<string, DefId[]>();
const seenPairs = new Set<string>();
for (const def of defs) {
const qname = def.qualifiedName;
if (qname === undefined || qname.length === 0) continue;
const pairKey = `${qname}\0${def.nodeId}`;
if (seenPairs.has(pairKey)) continue;
seenPairs.add(pairKey);
const bucket = byQualifiedName.get(qname);
if (bucket === undefined) {
byQualifiedName.set(qname, [def.nodeId]);
} else {
bucket.push(def.nodeId);
}
}
// Freeze bucket arrays so consumers can't mutate the index.
const frozen = new Map<string, readonly DefId[]>();
for (const [k, v] of byQualifiedName) {
frozen.set(k, Object.freeze(v.slice()));
}
return wrapIndex(frozen);
}
// ─── Internal ───────────────────────────────────────────────────────────────
const EMPTY: readonly DefId[] = Object.freeze([]);
function wrapIndex(byQualifiedName: Map<string, readonly DefId[]>): QualifiedNameIndex {
return {
byQualifiedName,
get size() {
return byQualifiedName.size;
},
get(qualifiedName: string): readonly DefId[] {
return byQualifiedName.get(qualifiedName) ?? EMPTY;
},
has(qualifiedName: string): boolean {
return byQualifiedName.has(qualifiedName);
},
};
}
@@ -0,0 +1,41 @@
/**
* `ClassRegistry` — scope-aware lookup for class-like symbols
* (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for class kinds:
*
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
* Template / Namespace.
* - `useReceiverTypeBinding` is **false** — classes are resolved by
* name through the lexical chain + global qualified fallback, not
* via a receiver type.
* - Arity filter is not applicable (classes are not called with
* argument counts at lookup time).
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import { CLASS_KINDS, type RegistryContext } from './context.js';
export interface ClassRegistry {
/**
* Look up a class-like symbol by simple or dotted name anchored at
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
* for the best answer.
*/
lookup(name: string, scope: ScopeId): readonly Resolution[];
}
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
const params: CoreLookupParams = {
acceptedKinds: CLASS_KINDS,
useReceiverTypeBinding: false,
ownerScopedContributor: null,
};
return {
lookup(name: string, scope: ScopeId) {
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,110 @@
/**
* `RegistryContext` — the injected state required by the scope-aware
* registry lookups (RFC §4; Ring 2 SHARED #917).
*
* Bundles every Ring 2 index + every provider hook the 7-step algorithm
* might consult. Threaded through `lookupCore` and the three public
* registries unchanged; construction is the caller's responsibility
* (typically once per workspace-indexing pass in Ring 2 PKG).
*
* The design intent is **pure-logic in `gitnexus-shared`, data + hooks
* supplied by the caller**. Nothing here loads files, parses AST, or
* reaches into the CLI package.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type { Callsite, DefId } from '../types.js';
import type { DefIndex } from '../def-index.js';
import type { QualifiedNameIndex } from '../qualified-name-index.js';
import type { ModuleScopeIndex } from '../module-scope-index.js';
import type { ScopeTree } from '../scope-tree.js';
import type { MethodDispatchIndex } from '../method-dispatch-index.js';
// ─── Provider hooks consumed by the registries ─────────────────────────────
export interface RegistryProviders {
/**
* Language-specific arity compatibility between a callsite and a candidate
* `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional:
* when absent, every candidate receives `'unknown'` (neutral signal).
*/
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
}
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
/**
* Per-owner membership view plugged into `LookupParams.ownerScopedContributor`.
*
* When the caller knows a receiver is of type `Owner` (e.g., after
* resolving an explicit receiver or via `self`), it can supply the
* `Owner`'s own member bucket here. `lookupCore` treats hits from this
* contributor as `origin: 'local'` inside the owner's body scope —
* strongest-visibility evidence, unaffected by the scope-chain hop
* deduction that punishes outer-scope hits.
*
* Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed
* to this concrete shape here in Ring 2 SHARED (#917).
*/
export interface OwnerScopedContributor {
/** The owner (class/struct/trait/interface) that bounds this view. */
readonly ownerDefId: DefId;
/**
* Methods / fields directly declared on the owner, keyed by simple name.
* Return empty array on miss; implementations should NOT walk the MRO —
* that's `MethodDispatchIndex`'s job, handled in the type-binding step.
*/
byName(name: string): readonly SymbolDefinition[];
}
// ─── Top-level context threaded through every lookup ───────────────────────
export interface RegistryContext {
readonly scopes: ScopeTree;
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
/**
* Method-dispatch index; required for method/field registries that
* honor `useReceiverTypeBinding`. Omit for class-only lookups.
*/
readonly methodDispatch?: MethodDispatchIndex;
readonly providers: RegistryProviders;
}
// ─── Per-kind default `acceptedKinds` sets ─────────────────────────────────
//
// Exported so the three public registries stay declarative (each one just
// points at the right constant + passes it to `lookupCore`).
export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
'Namespace',
]);
export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([
'Method',
'Function',
'Constructor',
]);
export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
'Variable',
'Property',
'Const',
'Static',
]);
@@ -0,0 +1,196 @@
/**
* `composeEvidence` — translate accumulated raw signals per candidate
* into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights`
* map (RFC §4.3 + Appendix A; Ring 2 SHARED #917).
*
* Each `RawSignals` record describes what was observed about a candidate
* during the 7-step walk: where it was found, at what depth, whether
* anything corroborates it. This module turns those raw facts into the
* typed evidence list attached to the outgoing `Resolution`.
*
* **Every weight comes from `EvidenceWeights`.** No inline magic numbers.
* Extends issue #429 (centralize hardcoded confidence values).
*
* **Confidence compose rule.** Signals add; the sum is capped at 1.0 at
* the call site (inside `lookupCore`). This module only emits the list;
* it does NOT compute the capped sum so callers can inspect per-signal
* contributions for debugging.
*/
import type { BindingRef, ResolutionEvidence } from '../types.js';
import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js';
/**
* Raw signals observed for a single candidate during the 7-step walk.
* Optional fields encode "this signal did not fire"; presence encodes
* "emit an evidence record".
*/
export interface RawSignals {
// ── Where-found ────────────────────────────────────────────────────────
/** Visibility origin of the binding that produced this candidate. */
readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
/** Depth at which the binding was found (hops up from start scope). */
readonly scopeChainDepth?: number;
/** `ImportEdge` that brought the name in; present when origin is a non-local. */
readonly viaUnlinkedImport?: boolean;
// ── Type-binding path ──────────────────────────────────────────────────
/** Set when the candidate came via the receiver's type-binding MRO walk. */
readonly typeBindingMroDepth?: number;
// ── Corroborators ──────────────────────────────────────────────────────
/** `def.ownerId === resolvedReceiver.def.nodeId`. */
readonly ownerMatch?: boolean;
/** Always fires for candidates that pass `acceptedKinds`; weight 0. */
readonly kindMatch: true;
// ── Arity ──────────────────────────────────────────────────────────────
readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible';
// ── Dynamic-unresolved passthrough ─────────────────────────────────────
/** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */
readonly dynamicUnresolved?: boolean;
}
/**
* Compose the raw signals into a stable `ResolutionEvidence[]` list.
*
* Emission order mirrors the `EvidenceWeights` layout: where-found →
* type-binding → corroborators → arity → degraded. Stable order makes
* the per-signal contributions easy to reason about in tests and in the
* shadow-mode parity dashboard.
*/
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
const out: ResolutionEvidence[] = [];
// ── Where-found visibility ─────────────────────────────────────────────
if (signals.origin !== undefined) {
const baseWeight = getOriginWeight(signals.origin);
const capped = signals.viaUnlinkedImport
? baseWeight * EvidenceWeights.unlinkedImportMultiplier
: baseWeight;
const evidenceKind = whereFoundEvidenceKind(signals.origin);
out.push({
kind: evidenceKind,
weight: capped,
...(signals.viaUnlinkedImport
? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` }
: {}),
});
}
// ── Scope-chain depth deduction (per-hop, only meaningful for lexical
// hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1
// emits a single `scope-chain` evidence with the accumulated penalty.
if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) {
out.push({
kind: 'scope-chain',
weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth,
note: `depth=${signals.scopeChainDepth}`,
});
}
// ── Type-binding / MRO path ────────────────────────────────────────────
if (signals.typeBindingMroDepth !== undefined) {
out.push({
kind: 'type-binding',
weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth),
note: `mroDepth=${signals.typeBindingMroDepth}`,
});
}
// ── Owner match (explanatory for debug) ────────────────────────────────
if (signals.ownerMatch === true) {
out.push({
kind: 'owner-match',
weight: EvidenceWeights.ownerMatch,
});
}
// ── Kind match (always present; weight 0; retained for debuggability) ──
out.push({
kind: 'kind-match',
weight: EvidenceWeights.kindMatch,
});
// ── Arity ──────────────────────────────────────────────────────────────
if (signals.arityVerdict !== undefined) {
const weight =
signals.arityVerdict === 'compatible'
? EvidenceWeights.arityMatchCompatible
: signals.arityVerdict === 'incompatible'
? EvidenceWeights.arityMatchIncompatible
: EvidenceWeights.arityMatchUnknown;
out.push({
kind: 'arity-match',
weight,
note: signals.arityVerdict,
});
}
// ── Dynamic-unresolved (degraded signal) ───────────────────────────────
if (signals.dynamicUnresolved === true) {
out.push({
kind: 'dynamic-import-unresolved',
weight: EvidenceWeights.dynamicImportUnresolved,
});
}
return out;
}
/**
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
* so tests and the parity dashboard can inspect the raw evidence list.
*/
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
let sum = 0;
for (const e of evidence) sum += e.weight;
if (sum < 0) return 0;
if (sum > 1) return 1;
return sum;
}
// ─── Internal ───────────────────────────────────────────────────────────────
function getOriginWeight(origin: NonNullable<RawSignals['origin']>): number {
switch (origin) {
case 'local':
return EvidenceWeights.local;
case 'import':
return EvidenceWeights.import;
case 'reexport':
return EvidenceWeights.reexport;
case 'namespace':
return EvidenceWeights.namespace;
case 'wildcard':
return EvidenceWeights.wildcard;
case 'global-qualified':
return EvidenceWeights.globalQualified;
case 'global-name':
// Reserved for Ring 3 byName global index. `lookupCore` today only
// emits `'global-qualified'` (via `lookupQualified`, dotted-name
// fallback); no code path constructs `origin: 'global-name'` yet.
// Kept here so the Appendix A weight stays live and `composeEvidence`
// remains exhaustive over the origin union.
return EvidenceWeights.globalName;
}
}
function whereFoundEvidenceKind(
origin: NonNullable<RawSignals['origin']>,
): ResolutionEvidence['kind'] {
switch (origin) {
case 'local':
return 'local';
case 'import':
case 'reexport':
case 'namespace':
case 'wildcard':
return 'import';
case 'global-qualified':
return 'global-qualified';
case 'global-name':
return 'global-name';
}
}
@@ -0,0 +1,43 @@
/**
* `FieldRegistry` — scope-aware lookup for field / property / variable
* access (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for data-member kinds:
*
* - `acceptedKinds` = Variable / Property / Const / Static.
* - `useReceiverTypeBinding` is **true** — fields are resolved against
* the receiver type's MRO first, then via the lexical chain for
* free variables.
* - `callsite` is not meaningful for field access (no arity), but the
* `explicitReceiver` and `ownerScopedContributor` knobs are.
*/
import type { Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { FIELD_KINDS } from './context.js';
export interface FieldLookupOptions {
readonly explicitReceiver?: { readonly name: string };
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface FieldRegistry {
lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[];
}
export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
return {
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: FIELD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,461 @@
/**
* `lookupCore` — the shared 7-step canonical resolution algorithm
* (RFC §4.2; Ring 2 SHARED #917).
*
* Pure function. Given a name, a starting scope, and per-kind parameters,
* walks lexical scopes + optional type-binding MRO + optional owner
* contributor + global qualified-name fallback, and returns a ranked
* `Resolution[]` with per-candidate evidence.
*
* All three public registries (`ClassRegistry` / `MethodRegistry` /
* `FieldRegistry`) dispatch into this function, differing only in the
* parameters they pass. The CHOICE of which steps fire is expressed
* through `LookupParams`, not through different algorithms per kind.
*
* ## Algorithm (RFC §4.2, verbatim names)
*
* **Step 1 — Lexical scope-chain walk.** From `startScope`, walk
* parent-ward. At each scope, consult `scope.bindings.get(name)`:
* - Filter candidates whose `def.type ∈ acceptedKinds`.
* - For each surviving candidate, record a raw signal with the
* binding's origin + the current scope-chain depth.
* - **Hard shadow.** If `bindings.get(name)` is non-empty (including
* non-kind-matching candidates), stop walking. The name is
* lexically bound here; outer scopes are not consulted.
*
* **Step 2 — Type-binding resolution.** When `useReceiverTypeBinding`
* is true, resolve the receiver's type at `startScope` (from
* `scope.typeBindings`), then walk the MRO via
* `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes
* through `RegistryContext.methodDispatch` + owner lookups into
* `scope.ownedDefs`; each hit records a raw signal with the owner's
* MRO depth.
*
* **Step 3 — Owner-scoped contributor.** When
* `params.ownerScopedContributor` is present, merge its `byName(name)`
* hits with `origin: 'local'` (they are declared directly on the
* receiver). Distinct from Step 2 — Step 2 walks the MRO; Step 3 only
* looks at the directly-declared owner members.
*
* **Step 4 — Kind filter (emit `kind-match` evidence).** Already
* applied during Steps 1-3; this step just adds a `kind-match` signal
* at weight 0 to every candidate for debuggability (so the evidence
* array is self-describing).
*
* **Step 5 — Arity filter.** Call `providers.arityCompatibility(callsite,
* def)` per surviving candidate. Verdicts: `compatible` / `unknown` /
* `incompatible`. If at least one candidate is `compatible`, drop
* `incompatible` ones. Otherwise keep all (the penalty weight alone
* will rank them lower but they remain in the result).
*
* **Step 6 — Global fallback.** When Steps 1-3 produced **no**
* candidates and the name contains a `.`, consult the
* `QualifiedNameIndex` via `lookupQualified` — see §4.5. The `scope`
* argument is NOT passed here because global lookup is scope-agnostic.
*
* **Step 7 — Rank + tie-break.** Compose evidence, compute confidence
* (sum capped at 1.0), sort by the RFC Appendix B cascade.
*
* ## What this module does NOT do
*
* - No AST reads (pure data in, pure data out).
* - No `gitnexus/` imports.
* - No language switches. Language-specific behavior flows exclusively
* through `providers.*` and the `params` object.
* - No caching. Callers that want memoization can wrap this function.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { SymbolDefinition } from '../symbol-definition.js';
import type {
BindingRef,
Callsite,
DefId,
LookupParams,
Resolution,
Scope,
ScopeId,
} from '../types.js';
import type { OriginForTieBreak } from '../origin-priority.js';
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import { lookupQualified } from './lookup-qualified.js';
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
// ─── Public entry point ─────────────────────────────────────────────────────
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
readonly ownerScopedContributor: OwnerScopedContributor | null;
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
readonly callsite?: Callsite;
}
/**
* Run the 7-step lookup. Returns a non-empty `Resolution[]` when any
* candidate was found; an empty array otherwise. Callers consume `[0]`
* for the best answer and optionally inspect the rest for alternates.
*/
export function lookupCore(
name: string,
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): readonly Resolution[] {
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const perCandidate = new Map<DefId, CandidateState>();
// ── Step 1: lexical scope-chain walk ──────────────────────────────────
const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
}
// ── Step 3: owner-scoped contributor ──────────────────────────────────
if (params.ownerScopedContributor !== null) {
seedFromOwnerScopedContributor(
name,
params.ownerScopedContributor,
acceptedKinds,
perCandidate,
);
}
// ── Step 4: kind-match evidence (emitted by composeEvidence directly) ──
// Handled inside `composeEvidence`.
// ── Step 5: arity filter ──────────────────────────────────────────────
if (params.callsite !== undefined) {
applyArityFilter(params.callsite, perCandidate, ctx);
}
// ── Step 6: global fallback (only when Steps 1-3 produced nothing) ──
if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) {
const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx);
if (globals.length > 0) return globals;
}
if (perCandidate.size === 0) return EMPTY;
// ── Step 7: compose evidence + rank ──────────────────────────────────
return rankCandidates(perCandidate);
}
// ─── Internal state ────────────────────────────────────────────────────────
interface CandidateState {
readonly def: SymbolDefinition;
readonly signals: MutableRawSignals;
readonly tieBreakKey: MutableTieBreakKey;
}
interface MutableRawSignals {
origin?: BindingRef['origin'] | 'global-qualified' | 'global-name';
scopeChainDepth?: number;
viaUnlinkedImport?: boolean;
typeBindingMroDepth?: number;
ownerMatch?: boolean;
kindMatch: true;
arityVerdict?: ArityVerdict;
dynamicUnresolved?: boolean;
}
interface MutableTieBreakKey {
scopeDepth: number;
mroDepth: number;
origin: OriginForTieBreak;
}
function ensureCandidate(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
): CandidateState {
const existing = perCandidate.get(def.nodeId);
if (existing !== undefined) return existing;
const fresh: CandidateState = {
def,
signals: { kindMatch: true },
tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' },
};
perCandidate.set(def.nodeId, fresh);
return fresh;
}
// ─── Step 1 implementation ─────────────────────────────────────────────────
/**
* Walk the lexical scope chain from `startScope` upward. Returns `true`
* iff a scope with any `bindings.get(name)` entries was found — the
* caller uses this to decide whether to run the global fallback.
*/
function walkLexicalChain(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): boolean {
let currentId: ScopeId | null = startScope;
let depth = 0;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
if (scope === undefined) return false;
const bindings = scope.bindings.get(name);
if (bindings !== undefined && bindings.length > 0) {
for (const binding of bindings) {
if (!acceptedKinds.has(binding.def.type)) continue;
recordLexicalHit(perCandidate, binding, depth);
}
return true; // hard shadow regardless of kind-filter survivorship
}
currentId = scope.parent;
depth++;
}
return false;
}
function recordLexicalHit(
perCandidate: Map<DefId, CandidateState>,
binding: BindingRef,
scopeChainDepth: number,
): void {
const state = ensureCandidate(perCandidate, binding.def);
state.signals.origin = binding.origin;
state.signals.scopeChainDepth = scopeChainDepth;
if (binding.via?.linkStatus === 'unresolved') {
state.signals.viaUnlinkedImport = true;
}
if (binding.via?.kind === 'dynamic-unresolved') {
state.signals.dynamicUnresolved = true;
}
state.tieBreakKey.scopeDepth = scopeChainDepth;
state.tieBreakKey.origin = binding.origin as OriginForTieBreak;
}
// ─── Step 2 implementation ─────────────────────────────────────────────────
function walkReceiverTypeBinding(
name: string,
startScope: ScopeId,
acceptedKinds: ReadonlySet<NodeLabel>,
params: CoreLookupParams,
ctx: RegistryContext,
perCandidate: Map<DefId, CandidateState>,
): void {
const ownerDefId = resolveReceiverOwner(startScope, params, ctx);
if (ownerDefId === undefined) return;
if (ctx.methodDispatch === undefined) return;
const ownerDef = ctx.defs.get(ownerDefId);
if (ownerDef === undefined) return;
// Walk the owner itself at depth 0, then its MRO chain.
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];
for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
const currentOwnerId = walk[mroDepth]!;
const members = collectOwnedMembers(currentOwnerId, name, ctx);
for (const def of members) {
if (!acceptedKinds.has(def.type)) continue;
recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId);
}
}
}
function resolveReceiverOwner(
startScope: ScopeId,
params: CoreLookupParams,
ctx: RegistryContext,
): DefId | undefined {
// Explicit receiver: consult the callsite scope's typeBindings for the
// named receiver; the attached TypeRef identifies the owner. Without a
// ready resolveTypeRef call (that module is separate), we do a direct
// lookup and trust the caller to have populated the binding.
if (params.explicitReceiver !== undefined) {
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
}
// Implicit `self` / `this` — the scope's typeBindings should carry it.
for (const implicitName of IMPLICIT_RECEIVERS) {
const owner = lookupReceiverType(startScope, implicitName, ctx);
if (owner !== undefined) return owner;
}
return undefined;
}
const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']);
function lookupReceiverType(
startScope: ScopeId,
receiverName: string,
ctx: RegistryContext,
): DefId | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) {
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
// can't claim the receiver type. No fallback — that's what
// `resolveTypeRef` would do, but we keep this path lean and let
// callers pre-resolve if they want the richer semantics.
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
if (candidateIds.length === 1) return candidateIds[0];
// Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via
// `resolveTypeRef` (#916) if they want the richer semantics. We
// intentionally do NOT re-implement a simple-name fallback here.
return undefined;
}
currentId = scope.parent;
}
return undefined;
}
function collectOwnedMembers(
ownerDefId: DefId,
memberName: string,
ctx: RegistryContext,
): readonly SymbolDefinition[] {
// An owner's members are defs whose `ownerId === ownerDefId` and whose
// simple name matches `memberName`. We iterate `defs.byId` — O(D) per
// call today. A future by-owner index would make this O(K); tracked as
// a follow-up optimization before Ring 3 flips go production.
const out: SymbolDefinition[] = [];
for (const def of ctx.defs.byId.values()) {
if (def.ownerId !== ownerDefId) continue;
if (simpleNameOf(def) !== memberName) continue;
out.push(def);
}
return out;
}
function simpleNameOf(def: SymbolDefinition): string | undefined {
if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined;
const dot = def.qualifiedName.lastIndexOf('.');
return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1);
}
function recordTypeBindingHit(
perCandidate: Map<DefId, CandidateState>,
def: SymbolDefinition,
mroDepth: number,
receiverOwner: DefId,
): void {
const state = ensureCandidate(perCandidate, def);
const existingMroDepth = state.signals.typeBindingMroDepth;
const firstHit = existingMroDepth === undefined;
// Only replace if this hit is shallower (smaller MRO depth). The local
// const lets TS narrow to `number` in the `else` branch so no `!`
// assertion is needed.
if (firstHit || mroDepth < existingMroDepth) {
state.signals.typeBindingMroDepth = mroDepth;
state.tieBreakKey.mroDepth = mroDepth;
}
if (def.ownerId === receiverOwner) {
state.signals.ownerMatch = true;
}
// Pure type-binding candidates (no lexical hit) would otherwise keep the
// `ensureCandidate` default `tieBreakKey.origin === 'local'`, making the
// Appendix B cascade lump them with local-origin candidates. Demote them
// to `'import'` — the strongest non-local origin — only when no earlier
// phase set an origin for this candidate. Lexical hits from Step 1 set
// `signals.origin` before Step 2 runs, so the guard skips them; Step 3
// (`seedFromOwnerScopedContributor`) runs AFTER Step 2 and unconditionally
// overrides `tieBreakKey.origin` back to `'local'` for direct-owner
// members, so any same-def overlap still ends up ranked correctly.
if (firstHit && state.signals.origin === undefined) {
state.tieBreakKey.origin = 'import';
}
}
// ─── Step 3 implementation ─────────────────────────────────────────────────
function seedFromOwnerScopedContributor(
name: string,
contributor: OwnerScopedContributor,
acceptedKinds: ReadonlySet<NodeLabel>,
perCandidate: Map<DefId, CandidateState>,
): void {
for (const def of contributor.byName(name)) {
if (!acceptedKinds.has(def.type)) continue;
const state = ensureCandidate(perCandidate, def);
// Treat the contributor's direct membership as `origin: 'local'` —
// strongest visibility, no scope-chain penalty.
state.signals.origin = 'local';
state.signals.scopeChainDepth = 0;
state.signals.ownerMatch = def.ownerId === contributor.ownerDefId;
state.tieBreakKey.origin = 'local';
}
}
// ─── Step 5 implementation ─────────────────────────────────────────────────
function applyArityFilter(
callsite: Callsite,
perCandidate: Map<DefId, CandidateState>,
ctx: RegistryContext,
): void {
const arityFn = ctx.providers.arityCompatibility;
if (arityFn === undefined) {
// No provider → record 'unknown' for every candidate; keeps signal
// shape uniform for composeEvidence.
for (const state of perCandidate.values()) {
state.signals.arityVerdict = 'unknown';
}
return;
}
let anyCompatible = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
}
if (!anyCompatible) return;
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {
if (state.signals.arityVerdict === 'incompatible') {
perCandidate.delete(defId);
}
}
}
// ─── Step 7 implementation ─────────────────────────────────────────────────
function rankCandidates(perCandidate: Map<DefId, CandidateState>): readonly Resolution[] {
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const state of perCandidate.values()) {
const evidence = composeEvidence(state.signals as RawSignals);
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def: state.def, confidence, evidence });
tieKeys.set(state.def.nodeId, { ...state.tieBreakKey });
}
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
// ─── Constants ──────────────────────────────────────────────────────────────
const EMPTY: readonly Resolution[] = Object.freeze([]);
@@ -0,0 +1,71 @@
/**
* `lookupQualified` — qualified-name fast path (RFC §4.5; Ring 2 SHARED #917).
*
* Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and
* returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by:
*
* - `resolveTypeRef` dotted fallback (#916)
* - `Registry.lookup` Step 6 when no lexical candidate survived
* - Explicit dotted identifiers in Cypher / MCP tools where the caller
* knows the target's canonical qualified name
*
* **Strict + deterministic.** No receiver-type resolution, no scope walk.
* Every surviving candidate gets the same base confidence (from
* `EvidenceWeights.globalQualified`), then the tie-break cascade
* disambiguates.
*/
import type { NodeLabel } from '../../graph/types.js';
import type { Resolution } from '../types.js';
import { composeEvidence, confidenceFromEvidence } from './evidence.js';
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
import type { RegistryContext } from './context.js';
export interface LookupQualifiedParams {
readonly acceptedKinds: readonly NodeLabel[];
}
/**
* Look up a canonical qualified name (e.g., `app.models.User`) across all
* defs, filtered by `acceptedKinds`. Returns an empty array when the name
* is not indexed or no candidate matches the kind filter.
*
* Callers consume `[0]` for the strict single-return answer; the remainder
* carries alternate candidates (partial classes, overloads, accidental
* cross-kind hits) ordered by the tie-break cascade.
*/
export function lookupQualified(
qualifiedName: string,
params: LookupQualifiedParams,
ctx: RegistryContext,
): readonly Resolution[] {
const defIds = ctx.qualifiedNames.get(qualifiedName);
if (defIds.length === 0) return EMPTY;
const acceptedKinds = new Set<NodeLabel>(params.acceptedKinds);
const resolutions: Resolution[] = [];
const tieKeys = new Map<string, TieBreakKey>();
for (const defId of defIds) {
const def = ctx.defs.get(defId);
if (def === undefined) continue;
if (!acceptedKinds.has(def.type)) continue;
const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true });
const confidence = confidenceFromEvidence(evidence);
resolutions.push({ def, confidence, evidence });
tieKeys.set(def.nodeId, {
scopeDepth: 0,
mroDepth: 0,
origin: 'global-qualified',
});
}
if (resolutions.length === 0) return EMPTY;
resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys));
return Object.freeze(resolutions);
}
const EMPTY: readonly Resolution[] = Object.freeze([]);
@@ -0,0 +1,54 @@
/**
* `MethodRegistry` — scope-aware lookup for method / function / constructor
* dispatch (RFC §4.4; Ring 2 SHARED #917).
*
* Thin wrapper over `lookupCore`, specialized for callable kinds:
*
* - `acceptedKinds` = Method / Function / Constructor.
* - `useReceiverTypeBinding` is **true** — the type-binding + MRO walk
* (Step 2) is the primary evidence path for receiver-dispatched calls.
* - `callsite.arity` flows through to `provider.arityCompatibility`
* when provided. When the provider is absent, arity evidence is
* `unknown` (neutral signal).
*/
import type { Callsite, Resolution, ScopeId } from '../types.js';
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
import type { OwnerScopedContributor, RegistryContext } from './context.js';
import { METHOD_KINDS } from './context.js';
/**
* Extra per-call parameters that vary across call sites but NOT across
* registries. Kept as a separate shape so `MethodRegistry.lookup` stays
* concise while still exposing the explicit-receiver + owner-contributor +
* arity knobs the RFC algorithm needs.
*/
export interface MethodLookupOptions {
/** Call-site arity for `provider.arityCompatibility`. */
readonly callsite?: Callsite;
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
readonly explicitReceiver?: { readonly name: string };
/** Optional per-owner contributor (Step 3). */
readonly ownerScopedContributor?: OwnerScopedContributor;
}
export interface MethodRegistry {
lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[];
}
export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
return {
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
const params: CoreLookupParams = {
acceptedKinds: METHOD_KINDS,
useReceiverTypeBinding: true,
ownerScopedContributor: options.ownerScopedContributor ?? null,
...(options.callsite !== undefined ? { callsite: options.callsite } : {}),
...(options.explicitReceiver !== undefined
? { explicitReceiver: options.explicitReceiver }
: {}),
};
return lookupCore(name, scope, params, ctx);
},
};
}
@@ -0,0 +1,76 @@
/**
* `compareByConfidenceWithTiebreaks` — the RFC §4.2 Step 7 total order
* over `Resolution` candidates (Ring 2 SHARED #917).
*
* Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON`
* fall through a deterministic cascade so the same inputs always produce
* the same winner, independent of insertion order.
*
* Tie-break cascade (per RFC Appendix B):
*
* 1. confidence DESC (primary)
* 2. scope depth ASC (nearer lexical scope wins)
* 3. MRO depth ASC (nearer class in hierarchy wins)
* 4. `ORIGIN_PRIORITY` ASC (local > import > … > global-name)
* 5. DefId.localeCompare (final deterministic tiebreaker)
*
* The per-candidate inputs needed beyond `Resolution.confidence` —
* `scopeDepth`, `mroDepth`, `origin` — are supplied via a sidecar
* `TieBreakKey` so the comparator stays pure and `Resolution` itself
* doesn't need to carry book-keeping fields.
*/
import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js';
import type { Resolution } from '../types.js';
export const CONFIDENCE_EPSILON = 0.001;
/** Side-information per candidate used for secondary tie-breaks. */
export interface TieBreakKey {
readonly scopeDepth: number;
readonly mroDepth: number;
readonly origin: OriginForTieBreak;
}
/**
* Pure comparator suitable for `Array.prototype.sort`. Return value follows
* the JavaScript convention: negative → `a` wins, positive → `b` wins.
*
* **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array
* index — stable across reorderings. Missing keys fall back to neutral
* values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means
* the tie-break degrades gracefully to defId-lexicographic ordering when
* side-info is unavailable. That keeps the total order deterministic
* even on malformed inputs.
*/
export function compareByConfidenceWithTiebreaks(
a: Resolution,
b: Resolution,
keys: ReadonlyMap<string, TieBreakKey>,
): number {
// Primary: confidence DESC, treating values within epsilon as equal.
const delta = b.confidence - a.confidence;
if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1;
const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY;
const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY;
// Secondary: scope depth ASC.
if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth;
// Tertiary: MRO depth ASC.
if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth;
// Quaternary: ORIGIN_PRIORITY ASC.
const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin];
if (po !== 0) return po;
// Final: DefId lexicographic, locale-aware for deterministic cross-platform output.
return a.def.nodeId.localeCompare(b.def.nodeId);
}
const DEFAULT_KEY: TieBreakKey = Object.freeze({
scopeDepth: 0,
mroDepth: 0,
origin: 'local',
});
@@ -0,0 +1,148 @@
/**
* `resolveTypeRef` — strict single-return resolver for `TypeRef`s
* (RFC §4.6; Ring 2 SHARED #916).
*
* Narrower contract than `Registry.lookup`: no name-only global fallback, no
* confidence ranking, no arity check. Used by `Registry.lookup` Step 2 (type-
* binding propagation) and by any caller that wants the single best type-
* target for an annotation without paying for the full evidence pipeline.
*
* **Algorithm (strict).** Walk the scope chain from `ref.declaredAtScope`:
*
* 1. At each scope, inspect `bindings.get(ref.rawName)`:
* - If one of the bindings is a **type-kind** def with a **strict origin**
* (`'local' | 'import' | 'namespace' | 'reexport'`), return it.
* - If any binding for this name exists at this scope but none qualifies
* (e.g., a local variable named `User` shadows an outer import of class
* `User`), return `null`. The nearer binding shadows; we do NOT fall
* through to the global qualified-name index.
* - Otherwise continue to the parent scope.
* 2. If the raw name is a dotted path (e.g., `'models.User'`) and the scope
* walk produced no match, consult `QualifiedNameIndex.byQualifiedName`.
* Only accept **exactly one** type-kind hit — anything ambiguous returns
* `null` rather than a guess.
* 3. Return `null`.
*
* **What `'strict' origins' means.** `'wildcard'` is intentionally excluded.
* A wildcard-expanded name (`from x import *`) is too loose to use as an
* anchor for type resolution — it gives no signal about whether the name was
* actually imported. `Registry.lookup` may accept wildcard bindings at its
* own discretion (with lower evidence weight); `resolveTypeRef` does not.
*
* **What 'type-kind' means.** The subset of `NodeLabel` that a type annotation
* may legitimately reference: class-like, interface-like, enum-like, and
* alias-like kinds. See `TYPE_KINDS` below.
*
* Pure function — safe to call repeatedly; no side effects.
*/
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js';
import type { DefIndex } from './def-index.js';
import type { QualifiedNameIndex } from './qualified-name-index.js';
// ─── Public contracts ───────────────────────────────────────────────────────
/**
* All inputs `resolveTypeRef` needs from the semantic model. Bundled into a
* context object so the call site stays short and the interface is stable as
* additional indexes get threaded through in later rings.
*/
export interface ResolveTypeRefContext {
readonly scopes: ScopeLookup;
readonly defIndex: DefIndex;
readonly qualifiedNameIndex: QualifiedNameIndex;
}
// ─── Strict policy constants ────────────────────────────────────────────────
/** `'wildcard'` is deliberately absent. See file header. */
const STRICT_ORIGINS: ReadonlySet<BindingRef['origin']> = new Set<BindingRef['origin']>([
'local',
'import',
'namespace',
'reexport',
]);
/**
* `NodeLabel` values that may appear on the RHS of a type annotation.
*
* Includes the usual class-like and interface-like kinds plus the alias-like
* ones (`TypeAlias`, `Typedef`). `Namespace` is excluded — it is a scope
* container, not a value type. `Function` / `Method` / `Variable` are
* excluded by design: a `rawName` bound to them at a strict origin is a
* *shadowing* binding, which the algorithm short-circuits to `null`.
*
* `'Type'` (the generic `NodeLabel` value) is also excluded — verified
* against `gitnexus/src/core/ingestion/` at the time of writing, no
* production extractor emits `type: 'Type'` for annotation-relevant
* symbols. Should a future extractor start emitting it, add `'Type'`
* here and add a test asserting the new path.
*/
const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
]);
// ─── Main entry point ──────────────────────────────────────────────────────
export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null {
// Phase 1: scope-chain walk anchored at the declaration site.
let currentId: ScopeId | null = ref.declaredAtScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
// Cycle guard — a well-formed scope tree never loops, but a bug in the
// construction path should fail fast here rather than hanging.
if (visited.has(currentId)) return null;
visited.add(currentId);
const scope = ctx.scopes.getScope(currentId);
if (scope === undefined) return null; // broken chain = unresolvable
const bindings = scope.bindings.get(ref.rawName);
if (bindings !== undefined && bindings.length > 0) {
// At least one binding exists at this scope → it is the shadowing site.
// Either one of them qualifies, or the name is shadowed by a non-type.
for (const binding of bindings) {
if (!STRICT_ORIGINS.has(binding.origin)) continue;
if (TYPE_KINDS.has(binding.def.type)) {
return binding.def;
}
}
// Shadowed by a non-type / non-strict-origin binding. Fail fast — no
// global fallback, no walk to the parent.
return null;
}
currentId = scope.parent;
}
// Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique
// type-kind hit; anything ambiguous returns null (strict: no guesses).
if (ref.rawName.includes('.')) {
const candidates = ctx.qualifiedNameIndex.get(ref.rawName);
let onlyTypeDef: SymbolDefinition | null = null;
for (const defId of candidates) {
const def = ctx.defIndex.get(defId);
if (def === undefined) continue;
if (!TYPE_KINDS.has(def.type)) continue;
if (onlyTypeDef !== null) return null; // ambiguous
onlyTypeDef = def;
}
if (onlyTypeDef !== null) return onlyTypeDef;
}
return null;
}
@@ -0,0 +1,57 @@
/**
* `ScopeId` canonical constructor + string intern pool
* (RFC §2.2; Ring 2 SHARED #912).
*
* `ScopeId` is a deterministic string derived from the scope's file path,
* byte range, and kind:
*
* scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}
*
* Two scopes produced by reparsing the same file at the same positions are
* `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also
* **interns** the string through a process-local pool, so repeated calls
* with structurally identical inputs return the same string reference —
* making `Map<ScopeId, ...>` lookups and cache keys identity-fast.
*
* The intern pool is unbounded. The number of distinct `ScopeId`s across a
* single indexing run is O(total scopes in workspace), which is bounded by
* source-text size and already in memory; interning adds no asymptotic
* pressure. `clearScopeIdInternPool` is exported for test isolation.
*/
import type { Range } from './types.js';
import type { ScopeId, ScopeKind } from './types.js';
/** Inputs required to construct a canonical `ScopeId`. */
export interface ScopeIdInput {
readonly filePath: string;
readonly range: Range;
readonly kind: ScopeKind;
}
/**
* Build a canonical `ScopeId` from its structural parts and intern it.
*
* Pure + referentially transparent: given the same input shape, always
* returns the same string reference for the lifetime of the pool.
*/
export function makeScopeId(input: ScopeIdInput): ScopeId {
const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`;
const existing = INTERN_POOL.get(raw);
if (existing !== undefined) return existing;
INTERN_POOL.set(raw, raw);
return raw;
}
/**
* Drop the intern pool. Intended for test setup/teardown — production code
* should not need this, since the pool's memory usage is bounded by the
* number of live scopes and cleaning it mid-run would break identity
* equality for existing scope ids.
*/
export function clearScopeIdInternPool(): void {
INTERN_POOL.clear();
}
/** Internal: shared intern pool (process-local). */
const INTERN_POOL = new Map<string, string>();
@@ -0,0 +1,254 @@
/**
* `ScopeTree` — the lexical-scope spine of the `SemanticModel`
* (RFC §2.2 + §3.1; Ring 2 SHARED #912).
*
* Generalizes the `enclosingFunctions` pattern from closed PR #902 to
* arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship
* derived from each `Scope.parent` pointer, and validates the structural
* invariants a well-formed scope tree must satisfy.
*
* Invariants enforced at build time (throw on violation):
*
* - Every non-`Module` scope has a non-null parent.
* - Every parent pointer references a scope that was also supplied to
* `buildScopeTree`.
* - Parent range **strictly contains** child range.
* - Sibling ranges under the same parent do not overlap.
* - Parent and child live in the same `filePath`. (Cross-file parent
* pointers would be a category error — a `File` scope is not the
* parent of another file's scopes; imports do that job.)
*
* Satisfies the `ScopeLookup` contract (defined in `./types.js`), so
* `resolveTypeRef` (#916) and the scope-aware registries (#917) can take a
* `ScopeTree` directly without adapters.
*
* Immutable surface: `byId` is a `ReadonlyMap`; children arrays are
* `Object.freeze`d; miss lookups return a shared frozen empty array.
*/
import type { Scope, ScopeId, ScopeLookup, Range } from './types.js';
// ─── Public contract ────────────────────────────────────────────────────────
export interface ScopeTree extends ScopeLookup {
readonly size: number;
readonly byId: ReadonlyMap<ScopeId, Scope>;
getScope(id: ScopeId): Scope | undefined;
getParent(id: ScopeId): Scope | undefined;
/** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */
getChildren(id: ScopeId): readonly ScopeId[];
/**
* Ancestor chain from the immediate parent up to (and including) the
* root module scope. Excludes the starting scope itself. Frozen empty
* array on miss / for a root scope.
*/
getAncestors(id: ScopeId): readonly ScopeId[];
has(id: ScopeId): boolean;
}
// ─── Build errors ───────────────────────────────────────────────────────────
/**
* Thrown by `buildScopeTree` when the input violates a structural
* invariant. Carries the offending ids + the invariant name so failed
* extraction pipelines can report actionable diagnostics.
*/
export class ScopeTreeInvariantError extends Error {
constructor(
readonly invariant:
| 'non-module-requires-parent'
| 'parent-not-found'
| 'parent-must-contain-child'
| 'sibling-ranges-overlap'
| 'parent-must-share-filepath'
| 'duplicate-scope-id',
message: string,
) {
super(message);
this.name = 'ScopeTreeInvariantError';
}
}
// ─── Builder ───────────────────────────────────────────────────────────────
/**
* Build an immutable `ScopeTree` from a flat list of `Scope` records.
*
* Throws `ScopeTreeInvariantError` on the first invariant violation; a
* malformed tree is a bug in the extraction pipeline, not a data case for
* consumers to handle, so fail-fast is the correct posture.
*/
export function buildScopeTree(scopes: readonly Scope[]): ScopeTree {
const byId = new Map<ScopeId, Scope>();
const childrenById = new Map<ScopeId, ScopeId[]>();
// ── Pass 1: collect by id + duplicate check ───────────────────────────
for (const scope of scopes) {
if (byId.has(scope.id)) {
throw new ScopeTreeInvariantError(
'duplicate-scope-id',
`Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`,
);
}
byId.set(scope.id, scope);
}
// ── Pass 2: validate parent pointers + build children buckets ─────────
for (const scope of scopes) {
if (scope.parent === null) {
if (scope.kind !== 'Module') {
throw new ScopeTreeInvariantError(
'non-module-requires-parent',
`Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`,
);
}
continue;
}
const parent = byId.get(scope.parent);
if (parent === undefined) {
throw new ScopeTreeInvariantError(
'parent-not-found',
`Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`,
);
}
if (parent.filePath !== scope.filePath) {
throw new ScopeTreeInvariantError(
'parent-must-share-filepath',
`Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`,
);
}
if (!rangeStrictlyContains(parent.range, scope.range)) {
throw new ScopeTreeInvariantError(
'parent-must-contain-child',
`Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`,
);
}
let bucket = childrenById.get(parent.id);
if (bucket === undefined) {
bucket = [];
childrenById.set(parent.id, bucket);
}
bucket.push(scope.id);
}
// ── Pass 3: sibling-overlap check ─────────────────────────────────────
for (const [parentId, childIds] of childrenById) {
if (childIds.length < 2) continue;
// Sort siblings by (startLine, startCol) for an O(n log n) pairwise
// scan instead of O(n²) all-pairs.
const children = childIds.map((id) => byId.get(id)!).slice();
children.sort((a, b) => comparePosition(a.range, b.range));
for (let i = 1; i < children.length; i++) {
const prev = children[i - 1]!;
const curr = children[i]!;
if (rangesOverlap(prev.range, curr.range)) {
throw new ScopeTreeInvariantError(
'sibling-ranges-overlap',
`Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`,
);
}
}
}
// Freeze children arrays so the surface is truly read-only.
const frozenChildren = new Map<ScopeId, readonly ScopeId[]>();
for (const [parentId, childIds] of childrenById) {
frozenChildren.set(parentId, Object.freeze(childIds.slice()));
}
return freezeTree(byId, frozenChildren);
}
// ─── Internals ──────────────────────────────────────────────────────────────
const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]);
function freezeTree(
byId: Map<ScopeId, Scope>,
childrenById: Map<ScopeId, readonly ScopeId[]>,
): ScopeTree {
return {
byId,
get size() {
return byId.size;
},
getScope(id: ScopeId): Scope | undefined {
return byId.get(id);
},
getParent(id: ScopeId): Scope | undefined {
const scope = byId.get(id);
if (scope === undefined || scope.parent === null) return undefined;
return byId.get(scope.parent);
},
getChildren(id: ScopeId): readonly ScopeId[] {
return childrenById.get(id) ?? EMPTY_CHILDREN;
},
getAncestors(id: ScopeId): readonly ScopeId[] {
const start = byId.get(id);
if (start === undefined || start.parent === null) return EMPTY_CHILDREN;
const out: ScopeId[] = [];
const visited = new Set<ScopeId>([id]);
let cursor: ScopeId | null = start.parent;
while (cursor !== null && !visited.has(cursor)) {
visited.add(cursor);
out.push(cursor);
const next = byId.get(cursor);
cursor = next === undefined ? null : next.parent;
}
return Object.freeze(out);
},
has(id: ScopeId): boolean {
return byId.has(id);
},
};
}
/**
* `outer` strictly contains `inner` when `outer`'s start is at or before
* `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are
* not the exact same range. Equal ranges are rejected — a child cannot
* occupy the exact same span as its parent.
*/
function rangeStrictlyContains(outer: Range, inner: Range): boolean {
if (
outer.startLine === inner.startLine &&
outer.startCol === inner.startCol &&
outer.endLine === inner.endLine &&
outer.endCol === inner.endCol
) {
return false;
}
const outerStartsAtOrBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const outerEndsAtOrAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return outerStartsAtOrBefore && outerEndsAtOrAfter;
}
/**
* Two ranges overlap when neither finishes before the other begins. Ranges
* that merely touch at a single boundary point (`a.end === b.start`) do
* NOT overlap — this matches tree-sitter's half-open-like range semantics
* and the typical "sibling blocks meet but don't overlap" pattern.
*/
function rangesOverlap(a: Range, b: Range): boolean {
const aEndsBeforeB =
a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol);
const bEndsBeforeA =
b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol);
return !(aEndsBeforeB || bEndsBeforeA);
}
function comparePosition(a: Range, b: Range): number {
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
return a.startCol - b.startCol;
}
function formatRange(r: Range): string {
return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`;
}
@@ -0,0 +1,188 @@
/**
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
*
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
* reads `.gitnexus/shadow-parity/latest.json` and renders.
*
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
* through the top-level `gitnexus-shared` barrel. Consumers import all
* three from `gitnexus-shared`, not from this module.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { SupportedLanguages } from '../../languages.js';
import type { ResolutionEvidence } from '../types.js';
import type { ShadowAgreement, ShadowDiff } from './diff.js';
// ─── Aggregated report shape ────────────────────────────────────────────────
export interface LanguageParityRow {
readonly language: SupportedLanguages;
readonly totalCalls: number;
readonly bothAgree: number;
readonly onlyLegacy: number;
readonly onlyNew: number;
readonly bothDisagree: number;
readonly bothEmpty: number;
/**
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
* at least one side resolved" = `totalCalls - bothEmpty`.
*
* When the denominator is 0 (all calls for this language were
* `both-empty`), returns 0. Callers rendering the dashboard should treat
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
* than "total disagreement".
*/
readonly parity: number;
/**
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
* and `both-empty` do not contribute.
*/
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
}
export interface ShadowParityReport {
readonly generatedAt: string; // ISO 8601
readonly perLanguage: readonly LanguageParityRow[];
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
* bucketed by language. Pure function.
*
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
* value for stable JSON output (the dashboard reads
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
* - `overall` is the column-wise sum across languages.
* - `generatedAt` is injected via the `now` parameter so tests stay
* deterministic; production callers let it default to `new Date()`.
*/
export function aggregateDiffs(
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
now: Date = new Date(),
): ShadowParityReport {
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
for (const { language, diff } of diffs) {
let counts = perLanguageMap.get(language);
if (!counts) {
counts = makeEmptyCounts();
perLanguageMap.set(language, counts);
}
tallyDiff(counts, diff);
}
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
.map(([language, counts]) => buildRow(language, counts))
.sort((a, b) => a.language.localeCompare(b.language));
const overall = buildOverallRow(perLanguage);
return {
generatedAt: now.toISOString(),
perLanguage,
overall,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
interface MutableCounts {
totalCalls: number;
bothAgree: number;
onlyLegacy: number;
onlyNew: number;
bothDisagree: number;
bothEmpty: number;
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
}
function makeEmptyCounts(): MutableCounts {
return {
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
evidenceBreakdown: new Map(),
};
}
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
counts.totalCalls += 1;
incrementAgreement(counts, diff.agreement);
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
for (const ev of diff.evidenceDelta) {
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
}
}
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
switch (agreement) {
case 'both-agree':
counts.bothAgree += 1;
return;
case 'only-legacy':
counts.onlyLegacy += 1;
return;
case 'only-new':
counts.onlyNew += 1;
return;
case 'both-disagree':
counts.bothDisagree += 1;
return;
case 'both-empty':
counts.bothEmpty += 1;
return;
}
}
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
const resolved = counts.totalCalls - counts.bothEmpty;
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
return {
language,
totalCalls: counts.totalCalls,
bothAgree: counts.bothAgree,
onlyLegacy: counts.onlyLegacy,
onlyNew: counts.onlyNew,
bothDisagree: counts.bothDisagree,
bothEmpty: counts.bothEmpty,
parity,
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
// can't mutate the aggregator's internal state.
evidenceBreakdown: new Map(
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
),
};
}
function buildOverallRow(
perLanguage: readonly LanguageParityRow[],
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
let totalCalls = 0;
let bothAgree = 0;
let onlyLegacy = 0;
let onlyNew = 0;
let bothDisagree = 0;
let bothEmpty = 0;
for (const row of perLanguage) {
totalCalls += row.totalCalls;
bothAgree += row.bothAgree;
onlyLegacy += row.onlyLegacy;
onlyNew += row.onlyNew;
bothDisagree += row.bothDisagree;
bothEmpty += row.bothEmpty;
}
const resolved = totalCalls - bothEmpty;
const parity = resolved > 0 ? bothAgree / resolved : 0;
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
}
@@ -0,0 +1,126 @@
/**
* Shadow-mode diff logic — RFC §6.3.
*
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
* DAG result + new scope-based registry result) and produces a structured
* diff record for the parity dashboard.
*
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
* call through legacy + new paths, diffs results, and persists per-run JSON
* for the parity dashboard.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { Resolution, ResolutionEvidence } from '../types.js';
// ─── Diff record shape ──────────────────────────────────────────────────────
export type ShadowAgreement =
| 'both-agree' // top match identical (same DefId)
| 'only-legacy' // legacy resolved; new did not
| 'only-new' // new resolved; legacy did not
| 'both-disagree' // both resolved, but to different targets
| 'both-empty'; // both returned empty
export interface ShadowDiff {
readonly callsite: ShadowCallsite;
readonly legacy: Resolution | null;
readonly newResult: Resolution | null;
readonly agreement: ShadowAgreement;
/**
* Symmetric difference of the two top resolutions' `evidence` arrays,
* keyed on `ResolutionEvidence.kind`.
*
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
* - For `'both-disagree'`, contains evidence kinds present on exactly one
* side (not in both).
* - For `'only-legacy'`, contains all of legacy's top evidence.
* - For `'only-new'`, contains all of new's top evidence.
*/
readonly evidenceDelta: readonly ResolutionEvidence[];
}
export interface ShadowCallsite {
readonly filePath: string;
readonly line: number;
readonly col: number;
readonly calledName: string;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
* `ShadowDiff`. Pure function.
*
* Agreement rules:
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
* preserved from input arrays)
*
* Evidence-delta rationale: callers aggregating divergences want to know
* which signal kinds explain a disagreement. Keying on `kind` (not full
* equality over `weight`/`note`) avoids spurious deltas when the same
* signal fires with slightly different calibration weights on each side.
*/
export function diffResolutions(
callsite: ShadowCallsite,
legacy: readonly Resolution[],
newResult: readonly Resolution[],
): ShadowDiff {
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
const agreement: ShadowAgreement = (() => {
if (legacyTop === null && newTop === null) return 'both-empty';
if (legacyTop === null) return 'only-new';
if (newTop === null) return 'only-legacy';
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
})();
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
return {
callsite,
legacy: legacyTop,
newResult: newTop,
agreement,
evidenceDelta,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
/**
* Symmetric difference of two evidence arrays, keyed on
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
* first (in legacy's original order), then new-only signals (in new's order).
*
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
* subtract against).
*/
function computeEvidenceDelta(
legacy: Resolution | null,
newResult: Resolution | null,
agreement: ShadowAgreement,
): readonly ResolutionEvidence[] {
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
if (agreement === 'only-legacy') return legacy!.evidence;
if (agreement === 'only-new') return newResult!.evidence;
// both-disagree: symmetric difference keyed on `kind`
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
return [...onlyInLegacy, ...onlyInNew];
}
@@ -0,0 +1,35 @@
/**
* `SymbolDefinition` — the canonical shape of an indexed symbol record.
*
* Historically defined in `gitnexus/src/core/ingestion/model/symbol-table.ts`;
* moved into `gitnexus-shared` as part of RFC #909 Ring 1 (#910) so the
* scope-resolution types that reference it can live in the shared package
* alongside their consumers (`gitnexus/` and `gitnexus-web/`).
*
* Shape is unchanged from the prior local definition.
*/
import type { NodeLabel } from '../graph/types.js';
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
@@ -0,0 +1,432 @@
/**
* Scope-resolution type definitions — RFC §2 data model (authoritative source).
*
* See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50
*
* Anti-drift rule: every type, interface, and enum defined here is the single
* source of truth. Later code that references these names must import them
* from `gitnexus-shared`; it must not re-define them locally.
*
* Lifecycle contract (RFC §2.8): scopes are **constructed during extraction,
* linked during finalize, immutable after finalize**. All fields are
* `readonly` at the type level; `Object.freeze` is applied at runtime in dev
* builds. `ReferenceIndex` is the sole structure populated after freeze — by
* resolution, before emission.
*/
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
// ─── §2.1 Type aliases ──────────────────────────────────────────────────────
/** Stable per-(file, range, kind) scope identifier; interned for identity-fast equality. */
export type ScopeId = string;
/** Stable symbol-definition identifier (graph nodeId). */
export type DefId = string;
/** Kinds of lexical scope a `Scope` node can represent. */
export type ScopeKind =
| 'Module' // file root
| 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod
| 'Class' // class/struct/trait/interface body
| 'Function' // function/method/closure/lambda body
| 'Block' // { ... }, if-body, for-body, with-body, match arms
| 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists
// ─── Range + Capture (parser-agnostic) ──────────────────────────────────────
/** Source-text range. 1-based `startLine`/`endLine`; 0-based `startCol`/`endCol`. */
export interface Range {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
/**
* Tagged capture emitted by a LanguageProvider's `emitScopeCaptures` hook.
*
* Parser-agnostic: tree-sitter queries and COBOL's regex tagger both produce
* `Capture[]`. The central `ScopeExtractor` consumes captures without
* knowing which parser produced them.
*/
export interface Capture {
/** Capture name, including leading `@` (e.g., `'@scope.module'`, `'@declaration.class'`). */
readonly name: string;
readonly range: Range;
/** The captured source text. */
readonly text: string;
}
/**
* A grouping of `Capture`s that came from a single query match (e.g., one
* `@import.statement` match carries `@import.source`, `@import.name`,
* `@import.alias?` as child captures). Keyed by capture name for O(1)
* child access.
*/
export type CaptureMatch = Readonly<Record<string, Capture>>;
// ─── Hook input/output types (RFC §5.2) ─────────────────────────────────────
/**
* Provider-interpreted raw import, consumed by finalize (Phase 2) to produce
* linked `ImportEdge[]`. The provider's `interpretImport` hook turns a
* `CaptureMatch` for an `@import.statement` into one of these; the central
* finalize algorithm resolves `targetRaw` to a concrete file via
* `resolveImportTarget` and materializes the final `ImportEdge`.
*
* Discriminated union — each variant carries only the fields that make sense
* for its kind. Invalid shapes (e.g., a `namespace` import with an alias-like
* `importedName` mismatch) are compile errors, not latent bugs. `'wildcard-
* expanded'` is deliberately NOT a variant: that kind is finalize output only,
* produced when `expandsWildcardTo` materializes a wildcard against target
* exports — a provider must never emit it at parse time.
*/
export type ParsedImport =
/**
* Per-name import without rename.
*
* Examples:
* - Python `from foo import X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo' }`
* - TS `import { X } from './foo'` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: './foo' }`
* - Java `import foo.bar.X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo.bar' }`
*/
| {
readonly kind: 'named';
readonly localName: string;
readonly importedName: string;
readonly targetRaw: string;
}
/**
* Per-name import with rename.
*
* Examples:
* - Python `from foo import X as Y` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: 'foo' }`
* - TS `import { X as Y } from './foo'` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './foo' }`
*/
| {
readonly kind: 'alias';
readonly localName: string;
readonly importedName: string;
readonly alias: string;
readonly targetRaw: string;
}
/**
* Qualified module handle, with or without rename. `importedName` is the
* module being aliased; `localName` is the scope-visible handle (often the
* same unless renamed).
*
* Examples:
* - Python `import numpy` → `{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' }`
* - Python `import numpy as np` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
* - TS `import * as np from 'numpy'` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }`
* - Go `import foo "pkg/bar"` → `{ kind: 'namespace', localName: 'foo', importedName: 'bar', targetRaw: 'pkg/bar' }`
*/
| {
readonly kind: 'namespace';
/** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */
readonly localName: string;
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
readonly importedName: string;
readonly targetRaw: string;
}
/**
* Syntactically-detectable parse-time re-export. Finalize may still produce
* `ImportEdge { kind: 'reexport', transitiveVia }` when flattening chains;
* this variant preserves the *parse-time* signal so finalize doesn't have
* to re-derive it from scratch.
*
* Examples:
* - TS `export { X } from './y'` → `{ kind: 'reexport', localName: 'X', importedName: 'X', targetRaw: './y' }`
* - TS `export { X as Y } from './y'` → `{ kind: 'reexport', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './y' }`
* - Rust `pub use foo::bar` → `{ kind: 'reexport', localName: 'bar', importedName: 'bar', targetRaw: 'foo' }`
*/
| {
readonly kind: 'reexport';
/** Name as re-exported in the current module. */
readonly localName: string;
/** Name in the source module. */
readonly importedName: string;
readonly targetRaw: string;
/** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */
readonly alias?: string;
}
/**
* Wildcard import — brings every exported name from the target module into
* the importing scope. The finalize algorithm expands this into one
* `BindingRef` per exported name via the provider's `expandsWildcardTo`
* hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`.
*
* Examples:
* - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }`
* - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }`
* - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }`
*/
| {
readonly kind: 'wildcard';
readonly targetRaw: string;
}
/**
* Runtime-computed target — the import path is not a static literal at
* parse time. Providers SHOULD emit the unresolvable expression's source
* text as `targetRaw` to aid diagnostics; `null` only when no string form
* exists.
*
* Examples:
* - JS `await import(expr)` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'expr' }`
* - Python `importlib.import_module(f'pkg.{name}')` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: "f'pkg.{name}'" }`
*/
| {
readonly kind: 'dynamic-unresolved';
readonly localName: string;
/** Source text of the unresolved expression when available; `null` otherwise. */
readonly targetRaw: string | null;
};
/**
* Provider-interpreted type binding. The provider's `interpretTypeBinding`
* hook turns a `CaptureMatch` (e.g., `@type-binding.parameter`) into one of
* these; the central extractor attaches the resulting `TypeRef` to the
* appropriate scope's `typeBindings` map.
*/
export interface ParsedTypeBinding {
/** The name being bound (parameter name, `self`, assignment LHS, …). */
readonly boundName: string;
/** The raw type name as written in source (`'User'`, `'models.User'`, …). */
readonly rawTypeName: string;
readonly source: TypeRef['source'];
}
/**
* Cross-file workspace index consumed by finalize-phase hooks
* (`resolveImportTarget`, `expandsWildcardTo`). Opaque placeholder in Ring 1;
* concretely typed in Ring 2 SHARED (#915).
*/
export type WorkspaceIndex = unknown;
// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912).
// The former opaque placeholder lived here during Ring 1; removed now that
// the concrete type exists. Consumers import from `gitnexus-shared` directly.
/**
* Minimal scope-lookup contract: map a `ScopeId` back to its `Scope` record.
*
* Lives in the data-model layer so both `ScopeTree` (§3.1) and
* `resolveTypeRef` / `Registry.lookup` (§4) can depend on it without
* inverting each other. `ScopeTree` is the canonical implementation;
* tests and future alternative containers may supply their own.
*/
export interface ScopeLookup {
getScope(id: ScopeId): Scope | undefined;
}
/** Call-site description passed to `arityCompatibility`. */
export interface Callsite {
/** Number of arguments at the call site. */
readonly arity: number;
}
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
/**
* A cross-file import edge attached to a module/namespace scope.
*
* Raw (unlinked) edges are emitted during parse (Phase 1); `targetModuleScope`
* and `targetDefId` are filled in during finalize (Phase 2) via SCC-aware
* bounded-fixpoint linking (RFC §3.2).
*/
export interface ImportEdge {
/** How this scope sees the imported name (after alias). */
readonly localName: string;
/** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */
readonly targetFile: string | null;
/** The name under which the target exports this symbol. */
readonly targetExportedName: string;
/** Pre-resolved at finalize: the module scope of the exporting file. */
readonly targetModuleScope?: ScopeId;
/** Pre-resolved at finalize: the exported symbol's `DefId`. */
readonly targetDefId?: DefId;
readonly kind:
| 'named'
| 'alias'
| 'namespace'
| 'wildcard-expanded'
| 'reexport'
| 'dynamic-unresolved';
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
readonly transitiveVia?: readonly string[];
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
readonly linkStatus?: 'unresolved';
}
// ─── §2.3 BindingRef ────────────────────────────────────────────────────────
/**
* A name binding visible at a scope, with provenance.
*
* Provenance stays at the visibility layer — a name being visible because it
* is local vs imported vs wildcard-expanded vs re-exported is a property of
* the binding itself. This keeps evidence emission and `import-use` reference
* stamping first-class instead of reconstructing provenance from a side table.
*/
export interface BindingRef {
readonly def: SymbolDefinition;
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
readonly via?: ImportEdge;
}
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
/**
* A reference to a named type, anchored at its declaration site.
*
* Design choice: raw name + declaration-site scope, resolved at lookup time.
* Pre-resolution would invert the extraction/resolution wall. Deferred thunks
* add no capability. Structured type systems are months of work per language.
* This shape keeps V1 tractable while preserving correctness for aliases,
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
*/
export interface TypeRef {
/** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */
readonly rawName: string;
/** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */
readonly declaredAtScope: ScopeId;
readonly source:
| 'annotation'
| 'parameter-annotation'
| 'return-annotation'
| 'self'
| 'assignment-inferred'
| 'constructor-inferred'
| 'receiver-propagated';
/** Reserved for V2+: generic type arguments (`List<User>` → `[TypeRef('User')]`). V1 ignores. */
readonly typeArgs?: readonly TypeRef[];
}
// ─── §2.2 Scope ─────────────────────────────────────────────────────────────
/**
* The canonical lexical-scope node. Forms the spine of the SemanticModel.
*
* ScopeId shape (RFC §2.2): `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
* — deterministic, stable across reparses of the same source, interned.
*/
export interface Scope {
readonly id: ScopeId;
readonly parent: ScopeId | null;
readonly kind: ScopeKind;
readonly range: Range;
readonly filePath: string;
/** Names visible from this scope. Provenance preserved via `BindingRef.origin`. */
readonly bindings: ReadonlyMap<string, readonly BindingRef[]>;
/** Defs structurally owned by this scope (e.g., methods owned by a class body scope). */
readonly ownedDefs: readonly SymbolDefinition[];
/** Import edges attached to this scope. Mostly module/namespace scopes, but some
* languages allow local imports (Python `def f(): from x import Y`, Rust
* fn-local `use`, TS dynamic `import()`). */
readonly imports: readonly ImportEdge[];
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
readonly typeBindings: ReadonlyMap<string, TypeRef>;
}
// ─── §2.6 Resolution + ResolutionEvidence ───────────────────────────────────
/**
* One piece of evidence for a `Resolution`. Multiple signals corroborate a
* single match; their weights compose additively to produce `confidence`.
*
* Weights come from `EvidenceWeights` (see `./evidence-weights.ts`).
*/
export interface ResolutionEvidence {
readonly kind:
| 'local'
| 'scope-chain'
| 'import'
| 'type-binding'
| 'owner-match'
| 'kind-match'
| 'arity-match'
| 'global-name'
| 'global-qualified'
| 'dynamic-import-unresolved';
/** Signal weight, sourced from `EvidenceWeights`. Additive; sum capped at 1.0. */
readonly weight: number;
/** Optional debug annotation (e.g., `'matched via self: User'`). */
readonly note?: string;
}
/**
* A ranked resolution candidate returned by `ClassRegistry.lookup` /
* `MethodRegistry.lookup` / `FieldRegistry.lookup`. Evidence composes
* additively; callers read `[0]` for the one-shot answer or inspect the
* evidence trace for debugging.
*/
export interface Resolution {
readonly def: SymbolDefinition;
/** Σ of `evidence[].weight`, capped at 1.0. */
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
/** Optional debug trace: scopes walked to reach `def`. */
readonly path?: readonly ScopeId[];
}
// ─── §2.7 Reference + ReferenceIndex ────────────────────────────────────────
/**
* A post-resolution usage fact: some code at `atRange` inside `fromScope`
* references `toDef` with the given confidence/evidence. Materialized by the
* resolution phase; emitted as graph edges (`CALLS`/`READS`/`WRITES`/etc.)
* during the emit phase.
*/
export interface Reference {
/** Innermost lexical scope containing `atRange`. */
readonly fromScope: ScopeId;
readonly toDef: DefId;
/** Location of the reference in source. */
readonly atRange: Range;
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use';
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
}
/**
* Two-way index over `Reference` records, populated during the resolution
* phase. Scopes stay immutable after finalize; references accumulate here.
*/
export interface ReferenceIndex {
readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]>;
readonly byTargetDef: ReadonlyMap<DefId, readonly Reference[]>;
}
// ─── §4.1 LookupParams ──────────────────────────────────────────────────────
/**
* Opaque placeholder for the per-kind registry passed as the owner-scoped
* contributor. Typed concretely in Ring 2 SHARED (#917); kept as `unknown`
* here so Ring 1 can ship without pulling in the registry implementation.
*/
export type RegistryContributor = unknown;
/**
* Parameters accepted by `Registry.lookup`. Three registries (Class/Method/
* Field) run the same 7-step algorithm with different parameter tuples; see
* RFC §4.4 for per-registry specializations.
*/
export interface LookupParams {
readonly acceptedKinds: readonly NodeLabel[];
/** Class lookups: false. Method/Field lookups: true. */
readonly useReceiverTypeBinding: boolean;
readonly ownerScopedContributor: RegistryContributor | null;
/** Optional arity hint fed to `provider.arityCompatibility`. */
readonly arityHint?: number;
/** Explicit receiver name (e.g., `'user'` in `user.save()`). When present,
* the receiver's type binding at the callsite scope is used; otherwise
* the enclosing method's implicit `self`/`this` is consulted. See §4.1. */
readonly explicitReceiver?: { readonly name: string };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.1",
"version": "1.6.3-rc.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.1",
"version": "1.6.3-rc.10",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.2",
"version": "1.6.3-rc.10",
"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",
@@ -1,11 +1,7 @@
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import type {
SymbolDefinition,
SymbolTableReader,
HeritageMap,
ExtractedHeritage,
} from './model/index.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import type { SymbolTableReader, HeritageMap, ExtractedHeritage } from './model/index.js';
import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js';
import type { DispatchDecision, ReceiverEnriched } from './call-types.js';
@@ -9,7 +9,23 @@
* so adding a language to the enum without creating a provider is a compiler error.
*/
import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared';
import type {
SupportedLanguages,
MroStrategy,
Capture,
CaptureMatch,
BindingRef,
TypeRef,
Scope,
ScopeId,
ScopeKind,
ScopeTree,
ParsedImport,
ParsedTypeBinding,
SymbolDefinition,
Callsite,
WorkspaceIndex,
} from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
import type {
@@ -272,6 +288,233 @@ interface LanguageProviderConfig {
/** Built-in/stdlib names that should be filtered from the call graph for this language.
* Default: undefined (no language-specific filtering). */
readonly builtInNames?: ReadonlySet<string>;
// ══════════════════════════════════════════════════════════════════════════
// Scope-based resolution hooks (RFC #909 — Ring 1 #911)
//
// All hooks below are OPTIONAL with safe defaults so existing providers
// continue to compile unchanged. Ring 2 (#919–#925) wires these into the
// central `ScopeExtractor` + finalize pipeline; Ring 3 per-language
// tickets implement the ones each language needs.
//
// See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50 §5.2
// ══════════════════════════════════════════════════════════════════════════
// ── Parse phase (per-capture interpretation) ───────────────────────
/**
* Emit scope captures from raw source. Tree-sitter-based providers run a
* `scopes.scm` query; standalone providers (COBOL) emit captures from a
* regex tagger. The return shape is parser-agnostic: the central
* `ScopeExtractor` consumes `Capture[]` without knowing which parser
* produced them.
*
* Required for any provider participating in scope-based resolution.
* Providers that have not yet migrated continue to run through the legacy
* DAG path (feature-flagged per `REGISTRY_PRIMARY_<LANG>`).
*
* Default: undefined (language continues to use legacy DAG).
*/
readonly emitScopeCaptures?: (
sourceText: string,
filePath: string,
) => Promise<readonly Capture[]>;
/**
* Interpret a raw `@import.statement` capture group into a `ParsedImport`.
* The central finalize algorithm resolves `ParsedImport.targetRaw` to a
* concrete file via `resolveImportTarget` and materializes the final
* `ImportEdge` with `targetModuleScope` / `targetDefId` filled in.
*
* Required when `emitScopeCaptures` is implemented.
*/
readonly interpretImport?: (captures: CaptureMatch) => ParsedImport | null;
/**
* What is the implicit receiver on a Function scope? For instance methods
* this is `self`/`this`; for standalone functions it is `null`. Consulted
* by `Registry.lookup` Step 2 via the `resolveTypeRef` helper.
*
* Required for any language with method dispatch (OO semantics).
*
* Default: undefined (treated as `null` — no implicit receiver).
*/
readonly receiverBinding?: (functionScope: Scope) => TypeRef | null;
/**
* Interpret a raw type-binding capture (parameter annotation, `self`,
* assignment with constructor RHS, …) into a `ParsedTypeBinding`. The
* central extractor attaches the resulting `TypeRef` to the appropriate
* scope's `typeBindings` map.
*
* Default: undefined (falls back to `{ boundName: captures.name, rawTypeName: captures.type, source: 'annotation' }`).
*/
readonly interpretTypeBinding?: (captures: CaptureMatch) => ParsedTypeBinding | null;
/**
* Override the `ScopeKind` assigned to a scope capture. Use when the
* capture name alone can't resolve the kind (e.g., tree-sitter captures
* a `block` that is semantically an `Expression` in this language).
*
* Default: undefined (the central extractor uses the capture name's
* suffix — `@scope.function` → `'Function'`, etc.).
*/
readonly resolveScopeKind?: (captures: CaptureMatch) => ScopeKind | null;
/**
* Should this scope capture materialize as a real `Scope` node? Return
* `false` to skip scope creation while still emitting declarations that
* would have gone inside (they attach to the enclosing real scope).
*
* Example: Python `if`/`for`/`while` bodies capture as `@scope.block` but
* Python has no block scope — hook returns `false` and child declarations
* lift to the enclosing function/module.
*
* Default: undefined (treated as `true` — always create).
*/
readonly shouldCreateScope?: (captures: CaptureMatch) => boolean;
/**
* Override where a declaration's name becomes visible. By default the name
* is bound in the innermost enclosing scope; return a different `ScopeId`
* to hoist it (JS `var` → enclosing function scope; Ruby `def` inside
* `begin` → enclosing class scope).
*
* Return `null` to delegate to the central default (innermost enclosing
* scope). This matches the `X | null` convention used by the other optional
* hooks and supports partial overrides — e.g., a JS provider can return a
* hoisted scope for `var` declarations and `null` for `let`/`const`, without
* re-implementing the default lookup.
*
* **Purity:** must be a pure function of its inputs — same parameters must
* yield the same `ScopeId` (or `null`) across invocations. No closure over
* mutable state. Required so scope-tree construction stays deterministic
* across re-parses.
*
* Default: undefined (the central extractor uses `innermostScope.id`).
*/
readonly bindingScopeFor?: (
declCapture: CaptureMatch,
innermostScope: Scope,
scopeTree: ScopeTree,
) => ScopeId | null;
// ── Finalize phase (cross-file + materialization) ──────────────────
/**
* Resolve a `ParsedImport.targetRaw` expression to a concrete file path in
* the workspace. Language-specific resolution: Python relative imports,
* JS package.json + node_modules, Go module paths, Java classpath,
* COBOL COPY paths. Ports today's per-language import resolver.
*
* Required when `emitScopeCaptures` is implemented. Ring 2 PKG #922
* provides the adapter that bridges today's resolver shape to this hook.
*/
readonly resolveImportTarget?: (
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
) => string | null;
/**
* Enumerate the exported names of a file — used by the finalize algorithm
* to expand `import * from M` into individual `BindingRef`s with
* `origin: 'wildcard'`.
*
* Default: undefined (central finalize walks the target file's
* `ExportMap.keys()`).
*/
readonly expandsWildcardTo?: (
targetFile: string,
workspaceIndex: WorkspaceIndex,
) => readonly string[];
/**
* Decide the scope to which a `ParsedImport` attaches. Most languages
* attach imports to the nearest enclosing `Module`/`Namespace` scope
* (the default); some languages allow local imports (Python function-local
* `from x import Y`, Rust fn-local `use`, TS dynamic `import()`) — return
* a `Function`/`Block` scope id instead.
*
* Return `null` to delegate to the central default (nearest enclosing
* `Module`/`Namespace`). This matches the `X | null` convention used by
* the other optional hooks and supports partial overrides — a provider
* that handles only specific import forms non-standardly can `return null`
* for the common cases and let the central walk handle them.
*
* **Purity:** must be a pure function of its inputs — same parameters must
* yield the same `ScopeId` (or `null`) across invocations. No closure over
* mutable state. Required so scope-tree construction stays deterministic
* across re-parses.
*
* Default: undefined (central finalize walks to the nearest enclosing
* `Module` or `Namespace` scope).
*/
readonly importOwningScope?: (
parsedImport: ParsedImport,
innermostScope: Scope,
scopeTree: ScopeTree,
) => ScopeId | null;
/**
* Merge local declarations and imported bindings for a single (scope, name)
* during finalize materialization of a scope's binding table. Language-
* specific precedence: Python local hides import; TypeScript namespace
* merging keeps both; Ruby constant resolution has its own rules.
*
* Default: undefined (central finalize uses local-first-then-imports,
* deduping by `DefId`).
*/
readonly mergeBindings?: (scope: Scope, bindings: readonly BindingRef[]) => readonly BindingRef[];
// ── Reference-extraction phase ─────────────────────────────────────
/**
* Classify a `@reference.call` capture as free / member / constructor /
* index. Preferred path is declarative via capture sub-tags
* (`@reference.call.free`, etc.); this hook handles the languages where
* call form can't be decided statically (Ruby bare `foo(x)` is free-or-
* member until resolved).
*
* Default: undefined (central extractor reads capture sub-tag if present;
* else treats as `'free'`).
*/
readonly classifyCallForm?: (
captures: CaptureMatch,
enclosingScope: Scope,
) => 'free' | 'member' | 'constructor' | 'index';
// ── Resolution phase (RFC §4v2) ────────────────────────────────────
/**
* Does a binding at this scope shadow bindings of the same name in outer
* scopes? Default: any binding shadows (standard lexical scoping). Return
* `false` for transparent-scope edge cases (Python `from x import *`
* contexts, JS `var` hoisting quirks, COBOL PARAGRAPH transparency).
*
* Consulted by `Registry.lookup` Step 1 and by `resolveTypeRef` for
* shadowing decisions during the lexical chain walk.
*
* Default: undefined (treated as `true` — any binding shadows).
*/
readonly shouldShadow?: (scope: Scope, bindings: readonly BindingRef[]) => boolean;
/**
* Is this callable definition compatible with the given call-site arity?
* Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default
* params + rest, Kotlin vararg + defaults, Ruby optional/splat/block, Go
* straight counts, Rust no-variadic-no-defaults.
*
* `'incompatible'` is a soft penalty (−0.15 per EvidenceWeights) and is
* filtered only when at least one `'compatible'` candidate exists;
* otherwise the incompatible candidate is kept with the penalty so the
* call-site still links to a best-guess target.
*
* Default: undefined (treated as `'unknown'` — no signal either way).
*/
readonly arityCompatibility?: (
def: SymbolDefinition,
callsite: Callsite,
) => 'compatible' | 'unknown' | 'incompatible';
}
/** Runtime type — same as LanguageProviderConfig but with defaults guaranteed present. */
@@ -5,7 +5,7 @@
* Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup.
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// Public read-only interface
+2 -1
View File
@@ -26,7 +26,6 @@ export {
type SymbolTableReader,
type SymbolTableWriter,
createSymbolTable,
type SymbolDefinition,
type AddMetadata,
CLASS_TYPES,
CLASS_TYPES_TUPLE,
@@ -36,6 +35,8 @@ export {
type FreeCallableLabel,
CALL_TARGET_TYPES,
} from './symbol-table.js';
// `SymbolDefinition` moved to `gitnexus-shared` (RFC #909 Ring 1 #910).
// Consumers should import it directly from `gitnexus-shared`, not via this barrel.
// Type registry (classes, structs, interfaces, enums, records, impls)
export {
@@ -7,7 +7,7 @@
* (array values) and arity-based filtering.
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// Public read-only interface
@@ -49,8 +49,8 @@
* `NodeLabel` is missing from all three sets.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js';
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
import type { ClassLikeLabel, FreeCallableLabel } from './symbol-table.js';
import { FREE_CALLABLE_TYPES } from './symbol-table.js';
import type { MutableTypeRegistry } from './type-registry.js';
import type { MutableMethodRegistry } from './method-registry.js';
@@ -18,7 +18,8 @@
* (three O(1) index lookups with a narrow, type-specific result set).
*/
import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import type { SymbolTableReader } from './symbol-table.js';
import type { MutableSemanticModel } from './semantic-model.js';
import { createSemanticModel } from './semantic-model.js';
+1 -1
View File
@@ -6,7 +6,7 @@
* on resolution-context.ts (circular dependency risk).
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import type { SemanticModel } from './semantic-model.js';
import type { HeritageMap } from './heritage-map.js';
import type { MroStrategy } from 'gitnexus-shared';
@@ -53,12 +53,8 @@ import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js';
import { createTypeRegistry } from './type-registry.js';
import { createMethodRegistry } from './method-registry.js';
import { createFieldRegistry } from './field-registry.js';
import type {
SymbolTableReader,
SymbolTableWriter,
SymbolDefinition,
AddMetadata,
} from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import type { SymbolTableReader, SymbolTableWriter, AddMetadata } from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import { createRegistrationTable } from './registration-table.js';
@@ -34,7 +34,7 @@
* logic up the dependency chain instead.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
/**
* Class-like NodeLabels — used for qualifiedName fallback inside
@@ -113,28 +113,10 @@ export const CALL_TARGET_TYPES: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Constructor',
]);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
// `SymbolDefinition` moved to `gitnexus-shared` as part of RFC #909 Ring 1
// (see #910). It is imported at the top of this file from `gitnexus-shared`
// and re-used unchanged throughout. Consumers should import
// `SymbolDefinition` directly from `gitnexus-shared`, not via this file.
/**
* Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate
@@ -6,7 +6,7 @@
* Also includes a separate index for Rust Impl blocks.
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// Public read-only interface
+108
View File
@@ -0,0 +1,108 @@
/**
* Per-phase wall-clock timing for the search pipeline and similar
* multi-stage flows. Designed to be called from query() with minimal
* ceremony and negligible overhead (< 0.1 ms per phase recorded).
*
* ### Sequential usage
*
* ```ts
* const t = new PhaseTimer();
* t.start('bm25'); await bm25Search(...); t.stop();
* t.start('merge'); doMerge(); t.stop();
* const phases = t.summary(); // { bm25: 42, merge: 3 }
* ```
*
* ### Concurrent usage (Promise.all)
*
* `start`/`stop` assume a single active phase at a time, which is wrong
* for concurrent work inside `Promise.all` — the second `start` would
* auto-stop the first and only one of the two would get timed. Use
* {@link PhaseTimer.time} to wrap each concurrent promise instead:
*
* ```ts
* const [a, b] = await Promise.all([
* t.time('bm25', bm25Search(...)),
* t.time('vector', semanticSearch(...)),
* ]);
* ```
*
* ### Pre-measured durations
*
* ```ts
* t.mark('inherited', 12.5);
* ```
*/
export class PhaseTimer {
private phases: Map<string, number> = new Map();
private current: string | null = null;
private t0 = 0;
/** Start a new phase. Implicitly stops the previous one, if any. */
start(phase: string): void {
this.stop();
this.current = phase;
this.t0 = performance.now();
}
/** Stop the current phase. No-op if no phase is active. */
stop(): void {
if (this.current !== null) {
const elapsed = performance.now() - this.t0;
this.phases.set(this.current, (this.phases.get(this.current) ?? 0) + elapsed);
this.current = null;
}
}
/**
* Record a pre-measured duration without touching the active phase.
* Use for concurrent operations inside `Promise.all` where
* `start`/`stop` would step on each other, or for durations imported
* from sub-systems. Additive across repeated calls with the same
* phase name. Ignores negative / non-finite inputs.
*/
mark(phase: string, durationMs: number): void {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
this.phases.set(phase, (this.phases.get(phase) ?? 0) + durationMs);
}
/**
* Wrap a promise with automatic timing. Records wall time via
* {@link PhaseTimer.mark} regardless of which other phases are
* active — safe to use inside `Promise.all`.
*/
async time<T>(phase: string, promise: Promise<T>): Promise<T> {
const t0 = performance.now();
try {
return await promise;
} finally {
this.mark(phase, performance.now() - t0);
}
}
/**
* Snapshot of accumulated durations rounded to 0.1 ms. Stops the
* current phase if one is still running.
*/
summary(): Record<string, number> {
this.stop();
const out: Record<string, number> = {};
for (const [k, v] of this.phases) out[k] = Math.round(v * 10) / 10;
return out;
}
/**
* Sum of every recorded phase duration.
*
* Note: for phases recorded via {@link PhaseTimer.time} or
* {@link PhaseTimer.mark} this is the *sum*, not the wall time —
* concurrent work overlaps and the sum can exceed the end-to-end
* wall time. Record wall time separately with `mark('wall', …)` if
* that distinction matters.
*/
totalMs(): number {
this.stop();
let t = 0;
for (const v of this.phases.values()) t += v;
return Math.round(t * 10) / 10;
}
}
+424 -156
View File
@@ -30,6 +30,7 @@ import {
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
import { collectBestChunks } from '../../core/embeddings/types.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@@ -156,6 +157,28 @@ function logQueryError(context: string, err: unknown): void {
console.error(`GitNexus [${context}]: ${msg}`);
}
/**
* Structured per-query latency log for production aggregation (#553).
*
* Emitted on stderr — NOT stdout — because the MCP stdio transport uses
* stdout exclusively for JSON-RPC responses (#324), and the CLI e2e test
* `tool output goes to stdout via fd 1` asserts that stdout parses cleanly
* as JSON. Any `console.log` from inside a tool handler would corrupt the
* protocol. Matches the existing `logQueryError` convention above, which
* uses stderr for the same reason.
*
* The `GitNexus [query:timing] …` prefix keeps lines greppable; the
* `phases` payload is JSON so log-scraping pipelines can parse it
* without custom format knowledge.
*/
function logQueryTiming(query: string, phases: Record<string, number>): void {
const totalMs = phases.wall ?? Object.values(phases).reduce((a, b) => a + b, 0);
const truncated = query.length > 80 ? `${query.slice(0, 80)}…` : query;
console.error(
`GitNexus [query:timing] query=${JSON.stringify(truncated)} totalMs=${totalMs} phases=${JSON.stringify(phases)}`,
);
}
export interface CodebaseContext {
projectName: string;
stats: {
@@ -534,17 +557,29 @@ export class LocalBackend {
const includeContent = params.include_content ?? false;
const searchQuery = params.query.trim();
// Step 1: Run hybrid search to get matching symbols
// Per-phase timing instrumentation (#553). Records wall time for each
// observable sub-step of the search pipeline so production latency can
// be aggregated offline for Pareto analysis and bottleneck detection.
// Overhead is <0.1 ms per phase; the timer is passive and never alters
// query behaviour.
const timer = new PhaseTimer();
const wallStart = performance.now();
// Step 1: Run hybrid search to get matching symbols. BM25 and vector
// search run concurrently via Promise.all — use `timer.time()` for
// each so both get independent wall-time records without fighting
// over a single `current` phase slot.
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
const [bm25SearchResult, semanticResults] = await Promise.all([
this.bm25Search(repo, searchQuery, searchLimit),
this.semanticSearch(repo, searchQuery, searchLimit),
timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)),
timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
]);
const bm25Results = bm25SearchResult.results;
const ftsUsed = bm25SearchResult.ftsUsed;
// Merge via reciprocal rank fusion
timer.start('merge');
const scoreMap = new Map<string, { score: number; data: any }>();
for (let i = 0; i < bm25Results.length; i++) {
@@ -574,8 +609,10 @@ export class LocalBackend {
const merged = Array.from(scoreMap.entries())
.sort((a, b) => b[1].score - a[1].score)
.slice(0, searchLimit);
timer.stop(); // merge
// Step 2: For each match with a nodeId, trace to process(es)
timer.start('symbol_lookup');
const processMap = new Map<
string,
{
@@ -708,7 +745,10 @@ export class LocalBackend {
}
}
timer.stop(); // symbol_lookup
// Step 3: Rank processes by aggregate score + internal cohesion boost
timer.start('ranking');
const rankedProcesses = Array.from(processMap.values())
.map((p) => ({
...p,
@@ -716,8 +756,10 @@ export class LocalBackend {
}))
.sort((a, b) => b.priority - a.priority)
.slice(0, processLimit);
timer.stop(); // ranking
// Step 4: Build response
timer.start('formatting');
const processes = rankedProcesses.map((p) => ({
id: p.id,
summary: p.heuristicLabel || p.label,
@@ -741,11 +783,20 @@ export class LocalBackend {
seen.add(s.id);
return true;
});
timer.stop(); // formatting
// End-to-end wall time — deliberately a separate mark so callers can
// compare sum(phases) vs wall to see how much Promise.all concurrency
// saved. Must come before summary() so it's included.
timer.mark('wall', performance.now() - wallStart);
const timing = timer.summary();
logQueryTiming(searchQuery, timing);
return {
processes,
process_symbols: dedupedSymbols,
definitions: definitions.slice(0, 20), // cap standalone definitions
timing,
...(!ftsUsed && {
warning:
'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
@@ -1079,9 +1130,296 @@ export class LocalBackend {
return result;
}
/**
* Patch the `type` field on candidates whose `labels(n)[0]` projection
* came back empty — a known LadybugDB behaviour for several node types.
*
* Uses one scoped UNION query across the five priority labels rather
* than per-candidate round-trips, so cost is a single DB call regardless
* of how many candidates need enrichment. No-op when every candidate
* already has a non-empty type.
*
* Failures are swallowed: label enrichment is an optimisation for
* downstream scoring and #480 Class/Interface BFS seeding; if it fails
* the symbol still resolves, just without the kind-priority bonus.
*/
private async enrichCandidateLabels(
repo: RepoHandle,
candidates: Array<{ id: string; type: string }>,
): Promise<void> {
const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
if (ids.length === 0) return;
try {
const rows = await executeParameterized(
repo.id,
`
MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label
UNION ALL
MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label
UNION ALL
MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label
UNION ALL
MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
UNION ALL
MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
`,
{ ids },
);
const labelById = new Map<string, string>();
for (const r of rows as any[]) {
const id = (r.id ?? r[0]) as string;
const label = (r.label ?? r[1]) as string;
if (id && label && !labelById.has(id)) labelById.set(id, label);
}
for (const c of candidates) {
if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string;
}
} catch {
/* best-effort — downstream resolvers still work without the label */
}
}
/**
* Score a symbol candidate for disambiguation ranking.
*
* Deterministic, no DB round-trip:
* - base 0.50
* - +0.40 when file_path hint matches (substring, case-insensitive)
* - +0.20 when kind hint exactly matches the candidate's kind
* - when no kind hint, a small priority bonus (Class > Interface >
* Function > Method > Constructor) to preserve the intuition that
* class-level names are usually what the user wanted.
*
* Capped at 1.0. Intentionally simple and inspectable — a future v2 can
* plug in BM25/embedding signals here without changing the surrounding
* resolver shape.
*/
private scoreCandidate(
c: { kind: string; filePath: string },
hints: { file_path?: string; kind?: string },
): number {
let s = 0.5;
if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
s += 0.4;
}
}
if (hints.kind && c.kind === hints.kind) {
s += 0.2;
}
if (!hints.kind) {
const priority: Record<string, number> = {
Class: 5,
Interface: 4,
Function: 3,
Method: 2,
Constructor: 1,
};
s += (priority[c.kind] ?? 0) * 0.02;
}
return Math.min(1.0, s);
}
/**
* Shared symbol resolver used by `context` and `impact`.
*
* Returns one of:
* - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
* (either direct UID, only one candidate after filtering, Class/
* Constructor collapse, or a top-scoring candidate with a clear gap
* to the runner-up).
* - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
* sorted by score desc. Each candidate carries a relevance score.
* - `{ kind: 'not_found' }` — no matches at all.
*
* Preserves the #480 Class/Constructor preference: when the only
* ambiguity is between a Class and its own Constructor (same name,
* same filePath), the Class wins silently.
*/
private async resolveSymbolCandidates(
repo: RepoHandle,
query: { uid?: string; name?: string; include_content?: boolean },
hints: { file_path?: string; kind?: string },
): Promise<
| {
kind: 'ok';
symbol: {
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
endLine: number;
content?: string;
};
resolvedLabel: string;
}
| {
kind: 'ambiguous';
candidates: Array<{
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
endLine: number;
score: number;
}>;
}
| { kind: 'not_found' }
> {
const { uid, name, include_content } = query;
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
// Direct UID — zero-ambiguity path.
if (uid) {
const rows = await executeParameterized(
repo.id,
`MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`,
{ uid },
);
if (rows.length === 0) return { kind: 'not_found' };
const r = rows[0] as any;
const symbol = {
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2] ?? '') as string,
filePath: (r.filePath ?? r[3]) as string,
startLine: (r.startLine ?? r[4]) as number,
endLine: (r.endLine ?? r[5]) as number,
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
};
// Same LadybugDB label-enrichment as the name-based path: a UID
// pointing at a Class must still surface `type: 'Class'` so impact's
// Class/Interface BFS seed fires. No-op when type is already set.
await this.enrichCandidateLabels(repo, [symbol]);
return { kind: 'ok', symbol, resolvedLabel: symbol.type };
}
if (!name) return { kind: 'not_found' };
const isQualified = name.includes('/') || name.includes(':');
let whereClause: string;
const queryParams: Record<string, any> = { symName: name };
if (hints.file_path) {
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams.filePath = hints.file_path;
} else if (isQualified) {
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
} else {
whereClause = `WHERE n.name = $symName`;
}
// LIMIT 20 (was 10) — scoring is the point now, so give the ranker
// headroom instead of arbitrary truncation.
const rows = await executeParameterized(
repo.id,
`MATCH (n) ${whereClause} RETURN ${selectClause} LIMIT 20`,
queryParams,
);
if (rows.length === 0) return { kind: 'not_found' };
// Normalise row shape across object / tuple returns from LadybugDB.
const normalized = rows.map((r: any) => ({
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2] ?? '') as string,
filePath: (r.filePath ?? r[3]) as string,
startLine: (r.startLine ?? r[4]) as number,
endLine: (r.endLine ?? r[5]) as number,
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
}));
// Enrich labels for any candidates where `labels(n)[0]` came back empty.
// LadybugDB returns an empty string for that projection on certain node
// types (notably Class), which left downstream consumers (impact's
// Class/Interface BFS seed, the kind-priority scoring bonus) unable to
// distinguish a Class target from "unknown kind". One scoped UNION
// across the five priority labels patches the type in-place without
// per-candidate round-trips.
await this.enrichCandidateLabels(repo, normalized);
// Preserve #480 Class/Constructor collapse: if we have exactly one
// Class (or Interface) candidate and one Constructor sharing name +
// filePath, fold into the Class. This used to require a follow-up
// label query because LadybugDB sometimes returns an empty labels()[0]
// for Class nodes — enrichment above handles the empty-type case, but
// the `type === 'Constructor'` gate still correctly triggers when a
// Class and its Constructor share the name.
if (!hints.kind && normalized.length > 1) {
const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor');
if (ambiguousType) {
const candidateIds = normalized.map((s) => s.id).filter(Boolean);
for (const label of ['Class', 'Interface']) {
const labelRows = await executeParameterized(
repo.id,
`MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1`,
{ candidateIds },
).catch(() => []);
if (labelRows.length > 0) {
const preferredId = (labelRows[0] as any).id ?? (labelRows[0] as any)[0];
const preferred = normalized.find((s) => s.id === preferredId);
if (preferred) {
return {
kind: 'ok',
symbol: preferred,
resolvedLabel: label,
};
}
}
}
}
}
if (normalized.length === 1) {
return {
kind: 'ok',
symbol: normalized[0],
resolvedLabel: '',
};
}
// Score, sort desc, stable tiebreak on shorter filePath then lex uid.
const scored = normalized.map((s) => ({
...s,
score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
}));
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const fpA = (a.filePath || '').length;
const fpB = (b.filePath || '').length;
if (fpA !== fpB) return fpA - fpB;
return String(a.id).localeCompare(String(b.id));
});
// Confident single-result: top score ≥ 0.95 AND beats runner-up by a
// clear margin. This lets a very strong file_path/kind hint resolve
// cleanly instead of forcing the caller through a disambiguation
// round-trip.
//
// The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose:
// IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90
// yields 0.09999999999999998, not exactly 0.10) would otherwise break
// the comparison for legitimate "top is 1.00, runner is 0.90" cases.
// The intent is a clearly-dominant winner; 0.09 is a large enough
// margin to mean that unambiguously.
//
// The `scored.length >= 2` guard is defensive. The `normalized.length === 1`
// early return above already handles the single-candidate path, so in
// practice `scored` always has at least two elements by the time we get
// here — keeping the guard means changes to the upstream early-return
// logic cannot accidentally index out of bounds at `scored[1]`.
if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) {
return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
}
return { kind: 'ambiguous', candidates: scored };
}
/**
* Context tool — 360-degree symbol view with categorized refs.
* Disambiguation when multiple symbols share a name.
* Disambiguation (ranked) when multiple symbols share a name.
* UID-based direct lookup. No cluster in output.
*/
private async context(
@@ -1090,124 +1428,47 @@ export class LocalBackend {
name?: string;
uid?: string;
file_path?: string;
kind?: string;
include_content?: boolean;
},
): Promise<any> {
await this.ensureInitialized(repo.id);
const { name, uid, file_path, include_content } = params;
const { name, uid, file_path, kind, include_content } = params;
if (!name && !uid) {
return { error: 'Either "name" or "uid" parameter is required.' };
}
// Step 1: Find the symbol
let symbols: any[];
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid, name, include_content },
{ file_path, kind },
);
if (uid) {
symbols = await executeParameterized(
repo.id,
`
MATCH (n {id: $uid})
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 1
`,
{ uid },
);
} else {
const isQualified = name!.includes('/') || name!.includes(':');
let whereClause: string;
let queryParams: Record<string, any>;
if (file_path) {
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams = { symName: name!, filePath: file_path };
} else if (isQualified) {
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
queryParams = { symName: name! };
} else {
whereClause = `WHERE n.name = $symName`;
queryParams = { symName: name! };
}
symbols = await executeParameterized(
repo.id,
`
MATCH (n) ${whereClause}
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 10
`,
queryParams,
);
}
if (symbols.length === 0) {
if (outcome.kind === 'not_found') {
return { error: `Symbol '${name || uid}' not found` };
}
// Step 2: Disambiguation
// When multiple nodes share the same name (e.g. a Java Class and its
// Constructor both named 'SessionTracker'), prefer the Class node so
// context() returns the semantically meaningful result rather than
// triggering ambiguous disambiguation (#480).
// labels(n)[0] returns empty string in LadybugDB, so we resolve the
// preferred node by re-querying with explicit label filters, scoped to
// the candidate IDs already in symbols.
//
// Guard: only attempt Class-preference when at least one candidate has an
// empty/unknown type (LadybugDB limitation) or is a Constructor — meaning
// the ambiguity may be a Class/Constructor name collision rather than two
// genuinely distinct symbols (e.g. two Functions in different files).
//
// resolvedLabel is set here and threaded to Step 3 to avoid a redundant
// classCheck round-trip later.
let resolvedLabel = '';
if (symbols.length > 1 && !uid) {
const hasAmbiguousType = symbols.some((s: any) => {
const t = s.type || s[2] || '';
return t === '' || t === 'Constructor';
});
if (hasAmbiguousType) {
const candidateIds = symbols.map((s: any) => s.id || s[0]).filter(Boolean);
const PREFER_LABELS = ['Class', 'Interface'];
let preferred: any = null;
for (const label of PREFER_LABELS) {
const match = await executeParameterized(
repo.id,
`
MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1
`,
{ candidateIds },
).catch(() => []);
if (match.length > 0) {
preferred = symbols.find((s: any) => (s.id || s[0]) === (match[0].id || match[0][0]));
if (preferred) {
resolvedLabel = label;
break;
}
}
}
if (preferred) symbols = [preferred];
}
}
if (symbols.length > 1 && !uid) {
if (outcome.kind === 'ambiguous') {
return {
status: 'ambiguous',
message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
candidates: symbols.map((s: any) => ({
uid: s.id || s[0],
name: s.name || s[1],
kind: s.type || s[2],
filePath: s.filePath || s[3],
line: s.startLine || s[4],
message: `Found ${outcome.candidates.length} symbols matching '${name}'. Use uid, file_path, or kind to disambiguate.`,
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
};
}
// Step 3: Build full context
const sym = symbols[0];
const symId = sym.id || sym[0];
const sym = outcome.symbol;
const resolvedLabel = outcome.resolvedLabel;
const symId = sym.id;
// Categorized incoming refs
const incomingRows = await executeParameterized(
@@ -1555,7 +1816,14 @@ export class LocalBackend {
let diffOutput: string;
try {
diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
// maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
// repos with large unstaged/untracked diffs (e.g. unignored build folders).
// See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
diffOutput = execFileSync('git', diffArgs, {
cwd: repo.repoPath,
encoding: 'utf-8',
maxBuffer: 256 * 1024 * 1024,
});
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
}
@@ -1829,6 +2097,8 @@ export class LocalBackend {
cwd: repo.repoPath,
encoding: 'utf-8',
timeout: 5000,
// Avoid ENOBUFS on large repos: rg -l can list many files.
maxBuffer: 256 * 1024 * 1024,
});
const files = output
.trim()
@@ -1901,6 +2171,9 @@ export class LocalBackend {
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
@@ -1927,6 +2200,9 @@ export class LocalBackend {
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
@@ -1969,65 +2245,57 @@ export class LocalBackend {
const includeTests = params.includeTests ?? false;
const minConfidence = params.minConfidence ?? 0;
// Resolve target by name, preferring Class/Interface over Constructor
// (fix #480: Java class and constructor share the same name).
// labels(n)[0] returns empty string in LadybugDB, so we use explicit
// label-typed sub-queries in a single UNION ordered by priority to avoid
// up to 6 serial round-trips for non-Class targets.
let sym: any = null;
let symType = '';
// Resolve target via the shared symbol resolver. When the caller passes
// target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
// we rank candidates (#470) and either proceed with a confident single
// match, or return a structured ambiguous response instead of silently
// picking the wrong symbol.
//
// The resolver preserves the #480 Class/Constructor preference heuristic:
// when a Class and its Constructor share name + filePath, the Class is
// selected silently.
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid: params.target_uid, name: target },
{ file_path: params.file_path, kind: params.kind },
);
try {
const rows = await executeParameterized(
repo.id,
`
MATCH (n:\`Class\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Interface\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Function\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Method\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1
UNION ALL
MATCH (n:\`Constructor\`) WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1
`,
{ targetName: target },
).catch(() => []);
if (rows.length > 0) {
// Pick the row with the lowest priority value (Class wins over Constructor)
const best = rows.reduce((a: any, b: any) =>
(a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b,
);
sym = best;
const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor'];
symType = priorityToLabel[best.priority ?? best[3]] ?? '';
}
} catch {
/* fall through to unlabeled match */
if (outcome.kind === 'not_found') {
const missing = params.target_uid ?? target;
return {
error: `Target '${missing}' not found`,
target: { name: target },
direction,
impactedCount: 0,
risk: 'UNKNOWN',
};
}
// Fall back to unlabeled match for any other node type
if (!sym) {
const rows = await executeParameterized(
repo.id,
`
MATCH (n)
WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
LIMIT 1
`,
{ targetName: target },
);
if (rows.length > 0) sym = rows[0];
if (outcome.kind === 'ambiguous') {
return {
status: 'ambiguous',
message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
target: { name: target },
direction,
impactedCount: 0,
risk: 'UNKNOWN',
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
};
}
if (!sym) return { error: `Target '${target}' not found` };
const sym = {
id: outcome.symbol.id,
name: outcome.symbol.name,
filePath: outcome.symbol.filePath,
};
const symType = outcome.resolvedLabel || outcome.symbol.type || '';
return this._runImpactBFS(repo, sym, symType, direction, {
maxDepth,
+22 -1
View File
@@ -154,7 +154,7 @@ Shows categorized incoming/outgoing references (calls, imports, extends, impleme
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.
Handles disambiguation: if multiple symbols share the same name, returns ranked candidates (each with a relevance score) for you to pick from. Use uid for zero-ambiguity lookup, or narrow the search with file_path and/or kind hints.
NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`,
inputSchema: {
@@ -166,6 +166,11 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup)',
},
file_path: { type: 'string', description: 'File path to disambiguate common names' },
kind: {
type: 'string',
description:
"Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
},
include_content: {
type: 'boolean',
description: 'Include full symbol source code (default: false)',
@@ -265,16 +270,32 @@ Depth groups:
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints.
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
inputSchema: {
type: 'object',
properties: {
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
target_uid: {
type: 'string',
description:
'Direct symbol UID from prior tool results (zero-ambiguity lookup, skips target resolution)',
},
direction: {
type: 'string',
description: 'upstream (what depends on this) or downstream (what this depends on)',
},
file_path: {
type: 'string',
description: 'File path hint to disambiguate common names',
},
kind: {
type: 'string',
description:
"Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
},
maxDepth: {
type: 'number',
description: 'Max relationship depth (default: 3)',
+48 -32
View File
@@ -20,7 +20,22 @@ import { createRequire } from 'module';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
const MINI_REPO = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
// `MINI_REPO` is a *per-run temp copy* of the fixture, not the shared
// source. Writing into the shared source races with other suites that
// ingest it read-only (pipeline-graph-golden, pipeline.test) — those
// suites copy the source to their own tmp dir but the copy happens at
// `beforeAll`, so if this suite's analyze has already created AGENTS.md
// / CLAUDE.md / .claude/ in the source when the other suite's cpSync
// runs, the pollution is captured before the isolation kicks in.
//
// The deterministic fix: this suite never touches the shared source.
// `beforeAll` copies the fixture to a fresh mkdtemp'd directory whose
// basename is `mini-repo` (so `--repo mini-repo` lookup by basename
// still works), `afterAll` rms the parent tmpdir.
let MINI_REPO: string;
let tmpParent: string;
// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd
// outside the project tree (bare 'tsx' specifier won't resolve there).
@@ -31,40 +46,39 @@ const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json'));
const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href;
beforeAll(() => {
// Copy the fixture into an isolated tmpdir named `mini-repo` so that the
// `--repo mini-repo` CLI arg (which matches by basename) still works.
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-'));
MINI_REPO = path.join(tmpParent, 'mini-repo');
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
// Initialize mini-repo as a git repo so the CLI analyze command
// can run the full pipeline (it requires a .git directory).
const gitDir = path.join(MINI_REPO, '.git');
if (!fs.existsSync(gitDir)) {
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'initial commit'], {
cwd: MINI_REPO,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
}
spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'initial commit'], {
cwd: MINI_REPO,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
});
afterAll(() => {
// Clean up all files/dirs created by analyze (git init, .gitnexus output,
// AI context files, skill files, .gitignore) so parallel tests like
// pipeline-graph-golden see a pristine fixture.
for (const entry of ['.git', '.gitnexus', '.claude', 'AGENTS.md', 'CLAUDE.md', '.gitignore']) {
const fullPath = path.join(MINI_REPO, entry);
if (fs.existsSync(fullPath)) {
fs.rmSync(fullPath, { recursive: true, force: true });
}
// Entire tmp copy goes away — no selective cleanup needed. The shared
// `test/fixtures/mini-repo/` source was never touched.
if (tmpParent) {
fs.rmSync(tmpParent, { recursive: true, force: true });
}
});
function runCli(command: string, cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command], {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
@@ -84,7 +98,7 @@ function runCli(command: string, cwd: string, timeoutMs = 15000) {
* can pass flags (e.g. --help) or omit a command entirely.
*/
function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...extraArgs], {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...extraArgs], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
@@ -190,9 +204,11 @@ describe('CLI end-to-end', () => {
}
it('status on non-indexed repo reports not indexed', () => {
// MINI_REPO is inside the project tree so findRepo() walks up and
// finds the parent project's .gitnexus. Use an isolated temp git
// repo to guarantee no .gitnexus exists anywhere in the path.
// Even though MINI_REPO is now in an isolated tmpdir, previous tests
// in this suite may have created MINI_REPO/.gitnexus via analyze,
// and findRepo() walks up so any `.gitnexus` along the path still
// counts. This test needs a GUARANTEED pristine repo to assert the
// "not indexed" output, so it mints its own throwaway tmp git repo.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-noindex-'));
try {
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
@@ -410,7 +426,7 @@ describe('CLI end-to-end', () => {
process.execPath,
[
'--import',
'tsx',
tsxImportUrl,
cliEntry,
'cypher',
'MATCH (n) RETURN n LIMIT 500',
@@ -469,7 +485,7 @@ describe('CLI end-to-end', () => {
return new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
['--import', 'tsx', cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'],
['--import', tsxImportUrl, cliEntry, 'eval-server', '--port', '0', '--idle-timeout', '3'],
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
@@ -104,6 +104,14 @@ withTestLbugDB(
(result.process_symbols?.length || 0) +
(result.definitions?.length || 0);
expect(totalResults).toBeGreaterThanOrEqual(1);
// #553: query response carries per-phase timing metadata.
expect(result.timing).toBeDefined();
expect(typeof result.timing.wall).toBe('number');
expect(result.timing.wall).toBeGreaterThanOrEqual(0);
// At least one of the search phases must have fired for any
// non-error response — bm25 and/or vector always runs.
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
});
it('unknown tool throws', async () => {
@@ -141,12 +149,20 @@ withTestLbugDB(
});
it('filters by OVERRIDES only', async () => {
// The seed has two Method nodes named 'authenticate' (AuthService's
// override and BaseService's base). Per #470, `impact` now returns
// a ranked-ambiguous response when the target name hits multiple
// symbols, so we must disambiguate with file_path to get the
// AuthService override (the one with the outgoing METHOD_OVERRIDES
// edge we want to follow downstream).
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['METHOD_OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
// AuthService.authenticate overrides BaseService.authenticate
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
@@ -158,12 +174,15 @@ withTestLbugDB(
// Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand
// it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge
// between BaseService.authenticate and AuthService.authenticate is found.
// file_path hint disambiguates the two 'authenticate' methods per #470.
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
@@ -132,9 +132,12 @@ describe('pipeline graph golden', () => {
let tmpDir: string;
beforeAll(async () => {
// Copy the fixture to a temp directory so parallel tests (cli-e2e)
// that create AGENTS.md / CLAUDE.md / .claude/ in the shared fixture
// don't pollute the golden snapshot.
// Copy the fixture to a temp directory as defense-in-depth against
// parallel tests writing into the shared fixture source. cli-e2e
// was the historical offender — it now copies to its own tmpdir
// (see test/integration/cli-e2e.test.ts `beforeAll`) — but this
// cpSync stays as a belt-and-suspenders guarantee that any future
// test adding files to the source won't pollute the golden snapshot.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-'));
fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true });
@@ -248,6 +248,215 @@ describe('LocalBackend.callTool', () => {
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
// #470: every candidate carries a relevance score in [0, 1] and the list
// is sorted descending by score (with deterministic tiebreakers).
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.score).toBeGreaterThanOrEqual(0);
expect(c.score).toBeLessThanOrEqual(1);
}
expect(result.candidates[0].score).toBeGreaterThanOrEqual(result.candidates[1].score);
});
it('context tool ranks file_path match higher than non-match (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:handleConnect:1',
name: 'handleConnect',
type: 'Function',
filePath: 'src/lib/socket.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:handleConnect:2',
name: 'handleConnect',
type: 'Function',
filePath: 'src/App.tsx',
startLine: 42,
endLine: 60,
},
]);
const result = await backend.callTool('context', {
name: 'handleConnect',
file_path: 'App.tsx',
});
// In production, `WHERE n.filePath CONTAINS $filePath` would pre-filter
// at the DB layer and only `src/App.tsx` would come back — resolving
// via the single-candidate early return rather than via scoring. The
// `executeParameterized` mock here returns both rows regardless of the
// WHERE clause parameters, so this asserts that the resolver ends up
// picking the App.tsx candidate in either case (via mock-relaxed DB
// pre-filter or via scoring promotion). The dedicated scoring-promotion
// path is covered by the next `it()` block below.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/App.tsx');
});
it('context tool promotes top candidate via scoring when multiple rows survive DB pre-filter (#470)', async () => {
// This test explicitly exercises the scored-promotion path (#470
// review): both candidates satisfy the file_path hint (so DB
// pre-filter would return both in production), and promotion is
// determined purely by the combined file_path + kind score.
(executeParameterized as any).mockResolvedValue([
{
id: 'fn:App:1',
name: 'render',
type: 'Function',
filePath: 'src/components/App.tsx',
startLine: 10,
endLine: 20,
},
{
id: 'method:App:1',
name: 'render',
type: 'Method',
filePath: 'src/pages/App.tsx',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', {
name: 'render',
file_path: 'App.tsx',
kind: 'Function',
});
// Expected scoring:
// Function candidate: 0.50 base + 0.40 file_path + 0.20 kind = 1.10 → cap 1.00
// Method candidate: 0.50 base + 0.40 file_path + 0.00 kind = 0.90
// Top score ≥ 0.95 and beats runner-up by 0.10 → confident promotion
// to `{ status: 'found' }` with the Function.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/components/App.tsx');
expect(result.symbol.kind).toBe('Function');
});
it('context tool returns ranked candidates when file_path only partially narrows (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:foo:1',
name: 'foo',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:foo:2',
name: 'foo',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
// No hints → both candidates score 0.56 (0.50 base + 0.06 Function
// priority). Tied scores fall back to deterministic tiebreakers.
const result = await backend.callTool('context', { name: 'foo' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.candidates[0].score).toBeCloseTo(0.56, 2);
expect(result.candidates[1].score).toBeCloseTo(0.56, 2);
});
it('context tool boosts the candidate whose kind matches the hint (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'method:save:1',
name: 'save',
type: 'Method',
filePath: 'src/service.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:save:1',
name: 'save',
type: 'Function',
filePath: 'src/util.ts',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', { name: 'save', kind: 'Function' });
// When kind hint is given, kind-priority bonus is suppressed and +0.20
// kind-match bonus applies instead. Function becomes the top candidate.
expect(result.status).toBe('ambiguous');
expect(result.candidates[0].kind).toBe('Function');
expect(result.candidates[0].score).toBeGreaterThan(result.candidates[1].score);
});
it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => {
// resolveSymbolCandidates issues a single name query; mock it to return
// two Function rows in different files with no hints.
(executeParameterized as any).mockResolvedValue([
{
id: 'func:login:1',
name: 'login',
type: 'Function',
filePath: 'src/auth.ts',
startLine: 5,
endLine: 15,
},
{
id: 'func:login:2',
name: 'login',
type: 'Function',
filePath: 'src/admin/login.ts',
startLine: 8,
endLine: 20,
},
]);
const result = await backend.callTool('impact', { target: 'login', direction: 'upstream' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.target.name).toBe('login');
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.uid).toBeDefined();
expect(c.kind).toBe('Function');
}
});
it('impact tool resolves via target_uid without running the name-based resolver (#470)', async () => {
// UID path: exactly one executeParameterized call for the lookup, then
// the BFS issues executeQuery calls (which we mock empty). Crucially,
// no `WHERE n.name =` query fires.
(executeParameterized as any).mockResolvedValue([
{
id: 'uid:1234',
name: 'pickedByUid',
type: 'Function',
filePath: 'src/pick.ts',
startLine: 1,
endLine: 10,
},
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', {
target: 'ignoredName',
target_uid: 'uid:1234',
direction: 'upstream',
});
// No ambiguous shape and no name-lookup error — the uid short-circuit won.
expect(result.status).not.toBe('ambiguous');
expect(result.target).toBeDefined();
// All executeParameterized calls this test dispatched must have been
// uid-keyed, never name-keyed. That proves the name resolver was skipped.
const calls = (executeParameterized as any).mock.calls as Array<
[string, string, Record<string, unknown>]
>;
for (const [, cypher] of calls) {
expect(cypher).not.toMatch(/WHERE n\.name = \$symName/);
}
});
it('dispatches impact tool', async () => {
@@ -0,0 +1,48 @@
/**
* Source-code regression: ENOBUFS on large git/rg output.
*
* Node's default maxBuffer for execFileSync is 1 MB, which is easily exceeded
* by `git diff` on repos with large unstaged changes (e.g. unignored build
* folders) — see the original bug report:
*
* "spawnSync git ENOBUFS in gitnexus_detect_changes(scope=\"unstaged\")
* due to missing maxBuffer".
*
* Every `execFileSync` call in `local-backend.ts` that captures stdout
* (i.e. sets `encoding`) MUST pass an explicit `maxBuffer`. This test is a
* lightweight static guard so the regression cannot silently come back.
*
* Kept as a standalone file (no LocalBackend import) so it does not depend
* on the LadybugDB native binding being available in the test environment.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const SOURCE_PATH = path.join(__dirname, '../../src/mcp/local/local-backend.ts');
describe('local-backend: execFileSync maxBuffer regression', () => {
const source = fs.readFileSync(SOURCE_PATH, 'utf-8');
it('every stdout-capturing execFileSync call passes maxBuffer', () => {
// Match each `execFileSync(...)` call. The local-backend.ts call sites use
// a single trailing options object literal, so a non-greedy match up to the
// closing `)` of the statement is sufficient.
const callRe = /execFileSync\s*\(([\s\S]*?)\)\s*;/g;
const offenders: string[] = [];
let match: RegExpExecArray | null;
while ((match = callRe.exec(source)) !== null) {
const args = match[1];
// Only stdout-capturing calls (encoding set) are at risk of ENOBUFS.
if (!/encoding\s*:/.test(args)) continue;
if (!/maxBuffer\s*:/.test(args)) {
const lineNo = source.slice(0, match.index).split('\n').length;
offenders.push(`line ${lineNo}: ${args.replace(/\s+/g, ' ').slice(0, 160)}`);
}
}
expect(
offenders,
`execFileSync calls missing explicit maxBuffer (ENOBUFS risk):\n${offenders.join('\n')}`,
).toEqual([]);
});
});
@@ -8,7 +8,7 @@
import { describe, it, expect } from 'vitest';
import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { makeDef as makeBaseDef } from './helpers.js';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition =>
+1 -1
View File
@@ -6,7 +6,7 @@
* test file that uses it.
*/
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
/**
* Build a {@link SymbolDefinition} with sensible defaults. Every field
@@ -9,7 +9,7 @@ import { createTypeRegistry } from '../../../src/core/ingestion/model/type-regis
import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js';
import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js';
import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { makeDef as makeBaseDef } from './helpers.js';
// ---------------------------------------------------------------------------
@@ -10,7 +10,7 @@
import { describe, it, expect } from 'vitest';
import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { makeDef as makeBaseDef } from './helpers.js';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition =>
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { PhaseTimer } from '../../src/core/search/phase-timer.js';
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
describe('PhaseTimer', () => {
it('start/stop records a single phase', async () => {
const t = new PhaseTimer();
t.start('bm25');
await sleep(20);
t.stop();
const phases = t.summary();
expect(phases.bm25).toBeGreaterThanOrEqual(15); // allow a bit of scheduler slack
expect(Object.keys(phases)).toEqual(['bm25']);
});
it('start implicitly stops the previous phase', async () => {
const t = new PhaseTimer();
t.start('a');
await sleep(10);
t.start('b'); // auto-stops 'a'
await sleep(10);
t.stop();
const phases = t.summary();
expect(phases.a).toBeGreaterThanOrEqual(5);
expect(phases.b).toBeGreaterThanOrEqual(5);
});
it('mark accumulates additive durations for the same phase', () => {
const t = new PhaseTimer();
t.mark('x', 5);
t.mark('x', 3);
t.mark('y', 7);
const phases = t.summary();
expect(phases.x).toBe(8);
expect(phases.y).toBe(7);
});
it('time() records concurrent promises independently (Promise.all safe)', async () => {
const t = new PhaseTimer();
await Promise.all([t.time('a', sleep(30)), t.time('b', sleep(80))]);
const phases = t.summary();
// Both phases recorded independently despite overlapping in time.
expect(phases.a).toBeGreaterThanOrEqual(25);
expect(phases.a).toBeLessThan(80);
expect(phases.b).toBeGreaterThanOrEqual(75);
});
it('mark rejects negative or non-finite durations', () => {
const t = new PhaseTimer();
t.mark('x', -1);
t.mark('x', Number.NaN);
t.mark('x', Number.POSITIVE_INFINITY);
const phases = t.summary();
expect(phases.x).toBeUndefined();
});
it('totalMs sums all phases and implicitly stops the active one', async () => {
const t = new PhaseTimer();
t.mark('a', 10);
t.mark('b', 15);
t.start('c');
await sleep(20);
// Call totalMs without stopping — it should stop 'c' implicitly.
const total = t.totalMs();
expect(total).toBeGreaterThanOrEqual(40); // 10 + 15 + ~20
const phases = t.summary();
expect(phases.c).toBeGreaterThanOrEqual(15);
});
});
@@ -0,0 +1,69 @@
/**
* Unit tests for `buildDefIndex` / `DefIndex` (RFC #909 Ring 2 SHARED #913).
*
* Covers: build-from-list, O(1) lookup contract, first-write-wins on
* duplicate `nodeId`, readonly surface.
*/
import { describe, it, expect } from 'vitest';
import { buildDefIndex, type SymbolDefinition } from 'gitnexus-shared';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:test',
filePath: 'src/test.ts',
type: 'Method',
...overrides,
});
describe('buildDefIndex', () => {
it('builds an empty index from an empty input', () => {
const idx = buildDefIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('anything')).toBeUndefined();
expect(idx.has('anything')).toBe(false);
});
it('stores a single def and round-trips by nodeId', () => {
const def = makeDef({ nodeId: 'def:User.save' });
const idx = buildDefIndex([def]);
expect(idx.size).toBe(1);
expect(idx.has('def:User.save')).toBe(true);
expect(idx.get('def:User.save')).toBe(def); // reference identity
});
it('stores multiple defs under their distinct ids', () => {
const a = makeDef({ nodeId: 'def:A' });
const b = makeDef({ nodeId: 'def:B' });
const c = makeDef({ nodeId: 'def:C' });
const idx = buildDefIndex([a, b, c]);
expect(idx.size).toBe(3);
expect(idx.get('def:A')).toBe(a);
expect(idx.get('def:B')).toBe(b);
expect(idx.get('def:C')).toBe(c);
});
it('first-write-wins on duplicate nodeId', () => {
const first = makeDef({ nodeId: 'def:dup', returnType: 'Original' });
const second = makeDef({ nodeId: 'def:dup', returnType: 'Shadow' });
const idx = buildDefIndex([first, second]);
expect(idx.size).toBe(1);
expect(idx.get('def:dup')).toBe(first);
expect(idx.get('def:dup')?.returnType).toBe('Original');
});
it("returns undefined for a missing id (doesn't throw)", () => {
const idx = buildDefIndex([makeDef({ nodeId: 'def:A' })]);
expect(idx.get('def:missing')).toBeUndefined();
expect(idx.has('def:missing')).toBe(false);
});
it('exposes byId as the underlying read-only Map for direct iteration', () => {
const a = makeDef({ nodeId: 'def:A' });
const b = makeDef({ nodeId: 'def:B' });
const idx = buildDefIndex([a, b]);
const entries = Array.from(idx.byId.entries())
.map(([id]) => id)
.sort();
expect(entries).toEqual(['def:A', 'def:B']);
});
});
@@ -0,0 +1,443 @@
/**
* Unit tests for `finalize` (RFC #909 Ring 2 SHARED #915).
*
* Covers: acyclic chain · single-SCC cycle · multi-SCC · wildcard
* expansion · re-export flattening · dynamic-unresolved passthrough ·
* bounded fixpoint cap · module-scope binding materialization · unresolved
* target · external target · provider `mergeBindings` precedence.
*/
import { describe, it, expect } from 'vitest';
import {
finalize,
type FinalizeFile,
type FinalizeHooks,
type ParsedImport,
type BindingRef,
type SymbolDefinition,
type ScopeId,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const def = (
nodeId: string,
type: SymbolDefinition['type'] = 'Class',
qualifiedName?: string,
): SymbolDefinition => ({
nodeId,
filePath: 'x',
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
});
const file = (
filePath: string,
localDefs: SymbolDefinition[] = [],
parsedImports: ParsedImport[] = [],
): FinalizeFile => ({
filePath,
moduleScope: `scope:${filePath}#1:0-9999:0:Module`,
localDefs: localDefs.map((d) => ({ ...d, filePath })),
parsedImports,
});
/** Simple hook set: `resolveImportTarget` does a direct path lookup; wildcard
* expansion returns the concrete names from the target's own local defs;
* `mergeBindings` appends (no precedence logic). */
const defaultHooks = (files: readonly FinalizeFile[]): FinalizeHooks => ({
resolveImportTarget(targetRaw) {
if (targetRaw === null || targetRaw.length === 0) return null;
return files.some((f) => f.filePath === targetRaw) ? targetRaw : null;
},
expandsWildcardTo(targetModuleScope) {
const target = files.find((f) => f.moduleScope === targetModuleScope);
if (target === undefined) return [];
return target.localDefs.map((d) => deriveSimple(d)).filter((n): n is string => n !== null);
},
mergeBindings(existing, incoming) {
return [...existing, ...incoming];
},
});
function deriveSimple(d: SymbolDefinition): string | null {
const q = d.qualifiedName;
if (q === undefined || q.length === 0) return null;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
const named = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'named',
localName,
importedName,
targetRaw,
});
const aliased = (
localName: string,
importedName: string,
alias: string,
targetRaw: string,
): ParsedImport => ({ kind: 'alias', localName, importedName, alias, targetRaw });
const namespace = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'namespace',
localName,
importedName,
targetRaw,
});
const reexport = (localName: string, importedName: string, targetRaw: string): ParsedImport => ({
kind: 'reexport',
localName,
importedName,
targetRaw,
});
const wildcard = (targetRaw: string): ParsedImport => ({ kind: 'wildcard', targetRaw });
const dynamic = (localName: string, targetRaw: string | null): ParsedImport => ({
kind: 'dynamic-unresolved',
localName,
targetRaw,
});
const firstImport = (out: ReturnType<typeof finalize>, scope: ScopeId) => {
const imports = out.imports.get(scope);
return imports?.[0];
};
const bindingsFor = (
out: ReturnType<typeof finalize>,
scope: ScopeId,
name: string,
): readonly BindingRef[] => {
const scopeBindings = out.bindings.get(scope);
return scopeBindings?.get(name) ?? [];
};
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('finalize', () => {
describe('trivial / acyclic', () => {
it('handles an empty workspace', () => {
const out = finalize({ files: [], workspaceIndex: undefined }, defaultHooks([]));
expect(out.stats.totalFiles).toBe(0);
expect(out.stats.totalEdges).toBe(0);
expect(out.sccs).toEqual([]);
});
it('resolves a single named import across two files', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('named');
expect(edge.targetFile).toBe('b');
expect(edge.targetModuleScope).toBe(b.moduleScope);
expect(edge.targetDefId).toBe('def:b.User');
expect(edge.linkStatus).toBeUndefined();
expect(out.stats.linkedEdges).toBe(1);
expect(out.stats.unresolvedEdges).toBe(0);
});
it('marks an edge unresolved when target file cannot be resolved', () => {
const a = file('a', [], [named('User', 'User', 'external-pkg')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBe('unresolved');
expect(edge.targetFile).toBeNull();
});
it('marks an edge unresolved when target file exists but name is not exported', () => {
const b = file('b', [def('def:b.Other', 'Class', 'b.Other')]);
const a = file('a', [], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.linkStatus).toBe('unresolved');
// targetFile still known — unresolvability is at the name level.
expect(edge.targetFile).toBe('b');
});
it('passes dynamic-unresolved edges through without linking', () => {
const a = file('a', [], [dynamic('', 'runtime.computed')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('dynamic-unresolved');
expect(edge.targetFile).toBeNull();
expect(edge.linkStatus).toBeUndefined();
});
});
describe('cycles + bounded fixpoint', () => {
it('finalizes a two-file cycle (A → B → A) without hanging', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const aEdge = firstImport(out, a.moduleScope)!;
const bEdge = firstImport(out, b.moduleScope)!;
expect(aEdge.targetDefId).toBe('def:b.Y');
expect(bEdge.targetDefId).toBe('def:a.X');
expect(out.stats.sccCount).toBeGreaterThanOrEqual(1);
});
it('packs cyclic files into a single SCC with isCycle=true', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const cycles = out.sccs.filter((scc) => scc.isCycle);
expect(cycles.length).toBe(1);
expect(cycles[0]!.files.length).toBe(2);
expect(new Set(cycles[0]!.files)).toEqual(new Set(['a', 'b']));
});
it('separates disjoint SCCs', () => {
// a↔b cycle, c↔d cycle — disjoint.
const a = file('a', [def('def:a.X', 'Class', 'a.X')], [named('Y', 'Y', 'b')]);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const c = file('c', [def('def:c.P', 'Class', 'c.P')], [named('Q', 'Q', 'd')]);
const d = file('d', [def('def:d.Q', 'Class', 'd.Q')], [named('P', 'P', 'c')]);
const files = [a, b, c, d];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const cycleSCCs = out.sccs.filter((scc) => scc.isCycle);
expect(cycleSCCs.length).toBe(2);
});
it('reports stats distinguishing linked from unresolved edges in a cycle', () => {
const a = file(
'a',
[def('def:a.X', 'Class', 'a.X')],
[named('Y', 'Y', 'b'), named('Ghost', 'Ghost', 'b')],
);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
expect(out.stats.linkedEdges).toBe(2); // a→b.Y and b→a.X resolve
expect(out.stats.unresolvedEdges).toBe(1); // a→b.Ghost doesn't
});
it('transitions an intra-SCC edge to linkStatus=unresolved when the cap is reached', () => {
// A↔B cycle; A imports a name that B never exports. The file-level
// target resolves (b exists), but the name-level lookup never
// succeeds, so the fixpoint exhausts its cap and we fall through to
// `linkStatus: 'unresolved'` (distinct from `targetFile: null`).
const a = file(
'a',
[def('def:a.X', 'Class', 'a.X')],
[named('Ghost', 'Ghost', 'b'), named('Y', 'Y', 'b')],
);
const b = file('b', [def('def:b.Y', 'Class', 'b.Y')], [named('X', 'X', 'a')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const aEdges = out.imports.get(a.moduleScope) ?? [];
const ghost = aEdges.find((e) => e.localName === 'Ghost');
expect(ghost).toBeDefined();
// Cap-hit distinction: file target is known, but name never resolved.
expect(ghost!.targetFile).toBe('b');
expect(ghost!.linkStatus).toBe('unresolved');
expect(ghost!.targetDefId).toBeUndefined();
});
});
describe('wildcard expansion', () => {
it('expands `wildcard` into one ImportEdge per exported name', () => {
const b = file('b', [
def('def:b.X', 'Class', 'b.X'),
def('def:b.Y', 'Class', 'b.Y'),
def('def:b.Z', 'Class', 'b.Z'),
]);
const a = file('a', [], [wildcard('b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edges = out.imports.get(a.moduleScope) ?? [];
expect(edges.length).toBe(3);
expect(edges.every((e) => e.kind === 'wildcard-expanded')).toBe(true);
expect(new Set(edges.map((e) => e.localName))).toEqual(new Set(['X', 'Y', 'Z']));
expect(new Set(edges.map((e) => e.targetDefId))).toEqual(
new Set(['def:b.X', 'def:b.Y', 'def:b.Z']),
);
});
it('leaves a wildcard unresolved when the target file cannot be resolved', () => {
const a = file('a', [], [wildcard('external-pkg')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const edges = out.imports.get(a.moduleScope) ?? [];
expect(edges.length).toBe(1);
expect(edges[0]!.linkStatus).toBe('unresolved');
});
it('expanded bindings land at `origin: wildcard`', () => {
const b = file('b', [def('def:b.X', 'Class', 'b.X')]);
const a = file('a', [], [wildcard('b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const bindings = bindingsFor(out, a.moduleScope, 'X');
expect(bindings.length).toBeGreaterThanOrEqual(1);
const imported = bindings.find((br) => br.origin === 'wildcard');
expect(imported).toBeDefined();
expect(imported!.def.nodeId).toBe('def:b.X');
});
});
describe('re-export flattening', () => {
it('sets transitiveVia on reexport edges', () => {
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
const b = file('b', [], [reexport('X', 'X', 'c')]);
const a = file('a', [], [named('X', 'X', 'b')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const reexportEdge = firstImport(out, b.moduleScope)!;
expect(reexportEdge.kind).toBe('reexport');
expect(reexportEdge.transitiveVia).toEqual(['c']);
});
it('multi-hop re-export chains only resolve when intermediate files include the name in localDefs', () => {
// Contract (see FinalizeFile.localDefs doc): `finalize` looks up
// `importedName` in `B.localDefs`. If B re-exports X from C but does
// NOT include X in its own localDefs, A's import of X from B cannot
// resolve — the fixpoint doesn't mutate localDefs across iterations.
//
// This test documents the current behavior: parsers that want
// multi-hop chains to settle end-to-end must surface re-exported
// names in the intermediate file's localDefs (with the original
// source DefId).
const c = file('c', [def('def:c.X', 'Class', 'c.X')]);
// Variant 1: B does NOT include X in its own localDefs → A's import
// fails.
const bThin = file('b', [], [reexport('X', 'X', 'c')]);
const aThin = file('a', [], [named('X', 'X', 'b')]);
const thinFiles = [aThin, bThin, c];
const thinOut = finalize(
{ files: thinFiles, workspaceIndex: undefined },
defaultHooks(thinFiles),
);
expect(firstImport(thinOut, aThin.moduleScope)!.linkStatus).toBe('unresolved');
// Variant 2: B includes X in its localDefs (re-exports surfaced) → A resolves.
const bThick = file(
'b',
[def('def:c.X', 'Class', 'b.X')], // B surfaces X with its own qname
[reexport('X', 'X', 'c')],
);
const aThick = file('a', [], [named('X', 'X', 'b')]);
const thickFiles = [aThick, bThick, c];
const thickOut = finalize(
{ files: thickFiles, workspaceIndex: undefined },
defaultHooks(thickFiles),
);
expect(firstImport(thickOut, aThick.moduleScope)!.linkStatus).toBeUndefined();
expect(firstImport(thickOut, aThick.moduleScope)!.targetDefId).toBe('def:c.X');
});
});
describe('aliased + namespace imports', () => {
it('resolves an alias under its local name while preserving targetExportedName', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [], [aliased('Account', 'User', 'Account', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const edge = firstImport(out, a.moduleScope)!;
expect(edge.kind).toBe('alias');
expect(edge.localName).toBe('Account');
expect(edge.targetExportedName).toBe('User');
expect(edge.targetDefId).toBe('def:b.User');
});
it('records namespace imports with origin=namespace in bindings', () => {
// Provider emits a synthetic module-representing def so the namespace
// binding can anchor to a real SymbolDefinition.
const numpyFile = file('numpy.py', [
def('def:numpy', 'Namespace', 'numpy'),
def('def:numpy.array', 'Function', 'numpy.array'),
]);
const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]);
const files = [a, numpyFile];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const npEdge = firstImport(out, a.moduleScope)!;
expect(npEdge.kind).toBe('namespace');
expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope);
const bindings = bindingsFor(out, a.moduleScope, 'np');
expect(bindings.some((b) => b.origin === 'namespace')).toBe(true);
expect(bindings.find((b) => b.origin === 'namespace')!.def.nodeId).toBe('def:numpy');
});
it('links a namespace import to the module scope even when no module-def exists', () => {
// No synthetic def in target — the edge still resolves to the module
// scope, just without a `targetDefId`. Bindings materialization skips
// the binding (no def to anchor to), but the edge itself is linked.
const numpyFile = file('numpy.py', [def('def:numpy.array', 'Function', 'numpy.array')]);
const a = file('a', [], [namespace('np', 'numpy', 'numpy.py')]);
const files = [a, numpyFile];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const npEdge = firstImport(out, a.moduleScope)!;
expect(npEdge.kind).toBe('namespace');
expect(npEdge.linkStatus).toBeUndefined();
expect(npEdge.targetModuleScope).toBe(numpyFile.moduleScope);
expect(npEdge.targetDefId).toBeUndefined();
});
});
describe('module-scope binding materialization', () => {
it('lays down local defs with origin=local', () => {
const a = file('a', [def('def:a.X', 'Class', 'a.X')]);
const out = finalize({ files: [a], workspaceIndex: undefined }, defaultHooks([a]));
const bindings = bindingsFor(out, a.moduleScope, 'X');
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('local');
expect(bindings[0]!.def.nodeId).toBe('def:a.X');
});
it('layers imports on top of local defs via mergeBindings', () => {
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]);
const files = [a, b];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
const bindings = bindingsFor(out, a.moduleScope, 'User');
expect(bindings.length).toBe(2);
expect(bindings.some((br) => br.origin === 'local')).toBe(true);
expect(bindings.some((br) => br.origin === 'import')).toBe(true);
});
it('honors provider precedence: mergeBindings can drop existing bindings', () => {
// Provider decides imports win over locals (Python-ish precedence).
const b = file('b', [def('def:b.User', 'Class', 'b.User')]);
const a = file('a', [def('def:a.User', 'Class', 'a.User')], [named('User', 'User', 'b')]);
const files = [a, b];
const hooks: FinalizeHooks = {
...defaultHooks(files),
mergeBindings(_existing, incoming) {
// Replace existing with incoming — last-write-wins across tiers.
return incoming;
},
};
const out = finalize({ files, workspaceIndex: undefined }, hooks);
const bindings = bindingsFor(out, a.moduleScope, 'User');
// Only the last merged layer (the import) remains.
expect(bindings.length).toBe(1);
expect(bindings[0]!.origin).toBe('import');
});
});
describe('SCC-DAG exposure for parallelism', () => {
it('returns SCCs in reverse-topological order (leaves first)', () => {
// c ← b ← a (a imports b, b imports c, c has no imports)
const c = file('c', [def('def:c.C', 'Class', 'c.C')]);
const b = file('b', [def('def:b.B', 'Class', 'b.B')], [named('C', 'C', 'c')]);
const a = file('a', [def('def:a.A', 'Class', 'a.A')], [named('B', 'B', 'b')]);
const files = [a, b, c];
const out = finalize({ files, workspaceIndex: undefined }, defaultHooks(files));
// First SCC processed must be `c` (leaf), last must be `a`.
expect(out.sccs[0]!.files[0]).toBe('c');
expect(out.sccs[out.sccs.length - 1]!.files[0]).toBe('a');
});
});
});
@@ -0,0 +1,235 @@
/**
* Unit tests for `buildMethodDispatchIndex` / `MethodDispatchIndex`
* (RFC #909 Ring 2 SHARED #914).
*
* Covers: empty input, single-inheritance chain, diamond inheritance (caller-
* determined MRO order), interface-only dispatch, multiple implementors,
* dedup, first-write-wins, C3 vs BFS strategy parity (both honored verbatim),
* readonly surface + frozen output.
*/
import { describe, it, expect } from 'vitest';
import { buildMethodDispatchIndex, type MethodDispatchInput, type DefId } from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const input = (
owners: readonly DefId[],
mroByOwner: Record<DefId, readonly DefId[]>,
implementsByOwner: Record<DefId, readonly DefId[]> = {},
): MethodDispatchInput => ({
owners,
computeMro: (owner) => mroByOwner[owner] ?? [],
implementsOf: (owner) => implementsByOwner[owner] ?? [],
});
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('buildMethodDispatchIndex', () => {
describe('empty / degenerate inputs', () => {
it('builds an empty index from no owners', () => {
const idx = buildMethodDispatchIndex(input([], {}));
expect(idx.mroByOwnerDefId.size).toBe(0);
expect(idx.implsByInterfaceDefId.size).toBe(0);
expect(idx.mroFor('anything')).toEqual([]);
expect(idx.implementorsOf('anything')).toEqual([]);
});
it('indexes an owner with no parents and no interfaces', () => {
const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] }));
expect(idx.mroByOwnerDefId.size).toBe(1);
expect(idx.implsByInterfaceDefId.size).toBe(0);
expect(idx.mroFor('def:A')).toEqual([]);
});
});
describe('MRO materialization (single / multi inheritance)', () => {
it('records a single-inheritance chain verbatim from the callback', () => {
// A extends B extends C
const idx = buildMethodDispatchIndex(
input(['def:A', 'def:B', 'def:C'], {
'def:A': ['def:B', 'def:C'],
'def:B': ['def:C'],
'def:C': [],
}),
);
expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']);
expect(idx.mroFor('def:B')).toEqual(['def:C']);
expect(idx.mroFor('def:C')).toEqual([]);
});
it('records a C3 linearization verbatim (Python diamond)', () => {
// D(B, C) where B(A), C(A). Classical C3 keeps A last because the
// merge step defers A until both B and C have been emitted.
// Our index stores MRO excluding self: [B, C, A].
const idx = buildMethodDispatchIndex(
input(['def:D'], { 'def:D': ['def:B', 'def:C', 'def:A'] }),
);
expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:C', 'def:A']);
});
it('records a BFS linearization verbatim (Java-style first-wins)', () => {
// Same class hierarchy as the C3 case, but the BFS walker visits
// A before C via the B→A edge. Expected MRO differs from C3: [B, A, C].
// This test proves the materializer preserves whatever ordering the
// per-language `computeMro` callback produces — NOT that C3 and BFS
// produce identical output.
const idx = buildMethodDispatchIndex(
input(['def:D'], { 'def:D': ['def:B', 'def:A', 'def:C'] }),
);
expect(idx.mroFor('def:D')).toEqual(['def:B', 'def:A', 'def:C']);
});
it('records a Ruby-style kind-aware ancestry verbatim', () => {
// class C prepend P1 prepend P2; include M1 include M2
// ruby-mixin walk order (per callback): [P2, P1, M2, M1]
const idx = buildMethodDispatchIndex(
input(['def:C'], { 'def:C': ['def:P2', 'def:P1', 'def:M2', 'def:M1'] }),
);
expect(idx.mroFor('def:C')).toEqual(['def:P2', 'def:P1', 'def:M2', 'def:M1']);
});
it('records an empty chain for Rust qualified-syntax owners', () => {
// Rust: no auto-MRO; callback returns []
const idx = buildMethodDispatchIndex(input(['def:RustStruct'], { 'def:RustStruct': [] }));
expect(idx.mroFor('def:RustStruct')).toEqual([]);
});
});
describe('implements inversion', () => {
it('inverts a single class → interface mapping', () => {
const idx = buildMethodDispatchIndex(
input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:IFace'] }),
);
expect(idx.implementorsOf('def:IFace')).toEqual(['def:Impl']);
});
it('aggregates multiple classes implementing the same interface', () => {
const idx = buildMethodDispatchIndex(
input(
['def:A', 'def:B', 'def:C'],
{ 'def:A': [], 'def:B': [], 'def:C': [] },
{ 'def:A': ['def:I'], 'def:B': ['def:I'], 'def:C': ['def:J'] },
),
);
expect(idx.implementorsOf('def:I')).toEqual(['def:A', 'def:B']);
expect(idx.implementorsOf('def:J')).toEqual(['def:C']);
});
it('preserves iteration order of owners in each implementors bucket', () => {
const idx = buildMethodDispatchIndex(
input(
['def:Z', 'def:Y', 'def:X'],
{ 'def:Z': [], 'def:Y': [], 'def:X': [] },
{ 'def:Z': ['def:I'], 'def:Y': ['def:I'], 'def:X': ['def:I'] },
),
);
expect(idx.implementorsOf('def:I')).toEqual(['def:Z', 'def:Y', 'def:X']);
});
it('deduplicates repeated (interface, owner) pairs within a single callback call', () => {
// Caller may legally return the same interface twice (e.g., a class that
// both `implements IFace` and inherits from a parent that also does).
const idx = buildMethodDispatchIndex(
input(['def:Impl'], { 'def:Impl': [] }, { 'def:Impl': ['def:I', 'def:I', 'def:I'] }),
);
expect(idx.implementorsOf('def:I')).toEqual(['def:Impl']);
});
it('deduplicates when the same owner is listed in `owners` twice (first-write-wins)', () => {
// First-write-wins parity with sibling indexes; subsequent owner entries
// should not re-invoke `computeMro` for existing MRO, and should not
// create duplicate implementor entries.
//
// NOTE on `implementsOf` call count: the builder calls `implementsOf`
// ONCE PER OCCURRENCE of an owner in `input.owners`, not once per
// unique owner. Duplicate owners therefore re-invoke `implementsOf`;
// the dedup lives at the bucket layer (via `implsSeen`), not the
// callback layer. Callers with expensive `implementsOf` callbacks
// should dedupe `input.owners` upfront. This counter assertion pins
// that contract so a future refactor can't silently collapse the
// second call without updating the docstring.
let mroCalls = 0;
let implementsOfCalls = 0;
const impls: Record<DefId, readonly DefId[]> = { 'def:A': ['def:I'] };
const idx = buildMethodDispatchIndex({
owners: ['def:A', 'def:A'],
computeMro: (_) => {
mroCalls++;
return ['def:B'];
},
implementsOf: (o) => {
implementsOfCalls++;
return impls[o] ?? [];
},
});
expect(mroCalls).toBe(1); // MRO dedup is at the callback layer (first-write-wins)
expect(implementsOfCalls).toBe(2); // implementsOf fires per occurrence; dedup at bucket
expect(idx.mroFor('def:A')).toEqual(['def:B']);
expect(idx.implementorsOf('def:I')).toEqual(['def:A']);
});
});
describe('lookup miss / safety surface', () => {
it('returns a frozen empty array on MRO miss', () => {
const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] }));
const miss = idx.mroFor('def:Missing');
expect(miss).toEqual([]);
expect(() => (miss as unknown as DefId[]).push('x')).toThrow();
});
it('returns a frozen empty array on implementors miss', () => {
const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': [] }));
const miss = idx.implementorsOf('def:Missing');
expect(miss).toEqual([]);
expect(() => (miss as unknown as DefId[]).push('x')).toThrow();
});
it('freezes stored MRO arrays (readonly surface)', () => {
const idx = buildMethodDispatchIndex(input(['def:A'], { 'def:A': ['def:B'] }));
const chain = idx.mroFor('def:A');
expect(() => (chain as unknown as DefId[]).push('x')).toThrow();
});
it('freezes stored implementors arrays (readonly surface)', () => {
const idx = buildMethodDispatchIndex(
input(['def:A'], { 'def:A': [] }, { 'def:A': ['def:I'] }),
);
const impls = idx.implementorsOf('def:I');
expect(() => (impls as unknown as DefId[]).push('x')).toThrow();
});
it('isolates stored MRO from later mutation of the callback-returned array', () => {
const mutable = ['def:B', 'def:C'];
const idx = buildMethodDispatchIndex({
owners: ['def:A'],
computeMro: () => mutable,
implementsOf: () => [],
});
mutable.push('def:D');
expect(idx.mroFor('def:A')).toEqual(['def:B', 'def:C']);
});
});
describe('readonly surface', () => {
it('exposes `mroByOwnerDefId` as a read-only Map for direct iteration', () => {
const idx = buildMethodDispatchIndex(
input(['def:A', 'def:B'], { 'def:A': [], 'def:B': ['def:A'] }),
);
const owners = Array.from(idx.mroByOwnerDefId.keys()).sort();
expect(owners).toEqual(['def:A', 'def:B']);
});
it('exposes `implsByInterfaceDefId` as a read-only Map for direct iteration', () => {
const idx = buildMethodDispatchIndex(
input(
['def:A', 'def:B'],
{ 'def:A': [], 'def:B': [] },
{ 'def:A': ['def:I'], 'def:B': ['def:J'] },
),
);
const keys = Array.from(idx.implsByInterfaceDefId.keys()).sort();
expect(keys).toEqual(['def:I', 'def:J']);
});
});
});
@@ -0,0 +1,62 @@
/**
* Unit tests for `buildModuleScopeIndex` / `ModuleScopeIndex`
* (RFC #909 Ring 2 SHARED #913).
*/
import { describe, it, expect } from 'vitest';
import { buildModuleScopeIndex, type ModuleScopeEntry, type ScopeId } from 'gitnexus-shared';
const entry = (filePath: string, moduleScopeId: ScopeId): ModuleScopeEntry => ({
filePath,
moduleScopeId,
});
describe('buildModuleScopeIndex', () => {
it('builds an empty index from no entries', () => {
const idx = buildModuleScopeIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('src/app.ts')).toBeUndefined();
expect(idx.has('src/app.ts')).toBe(false);
});
it('round-trips a single entry', () => {
const idx = buildModuleScopeIndex([entry('src/app.ts', 'scope:src/app.ts#1:0-100:0:Module')]);
expect(idx.size).toBe(1);
expect(idx.has('src/app.ts')).toBe(true);
expect(idx.get('src/app.ts')).toBe('scope:src/app.ts#1:0-100:0:Module');
});
it('stores distinct files under their own scopes', () => {
const entries: ModuleScopeEntry[] = [
entry('src/a.ts', 'scope:a'),
entry('src/b.ts', 'scope:b'),
entry('src/c.ts', 'scope:c'),
];
const idx = buildModuleScopeIndex(entries);
expect(idx.size).toBe(3);
expect(idx.get('src/a.ts')).toBe('scope:a');
expect(idx.get('src/b.ts')).toBe('scope:b');
expect(idx.get('src/c.ts')).toBe('scope:c');
});
it('first-write-wins when the same filePath appears twice', () => {
const idx = buildModuleScopeIndex([
entry('src/app.ts', 'scope:first'),
entry('src/app.ts', 'scope:second'),
]);
expect(idx.size).toBe(1);
expect(idx.get('src/app.ts')).toBe('scope:first');
});
it('returns undefined for a missing filePath (no throw)', () => {
const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a')]);
expect(idx.get('src/missing.ts')).toBeUndefined();
expect(idx.has('src/missing.ts')).toBe(false);
});
it('exposes byFilePath as the underlying read-only Map', () => {
const idx = buildModuleScopeIndex([entry('src/a.ts', 'scope:a'), entry('src/b.ts', 'scope:b')]);
const paths = Array.from(idx.byFilePath.keys()).sort();
expect(paths).toEqual(['src/a.ts', 'src/b.ts']);
});
});
@@ -0,0 +1,209 @@
/**
* Unit tests for `buildPositionIndex` / `PositionIndex`
* (RFC #909 Ring 2 SHARED #912).
*
* Covers: empty input, single scope, nested scopes (innermost-wins),
* positions before/after all scopes, boundary positions (inclusive ends),
* multi-file isolation, and duplicate-scope-id dedup.
*/
import { describe, it, expect } from 'vitest';
import {
buildPositionIndex,
type Range,
type Scope,
type ScopeId,
type ScopeKind,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
startLine,
startCol,
endLine,
endCol,
});
const mkScope = (
id: ScopeId,
filePath: string,
kind: ScopeKind,
range: Range,
parent: ScopeId | null = null,
): Scope => ({
id,
parent,
kind,
range,
filePath,
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('buildPositionIndex', () => {
describe('empty / missing', () => {
it('returns undefined for any query on an empty index', () => {
const idx = buildPositionIndex([]);
expect(idx.size).toBe(0);
expect(idx.atPosition('src/any.ts', 1, 0)).toBeUndefined();
});
it('returns undefined for unindexed filePaths', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]);
expect(idx.atPosition('b.ts', 5, 0)).toBeUndefined();
});
it('returns undefined for positions before any scope in the file', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 0, 10, 0))]);
expect(idx.atPosition('a.ts', 1, 0)).toBeUndefined();
expect(idx.atPosition('a.ts', 4, 99)).toBeUndefined();
});
it('returns undefined for positions after all scopes in the file', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]);
expect(idx.atPosition('a.ts', 11, 0)).toBeUndefined();
expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined();
});
});
describe('single scope lookup', () => {
it('returns the scope id for a point inside its range', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 0))]);
expect(idx.atPosition('a.ts', 5, 4)).toBe('scope:m');
});
it('includes the start boundary', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(5, 2, 10, 0))]);
expect(idx.atPosition('a.ts', 5, 2)).toBe('scope:m');
expect(idx.atPosition('a.ts', 5, 1)).toBeUndefined();
});
it('includes the end boundary', () => {
const idx = buildPositionIndex([mkScope('scope:m', 'a.ts', 'Module', r(1, 0, 10, 5))]);
expect(idx.atPosition('a.ts', 10, 5)).toBe('scope:m');
expect(idx.atPosition('a.ts', 10, 6)).toBeUndefined();
});
});
describe('innermost-containing wins', () => {
it('picks the innermost of nested scopes', () => {
// Module[1..100] ⊃ Class[5..80] ⊃ Function[10..60] ⊃ Block[20..50]
const idx = buildPositionIndex([
mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)),
mkScope('scope:cls', 'a.ts', 'Class', r(5, 0, 80, 0), 'scope:mod'),
mkScope('scope:fn', 'a.ts', 'Function', r(10, 0, 60, 0), 'scope:cls'),
mkScope('scope:blk', 'a.ts', 'Block', r(20, 0, 50, 0), 'scope:fn'),
]);
expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:blk'); // deepest
expect(idx.atPosition('a.ts', 15, 0)).toBe('scope:fn'); // inside fn, outside blk
expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:cls'); // inside class body only
expect(idx.atPosition('a.ts', 2, 0)).toBe('scope:mod'); // module top
});
it('innermost wins when two scopes start at the same position', () => {
// Two scopes both start at line 5 col 0; outer ends at 50, inner at 20.
const idx = buildPositionIndex([
mkScope('scope:outer', 'a.ts', 'Module', r(5, 0, 50, 0)),
mkScope('scope:inner', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:outer'),
]);
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:inner'); // both contain; inner wins
expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:outer'); // only outer contains
});
it('innermost wins when scopes share an end position but differ in start', () => {
const idx = buildPositionIndex([
mkScope('scope:outer', 'a.ts', 'Module', r(1, 0, 50, 0)),
mkScope('scope:inner', 'a.ts', 'Function', r(30, 0, 50, 0), 'scope:outer'),
]);
expect(idx.atPosition('a.ts', 40, 0)).toBe('scope:inner');
expect(idx.atPosition('a.ts', 20, 0)).toBe('scope:outer');
});
it('returns the sibling whose range contains the query, not the other', () => {
// Two non-overlapping siblings under the same parent.
const idx = buildPositionIndex([
mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)),
mkScope('scope:a', 'a.ts', 'Function', r(5, 0, 20, 0), 'scope:mod'),
mkScope('scope:b', 'a.ts', 'Function', r(25, 0, 40, 0), 'scope:mod'),
]);
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a');
expect(idx.atPosition('a.ts', 30, 0)).toBe('scope:b');
expect(idx.atPosition('a.ts', 22, 0)).toBe('scope:mod'); // gap between siblings
});
it('returns the right (later-start) sibling when two siblings share a boundary point', () => {
// Legal touching-boundary scenario per ScopeTree's non-overlap rule:
// [5:0..10:0] and [10:0..15:0] meet at (10, 0) but do not overlap
// (rangesOverlap treats end == start as "touches, not overlaps").
// A query AT the shared point is contained by BOTH siblings; the
// innermost-wins comparator breaks the tie by start position ASC:
// the right sibling (starts at 10:0) is scanned first during the
// backward pass and wins. See `atPosition` JSDoc.
const idx = buildPositionIndex([
mkScope('scope:mod', 'a.ts', 'Module', r(1, 0, 100, 0)),
mkScope('scope:left', 'a.ts', 'Block', r(5, 0, 10, 0), 'scope:mod'),
mkScope('scope:right', 'a.ts', 'Block', r(10, 0, 15, 0), 'scope:mod'),
]);
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:right'); // shared boundary
expect(idx.atPosition('a.ts', 7, 0)).toBe('scope:left'); // inside left only
expect(idx.atPosition('a.ts', 12, 0)).toBe('scope:right'); // inside right only
});
});
describe('multi-file isolation', () => {
it('indexes each filePath independently — no cross-file hits', () => {
const idx = buildPositionIndex([
mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)),
mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)),
]);
expect(idx.atPosition('a.ts', 10, 0)).toBe('scope:a-mod');
expect(idx.atPosition('b.ts', 10, 0)).toBe('scope:b-mod');
});
it('counts all indexed scopes in `size`', () => {
const idx = buildPositionIndex([
mkScope('scope:a-mod', 'a.ts', 'Module', r(1, 0, 50, 0)),
mkScope('scope:a-fn', 'a.ts', 'Function', r(10, 0, 20, 0), 'scope:a-mod'),
mkScope('scope:b-mod', 'b.ts', 'Module', r(1, 0, 50, 0)),
]);
expect(idx.size).toBe(3);
});
});
describe('column handling on the same line', () => {
it('handles a single-line scope across columns', () => {
const idx = buildPositionIndex([
mkScope('scope:expr', 'a.ts', 'Expression', r(5, 10, 5, 20)),
]);
expect(idx.atPosition('a.ts', 5, 10)).toBe('scope:expr'); // start inclusive
expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:expr'); // middle
expect(idx.atPosition('a.ts', 5, 20)).toBe('scope:expr'); // end inclusive
expect(idx.atPosition('a.ts', 5, 9)).toBeUndefined();
expect(idx.atPosition('a.ts', 5, 21)).toBeUndefined();
});
it('handles nested scopes on the same line', () => {
const idx = buildPositionIndex([
mkScope('scope:outer', 'a.ts', 'Expression', r(5, 0, 5, 30)),
mkScope('scope:inner', 'a.ts', 'Expression', r(5, 10, 5, 20), 'scope:outer'),
]);
expect(idx.atPosition('a.ts', 5, 15)).toBe('scope:inner');
expect(idx.atPosition('a.ts', 5, 5)).toBe('scope:outer');
expect(idx.atPosition('a.ts', 5, 25)).toBe('scope:outer');
});
});
describe('robustness', () => {
it('deduplicates scopes with the same id', () => {
const s = mkScope('scope:dup', 'a.ts', 'Module', r(1, 0, 10, 0));
const idx = buildPositionIndex([s, s, s]);
expect(idx.size).toBe(1);
expect(idx.atPosition('a.ts', 5, 0)).toBe('scope:dup');
});
});
});
@@ -0,0 +1,125 @@
/**
* Unit tests for `buildQualifiedNameIndex` / `QualifiedNameIndex`
* (RFC #909 Ring 2 SHARED #913).
*
* Covers: per-kind accumulation, multi-def-per-qname (partial classes /
* overloads), skipping defs without a qualifiedName, duplicate-pair dedup,
* and empty-bucket iteration guarantee.
*/
import { describe, it, expect } from 'vitest';
import { buildQualifiedNameIndex, type SymbolDefinition } from 'gitnexus-shared';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:test',
filePath: 'src/test.ts',
type: 'Class',
...overrides,
});
describe('buildQualifiedNameIndex', () => {
it('builds an empty index from no defs', () => {
const idx = buildQualifiedNameIndex([]);
expect(idx.size).toBe(0);
expect(idx.get('anything')).toEqual([]);
expect(idx.has('anything')).toBe(false);
});
it('indexes a single qualified-named def', () => {
const def = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' });
const idx = buildQualifiedNameIndex([def]);
expect(idx.size).toBe(1);
expect(idx.has('app.User')).toBe(true);
expect(idx.get('app.User')).toEqual(['def:app.User']);
});
it('accumulates distinct DefIds under the same qualified name (partial classes)', () => {
// C# partial classes: same qname, different files/nodeIds
const a = makeDef({
nodeId: 'def:app.User:Core',
qualifiedName: 'app.User',
filePath: 'src/User.Core.cs',
});
const b = makeDef({
nodeId: 'def:app.User:Api',
qualifiedName: 'app.User',
filePath: 'src/User.Api.cs',
});
const idx = buildQualifiedNameIndex([a, b]);
expect(idx.get('app.User')).toEqual(['def:app.User:Core', 'def:app.User:Api']);
// Hit-path bucket is frozen just like the miss path — consumers cannot
// mutate the returned array.
expect(() => (idx.get('app.User') as unknown as string[]).push('x')).toThrow();
});
it('preserves input order in the bucket', () => {
const a = makeDef({ nodeId: 'def:a', qualifiedName: 'app.Foo' });
const b = makeDef({ nodeId: 'def:b', qualifiedName: 'app.Foo' });
const c = makeDef({ nodeId: 'def:c', qualifiedName: 'app.Foo' });
const idx = buildQualifiedNameIndex([c, a, b]);
expect(idx.get('app.Foo')).toEqual(['def:c', 'def:a', 'def:b']);
});
it('separates defs that share a simple name but differ in qualifiedName', () => {
const appUser = makeDef({ nodeId: 'def:app.User', qualifiedName: 'app.User' });
const adminUser = makeDef({ nodeId: 'def:admin.User', qualifiedName: 'admin.User' });
const idx = buildQualifiedNameIndex([appUser, adminUser]);
expect(idx.get('app.User')).toEqual(['def:app.User']);
expect(idx.get('admin.User')).toEqual(['def:admin.User']);
});
it('skips defs that have no qualifiedName', () => {
const qnamed = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' });
const anon = makeDef({ nodeId: 'def:anon', qualifiedName: undefined });
const idx = buildQualifiedNameIndex([qnamed, anon]);
expect(idx.size).toBe(1);
expect(idx.get('app.Foo')).toEqual(['def:app.Foo']);
expect(idx.has('')).toBe(false);
});
it('skips defs with an empty-string qualifiedName', () => {
const empty = makeDef({ nodeId: 'def:empty', qualifiedName: '' });
const idx = buildQualifiedNameIndex([empty]);
expect(idx.size).toBe(0);
expect(idx.has('')).toBe(false);
});
it('deduplicates exact (qname, DefId) pairs when the same def appears twice in input', () => {
const def = makeDef({ nodeId: 'def:app.Foo', qualifiedName: 'app.Foo' });
const idx = buildQualifiedNameIndex([def, def]);
expect(idx.get('app.Foo')).toEqual(['def:app.Foo']); // not duplicated
});
it('indexes across heterogeneous kinds (Class + Method + Field may share qname convention)', () => {
const klass = makeDef({
nodeId: 'def:class:app.User',
type: 'Class',
qualifiedName: 'app.User',
});
const method = makeDef({
nodeId: 'def:method:app.User.save',
type: 'Method',
qualifiedName: 'app.User.save',
});
const idx = buildQualifiedNameIndex([klass, method]);
expect(idx.size).toBe(2);
expect(idx.get('app.User')).toEqual(['def:class:app.User']);
expect(idx.get('app.User.save')).toEqual(['def:method:app.User.save']);
});
it('returns a frozen empty array (not undefined) for misses so callers can iterate safely', () => {
const idx = buildQualifiedNameIndex([makeDef({ qualifiedName: 'app.Foo' })]);
const miss = idx.get('app.Missing');
expect(miss).toEqual([]);
expect(() => (miss as unknown as string[]).push('x')).toThrow();
});
it('exposes byQualifiedName as a read-only Map for direct iteration', () => {
const idx = buildQualifiedNameIndex([
makeDef({ nodeId: 'def:A', qualifiedName: 'app.A' }),
makeDef({ nodeId: 'def:B', qualifiedName: 'app.B' }),
]);
const names = Array.from(idx.byQualifiedName.keys()).sort();
expect(names).toEqual(['app.A', 'app.B']);
});
});
@@ -0,0 +1,746 @@
/**
* Unit tests for the scope-aware registries (RFC §4; Ring 2 SHARED #917).
*
* Tests are organized per RFC §4.2 step so a regression localizes to the
* step it broke:
*
* §4.2 Step 1 — lexical scope-chain walk + shadowing
* §4.2 Step 2 — type-binding / MRO walk (method/field registries)
* §4.2 Step 3 — owner-scoped contributor
* §4.2 Step 4 — kind filter + kind-match evidence
* §4.2 Step 5 — arity filter
* §4.2 Step 6 — global-qualified fallback
* §4.2 Step 7 — rank + tie-break cascade
* §4.5 — lookupQualified helper
* §4.7 — invariants
*
* Corroborators (owner-match, unresolved-import cap, dynamic-unresolved)
* get their own sections.
*/
import { describe, it, expect } from 'vitest';
import {
buildClassRegistry,
buildFieldRegistry,
buildMethodRegistry,
buildDefIndex,
buildMethodDispatchIndex,
buildModuleScopeIndex,
buildQualifiedNameIndex,
buildScopeTree,
lookupCore,
lookupQualified,
EvidenceWeights,
type BindingRef,
type ImportEdge,
type Range,
type RegistryContext,
type Resolution,
type Scope,
type ScopeId,
type ScopeKind,
type SymbolDefinition,
type TypeRef,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
startLine,
startCol,
endLine,
endCol,
});
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: overrides.filePath ?? 'x.ts',
type: overrides.type ?? 'Class',
...overrides,
});
const mkBinding = (
def: SymbolDefinition,
origin: BindingRef['origin'],
via?: ImportEdge,
): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) });
interface ScopeSpec {
id: ScopeId;
parent: ScopeId | null;
kind?: ScopeKind;
range?: Range;
filePath?: string;
bindings?: Record<string, readonly BindingRef[]>;
ownedDefs?: readonly SymbolDefinition[];
typeBindings?: Record<string, TypeRef>;
}
const mkScope = (s: ScopeSpec): Scope => ({
id: s.id,
parent: s.parent,
kind: s.kind ?? 'Module',
range: s.range ?? r(1, 0, 1000, 0),
filePath: s.filePath ?? 'x.ts',
bindings: new Map(Object.entries(s.bindings ?? {})),
ownedDefs: s.ownedDefs ?? [],
imports: [],
typeBindings: new Map(Object.entries(s.typeBindings ?? {})),
});
const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({
rawName,
declaredAtScope,
source: 'parameter-annotation',
});
function makeCtx(
scopes: Scope[],
defs: SymbolDefinition[],
opts: {
mro?: Record<string, readonly string[]>;
implsByInterface?: Record<string, readonly string[]>;
arity?: (
callsite: { arity: number },
def: SymbolDefinition,
) => 'compatible' | 'unknown' | 'incompatible';
} = {},
): RegistryContext {
const defIndex = buildDefIndex(defs);
const qualifiedNameIndex = buildQualifiedNameIndex(defs);
const moduleScopes = buildModuleScopeIndex(
scopes
.filter((s) => s.kind === 'Module')
.map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })),
);
const owners = Array.from(new Set(defs.map((d) => d.nodeId)));
const methodDispatch = buildMethodDispatchIndex({
owners,
computeMro: (owner) => opts.mro?.[owner] ?? [],
implementsOf: (owner) => {
const out: string[] = [];
for (const [iface, impls] of Object.entries(opts.implsByInterface ?? {})) {
if (impls.includes(owner)) out.push(iface);
}
return out;
},
});
return {
scopes: buildScopeTree(scopes),
defs: defIndex,
qualifiedNames: qualifiedNameIndex,
moduleScopes,
methodDispatch,
providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {},
};
}
const evidenceOfKind = (res: Resolution, kind: string) => res.evidence.find((e) => e.kind === kind);
// ─── §4.2 Step 1 — lexical scope-chain walk + shadowing ────────────────────
describe('Step 1: lexical scope-chain walk', () => {
it('finds a class declared at the start scope with origin=local', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'local')] },
});
const ctx = makeCtx([mod], [userClass]);
const registry = buildClassRegistry(ctx);
const results = registry.lookup('User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(userClass);
expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local);
});
it('walks parent scopes when the name is not bound at the start scope', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'import')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
});
const ctx = makeCtx([mod, fn], [userClass]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
expect(results[0]!.def).toBe(userClass);
const scopeChain = evidenceOfKind(results[0]!, 'scope-chain');
expect(scopeChain?.weight).toBe(EvidenceWeights.scopeChainPerDepth * 1);
});
it('enforces hard shadow: outer bindings are ignored once a name is bound at an inner scope', () => {
const outerClass = mkDef({ nodeId: 'def:outer', type: 'Class' });
const innerVar = mkDef({ nodeId: 'def:inner', type: 'Variable' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(outerClass, 'local')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
bindings: { User: [mkBinding(innerVar, 'local')] },
});
const ctx = makeCtx([mod, fn], [outerClass, innerVar]);
// Inner binding is a Variable (not a Class) → class registry returns empty.
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
expect(results).toEqual([]);
});
it('emits origin=import evidence when the binding is imported', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(userClass, 'import')] },
});
const ctx = makeCtx([mod], [userClass]);
const res = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(evidenceOfKind(res[0]!, 'import')?.weight).toBe(EvidenceWeights.import);
});
});
// ─── §4.2 Step 5 — arity filter ────────────────────────────────────────────
describe('Step 5: arity filter', () => {
it('drops incompatible candidates when at least one compatible candidate exists', () => {
const save2 = mkDef({
nodeId: 'def:save-two',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 2,
});
const save1 = mkDef({
nodeId: 'def:save-one',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 1,
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(save2, 'local'), mkBinding(save1, 'local')] },
});
const ctx = makeCtx([mod], [save2, save1], {
arity: (callsite, def) => {
const count = def.parameterCount ?? 0;
if (count === callsite.arity) return 'compatible';
return 'incompatible';
},
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(results[0]!.def.nodeId).toBe('def:save-one');
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchCompatible,
);
});
it('keeps incompatible candidates when no compatible candidate exists (soft penalty)', () => {
const save3 = mkDef({
nodeId: 'def:save-three',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 3,
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(save3, 'local')] },
});
const ctx = makeCtx([mod], [save3], {
arity: () => 'incompatible',
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchIncompatible,
);
});
it('records arity=unknown when the provider is missing (neutral signal)', () => {
const m = mkDef({ nodeId: 'def:m', type: 'Method', qualifiedName: 'C.m' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { m: [mkBinding(m, 'local')] },
});
const ctx = makeCtx([mod], [m]); // no arity provider
const results = buildMethodRegistry(ctx).lookup('m', 'scope:m', {
callsite: { arity: 7 },
});
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchUnknown,
);
});
});
// ─── §4.2 Step 6 — global-qualified fallback ───────────────────────────────
describe('Step 6: global-qualified fallback', () => {
it('falls back to the qualified-name index when no lexical candidate is found', () => {
const cls = mkDef({ nodeId: 'def:app.User', qualifiedName: 'app.User', type: 'Class' });
const mod = mkScope({ id: 'scope:m', parent: null }); // no lexical binding
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('app.User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(cls);
expect(evidenceOfKind(results[0]!, 'global-qualified')?.weight).toBe(
EvidenceWeights.globalQualified,
);
});
it('does NOT consult the global index when a lexical hit exists (shadowing)', () => {
const localCls = mkDef({ nodeId: 'def:local', type: 'Class' });
const globalCls = mkDef({
nodeId: 'def:global',
qualifiedName: 'other.User',
type: 'Class',
});
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(localCls, 'local')] },
});
const ctx = makeCtx([mod], [localCls, globalCls]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(localCls);
});
it('does NOT apply the global fallback for non-dotted names', () => {
const cls = mkDef({ nodeId: 'def:x', qualifiedName: 'User', type: 'Class' });
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], [cls]);
// 'User' has no dot → no qname fallback.
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results).toEqual([]);
});
});
// ─── §4.2 Step 7 — tie-breaks ──────────────────────────────────────────────
describe('Step 7: tie-break cascade', () => {
it('inner scope shadows outer, yielding single result (hard-shadow baseline)', () => {
// Baseline: the hard-shadow rule in Step 1 means a near binding fully
// replaces the far one. No "confidence DESC" ordering to observe here
// because there is only one candidate — the far class never enters
// the result set. See the next test for true multi-candidate ranking.
const nearClass = mkDef({ nodeId: 'def:near', type: 'Class' });
const farClass = mkDef({ nodeId: 'def:far', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(farClass, 'local')] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(2, 0, 10, 0),
bindings: { User: [mkBinding(nearClass, 'local')] },
});
const ctx = makeCtx([mod, fn], [nearClass, farClass]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:f');
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(nearClass);
});
it('orders multiple same-scope candidates by confidence DESC', () => {
// Two candidates co-exist at the same scope, one with origin=local
// (weight 0.55) and one with origin=wildcard (weight 0.30). Both pass
// the Class kind filter; confidence DESC should sort local first.
const localClass = mkDef({ nodeId: 'def:local', type: 'Class' });
const wildcardClass = mkDef({ nodeId: 'def:wildcard', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: {
User: [mkBinding(wildcardClass, 'wildcard'), mkBinding(localClass, 'local')],
},
});
const ctx = makeCtx([mod], [localClass, wildcardClass]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results).toHaveLength(2);
expect(results[0]!.def).toBe(localClass); // local (0.55) > wildcard (0.30)
expect(results[1]!.def).toBe(wildcardClass);
expect(results[0]!.confidence).toBeGreaterThan(results[1]!.confidence);
});
it('breaks ties by DefId.localeCompare when all secondary keys are equal', () => {
const a = mkDef({ nodeId: 'def:aaa', type: 'Class' });
const b = mkDef({ nodeId: 'def:bbb', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(b, 'local'), mkBinding(a, 'local')] }, // reversed
});
const ctx = makeCtx([mod], [a, b]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
expect(results[0]!.def.nodeId).toBe('def:aaa');
expect(results[1]!.def.nodeId).toBe('def:bbb');
});
});
// ─── Corroborators: unresolved-import cap (per-signal) ─────────────────────
describe('unresolved-import cap (per-signal)', () => {
it('halves the import evidence weight when via.linkStatus is unresolved', () => {
const cls = mkDef({ nodeId: 'def:User', type: 'Class' });
const unresolvedEdge: ImportEdge = {
localName: 'User',
targetFile: null,
targetExportedName: 'User',
kind: 'named',
linkStatus: 'unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { User: [mkBinding(cls, 'import', unresolvedEdge)] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('User', 'scope:m');
const importEv = evidenceOfKind(results[0]!, 'import');
expect(importEv?.weight).toBe(
EvidenceWeights.import * EvidenceWeights.unlinkedImportMultiplier,
);
});
it('leaves arity & owner-match signals unaffected by the unresolved-import cap', () => {
const m = mkDef({
nodeId: 'def:m',
type: 'Method',
qualifiedName: 'User.save',
parameterCount: 1,
});
const unresolved: ImportEdge = {
localName: 'save',
targetFile: null,
targetExportedName: 'save',
kind: 'named',
linkStatus: 'unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(m, 'import', unresolved)] },
});
const ctx = makeCtx([mod], [m], {
arity: () => 'compatible',
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
callsite: { arity: 1 },
});
expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe(
EvidenceWeights.arityMatchCompatible,
);
});
});
// ─── Corroborators: dynamic-unresolved degraded signal ─────────────────────
describe('dynamic-unresolved passthrough', () => {
it('emits a degraded dynamic-import-unresolved signal for dynamic edges', () => {
const cls = mkDef({ nodeId: 'def:X', type: 'Class' });
const dynEdge: ImportEdge = {
localName: 'X',
targetFile: null,
targetExportedName: '',
kind: 'dynamic-unresolved',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'import', dynEdge)] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(evidenceOfKind(results[0]!, 'dynamic-import-unresolved')?.weight).toBe(
EvidenceWeights.dynamicImportUnresolved,
);
});
});
// ─── lookupQualified helper (§4.5) ─────────────────────────────────────────
describe('lookupQualified (§4.5)', () => {
it('filters by acceptedKinds', () => {
const cls = mkDef({ nodeId: 'def:c', qualifiedName: 'app.User', type: 'Class' });
const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' });
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [cls, fn]);
const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx);
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(cls);
});
it('returns empty for unknown qualified names', () => {
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], []);
expect(lookupQualified('app.Ghost', { acceptedKinds: ['Class'] }, ctx)).toEqual([]);
});
it('orders multiple partial-class defs deterministically by defId', () => {
const a = mkDef({ nodeId: 'def:aaa', qualifiedName: 'app.User', type: 'Class' });
const b = mkDef({ nodeId: 'def:bbb', qualifiedName: 'app.User', type: 'Class' });
const ctx = makeCtx([mkScope({ id: 'scope:m', parent: null })], [b, a]);
const results = lookupQualified('app.User', { acceptedKinds: ['Class'] }, ctx);
expect(results.map((r) => r.def.nodeId)).toEqual(['def:aaa', 'def:bbb']);
});
});
// ─── lookupCore with owner-scoped contributor (Step 3) ─────────────────────
describe('Step 3: owner-scoped contributor', () => {
it('merges contributor hits as origin=local at the receiver scope', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], [userClass, saveMethod]);
const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', {
ownerScopedContributor: {
ownerDefId: 'def:User',
byName: (n) => (n === 'save' ? [saveMethod] : []),
},
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
expect(evidenceOfKind(results[0]!, 'local')?.weight).toBe(EvidenceWeights.local);
expect(evidenceOfKind(results[0]!, 'owner-match')?.weight).toBe(EvidenceWeights.ownerMatch);
});
});
// ─── Step 2: type-binding / MRO walk ───────────────────────────────────────
describe('Step 2: type-binding + MRO walk', () => {
it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const ctx = makeCtx([callScope], [userClass, saveMethod]);
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
const typeBinding = evidenceOfKind(results[0]!, 'type-binding');
expect(typeBinding?.weight).toBe(EvidenceWeights.typeBindingByMroDepth[0]);
});
it('demotes Step-2-only candidates to tieBreakKey.origin=import (pins rank vs same-origin siblings)', () => {
// Two method defs named `impl`, both owned by the same interface and
// both reached ONLY via the Step 2 type-binding MRO walk (no lexical
// binding). Each candidate's `recordTypeBindingHit` path demotes its
// `tieBreakKey.origin` from the `ensureCandidate` default `'local'`
// to `'import'`. With both at equal confidence (owner-match + type-
// binding at depth 0), the tie-break cascade must fall through
// scope-depth / MRO-depth / origin (all equal) to DefId.localeCompare.
//
// If the demotion regressed (e.g., tieBreakKey.origin left as `'local'`),
// both candidates would still share the same origin and this test would
// pass by coincidence — so the test ALSO asserts `signals.origin` is
// absent from the evidence list (no false where-found weight emitted),
// which is the strongest observable invariant the demotion guarantees.
const iface = mkDef({
nodeId: 'def:Iface',
type: 'Interface',
qualifiedName: 'Iface',
});
const implA = mkDef({
nodeId: 'def:aaa.impl',
type: 'Method',
qualifiedName: 'Iface.impl',
ownerId: 'def:Iface',
});
const implB = mkDef({
nodeId: 'def:bbb.impl',
type: 'Method',
qualifiedName: 'Iface.impl',
ownerId: 'def:Iface',
});
const scope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { x: typeRef('Iface', 'scope:call') },
});
const ctx = makeCtx([scope], [iface, implA, implB]);
const results = buildMethodRegistry(ctx).lookup('impl', 'scope:call', {
explicitReceiver: { name: 'x' },
});
expect(results).toHaveLength(2);
// DefId.localeCompare: 'def:aaa.impl' < 'def:bbb.impl'.
expect(results[0]!.def).toBe(implA);
expect(results[1]!.def).toBe(implB);
// Demotion invariant: Step-2-only candidates have no `signals.origin`,
// so composeEvidence never emits a where-found signal for them.
for (const res of results) {
expect(evidenceOfKind(res, 'local')).toBeUndefined();
expect(evidenceOfKind(res, 'import')).toBeUndefined();
expect(evidenceOfKind(res, 'type-binding')).toBeDefined();
}
});
it('walks up the MRO when the method is declared on an ancestor', () => {
const baseClass = mkDef({ nodeId: 'def:Base', type: 'Class', qualifiedName: 'Base' });
const derivedClass = mkDef({ nodeId: 'def:Derived', type: 'Class', qualifiedName: 'Derived' });
const saveOnBase = mkDef({
nodeId: 'def:Base.save',
type: 'Method',
qualifiedName: 'Base.save',
ownerId: 'def:Base',
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { d: typeRef('Derived', 'scope:call') },
});
const ctx = makeCtx([callScope], [baseClass, derivedClass, saveOnBase], {
mro: { 'def:Derived': ['def:Base'] },
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'd' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveOnBase);
// MRO depth for Base when receiver is Derived = 1.
expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe(
EvidenceWeights.typeBindingByMroDepth[1],
);
});
});
// ─── §4.7 invariants ──────────────────────────────────────────────────────
describe('§4.7 invariants', () => {
it('Resolution has confidence per-candidate (not per-tier)', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(typeof results[0]!.confidence).toBe('number');
expect(results[0]!.confidence).toBeGreaterThan(0);
expect(results[0]!.confidence).toBeLessThanOrEqual(1);
});
it('Resolution confidence is capped at 1.0', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const dummyVia: ImportEdge = {
localName: 'X',
targetFile: 't.ts',
targetExportedName: 'X',
kind: 'named',
};
const mod = mkScope({
id: 'scope:m',
parent: null,
// Same def bound via multiple origins — evidence may stack.
bindings: { X: [mkBinding(cls, 'local', dummyVia)] },
});
const ctx = makeCtx([mod], [cls], { arity: () => 'compatible' });
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(results[0]!.confidence).toBeLessThanOrEqual(1);
});
it('kind-match evidence is always present (weight 0) for debuggability', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(evidenceOfKind(results[0]!, 'kind-match')).toBeDefined();
expect(evidenceOfKind(results[0]!, 'kind-match')!.weight).toBe(0);
});
it('caller can read [0] for one-shot answers', () => {
const cls = mkDef({ nodeId: 'def:c', type: 'Class' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { X: [mkBinding(cls, 'local')] },
});
const ctx = makeCtx([mod], [cls]);
const results = buildClassRegistry(ctx).lookup('X', 'scope:m');
expect(results[0]!.def).toBe(cls);
});
});
// ─── Misses ───────────────────────────────────────────────────────────────
describe('misses', () => {
it('returns empty for an unknown name with no lexical or global hit', () => {
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], []);
expect(buildClassRegistry(ctx).lookup('Ghost', 'scope:m')).toEqual([]);
});
it('filters out candidates whose kind is not in acceptedKinds', () => {
const method = mkDef({ nodeId: 'def:m', type: 'Method' });
const mod = mkScope({
id: 'scope:m',
parent: null,
bindings: { save: [mkBinding(method, 'local')] },
});
const ctx = makeCtx([mod], [method]);
// ClassRegistry excludes Method kind → empty.
expect(buildClassRegistry(ctx).lookup('save', 'scope:m')).toEqual([]);
// FieldRegistry also excludes Method → empty.
expect(buildFieldRegistry(ctx).lookup('save', 'scope:m')).toEqual([]);
});
});
// ─── lookupCore direct invocation ─────────────────────────────────────────
describe('lookupCore direct invocation', () => {
it('accepts an empty params surface and returns empty for an unknown name', () => {
const mod = mkScope({ id: 'scope:m', parent: null });
const ctx = makeCtx([mod], []);
const results = lookupCore(
'Ghost',
'scope:m',
{
acceptedKinds: ['Class'],
useReceiverTypeBinding: false,
ownerScopedContributor: null,
},
ctx,
);
expect(results).toEqual([]);
});
});
@@ -0,0 +1,318 @@
/**
* Unit tests for `resolveTypeRef` (RFC #909 Ring 2 SHARED #916).
*
* Covers: local type, parameter/return annotation via scope walk, aliased
* import, re-exported type, shadowing by local variable, wildcard-origin
* ignored, qualified-name fallback (unique + ambiguous), broken scope chain,
* cycle guard.
*/
import { describe, it, expect } from 'vitest';
import {
resolveTypeRef,
buildDefIndex,
buildQualifiedNameIndex,
type ResolveTypeRefContext,
type ScopeLookup,
type BindingRef,
type ImportEdge,
type Scope,
type ScopeId,
type SymbolDefinition,
type TypeRef,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: overrides.filePath ?? 'src/test.ts',
type: overrides.type ?? 'Class',
...overrides,
});
const mkBinding = (
def: SymbolDefinition,
origin: BindingRef['origin'],
via?: ImportEdge,
): BindingRef => ({ def, origin, ...(via !== undefined ? { via } : {}) });
const mkScope = (
id: ScopeId,
parent: ScopeId | null,
bindings: Record<string, BindingRef[]> = {},
filePath = 'src/test.ts',
): Scope => ({
id,
parent,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath,
bindings: new Map(Object.entries(bindings)),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
const mkLookup = (scopes: Scope[]): ScopeLookup => {
const byId = new Map(scopes.map((s) => [s.id, s]));
return { getScope: (id) => byId.get(id) };
};
const mkCtx = (scopes: Scope[], defs: SymbolDefinition[]): ResolveTypeRefContext => ({
scopes: mkLookup(scopes),
defIndex: buildDefIndex(defs),
qualifiedNameIndex: buildQualifiedNameIndex(defs),
});
const typeRef = (
rawName: string,
declaredAtScope: ScopeId,
source: TypeRef['source'] = 'parameter-annotation',
): TypeRef => ({ rawName, declaredAtScope, source });
// ─── Tests ─────────────────────────────────────────────────────────────────
describe('resolveTypeRef', () => {
describe('scope-chain walk', () => {
it('resolves a local type defined in the same scope', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(userClass, 'local')],
});
const ctx = mkCtx([moduleScope], [userClass]);
const result = resolveTypeRef(typeRef('User', 'scope:module'), ctx);
expect(result).toBe(userClass);
});
it('walks parent scopes when the name is not bound locally (return-annotation case)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(userClass, 'local')],
});
const functionScope = mkScope('scope:fn', 'scope:module');
const ctx = mkCtx([moduleScope, functionScope], [userClass]);
const result = resolveTypeRef(typeRef('User', 'scope:fn', 'return-annotation'), ctx);
expect(result).toBe(userClass);
});
it('returns the closest binding when the name is bound at multiple levels', () => {
const outerUser = mkDef({ nodeId: 'def:outer', type: 'Class' });
const innerUser = mkDef({ nodeId: 'def:inner', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(outerUser, 'local')],
});
const classScope = mkScope('scope:class', 'scope:module', {
User: [mkBinding(innerUser, 'local')],
});
const ctx = mkCtx([moduleScope, classScope], [outerUser, innerUser]);
const result = resolveTypeRef(typeRef('User', 'scope:class'), ctx);
expect(result).toBe(innerUser); // inner shadows outer
});
});
describe('import origins', () => {
it('resolves a plain named import', () => {
const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(userClass, 'import')],
});
const ctx = mkCtx([moduleScope], [userClass]);
expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(userClass);
});
it('resolves an aliased import under the alias (e.g., `import { User as Account }`)', () => {
// In `def save_user(user: Account)` where `Account` is an aliased import
// of `User`, we resolve `Account` to the underlying `User` class def.
const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' });
const importEdge: ImportEdge = {
localName: 'Account',
targetFile: 'models.ts',
targetExportedName: 'User',
targetDefId: 'def:User',
kind: 'alias',
};
const moduleScope = mkScope('scope:module', null, {
Account: [mkBinding(userClass, 'import', importEdge)],
});
const ctx = mkCtx([moduleScope], [userClass]);
expect(resolveTypeRef(typeRef('Account', 'scope:module'), ctx)).toBe(userClass);
});
it('returns null for a namespace-origin binding whose def is not a type kind', () => {
const numpyMod = mkDef({ nodeId: 'def:numpy-mod', type: 'Namespace' });
// A namespace binding must resolve to a type-kind def to satisfy strict
// mode. Here the binding is the namespace module itself — `Namespace`
// is intentionally NOT in `TYPE_KINDS` (see resolve-type-ref.ts), so
// the binding is treated as a shadowing non-type and we fail fast.
const moduleScope = mkScope('scope:module', null, {
np: [mkBinding(numpyMod, 'namespace')],
});
const ctx = mkCtx([moduleScope], [numpyMod]);
expect(resolveTypeRef(typeRef('np', 'scope:module'), ctx)).toBeNull();
});
it('resolves a re-exported type (`export { X } from `./y`)', () => {
const userClass = mkDef({ nodeId: 'def:User', filePath: 'y.ts', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(userClass, 'reexport')],
});
const ctx = mkCtx([moduleScope], [userClass]);
expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(userClass);
});
it('ignores wildcard origin — not in the strict set', () => {
const userClass = mkDef({ nodeId: 'def:User', filePath: 'models.ts', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(userClass, 'wildcard')],
});
const ctx = mkCtx([moduleScope], [userClass]);
// Binding exists but wildcard is not strict → return null (shadow).
expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBeNull();
});
it('prefers a strict-origin type binding over a non-strict binding at the same scope', () => {
const wildcardClass = mkDef({ nodeId: 'def:wild', type: 'Class' });
const importClass = mkDef({ nodeId: 'def:import', type: 'Class' });
const moduleScope = mkScope('scope:module', null, {
// The strict-origin binding wins regardless of input order.
User: [mkBinding(wildcardClass, 'wildcard'), mkBinding(importClass, 'import')],
});
const ctx = mkCtx([moduleScope], [wildcardClass, importClass]);
expect(resolveTypeRef(typeRef('User', 'scope:module'), ctx)).toBe(importClass);
});
});
describe('shadowing (non-type binding fails fast)', () => {
it('returns null when a local variable shadows an outer imported type', () => {
// Outer module has `import { User }`; inner function declares `User = 5`.
// Per RFC §4.6, the local non-type shadows; strict resolver returns null.
const importedUser = mkDef({ nodeId: 'def:imp', type: 'Class' });
const localUser = mkDef({ nodeId: 'def:local', type: 'Variable' });
const moduleScope = mkScope('scope:module', null, {
User: [mkBinding(importedUser, 'import')],
});
const functionScope = mkScope('scope:fn', 'scope:module', {
User: [mkBinding(localUser, 'local')],
});
const ctx = mkCtx([moduleScope, functionScope], [importedUser, localUser]);
expect(resolveTypeRef(typeRef('User', 'scope:fn'), ctx)).toBeNull();
});
it('returns null when the only binding at the declaration scope is a Method', () => {
const method = mkDef({ nodeId: 'def:m', type: 'Method' });
const scope = mkScope('scope:class', null, {
save: [mkBinding(method, 'local')],
});
const ctx = mkCtx([scope], [method]);
expect(resolveTypeRef(typeRef('save', 'scope:class'), ctx)).toBeNull();
});
it('does NOT fall through to the qualified-name index when shadowed', () => {
// Even if `app.User` is a unique type in the qualified-name index, a
// shadowing non-type binding at the declaration scope should short-circuit.
const shadowVar = mkDef({ nodeId: 'def:v', type: 'Variable' });
const globalType = mkDef({
nodeId: 'def:g',
qualifiedName: 'app.User',
type: 'Class',
});
const scope = mkScope('scope:s', null, {
'app.User': [mkBinding(shadowVar, 'local')],
});
const ctx = mkCtx([scope], [shadowVar, globalType]);
expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull();
});
});
describe('dotted qualified-name fallback', () => {
it('resolves a unique qualified name when no scope binding matches', () => {
const userClass = mkDef({
nodeId: 'def:appUser',
qualifiedName: 'app.models.User',
type: 'Class',
});
const scope = mkScope('scope:s', null); // no bindings for `app.models.User`
const ctx = mkCtx([scope], [userClass]);
expect(resolveTypeRef(typeRef('app.models.User', 'scope:s'), ctx)).toBe(userClass);
});
it('does NOT apply the qualified-name fallback for non-dotted names', () => {
const simple = mkDef({ nodeId: 'def:s', qualifiedName: 'User', type: 'Class' });
const scope = mkScope('scope:s', null);
const ctx = mkCtx([scope], [simple]);
// Even though `User` is in the qname index as `User`, the fallback only
// fires for dotted names (scope walk is the answer for simple names).
expect(resolveTypeRef(typeRef('User', 'scope:s'), ctx)).toBeNull();
});
it('returns null when the qualified name is ambiguous across type defs', () => {
const a = mkDef({ nodeId: 'def:a', qualifiedName: 'app.User', type: 'Class' });
const b = mkDef({ nodeId: 'def:b', qualifiedName: 'app.User', type: 'Class' });
const scope = mkScope('scope:s', null);
const ctx = mkCtx([scope], [a, b]);
expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull();
});
it('ignores non-type-kind hits and accepts the single type hit', () => {
const cls = mkDef({ nodeId: 'def:c', qualifiedName: 'app.User', type: 'Class' });
const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' });
const scope = mkScope('scope:s', null);
const ctx = mkCtx([scope], [cls, fn]);
expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBe(cls);
});
it('returns null when no qualified-name hit is a type kind', () => {
const fn = mkDef({ nodeId: 'def:f', qualifiedName: 'app.User', type: 'Function' });
const scope = mkScope('scope:s', null);
const ctx = mkCtx([scope], [fn]);
expect(resolveTypeRef(typeRef('app.User', 'scope:s'), ctx)).toBeNull();
});
});
describe('robustness', () => {
it('returns null for a missing type (no scope binding, no qname hit)', () => {
const scope = mkScope('scope:s', null);
const ctx = mkCtx([scope], []);
expect(resolveTypeRef(typeRef('User', 'scope:s'), ctx)).toBeNull();
});
it('returns null when the declaredAtScope id is not known to the lookup', () => {
const ctx = mkCtx([], []);
expect(resolveTypeRef(typeRef('User', 'scope:missing'), ctx)).toBeNull();
});
it('returns null when a parent pointer references an unknown scope (broken chain)', () => {
const functionScope = mkScope('scope:fn', 'scope:ghost');
const ctx = mkCtx([functionScope], []);
expect(resolveTypeRef(typeRef('User', 'scope:fn'), ctx)).toBeNull();
});
it('terminates cleanly on a cyclic parent chain (defensive guard)', () => {
// A well-formed scope tree is acyclic; construction bugs shouldn't hang.
const a: Scope = mkScope('scope:a', 'scope:b');
const b: Scope = mkScope('scope:b', 'scope:a');
const ctx = mkCtx([a, b], []);
expect(resolveTypeRef(typeRef('User', 'scope:a'), ctx)).toBeNull();
});
it('accepts all annotation source flavors uniformly', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class' });
const scope = mkScope('scope:s', null, {
User: [mkBinding(userClass, 'local')],
});
const ctx = mkCtx([scope], [userClass]);
for (const source of [
'annotation',
'parameter-annotation',
'return-annotation',
'self',
'assignment-inferred',
'constructor-inferred',
'receiver-propagated',
] as const) {
expect(resolveTypeRef(typeRef('User', 'scope:s', source), ctx)).toBe(userClass);
}
});
});
});
@@ -0,0 +1,81 @@
/**
* Unit tests for `makeScopeId` (RFC #909 Ring 2 SHARED #912).
*
* Covers canonical shape, determinism across calls, string interning,
* and that different inputs produce different ids.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { makeScopeId, clearScopeIdInternPool, type Range, type ScopeKind } from 'gitnexus-shared';
const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
startLine,
startCol,
endLine,
endCol,
});
describe('makeScopeId', () => {
beforeEach(() => {
clearScopeIdInternPool();
});
it('produces the canonical RFC §2.2 shape', () => {
const id = makeScopeId({ filePath: 'src/app.ts', range: r(1, 0, 100, 0), kind: 'Module' });
expect(id).toBe('scope:src/app.ts#1:0-100:0:Module');
});
it('encodes each ScopeKind verbatim in the id', () => {
const kinds: readonly ScopeKind[] = [
'Module',
'Namespace',
'Class',
'Function',
'Block',
'Expression',
];
for (const kind of kinds) {
const id = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind });
expect(id.endsWith(`:${kind}`)).toBe(true);
}
});
it('returns the SAME string reference for structurally identical inputs (interned)', () => {
const a = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' });
const b = makeScopeId({ filePath: 'src/a.ts', range: r(5, 4, 10, 2), kind: 'Function' });
expect(a).toBe(b);
// `Object.is` catches the same reference even for weird strings.
expect(Object.is(a, b)).toBe(true);
});
it('distinguishes ids that differ only by filePath', () => {
const a = makeScopeId({ filePath: 'src/a.ts', range: r(1, 0, 2, 0), kind: 'Module' });
const b = makeScopeId({ filePath: 'src/b.ts', range: r(1, 0, 2, 0), kind: 'Module' });
expect(a).not.toBe(b);
});
it('distinguishes ids that differ only by range', () => {
const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' });
const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 3, 0), kind: 'Function' });
expect(a).not.toBe(b);
});
it('distinguishes ids that differ only by kind', () => {
const a = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Function' });
const b = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Block' });
expect(a).not.toBe(b);
});
it('is safe to call repeatedly (pure)', () => {
const inputs = { filePath: 'f.ts', range: r(1, 0, 5, 0), kind: 'Function' as const };
const ids = Array.from({ length: 10 }, () => makeScopeId(inputs));
expect(new Set(ids).size).toBe(1);
});
it('clearScopeIdInternPool drops the intern pool without changing id shape', () => {
const before = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' });
clearScopeIdInternPool();
const after = makeScopeId({ filePath: 'f.ts', range: r(1, 0, 2, 0), kind: 'Module' });
expect(after).toBe(before); // same string value, canonical-by-construction
});
});
@@ -0,0 +1,299 @@
/**
* Unit tests for `buildScopeTree` / `ScopeTree` (RFC #909 Ring 2 SHARED #912).
*
* Covers: empty tree, single-module tree, nested module→class→function,
* siblings, ancestors walk, children lookup, readonly surface, and all six
* invariant violations (non-module without parent, parent not found, parent
* doesn't contain child, siblings overlap, cross-file parent, duplicate id).
* Also confirms that a `ScopeTree` satisfies the `ScopeLookup` contract from
* #916 so `resolveTypeRef` can consume it directly.
*/
import { describe, it, expect } from 'vitest';
import {
buildScopeTree,
ScopeTreeInvariantError,
resolveTypeRef,
buildDefIndex,
buildQualifiedNameIndex,
type BindingRef,
type Range,
type Scope,
type ScopeId,
type ScopeKind,
type SymbolDefinition,
} from 'gitnexus-shared';
// ─── Test helpers ───────────────────────────────────────────────────────────
const r = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
startLine,
startCol,
endLine,
endCol,
});
interface ScopeFixture {
id: ScopeId;
parent: ScopeId | null;
kind: ScopeKind;
range: Range;
filePath?: string;
bindings?: Record<string, readonly BindingRef[]>;
}
const mkScope = (f: ScopeFixture): Scope => ({
id: f.id,
parent: f.parent,
kind: f.kind,
range: f.range,
filePath: f.filePath ?? 'src/test.ts',
bindings: new Map(Object.entries(f.bindings ?? {})),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('buildScopeTree', () => {
describe('shape + lookup', () => {
it('builds an empty tree from no scopes', () => {
const tree = buildScopeTree([]);
expect(tree.size).toBe(0);
expect(tree.has('scope:missing')).toBe(false);
expect(tree.getScope('scope:missing')).toBeUndefined();
expect(tree.getParent('scope:missing')).toBeUndefined();
expect(tree.getChildren('scope:missing')).toEqual([]);
expect(tree.getAncestors('scope:missing')).toEqual([]);
});
it('round-trips a single module scope', () => {
const m = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) });
const tree = buildScopeTree([m]);
expect(tree.size).toBe(1);
expect(tree.has('scope:m')).toBe(true);
expect(tree.getScope('scope:m')).toBe(m);
expect(tree.getParent('scope:m')).toBeUndefined();
expect(tree.getChildren('scope:m')).toEqual([]);
expect(tree.getAncestors('scope:m')).toEqual([]);
});
it('tracks parent/children for a nested module → class → function tree', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) });
const cls = mkScope({
id: 'scope:c',
parent: 'scope:m',
kind: 'Class',
range: r(5, 0, 40, 0),
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:c',
kind: 'Function',
range: r(10, 2, 30, 2),
});
const tree = buildScopeTree([mod, cls, fn]);
expect(tree.size).toBe(3);
expect(tree.getParent('scope:f')).toBe(cls);
expect(tree.getParent('scope:c')).toBe(mod);
expect(tree.getChildren('scope:m')).toEqual(['scope:c']);
expect(tree.getChildren('scope:c')).toEqual(['scope:f']);
expect(tree.getAncestors('scope:f')).toEqual(['scope:c', 'scope:m']);
expect(tree.getAncestors('scope:c')).toEqual(['scope:m']);
});
it('records multiple siblings in input order', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) });
const fn1 = mkScope({
id: 'scope:f1',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 10, 0),
});
const fn2 = mkScope({
id: 'scope:f2',
parent: 'scope:m',
kind: 'Function',
range: r(15, 0, 20, 0),
});
const fn3 = mkScope({
id: 'scope:f3',
parent: 'scope:m',
kind: 'Function',
range: r(25, 0, 30, 0),
});
const tree = buildScopeTree([mod, fn2, fn1, fn3]); // deliberately out of order
expect(tree.getChildren('scope:m')).toEqual(['scope:f2', 'scope:f1', 'scope:f3']);
});
});
describe('ScopeLookup compatibility (#916)', () => {
it('resolveTypeRef can consume a ScopeTree directly', () => {
const userClass: SymbolDefinition = {
nodeId: 'def:User',
filePath: 'src/test.ts',
type: 'Class',
};
const module = mkScope({
id: 'scope:m',
parent: null,
kind: 'Module',
range: r(1, 0, 100, 0),
bindings: { User: [{ def: userClass, origin: 'local' }] },
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 10, 0),
});
const tree = buildScopeTree([module, fn]);
const result = resolveTypeRef(
{ rawName: 'User', declaredAtScope: 'scope:f', source: 'parameter-annotation' },
{
scopes: tree,
defIndex: buildDefIndex([userClass]),
qualifiedNameIndex: buildQualifiedNameIndex([userClass]),
},
);
expect(result).toBe(userClass);
});
});
describe('readonly surface', () => {
it('freezes children arrays', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) });
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 10, 0),
});
const tree = buildScopeTree([mod, fn]);
const children = tree.getChildren('scope:m');
expect(() => (children as unknown as ScopeId[]).push('x')).toThrow();
});
it('freezes ancestor arrays', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 50, 0) });
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 10, 0),
});
const tree = buildScopeTree([mod, fn]);
const ancestors = tree.getAncestors('scope:f');
expect(() => (ancestors as unknown as ScopeId[]).push('x')).toThrow();
});
});
describe('invariant violations', () => {
it('throws when a non-Module scope has a null parent', () => {
const orphan = mkScope({
id: 'scope:f',
parent: null,
kind: 'Function',
range: r(1, 0, 5, 0),
});
expect(() => buildScopeTree([orphan])).toThrowError(ScopeTreeInvariantError);
expect(() => buildScopeTree([orphan])).toThrowError(/Module/);
});
it('throws when a parent pointer references a scope not in the tree', () => {
const fn = mkScope({
id: 'scope:f',
parent: 'scope:ghost',
kind: 'Function',
range: r(1, 0, 5, 0),
});
expect(() => buildScopeTree([fn])).toThrowError(ScopeTreeInvariantError);
});
it('throws when a parent range does not strictly contain a child range', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 50, 0), // extends beyond the module
});
expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError);
expect(() => buildScopeTree([mod, fn])).toThrowError(/strictly contain/i);
});
it('rejects child ranges identical to the parent (not strictly contained)', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(1, 0, 10, 0),
});
expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError);
});
it('throws when sibling ranges overlap', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) });
const a = mkScope({
id: 'scope:a',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 20, 0),
});
const b = mkScope({
id: 'scope:b',
parent: 'scope:m',
kind: 'Function',
range: r(15, 0, 30, 0), // overlaps with a
});
expect(() => buildScopeTree([mod, a, b])).toThrowError(ScopeTreeInvariantError);
expect(() => buildScopeTree([mod, a, b])).toThrowError(/overlap/i);
});
it('accepts sibling ranges that merely touch at the boundary', () => {
const mod = mkScope({ id: 'scope:m', parent: null, kind: 'Module', range: r(1, 0, 100, 0) });
const a = mkScope({
id: 'scope:a',
parent: 'scope:m',
kind: 'Block',
range: r(5, 0, 10, 0),
});
const b = mkScope({
id: 'scope:b',
parent: 'scope:m',
kind: 'Block',
range: r(10, 0, 15, 0), // touches a at 10:0 but does not overlap
});
expect(() => buildScopeTree([mod, a, b])).not.toThrow();
});
it('throws when parent and child live in different files', () => {
const mod = mkScope({
id: 'scope:m',
parent: null,
kind: 'Module',
range: r(1, 0, 100, 0),
filePath: 'a.ts',
});
const fn = mkScope({
id: 'scope:f',
parent: 'scope:m',
kind: 'Function',
range: r(5, 0, 10, 0),
filePath: 'b.ts',
});
expect(() => buildScopeTree([mod, fn])).toThrowError(ScopeTreeInvariantError);
expect(() => buildScopeTree([mod, fn])).toThrowError(/filePath/i);
});
it('throws on duplicate scope ids', () => {
const a = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
const b = mkScope({ id: 'scope:dup', parent: null, kind: 'Module', range: r(1, 0, 10, 0) });
expect(() => buildScopeTree([a, b])).toThrowError(ScopeTreeInvariantError);
});
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* Unit tests for `aggregateDiffs` (RFC #909 Ring 2 SHARED #918).
*
* Covers bucketing by language, parity math (incl. zero-resolved edge),
* evidence-kind breakdown, and stable sort order on the output rows.
*/
import { describe, it, expect } from 'vitest';
import {
aggregateDiffs,
SupportedLanguages,
type LanguageParityRow,
type ResolutionEvidence,
type ShadowAgreement,
type ShadowDiff,
} from 'gitnexus-shared';
// ─── Fixtures ───────────────────────────────────────────────────────────────
const FIXED_NOW = new Date('2026-04-18T12:00:00.000Z');
const makeDiff = (
agreement: ShadowAgreement,
evidenceKinds: readonly ResolutionEvidence['kind'][] = [],
): ShadowDiff => ({
callsite: { filePath: 'src/x.ts', line: 1, col: 0, calledName: 'foo' },
legacy: null,
newResult: null,
agreement,
evidenceDelta: evidenceKinds.map((kind) => ({ kind, weight: 0.3 })),
});
const entry = (language: SupportedLanguages, diff: ShadowDiff) => ({ language, diff });
const findRow = (
rows: readonly LanguageParityRow[],
language: SupportedLanguages,
): LanguageParityRow => {
const row = rows.find((r) => r.language === language);
if (!row) throw new Error(`no row for ${language}`);
return row;
};
// ─── Empty input ────────────────────────────────────────────────────────────
describe('aggregateDiffs — empty input', () => {
it('returns empty perLanguage, zeroed overall, generatedAt populated', () => {
const report = aggregateDiffs([], FIXED_NOW);
expect(report.perLanguage).toEqual([]);
expect(report.overall).toEqual({
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
parity: 0,
});
expect(report.generatedAt).toBe('2026-04-18T12:00:00.000Z');
});
});
// ─── Single language, single outcome ────────────────────────────────────────
describe('aggregateDiffs — single language', () => {
it('all both-agree → parity = 1.0', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
expect(report.perLanguage).toHaveLength(1);
const row = findRow(report.perLanguage, SupportedLanguages.Python);
expect(row).toMatchObject({
language: SupportedLanguages.Python,
totalCalls: 3,
bothAgree: 3,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
parity: 1,
});
});
it('mixed outcomes → parity excludes both-empty from denominator', () => {
const diffs = [
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.TypeScript, makeDiff('only-legacy', ['global-name'])),
entry(SupportedLanguages.TypeScript, makeDiff('only-new', ['local'])),
entry(SupportedLanguages.TypeScript, makeDiff('both-disagree', ['import'])),
entry(SupportedLanguages.TypeScript, makeDiff('both-empty')),
entry(SupportedLanguages.TypeScript, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.TypeScript);
expect(row.totalCalls).toBe(7);
expect(row.bothAgree).toBe(2);
expect(row.onlyLegacy).toBe(1);
expect(row.onlyNew).toBe(1);
expect(row.bothDisagree).toBe(1);
expect(row.bothEmpty).toBe(2);
// parity = bothAgree / (totalCalls - bothEmpty) = 2 / (7 - 2) = 0.4
expect(row.parity).toBeCloseTo(0.4, 10);
});
it('all both-empty → parity = 0 (not NaN)', () => {
const diffs = [
entry(SupportedLanguages.Java, makeDiff('both-empty')),
entry(SupportedLanguages.Java, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Java);
expect(row.totalCalls).toBe(2);
expect(row.bothEmpty).toBe(2);
expect(row.parity).toBe(0);
expect(Number.isNaN(row.parity)).toBe(false);
});
});
// ─── Multi-language ─────────────────────────────────────────────────────────
describe('aggregateDiffs — multiple languages', () => {
it('buckets rows by language and sums overall column-wise', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local'])),
entry(SupportedLanguages.Ruby, makeDiff('both-agree')),
entry(SupportedLanguages.Ruby, makeDiff('both-agree')),
entry(SupportedLanguages.Ruby, makeDiff('only-new', ['type-binding'])),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
expect(report.perLanguage).toHaveLength(2);
const python = findRow(report.perLanguage, SupportedLanguages.Python);
expect(python.totalCalls).toBe(2);
expect(python.bothAgree).toBe(1);
expect(python.bothDisagree).toBe(1);
expect(python.parity).toBe(0.5);
const ruby = findRow(report.perLanguage, SupportedLanguages.Ruby);
expect(ruby.totalCalls).toBe(3);
expect(ruby.bothAgree).toBe(2);
expect(ruby.onlyNew).toBe(1);
expect(ruby.parity).toBeCloseTo(2 / 3, 10);
expect(report.overall).toEqual({
totalCalls: 5,
bothAgree: 3,
onlyLegacy: 0,
onlyNew: 1,
bothDisagree: 1,
bothEmpty: 0,
parity: 3 / 5,
});
});
it('perLanguage rows are sorted alphabetically by language value for stable output', () => {
const diffs = [
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.C, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Java, makeDiff('both-agree')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const ordered = report.perLanguage.map((r) => r.language);
// Alphabetical by enum VALUE: 'c' < 'java' < 'python' < 'typescript'
expect(ordered).toEqual([
SupportedLanguages.C,
SupportedLanguages.Java,
SupportedLanguages.Python,
SupportedLanguages.TypeScript,
]);
});
});
// ─── Evidence breakdown ─────────────────────────────────────────────────────
describe('aggregateDiffs — evidence breakdown', () => {
it('counts divergence evidence kinds across non-agreeing rows only', () => {
const diffs = [
entry(SupportedLanguages.Go, makeDiff('both-disagree', ['import', 'owner-match'])),
entry(SupportedLanguages.Go, makeDiff('only-legacy', ['import', 'global-name'])),
entry(SupportedLanguages.Go, makeDiff('only-new', ['local'])),
// both-agree contributes 0 to evidence breakdown regardless of any attached evidence
entry(SupportedLanguages.Go, makeDiff('both-agree', ['import'])),
// both-empty also contributes 0
entry(SupportedLanguages.Go, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Go);
expect(Array.from(row.evidenceBreakdown.entries())).toEqual([
['global-name', 1],
['import', 2],
['local', 1],
['owner-match', 1],
]);
});
it('emits empty evidenceBreakdown when all calls agree or are empty', () => {
const diffs = [
entry(SupportedLanguages.Rust, makeDiff('both-agree')),
entry(SupportedLanguages.Rust, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Rust);
expect(row.evidenceBreakdown.size).toBe(0);
});
});
// ─── Determinism ────────────────────────────────────────────────────────────
describe('aggregateDiffs — determinism', () => {
it('injected `now` is used verbatim for generatedAt', () => {
const t = new Date('2030-01-01T00:00:00.000Z');
const report = aggregateDiffs([], t);
expect(report.generatedAt).toBe('2030-01-01T00:00:00.000Z');
});
it('same input produces byte-identical JSON (stable keys + sort)', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local', 'import'])),
entry(SupportedLanguages.Java, makeDiff('both-agree')),
];
const a = aggregateDiffs(diffs, FIXED_NOW);
const b = aggregateDiffs(diffs, FIXED_NOW);
// Round-trip through JSON to drop Map identity and force structural comparison.
const toJson = (r: typeof a): string =>
JSON.stringify(r, (_key, v: unknown) => (v instanceof Map ? Object.fromEntries(v) : v));
expect(toJson(a)).toBe(toJson(b));
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Unit tests for `diffResolutions` (RFC #909 Ring 2 SHARED #918).
*
* Pins the 5 `ShadowAgreement` outcomes and the symmetric-by-kind evidence-
* delta contract. Inputs are pure data fixtures — no real pipeline state.
*/
import { describe, it, expect } from 'vitest';
import {
diffResolutions,
type Resolution,
type ResolutionEvidence,
type ShadowCallsite,
type SymbolDefinition,
} from 'gitnexus-shared';
// ─── Fixtures ───────────────────────────────────────────────────────────────
const callsite: ShadowCallsite = {
filePath: 'src/app.ts',
line: 42,
col: 8,
calledName: 'save',
};
const makeDef = (nodeId: string): SymbolDefinition => ({
nodeId,
filePath: 'src/models.ts',
type: 'Method',
});
const makeEvidence = (kind: ResolutionEvidence['kind'], weight = 0.5): ResolutionEvidence => ({
kind,
weight,
});
const makeResolution = (
nodeId: string,
evidenceKinds: readonly ResolutionEvidence['kind'][],
): Resolution => ({
def: makeDef(nodeId),
confidence: Math.min(1, evidenceKinds.length * 0.3),
evidence: evidenceKinds.map((k) => makeEvidence(k)),
});
// ─── Agreement outcomes ─────────────────────────────────────────────────────
describe('diffResolutions — agreement outcomes', () => {
it("both arrays empty → 'both-empty' with no evidence delta", () => {
const result = diffResolutions(callsite, [], []);
expect(result.agreement).toBe('both-empty');
expect(result.evidenceDelta).toEqual([]);
expect(result.legacy).toBeNull();
expect(result.newResult).toBeNull();
});
it("identical top DefIds → 'both-agree' with empty evidence delta", () => {
const legacy = [makeResolution('def:User.save', ['local', 'owner-match'])];
const next = [makeResolution('def:User.save', ['local', 'kind-match'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-agree');
expect(result.evidenceDelta).toEqual([]);
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBe(next[0]);
});
it("legacy empty, new non-empty → 'only-new' with new's evidence as delta", () => {
const next = [makeResolution('def:User.save', ['local', 'owner-match'])];
const result = diffResolutions(callsite, [], next);
expect(result.agreement).toBe('only-new');
expect(result.evidenceDelta).toEqual(next[0].evidence);
expect(result.legacy).toBeNull();
expect(result.newResult).toBe(next[0]);
});
it("legacy non-empty, new empty → 'only-legacy' with legacy's evidence as delta", () => {
const legacy = [makeResolution('def:User.save', ['global-name'])];
const result = diffResolutions(callsite, legacy, []);
expect(result.agreement).toBe('only-legacy');
expect(result.evidenceDelta).toEqual(legacy[0].evidence);
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBeNull();
});
it("different top DefIds → 'both-disagree'", () => {
const legacy = [makeResolution('def:ModelA.save', ['global-name'])];
const next = [makeResolution('def:ModelB.save', ['local'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-disagree');
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBe(next[0]);
});
});
// ─── Evidence delta — symmetric difference by `kind` ────────────────────────
describe('diffResolutions — evidence delta (symmetric-by-kind)', () => {
it("'both-disagree' with disjoint evidence → delta contains both sides' kinds", () => {
const legacy = [makeResolution('def:A', ['global-name'])];
const next = [makeResolution('def:B', ['local', 'owner-match'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'global-name',
'local',
'owner-match',
]);
});
it("'both-disagree' with overlapping kinds → overlapping kinds removed from delta", () => {
const legacy = [makeResolution('def:A', ['local', 'scope-chain', 'global-name'])];
const next = [makeResolution('def:B', ['local', 'import', 'owner-match'])];
const result = diffResolutions(callsite, legacy, next);
// 'local' is on both sides → dropped
// Remaining: legacy-only ['scope-chain', 'global-name'], then new-only ['import', 'owner-match']
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'scope-chain',
'global-name',
'import',
'owner-match',
]);
});
it("'both-disagree' with fully overlapping kinds → empty evidence delta", () => {
const legacy = [makeResolution('def:A', ['local', 'owner-match'])];
const next = [makeResolution('def:B', ['owner-match', 'local'])];
const result = diffResolutions(callsite, legacy, next);
// Same kind set, different order → symmetric difference is empty
expect(result.evidenceDelta).toEqual([]);
expect(result.agreement).toBe('both-disagree'); // agreement still disagrees because nodeIds differ
});
it('differing weights on the same kind → NOT a delta (keyed on kind only)', () => {
const legacy = [
{
def: makeDef('def:A'),
confidence: 0.9,
evidence: [{ kind: 'local' as const, weight: 0.55 }],
},
];
const next = [
{
def: makeDef('def:B'),
confidence: 0.1,
evidence: [{ kind: 'local' as const, weight: 0.25 }],
},
];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-disagree');
expect(result.evidenceDelta).toEqual([]);
});
});
// ─── Metadata + ordering ────────────────────────────────────────────────────
describe('diffResolutions — metadata + ordering', () => {
it('ignores resolutions beyond index 0 (top match only)', () => {
const legacy = [
makeResolution('def:User.save', ['local']),
makeResolution('def:other', ['global-name']),
];
const next = [
makeResolution('def:User.save', ['local']),
// The 2nd entry is here to verify index-0 isolation — the only kind
// requirement is that it be a valid `ResolutionEvidence.kind` so the
// fixture is type-correct. `'global-name'` is a real kind that
// `diffResolutions` never treats specially.
makeResolution('def:yet-another', ['global-name']),
];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-agree');
});
it('preserves callsite verbatim', () => {
const result = diffResolutions(callsite, [], []);
expect(result.callsite).toBe(callsite);
});
it("'both-disagree' delta order: legacy-only first (input order), then new-only", () => {
const legacy = [makeResolution('def:A', ['owner-match', 'scope-chain', 'kind-match'])];
const next = [makeResolution('def:B', ['import', 'owner-match', 'arity-match'])];
const result = diffResolutions(callsite, legacy, next);
// 'owner-match' overlaps → dropped
// legacy-only in original order: ['scope-chain', 'kind-match']
// then new-only in original order: ['import', 'arity-match']
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'scope-chain',
'kind-match',
'import',
'arity-match',
]);
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js';
import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js';
import { type SymbolDefinition } from 'gitnexus-shared';
import {
createSemanticModel,
type SemanticModel,