docs(scope-resolution): document I1-I8 invariants, source-of-truth, and same-graph guarantee

Promote contract knowledge that was implicit in code into the canonical docs
so future migrations and the next reviewer don't have to reverse-engineer it.

contract/scope-resolver.ts:
  * Migration cookbook lists every optional hook (was: only the two
    booleans), with one-line guidance per hook including when to enable
    `hoistTypeBindingsToModule`.
  * Contract Invariants I1-I7 are now spelled out in full (was: only
    I1/I3/I5 summarized with a pointer to a plan file). Added new I8
    "post-finalize hooks may mutate Scope.typeBindings and indexes.bindings;
    consumers must not freeze or snapshot before all post-finalize hooks
    have run".
  * New "Semantic-model source of truth" section: ParsedFile is the
    single semantic model; passes that need AST-level facts must reuse
    the orchestrator's treeCache rather than re-parse.
  * New "Same-graph guarantee" section: legacy DAG and scope-resolution
    emit indistinguishable edges (node identity, edge vocabulary,
    confidence). CI parity workflow enforces this.

gitnexus-shared/src/scope-resolution/parsed-file.ts:
  * Added "Source-of-truth invariant" pointer paragraph.

ARCHITECTURE.md (Coexistence section):
  * Updated migrated-language list (Python + C#).
  * Added "Same-graph guarantee" subsection.
  * Added "Semantic-model source of truth" subsection.
  * Filled in the ScopeResolver hook table with the five optional hooks
    that landed in this branch (unwrapCollectionAccessor,
    collapseMemberCallsByCallerTarget, populateNamespaceSiblings,
    hoistTypeBindingsToModule, fieldFallbackOnMethodLookup).
  * Added C# rows to the code-references table.

Verified:
  - npx tsc --noEmit                                 clean
  - C# + Python integration                          393/393 passing
This commit is contained in:
Gergo Magyar 2026-04-22 12:03:51 +01:00
parent f9956d89e2
commit 84a89a42b7
3 changed files with 146 additions and 13 deletions

View file

@ -215,10 +215,24 @@ Both hooks are optional on `LanguageProvider`. Ruby is the only current implemen
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
- **Migrated language** (currently: Python) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
- `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
#### Same-graph guarantee
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
#### Semantic-model source of truth
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single semantic model both paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
---
## Scope-Resolution Pipeline (RFC #909 Ring 3)
@ -260,6 +274,11 @@ Single interface a language implements to plug into the pipeline. Contract fully
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off |
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` |
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
### Per-language registration
@ -284,6 +303,9 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
### Performance notes

View file

@ -37,6 +37,18 @@
* `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile`
* by picking those four fields, so the finalize orchestrator threads
* ParsedFile through to the shared algorithm without shape-shifting.
*
* ## Source-of-truth invariant
*
* `ParsedFile` is the single semantic model consumed by both the legacy
* DAG (`gitnexus/src/core/ingestion/` outside `scope-resolution/`) and
* the scope-resolution pipeline (`gitnexus/src/core/ingestion/scope-resolution/`).
* Downstream passes MUST NOT build a parallel parse representation; if
* a pass needs AST-level facts that `ParsedFile` doesn't expose, it
* should reuse the orchestrator's `treeCache` rather than re-invoke
* `parser.parse(...)` on its own. See the
* `ScopeResolver` contract (`gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts`)
* for the full list of invariants downstream consumers rely on.
*/
import type { Scope, ScopeId } from './types.js';

View file

@ -8,11 +8,19 @@
*
* 1. Implement `ScopeResolver` in
* `gitnexus/src/core/ingestion/languages/<lang>/scope-resolver.ts`.
* Six required fields (language, languageProvider,
* Nine required fields (language, languageProvider,
* importEdgeReason, resolveImportTarget, mergeBindings,
* arityCompatibility, buildMro, populateOwners, isSuperReceiver)
* plus two optional booleans (propagatesReturnTypesAcrossImports,
* fieldFallbackOnMethodLookup).
* plus optional toggles / hooks:
* - propagatesReturnTypesAcrossImports (default true)
* - fieldFallbackOnMethodLookup (default true — turn OFF for
* statically-typed languages; the heuristic over-connects)
* - unwrapCollectionAccessor — property-style collection views
* - collapseMemberCallsByCallerTarget — one edge per caller/target
* - populateNamespaceSiblings — cross-file implicit visibility
* - hoistTypeBindingsToModule — enable ONLY when method return
* types are stored on the enclosing Module scope; most
* languages attach them to the class scope and leave this off
* 2. Export a thin entry point:
* `runYourLangScopeResolution(input) = runScopeResolution(input, yourScopeResolver)`.
* 3. Register the provider in
@ -59,18 +67,109 @@
*
* ## Contract Invariants the orchestrator depends on
*
* (See the plan for the full list. Highlights only here.)
* These are non-obvious behaviors that the orchestrator and the
* existing Python + C# resolvers depend on. Future implementers will
* break them silently if not documented.
*
* - **I1 — Phase 4 emission order is load-bearing.** `emitReceiverBoundCalls`
* runs FIRST (populates `handledSites`), then `emitFreeCallFallback`,
* then `emitReferencesViaLookup` (consumes `handledSites` as a skip
* set), then `emitImportEdges`. Reordering breaks same-name collision
* resolution: the shared lookup can mis-resolve `app_metrics.get_metrics()`
* to a same-named local function, and only the precise per-receiver
* pass running first prevents the wrong edge.
*
* - **I2 — `handledSites` semantics.** A site is added to
* `handledSites` IFF a `tryEmitEdge` call returned `true` for it.
* Sites a pass touched but couldn't resolve do NOT get marked —
* they still get a chance from the shared resolver. Exception:
* the free-call fallback marks the site unconditionally after
* attempting emission (even on dedup-collapse), because the
* per-(caller, target) collapse semantics require multiple call
* sites in the same caller body not produce multiple edges.
*
* - **I3 — `propagateImportedReturnTypes` mutation timing.** The
* pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
* `draftToScope`, NOT frozen). It MUST run AFTER `finalizeScopeModel`
* (so `indexes.bindings` is populated) and BEFORE
* `resolveReferenceSites` (so resolution sees the propagated types).
* The pass also re-runs `followChainPostFinalize` on every scope's
* typeBindings because scope-extractor's pass-4 already ran and
* missed any chain whose terminal lives in a foreign file.
*
* - **I4 — `emitReceiverBoundCalls` case order.** Cases are evaluated
* in this order; the FIRST that emits an edge wins:
* 1. super branch (`provider.isSuperReceiver(receiverName)`)
* 2. Case 0 compound (`receiverName` has `.` or `(`)
* 3. Case 1 namespace-receiver
* 4. Case 2 class-name receiver
* 5. Case 3 dotted typeBinding for namespace prefix
* 6. Case 3b chain-typebinding (compound resolver)
* 7. Case 4 simple typeBinding (MRO walk + findOwnedMember)
* Reordering or merging cases changes resolution semantics. The
* numbering is part of the contract — keep the comments.
*
* - **I1 — Phase 4 emission order is load-bearing.** Receiver-bound
* pass FIRST, then free-call fallback, then `emitReferencesViaLookup`.
* - **I3 — `propagateImportedReturnTypes` runs after finalize and
* before `resolveReferenceSites`.** It mutates non-frozen
* `Scope.typeBindings`. Do not freeze typeBindings in any
* downstream refactor.
* - **I5 — Pre-seeding `seen` from `referenceIndex` is forbidden.**
* The receiver-bound pass relies on this never happening.
* Earlier versions of the receiver-bound pass pre-populated `seen`
* to avoid double-emit. After Phase 4 was reordered, pre-seeding
* became actively harmful: it suppresses correct emissions for
* sites the shared resolver happened to resolve to a wrong target.
* The orchestrator MUST NOT pre-seed.
*
* Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
* - **I6 — `Scope.typeBindings` is mutable post-finalize.** `draftToScope`
* (in `scope-extractor.ts`) builds `typeBindings` as a plain
* `new Map(...)` — not frozen, intentionally. Passes below rely on
* this. Do NOT freeze `typeBindings` in any downstream refactor.
*
* - **I7 — `ScopeResolver` and `LanguageProvider` are distinct contracts.**
* Python and C# pass the SAME function reference through both
* interfaces where they share a hook name — no second copy of the
* logic. Rationale for not collapsing them: lifecycles differ
* (parsing-side runs once per file at extract time, emit-side runs
* once per workspace at resolve time), and merging would create a
* god-interface that complicates future migrations.
*
* - **I8 — Post-finalize hooks may mutate `Scope.typeBindings` and
* `indexes.bindings`.** `propagateImportedReturnTypes` and
* `populateNamespaceSiblings` both write to these structures via
* `as Map<...>` casts through `ReadonlyMap` facades. Downstream
* consumers MUST NOT freeze or snapshot these maps before all
* post-finalize hooks have run. The `ReadonlyMap<...>` type on
* `ScopeResolutionIndexes` is a read-guidance surface for
* consumers, NOT an immutability promise during the resolve phase.
*
* ## Semantic-model source of truth
*
* `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`)
* is the single semantic model consumed by both the legacy DAG and the
* scope-resolution pipeline. Scope-resolution passes MUST NOT build a
* parallel parse representation; if a pass needs AST-level facts that
* `ParsedFile` doesn't expose, it should reuse the orchestrator's
* `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than
* re-invoke `parser.parse(...)` on its own.
*
* ## Same-graph guarantee
*
* Edges emitted by `runScopeResolution` and edges emitted by the legacy
* DAG are indistinguishable to downstream consumers:
* - Node identity: same `generateId(...)` helper, same qualified-name
* keyspace, same File/Folder/Method/Class node labels.
* - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' |
* 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both
* paths emit the same reasons (see
* `gitnexus/src/core/ingestion/call-processor.ts` for the legacy
* emitter and `passes/receiver-bound-calls.ts` /
* `passes/free-call-fallback.ts` for the scope-resolution emitters).
* - Overload disambiguation: both paths use
* `generateId('Method', ...)` suffixed with `parameterTypes` when a
* method has overloads — see `graph-bridge/ids.ts`.
*
* The CI parity workflow (`.github/workflows/ci-scope-parity.yml`)
* runs both paths on every migrated language's fixture corpus and
* fails if the graph outputs diverge.
*
* Plan that introduced most of these invariants:
* `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
*/
import type {