feat(shared,ingestion): extend LanguageProvider with scope-resolution hooks (#911, RFC #909 Ring 1) (#950)

Adds the 14 optional scope-resolution hooks from RFC #909 §5.2 to
`LanguageProviderConfig` plus the supporting input/output types in
`gitnexus-shared`. Contract-only; no runtime behavior changes.

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

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

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

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

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

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

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

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

Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
This commit is contained in:
Gergő Magyar 2026-04-18 14:54:51 +01:00 committed by GitHub
parent afc0a8b6c5
commit af1d278a7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 394 additions and 1 deletions

View file

@ -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)

View file

@ -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<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;
}
/**
* 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 ────────────────────────────────────────────────────────
/**

View file

@ -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. */