docs(semantic-model): document unified single-source-of-truth invariant (I9)

Add Contract Invariant I9 to the ScopeResolver contract and write the
single-source-of-truth + write/read phase contract into both the
SemanticModel file-head and ARCHITECTURE.md.

Three landing points so the rule is reachable from every entry:

  * contract/scope-resolver.ts — new I9 entry in the Contract
    Invariants list: scope-resolution passes consult SemanticModel
    exclusively for symbol-keyed lookups; WorkspaceResolutionIndex is
    reserved for Scope-valued maps. Documents the two-phase write
    (legacy parse + reconcileOwnership) and the narrowed-handle read
    posture. Calls out the reconciliation shim as transitional.

  * model/semantic-model.ts — new "Single-source-of-truth invariant"
    and "Write / read phase contract" sections in the file-head.
    Three ordered write phases (parse → reconcile → attachScopeIndexes),
    then frozen for readers.

  * ARCHITECTURE.md § "Semantic-model source of truth" — expanded
    subsection covering both invariants (ParsedFile = AST truth,
    SemanticModel = symbol truth), the write/read phase diagram, and
    the reconciliation-shim rationale.

No code change.
This commit is contained in:
Gergo Magyar 2026-04-22 17:03:30 +01:00
parent e3f4d8b1eb
commit ba94fdca91
3 changed files with 106 additions and 1 deletions

View file

@ -231,7 +231,35 @@ The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths
#### 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.
Two independent invariants.
**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution 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.
**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
```
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
─────────────────────────── phase boundary ───────────────────────────
Read phase: all resolution passes + MCP + HTTP + embeddings see
SemanticModel (read-only handle); writes are type-errors.
```
`runScopeResolution` narrows `MutableSemanticModel``SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
References: `semantic-model.ts` file-head (full write/read contract); `contract/scope-resolver.ts` Contract Invariant I9 (scope-resolution-side rule).
---

View file

@ -44,6 +44,45 @@
* direct `createSymbolTable()` caller (e.g. an isolated unit test) gets
* the pure, registry-free behavior no surprises, no hidden side
* effects.
*
* ## Single-source-of-truth invariant
*
* `SemanticModel` is the authoritative symbol store for the whole
* ingestion pipeline. Both the legacy Call-Resolution DAG and the
* new scope-resolution pipeline read symbol-keyed lookups from here
* exclusively no parallel owner-keyed, name-keyed, or file-keyed
* symbol indexes exist outside this module. The scope-resolution
* pipeline does carry a small `WorkspaceResolutionIndex` for
* `Scope`-valued maps (`classScopeByDefId`, `moduleScopeByFile`) that
* `SemanticModel` structurally cannot hold, but nothing else.
*
* ## Write / read phase contract
*
* Writes to the model happen in three clearly-ordered phases during a
* single ingestion run:
*
* 1. **Legacy parse phase** (`parsing-processor`) calls
* `symbols.add(...)` per extracted symbol, which fans out via
* the dispatch table into `types` / `methods` / `fields`.
* 2. **Scope-resolution reconciliation** (`reconcileOwnership` in
* `scope-resolution/pipeline/reconcile-ownership.ts`) registers
* any `parsed.localDefs[i]` with a scope-resolution-corrected
* `ownerId` that the legacy pass missed (Python class-body
* methods are the canonical case). Idempotent.
* 3. **Finalize-orchestrator** calls `attachScopeIndexes(...)` to
* stamp the materialized `ScopeResolutionIndexes` bundle onto
* `model.scopes`. One-shot; throws on a second call.
*
* After these three phases, the model is effectively frozen:
* - `attachScopeIndexes` applied `Object.freeze` to its bundle.
* - Downstream passes receive the narrowed `SemanticModel` reader
* handle (not `MutableSemanticModel`), so `.register()` /
* `.clear()` / `attachScopeIndexes()` are structurally absent.
*
* See `scope-resolution/contract/scope-resolver.ts` Contract
* Invariant I9 for the scope-resolution-side rule and
* `ARCHITECTURE.md` § "Semantic-model source of truth" for the
* overall architecture.
*/
import type { NodeLabel } from 'gitnexus-shared';

View file

@ -138,6 +138,44 @@
* `ScopeResolutionIndexes` is a read-guidance surface for
* consumers, NOT an immutability promise during the resolve phase.
*
* - **I9 `SemanticModel` is the single authoritative symbol store.**
* Every symbol-indexed lookup (key = `nodeId | simpleName |
* qualifiedName | filePath`) resolves through
* `SemanticModel.{symbols,types,methods,fields}`. Scope-resolution
* passes MUST NOT maintain parallel owner-keyed or name-keyed
* symbol indexes `WorkspaceResolutionIndex` is reserved for
* `Scope`-valued lookups that `SemanticModel` structurally cannot
* carry.
*
* The `runScopeResolution` orchestrator guarantees this invariant
* in two steps:
* 1. The legacy `parse` phase populates `SemanticModel` via
* `symbolTable.add(...)`. For languages whose extractor
* resolves `enclosingClassId` at parse time, class-body defs
* are correctly owner-keyed there.
* 2. The `reconcileOwnership` pass runs after
* `provider.populateOwners(parsed)` and registers any def in
* `parsed.localDefs[i]` with a corrected `ownerId` that the
* legacy pass missed (primarily Python class-body methods).
* Idempotent duplicates are skipped by `nodeId`.
*
* Contract for consumers: `model` is `MutableSemanticModel` only
* during those two write phases. Downstream passes receive a
* narrowed `SemanticModel` (read-only) handle. This is enforced by
* `runScopeResolution`'s type-level narrowing at the phase
* boundary.
*
* The dev-mode runtime validator (`validateOwnershipParity`)
* surfaces any drift between `parsed.localDefs` ownership and the
* registries via `onWarn` when
* `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
*
* This invariant is a **transitional shim**: the architectural
* end state is for every language's parse-time extractor to emit
* the correct `ownerId` directly, removing the need for
* reconciliation. Tracked as a follow-up; see ARCHITECTURE.md §
* "Semantic-model source of truth".
*
* ## Semantic-model source of truth
*
* `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`)