Compare commits
15
Commits
v1.6.2
...
v1.6.3-rc.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b8c9f34d | ||
|
|
e944f90879 | ||
|
|
1bf9fb4ef1 | ||
|
|
a9a5e1c388 | ||
|
|
8cf9ae0e0d | ||
|
|
ac148612ab | ||
|
|
5d76dbcfa2 | ||
|
|
56e32b310b | ||
|
|
ac2012e5ed | ||
|
|
f73389eac3 | ||
|
|
22f0beb057 | ||
|
|
af1d278a7e | ||
|
|
afc0a8b6c5 | ||
|
|
d9da7d6692 | ||
|
|
131d411ae4 |
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+2
-2
@@ -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,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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user