diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 8f9bbdb3b..5b1d67072 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -33,6 +33,7 @@ export type { ScopeKind, Range, Capture, + CaptureMatch, BindingRef, ImportEdge, TypeRef, @@ -43,6 +44,11 @@ export type { ReferenceIndex, LookupParams, RegistryContributor, + ParsedImport, + ParsedTypeBinding, + WorkspaceIndex, + ScopeTree, + Callsite, } from './scope-resolution/types.js'; // Evidence + tie-break constants (RFC Appendix A, Appendix B) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 3ee1763b4..84945dcd9 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -59,6 +59,150 @@ export interface Capture { 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>; + +// ─── 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; + } + /** + * 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; + +/** + * Scope tree handle consumed by parse-phase hooks (`bindingScopeFor`, + * `importOwningScope`) to navigate the in-progress scope tree. Opaque + * placeholder in Ring 1; concretely typed in Ring 2 SHARED (#912). + */ +export type ScopeTree = unknown; + +/** Call-site description passed to `arityCompatibility`. */ +export interface Callsite { + /** Number of arguments at the call site. */ + readonly arity: number; +} + // ─── §2.4 ImportEdge ──────────────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 3960c6a8a..2777c991f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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; + + // ══════════════════════════════════════════════════════════════════════════ + // 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_`). + * + * Default: undefined (language continues to use legacy DAG). + */ + readonly emitScopeCaptures?: ( + sourceText: string, + filePath: string, + ) => Promise; + + /** + * 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. */