From 84a89a42b7d72dd888359c2488828b00252bada0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 22 Apr 2026 12:03:51 +0100 Subject: [PATCH] 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 --- ARCHITECTURE.md | 24 +++- .../src/scope-resolution/parsed-file.ts | 12 ++ .../contract/scope-resolver.ts | 123 ++++++++++++++++-- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f54776eef..d190486ab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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_` 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 diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index ddd8afccd..50eb5a795 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index b7c4dfe18..d04afbc3d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -8,11 +8,19 @@ * * 1. Implement `ScopeResolver` in * `gitnexus/src/core/ingestion/languages//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 {