From 0d1f4eea79e4f6bc9c5bdc6c5e032ecad0003802 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 1 Sep 2026 20:09:54 +0600 Subject: [PATCH 01/80] Establish explicit semantic phase results Keep parsed call syntax immutable while publishing generated default arguments as typechecker evidence. Split staged bindings and typechecker facts into explicit generation-owned results, remove SemanticInfo, and simplify interface and match evidence without semantic rediscovery. Preserve reset, diagnostics, flow, ownership, lowering, LSP, and fingerprint behavior. Result constructors initialize required phase maps; BaseExprType, ExpandedDefaultBinding, MatchCases, CallArgumentsOrSource, and RebuildTypedASTIndex centralize cross-phase invariants. --- COMPILER_GUIDELINES.md | 5 +- docs/compiler-framework/README.md | 585 ++++++++++++++++++ docs/compiler-framework/semantic-results.md | 403 ++++++++++++ internal/ir/hir/lower/lower_interface.go | 4 +- internal/ir/hir/lower/module_lower.go | 142 ++--- internal/ir/hir/lower/module_lower_test.go | 38 +- internal/lsp/completion.go | 18 +- internal/lsp/cursor.go | 27 +- internal/lsp/hover.go | 10 +- internal/lsp/workspace_test.go | 79 +++ internal/pipeline/pipeline.go | 16 +- internal/pipeline/pipeline_test.go | 14 +- internal/project/export_fingerprint.go | 14 +- internal/project/export_fingerprint_test.go | 63 +- internal/project/modules.go | 174 ++---- internal/project/modules_test.go | 99 ++- internal/semantics/binder/binder.go | 6 +- internal/semantics/binder/binder_test.go | 2 +- internal/semantics/bindingresult/result.go | 25 + internal/semantics/collector/collector.go | 8 +- .../semantics/collector/collector_test.go | 4 +- internal/semantics/consteval/consteval.go | 99 ++- .../semantics/consteval/consteval_test.go | 27 +- .../semantics/definiteinit/initialization.go | 8 +- .../definiteinit/initialization_test.go | 14 +- internal/semantics/flowresult/result.go | 44 +- internal/semantics/ownership/expr.go | 48 +- internal/semantics/ownership/ownership.go | 50 +- .../semantics/ownership/ownership_test.go | 10 +- internal/semantics/ownership/reference.go | 27 +- internal/semantics/resolver/resolver.go | 29 +- internal/semantics/resolver/resolver_test.go | 14 +- .../semantics/typechecker/assignability.go | 51 +- internal/semantics/typechecker/check_call.go | 157 +++-- internal/semantics/typechecker/check_expr.go | 41 +- internal/semantics/typechecker/check_fn.go | 9 +- internal/semantics/typechecker/check_stmt.go | 65 +- internal/semantics/typechecker/flow.go | 68 +- internal/semantics/typechecker/flow_test.go | 10 +- internal/semantics/typechecker/for_in_test.go | 26 +- internal/semantics/typechecker/typechecker.go | 17 +- .../semantics/typechecker/typechecker_test.go | 62 +- internal/semantics/typecheckresult/result.go | 180 ++++++ internal/semantics/typeinfo/syntax.go | 10 +- internal/semantics/typeinfo/types_test.go | 12 +- internal/semantics/usage/usage.go | 4 +- .../src/external.peep | 8 + .../import_default_parameters/src/main.peep | 2 +- 48 files changed, 2086 insertions(+), 742 deletions(-) create mode 100644 docs/compiler-framework/README.md create mode 100644 docs/compiler-framework/semantic-results.md create mode 100644 internal/semantics/bindingresult/result.go create mode 100644 internal/semantics/typecheckresult/result.go diff --git a/COMPILER_GUIDELINES.md b/COMPILER_GUIDELINES.md index 7c3de41a..4e5ae073 100644 --- a/COMPILER_GUIDELINES.md +++ b/COMPILER_GUIDELINES.md @@ -19,7 +19,10 @@ design, do not follow it silently. Report conflict and evidence so maintainer ca decide whether guideline or design must change. Current ownership, pointer, copy, and optional design lives in -[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). +[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). Planned work for +mechanically enforced phase contracts, traversal completeness, artifact validation, +and contributor change points lives in +[docs/compiler-framework/README.md](docs/compiler-framework/README.md). ## 1. Priorities diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md new file mode 100644 index 00000000..b51f08db --- /dev/null +++ b/docs/compiler-framework/README.md @@ -0,0 +1,585 @@ +# Compiler Framework + +This document defines planned framework work for making Peeper compiler changes +mechanical, auditable, and hard to complete incorrectly. Goal is not generic +compiler infrastructure. Goal is explicit contracts around Peeper's real phases so +adding syntax, semantics, analyses, or backends immediately exposes every required +change point. + +This is a roadmap. Sections marked **Current** describe code that exists on `main`. +Sections marked **Target** describe work still requiring implementation and review. +Mandatory policy remains in [`RULES.md`](../../RULES.md). Durable engineering +principles remain in [`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). +This document applies those rules to concrete compiler subsystems; it does not +replace them. + +## Objectives + +Framework work must establish these guarantees: + +1. Every phase has one named owner, explicit inputs, explicit output, documented + invariants, diagnostics, consumers, and invalidation rules. +2. Adding a node or changing child structure cannot silently omit traversal. +3. Every semantic phase makes an explicit handle, traverse, ignore, or reject + decision for every relevant node kind. +4. Invalid phase artifacts fail at their producing boundary, not in an unrelated + downstream phase or backend. +5. Later phases consume established semantic evidence instead of rediscovering it. +6. Control-flow consumers use canonical CFG topology and typed edges/sites instead + of inferring meaning from incidental block shape. Construct metadata is added + only when inspected consumers prove a missing shared invariant. +7. Naming, mangling, generated artifacts, and backend ABI naming each have one + purposeful owner. +8. Contributor tests fail when a required phase decision is missing. + +A fork should be able to replace syntax, selected semantic rules, runtime policy, +or backend details while retaining these safety contracts. Copyability is a useful +design pressure, not promise of a language-generator product. + +## Non-goals + +Framework work must not introduce: + +- generic pass manager hiding current scheduler or phase dependencies; +- compiler-wide artifact builder mixing AST, HIR, MIR, and backend policy; +- default visitor methods that silently ignore new node kinds; +- pass-through wrappers around existing canonical functions; +- duplicate result maps kept during migration; +- validators that repeat semantic analysis; +- source-shape rediscovery in HIR, MIR, ownership, or backend lowering; +- fake HIR, MIR, or backend artifacts created only for tests or examples; +- stable extension APIs before real independent consumers exist. + +A boundary earns its place only when it owns a phase result, lifetime, invariant, +policy, or independently reused operation. + +## Current framework kernel + +### Pipeline + +**Current.** Phase identity lives in `internal/phase/phase.go`. Project orchestration +lives in `internal/pipeline/pipeline.go`. + +```mermaid +flowchart TD + Setup[Setup compiler context] + Load[Load graph and parse modules] + Parse[Parsed module checkpoint] + Collect[Collect declarations] + Bind[Bind declaration types] + Resolve[Resolve names and scopes] + Const[Evaluate constants] + Type[Typecheck] + CFG[Build CFG] + Flow[Flow typing] + Init[Definite initialization] + Own[Ownership and cleanup] + Usage[Usage barrier] + HIR[Lower HIR and fold] + MIR[Lower MIR] + Backend[Emit backend IR] + Finalize[Project finalization] + + Setup --> Load --> Parse --> Collect --> Bind --> Resolve --> Const --> Type + Type --> CFG --> Flow --> Init --> Own --> Usage --> HIR --> MIR + MIR --> Backend --> Finalize +``` + +Canonical orchestration points: + +| Responsibility | Current owner | +| --- | --- | +| Run one project | `pipeline.Run` | +| Schedule ready modules | `advanceModulesThrough` | +| Choose next phase | `nextModulePhase` | +| Check import prerequisites | `moduleReadyForNextPhase` and `importPrerequisitePhase` | +| Execute one module phase | `advanceModulePhase` | +| Detect scheduler stalls | `requireScheduledModulesAtLeast` | +| Invalidate semantic dependents | `invalidateSemanticDependents` | +| Store one source unit and retained artifacts | `project.Module` | +| Store shared compilation state | `project.CompilerContext` | + +`Setup`, `Load`, and `Finalize` are project checkpoints. `Parsed` through `Backend` +are retained per-module checkpoints. `Usage` is a project barrier after all +scheduled modules reach ownership. Framework work must preserve distinction +between per-module transitions, dependency readiness, and project barriers. Do not +add a uniform `Pass.Run` abstraction that hides these differences. + +### Retained artifacts + +**Current.** `project.Module` retains artifacts across incremental compilation and +`resetToPhase` defines their lifetimes. + +| Artifact | Current field or owner | Producer | Main consumers | +| --- | --- | --- | --- | +| Parsed syntax | `Module.AST` | parser | semantic phases, CFG, HIR | +| Module symbols | `Module.ModuleScope` | collector and binder | resolver onward | +| Staged binding graph | `Module.Bindings` / `bindingresult.Result` | collector through typechecker | CFG, flow, ownership, HIR, LSP | +| Constant values and query cache | `Module.ConstValues` | const evaluation and later constant queries | fingerprinting, CFG, flow, HIR, MIR | +| Base typechecker evidence | `Module.Typechecking` / `typecheckresult.Result` | base typechecker | CFG, consteval, flow, definite-init, ownership, HIR | +| Typed AST index | `Module.TypedASTNodes` | pipeline after typecheck | CFG-sensitive phases | +| Control-flow graph | `Module.CFG` | CFG builder | flow, definite-init, ownership, MIR | +| Flow evidence | `Module.Flow` / `flowresult.Result` | flow typechecker | ownership, HIR, tooling | +| Ownership evidence | `Module.Ownership` / `ownershipresult.Result` | ownership | MIR | +| High-level IR | `Module.HIR` | HIR lowering plus typed expression folding | MIR, dumps/tooling | +| Mid-level IR | `Module.MIR` | MIR lowering | backend | +| Backend text | `Module.LLVMIR` | LLVM backend | build/link and dumps | + +`project.Module` is a source-unit aggregate. It is not itself a phase result. +Framework work should split mixed result ownership where useful without wrapping the +aggregate or forcing every artifact into a generic result interface. + +### Structural traversal + +**Current.** Reuse these APIs: + +| Representation | Canonical traversal | +| --- | --- | +| AST declarations | `ast.ForEachDecl` | +| AST nodes | `ast.Inspect` with node-owned `forEachChild` | +| HIR statements | `hir.InspectStmt` with statement-owned `forEachChild` | +| Shared expressions | `ir.InspectExpr` | +| Places and projection expressions | `ir.InspectPlace` | + +These APIs solve structural recursion. They do not solve exhaustive semantic +handling. A resolver or typechecker still needs a phase-specific decision for each +node kind because handling behavior differs by phase. + +MIR and CFG currently use explicit graph/block/instruction loops. Add canonical +walkers only after concrete consumers need identical traversal semantics. + +### Existing phase-owned results + +**Current.** Four semantic results have purposeful packages or direct owners: + +- `bindingresult.Result` owns block scopes, node-to-symbol bindings, method receiver/declaration indexes, and operation-function catalog over one staged symbol graph. Collector initializes it; collector, binder, resolver, and typechecker complete it; reset to `Parsed` discards it. +- `typecheckresult.Result` owns base expression types, effective call arguments, generated-default binding markers, implicit conversions, implicit call arguments, interface implementation slots, intrinsic dispatch, string concatenation classification, variant construction, base case tests, and match evidence for one base-typecheck generation. It also owns `CaseTest` and match evidence models. `typechecker.Check` publishes a fresh result; reset below `Typechecked` discards it. +- `flowresult.Result` owns flow-refined types, origins, payload access, flow-sensitive case tests, and variant-field evidence. Its case-test entries use the earlier `typecheckresult.CaseTest` model while remaining a distinct flow result map. +- `internal/semantics/ownershipresult` owns cleanup plans consumed by MIR. + +`project.SemanticInfo` has been removed. `Module.ConstValues` remains one mutable map combining finalized module constants with later query-cache entries; separating those lifetimes is remaining semantic-result migration target. + +### Existing validation + +**Current.** Validation is mostly embedded in producing or consuming phases: + +- parser and semantic phases emit source diagnostics; +- `cfg.Analyze` checks unreachable sites, constant non-loop conditions, and + return completeness without mutating finalized topology; +- pipeline validates program entrypoint and scheduler completion; +- `llvm.ValidateRuntimeSymbols` validates reserved runtime symbols and extern + ownership constraints; +- backend layout and typed emission helpers reject physical type mismatches. + +No canonical structural verifier currently exists for complete CFG, HIR, or MIR +artifacts. + +## Phase contract + +**Target.** Every phase must publish or document this contract: + +| Contract field | Required meaning | +| --- | --- | +| Owner | One package responsible for decision and output | +| Inputs | Exact artifacts and prerequisite guarantees consumed | +| Output | Explicit result, artifact mutation, or diagnostic-only effect | +| Invariants | Facts guaranteed when phase completes without internal error | +| Diagnostics | Codes, text, spans, ordering, deduplication, and source identity owned by phase | +| Consumers | Later phases allowed to depend on output | +| Invalidation | Earliest edit/checkpoint that discards output | +| Mutation and concurrency | State mutated, synchronization owner, and whether modules may run in parallel | +| Determinism | Output, diagnostics, fingerprints, and names that must not depend on scheduler order | +| Failure policy | User diagnostic, recoverable invalid artifact, or internal error | +| Verification | Focused tests and boundary validator proving contract | + +A phase may mutate a purposeful artifact when identity continuity requires it, such +as binding collected symbol objects in place. Contract must state that mutation; +it must not be hidden behind generic `Run` methods. + +### Proposed migration constraints + +These constraints require confirmation from Workstream 1 inventory before becoming +code or repository policy: + +1. Phase result models should contain inert phase data rather than orchestration. +2. Candidate dependency direction is from `project.Module` to result models, with + result models avoiding imports of scheduler/orchestration state. +3. One fact needs one producer and one canonical storage location. +4. Each migrated field should move with all consumers in one reviewable step when + feasible; if a larger migration cannot do that safely, approved plan must state + temporary state and removal gate explicitly. +5. No compatibility map, stale alias, or forwarding accessor may remain after a + migration step closes. +6. Existing flow and ownership results stay separate unless inspected ownership, + lifetime, and consumer evidence supports another boundary. +7. Backend physical layout remains backend-owned and never mutates semantic order. + +## Workstream 1: Separate phase-owned semantic results + +**Current.** Field inventory and approved ownership/lifetime decisions are tracked in [`semantic-results.md`](semantic-results.md). Completed migration slices extracted all base-typechecker evidence into `typecheckresult.Result` and the staged collection/binding/resolution graph into `bindingresult.Result`. `SemanticInfo` no longer exists. Remaining constant work must split finalized module values from mutable query cache without duplicate storage. + +For each field record: + +- producing phase and exact write sites; +- consuming phases and exact read sites; +- key identity (`NodeID`, `SymbolID`, CFG site, or declaration identity); +- reset checkpoint; +- incremental fingerprint dependency; +- whether field is base semantics, flow semantics, lowering evidence, or shared + symbol state. + +Then migrate one real owner at a time. Likely boundaries include resolver result and +typechecker result, but package names and shapes must follow inventory rather than +this document's guess. + +Acceptance criteria: + +- every migrated field has one producer and one storage location; +- `ResetModule` and `resetToPhase` discard result at correct checkpoint; +- semantic export fingerprints remain stable or intentionally change with tests; +- diagnostic codes, text, primary/secondary spans, module/source identity, + observable ordering, and deduplication remain unchanged unless an intentional + change has focused regression coverage; +- shared state remains race-free under parallel module scheduling; +- repeated runs preserve applicable fingerprints, diagnostics, generated names, + and HIR/MIR output; +- all call sites use new canonical result directly; +- no wrapper or duplicate compatibility map survives migration. + +## Workstream 2: Exhaustive node-handling contracts + +**Target.** Structural inspectors continue owning child traversal. Separate +phase-handling contracts make node-kind omissions visible. + +First experiment covers all production `ast.Stmt` implementations and an inspected +set of phases that dispatch on statements. Exact phase list and canonical kind +registry must be recorded before implementation. If experiment works without +excess boilerplate, extend same contract separately to declarations, expressions, +and type syntax. + +Each participating phase explicitly classifies each statement kind as: + +- **handle**: phase owns distinct semantics for node; +- **traverse**: phase only needs canonical child walk; +- **ignore**: node is intentionally irrelevant, with reason; +- **reject**: node is invalid at this phase boundary. + +Do not implement this with visitor base classes containing no-op defaults. Candidate +mechanisms must be evaluated against real AST statement family first: + +1. compile-time visitor interfaces requiring every method; +2. mechanically checked dispatch tables keyed by declared node kind; +3. focused completeness tests comparing declared kinds to phase decisions. + +Choose least boilerplate mechanism that makes omission fail compilation or normal +tests. + +Acceptance criteria: + +- adding one production-registered statement kind fails until every participating + phase updates; +- completeness source is same registry or sealed dispatch mechanism used by + production nodes, not test-only parallel list; +- adding child field fails traversal completeness test until `forEachChild` updates; +- recovery statements have explicit phase policy; +- intentional ignores are named and reviewable; +- structural recursion remains centralized in node-owning package. + +## Workstream 3: Canonical artifact validators + +**Target.** Add validators at real representation boundaries. Validator checks +published shape and evidence; it does not rerun semantic decisions. + +### AST boundary + +Validate only invariants parser promises under valid or recovered syntax, such as +stable node identity, source locations, and explicitly documented missing nodes. +Invalid user syntax remains parser diagnostics, not internal errors. + +### CFG boundary + +Verify: + +- entry, exit, block, and target ownership; +- one terminator per finalized block; +- successor/predecessor symmetry; +- valid edge kinds for terminator kind; +- valid `SiteID` block/index pairs; +- site predecessor/successor symmetry; +- lexical scope-exit chains; +- reachable flags consistent with entry traversal; +- construct descriptors reference blocks in same graph. + +### HIR boundary + +Verify: + +- source-backed nodes retain valid source identity; +- generated nodes obey generated identity contract; +- symbols and types exist in shared tables; +- places and expressions have compatible types; +- structured control has valid bodies and targets. + +Lowering conformance tests, not HIR structural validation, prove semantic evidence +maps into expected HIR shape. + +### MIR boundary + +Verify: + +- block IDs and branch targets exist in function; +- every block has one valid terminator; +- operand and result types match instruction contract; +- referenced symbols, static data, and type IDs exist; +- emitted cleanup instructions satisfy MIR-local instruction and control-flow shape; +- target-sized carriers are normalized before backend. + +Validate `ownershipresult.CleanupPlan` against CFG and HIR immediately before MIR +lowering. Post-lowering MIR validation cannot reconstruct consumed cleanup-plan +references and must not attempt to repeat ownership analysis. + +### Backend boundary + +Keep ABI/layout checks backend-owned. Shared validators must not duplicate LLVM +layout decisions. Backend validates physical type, pointee, alignment, calling +convention, and runtime symbol policy. + +Failure policy and acceptance: + +- invalid source produces user diagnostics; +- validator failure follows `RULES.md` section 12: return `error` when caller is + expected to handle validation failure; panic for violated internal invariants or + impossible IR states; +- internal artifact failures never become user diagnostics; +- package tests invoke validators directly; +- pipeline tests invoke validators immediately after production through an + explicit test/configured hook; +- production invocation policy is decided separately after cost is measured. + +## Workstream 4: Verify CFG structure before adding construct metadata + +**Target.** CFG remains canonical control-flow topology. Existing blocks, typed +edges, semantic sites, and exact `SiteID` values are current authority. Do not add +construct descriptors until an inspected consumer demonstrably infers semantic +roles from incidental `NodeID`, block order, or `BlockOrigin` shape. + +First step is an ownership table for every current topology query in flow typing, +definite initialization, ownership, and MIR. For each query record whether existing +edge/site APIs express required fact directly. If they do, keep them. If two or more +consumers need same missing structured-control fact, propose smallest immutable +descriptor owned by CFG construction. + +Condition and infinite loops on current `main` are initial verification corpus. +Range/sequence loops and `break`/`continue` are contingent on PR #124 or later +merged language work and must be re-audited against resulting code before shaping +public CFG evidence. + +Builder-local active target state remains construction state, not public CFG +evidence. Rename or restructure it only when touched by concrete behavior change; +do not create a public descriptor merely to mirror builder fields. + +Acceptance criteria: + +- every current CFG role query names exact source field/API it uses; +- CFG validator proves block, edge, site, and scope-exit consistency; +- current condition/infinite loops have valid, malformed, nested, and terminating + coverage; +- any new descriptor has at least two concrete consumers or protects one + non-obvious cross-phase invariant; +- consumers stop topology inference only where descriptor replaces inspected + duplicated logic; +- no descriptor is added when existing typed edges and sites already suffice. + +## Workstream 5: Naming and generated artifact subsystems + +**Target.** Separate naming policy from typed artifact construction. + +### Naming and mangling + +Language/linkage mangling owns: + +- extern link names; +- canonical entry `main`; +- module and dependency identity; +- callable kind and receiver identity; +- symbol instance suffixes; +- collision resistance and deterministic output. + +Current authorities include HIR lowering's callable/symbol naming, +`ir.SanitizeSymbolName`, `ir.StripSymbolInstance`, nominal module identity, and +backend-owned interface/type ABI symbols. Before moving anything, audit exact output +strings and all call sites. Backend symbols involving physical layout stay +backend-owned. + +### Generated artifacts + +Generated binding, identifier, assignment, projection, or control nodes belong to +phase-local artifact construction. A phase-local builder is allowed only when it +owns real lowering state and centralizes repeated invariants such as: + +- module and compiler context; +- symbol identity and canonical generated name; +- lowered type ID; +- source/generated location policy; +- generated node identity; +- target-sized carrier rules. + +Do not call artifact constructors manglers. Do not create compiler-wide builder. +Do not move one-use composite literals into decorative helpers. + +Acceptance criteria: + +- every linkage name has one canonical naming owner; +- every repeated generated HIR shape has one purposeful construction path; +- extern, entrypoint, generic instance, receiver, and collision tests preserve + exact naming behavior; +- generated control-flow and interface artifacts retain symbol/type/location identity; +- old names and constructors are deleted, not wrapped. + +## Workstream 6: Validate compound semantic evidence + +**Target.** Compound semantic evidence must not permit contradictory states to +travel silently into lowering. Start with inventory, not assumed variant shape. + +For each evidence type with a kind/tag plus nullable or optional fields, record: + +- producer and publication point; +- fields required by each semantic state; +- consumers and their current nil/kind checks; +- invalid combinations representable by current type; +- whether constructor, validator, separate variants, or simpler flat shape best + protects actual invariant. + +PR #124 introduces for-iteration evidence on its feature branch. After it merges, +inspect exact merged artifact and consumers before choosing package or type shape. +Range/sequence plans, guaranteed-entry proof, and target-sized cursors are not +current `main` contracts and must not be encoded here in advance. + +Candidate evidence includes conversions, compiler calls, interface conformance, +variant construction, and merged for-iteration evidence. Typechecker remains owner +of semantic decisions; CFG, ownership, HIR, MIR, and backend consume published +evidence without rediscovery. + +Acceptance criteria: + +- inventory identifies concrete contradictory states and affected consumers; +- chosen representation is smallest shape that protects proved invariant; +- rejected source does not publish valid-looking evidence; +- validator or constructor reports missing symbols, mismatched types, or wrong + syntax association at producer boundary; +- consumers remove duplicated defensive checks only after producer guarantee exists; +- target-width tests apply when evidence contains target-sized values; +- positive and negative source fixtures cover changed language behavior. + +## Workstream 7: Enforcement in normal development + +**Target.** Framework contracts must run under ordinary `go test ./...` and normal +review, not optional audits. + +Required enforcement: + +1. traversal completeness tests; +2. per-phase node handling completeness tests; +3. negative tests for every artifact validator invariant; +4. reset/invalidation tests for every phase result; +5. source prefix and malformed-input corpus after recovered-AST contract is + defined; +6. whole-pipeline regressions for every fixed panic or malformed artifact; +7. target-width tests for target-sized lengths, indexes, pointers, and carriers; +8. generated artifact tests through real HIR, MIR, and backend lowering; +9. contributor checklist linked from `CONTRIBUTING.md` after framework APIs exist. + +A simulated new construct must fail expected traversal, dispatch, evidence, +validation, and fixture checks until contributor updates all required owners. + +## Adding or changing a language construct + +Use this matrix before implementation. Mark each row **changed**, **verified +unchanged**, or **not applicable with reason**. + +| Area | Required question | +| --- | --- | +| Lexer/token model | Does syntax require token or lexical-state change? | +| Parser | What valid and recovered syntax shapes are produced? | +| AST model | Which node owns each child and source location? | +| AST traversal | Does canonical `forEachChild` expose every new child? | +| Collection | Which declarations/symbol shells become visible? | +| Binding | Which declaration types or cycles must be bound? | +| Resolution | Which names, scopes, paths, and shadowing rules apply? | +| Constant evaluation | Does construct produce or consume constant evidence? | +| Typechecking | Which semantic rule and explicit evidence are established? | +| Target validation | Are lengths, indexes, pointers, or carriers representable? | +| CFG | Which blocks, sites, edges, and construct roles are required? | +| Flow typing | Which facts differ by branch, case, or iteration? | +| Definite initialization | Which paths initialize or consume storage? | +| Ownership | Which loans, moves, drops, and cleanup sites occur? | +| Usage | Which bindings/imports count as used? | +| HIR | How is established evidence represented without rediscovery? | +| MIR | How does normalized CFG and ownership evidence lower? | +| Backend | Which physical layout/instruction/ABI rules apply? | +| LSP | Can partial source and stale revisions be handled safely? | +| Diagnostics | Which phase owns each error and source span? | +| Incremental reset | Which edit invalidates which result? | +| Fixtures | Which positive runtime/type and negative semantics cases are needed? | +| Width/backend matrix | Which supported targets/backends need explicit coverage? | + +Feature is incomplete while any applicable row is unanswered. + +## Copy and adaptation boundaries + +A language fork should normally customize: + +- tokens, parser grammar, and source AST; +- collector/binder/resolver rules; +- type system and semantic evidence; +- runtime intrinsics and bundled library; +- target policy and backend implementation; +- diagnostics and language-server presentation. + +It should normally retain or deliberately replace: + +- explicit phase contracts; +- artifact handoff discipline; +- canonical structural traversal; +- exhaustive semantic handling checks; +- validator boundaries; +- dependency-aware scheduling; +- incremental invalidation contracts; +- generated/source identity distinction; +- source fixtures and whole-pipeline tests. + +This separation makes experimentation safer without pretending Peeper semantics are +configuration data. + +## Delivery order + +Workstreams are ordered to minimize migration risk: + +1. Inventory and split phase-owned semantic results. +2. Prove exhaustive handling on one AST statement family. +3. Define validator contracts and failure policy. +4. Verify CFG queries and add construct metadata only where concrete consumers require it. +5. Separate canonical mangling from phase-local artifact construction. +6. Inventory compound evidence and protect concrete invalid states. +7. Make all contracts mandatory in normal tests and contributor workflow. + +Each workstream should land independently with focused and broad validation required +by `RULES.md`. Review every touched owner before starting next migration. + +## Completion criteria + +Framework work is complete when: + +- every retained phase artifact has documented producer, consumers, and reset rule; +- mixed semantic state has explicit phase ownership; +- adding node kind or child causes immediate compile/test failure at every required + handling point; +- CFG, HIR, and MIR boundaries have canonical validators; +- structured-control consumers use canonical typed edges/sites or justified + descriptors rather than topology guesses; +- mangling and generated artifact construction have distinct canonical owners; +- compound semantic evidence has producer-enforced invariants using smallest + representation justified by inspected states and consumers; +- contributor checklist and CI enforce full pipeline coverage; +- no pass-through wrappers, stale aliases, ignored parameters, duplicate maps, or + semantic rediscovery paths remain from migrations. diff --git a/docs/compiler-framework/semantic-results.md b/docs/compiler-framework/semantic-results.md new file mode 100644 index 00000000..c182e258 --- /dev/null +++ b/docs/compiler-framework/semantic-results.md @@ -0,0 +1,403 @@ +# Semantic Result Ownership Inventory + +Status: **design approved; migration active**. + +This document records baseline `project.SemanticInfo` ownership before framework migration. It is the Step 1 design record for the [compiler framework roadmap](README.md). Facts below came from inspected producers, consumers, reset paths, scheduler prerequisites, and incremental LSP reuse. Approved migration progress is recorded below; baseline tables remain for rationale and traceability. + +## Approved migration progress + +Completed slices: + +1. Parsed AST remains immutable during default expansion. `typecheckresult.Result.EffectiveCallArguments` stores source-plus-default argument evidence, and `Module.RebuildTypedASTIndex` indexes source and generated expression trees. +2. `Module.Typechecking` now owns one `typecheckresult.Result` per base-typecheck generation. It contains `ExpandedDefaultBindings`, `EffectiveCallArguments`, `InterfaceImplementations`, `ImplicitConversions`, and `ImplicitCallArguments`. These fields and `InterfaceImplementation` were deleted from `project.SemanticInfo`; no compatibility maps or aliases remain. +3. `typechecker.Check` publishes a fresh result, `resetToPhase` discards it below `Typechecked`, partial semantic consumers use its canonical effective-argument fallback, and HIR consumes its evidence strictly. +4. Intrinsic dispatch, string concatenation classification, and variant construction evidence moved into the same result. `CompilerCall` and `VariantConstruction` moved with their maps; eager constant evaluation treats a missing pre-typecheck result exactly like the previous empty proof map. +5. Base `CaseTests` and `Matches` moved into `typecheckresult.Result`, along with `CaseTest`, `Match`, `MatchArm`, `MatchBinding`, explicit match projections, and canonical `MatchCases` validation. `flowresult.Result.CaseTests` uses `flowresult.CaseTest`, which embeds base case evidence and owns flow-only payload paths. +6. Base `ExprTypes` moved into `typecheckresult.Result`. `Module.BaseExprType` is canonical base lookup; `Module.EffectiveExprType` gives flow evidence precedence and falls back to base evidence. `flowresult.Result.ExprTypes` remains distinct flow-refined evidence. +7. Staged collection, binding, resolution, and type-dependent symbol evidence moved into `bindingresult.Result`: `BlockScopes`, `NodeSymbols`, `MethodsByReceiver`, `MethodsByDecl`, and `OperationFunctions`. Generated defaults and selectors write the same canonical node-symbol table; no precedence accessor or duplicate map exists. +8. `SemanticInfo` was deleted. Constant storage moved directly to `Module.ConstValues` without changing behavior or lifetime. Dedicated constant migration remains: finalized module constants and mutable query cache still require separate ownership. +9. Typechecker evidence cleanup removed redundant interface method name/owner keys, replaced copied match case descriptors with `CaseCount`, moved `PayloadPath` to flow-owned evidence, and made match field/whole-payload projection explicit with an invalid sentinel consumed exhaustively. + +All dedicated typechecker and staged binding fields/models have explicit owners. Constant-result separation remains unmigrated. + +## Current lifecycle + +`collector.collectModule` calls `Module.ResetSemanticData`, publishing fresh +`Module.Bindings` and `Module.ConstValues` at start of one semantic generation. +Collector, binder, resolver, and typechecker stage one shared binding/scope graph; +constant evaluation and later CFG/flow/HIR queries mutate the separate constant map. + +`Module.resetToPhase` follows approved production contract: + +```text +retained <= Parsed -> clear ModuleScope, Bindings, ConstValues, and later results +exact later reuse -> retain completed artifacts without phase re-entry +``` + +Intermediate semantic re-entry remains unsupported because shared symbol objects and +constant cache cannot be rewound independently. Production invalidation uses exact +artifact reuse or resets to `Parsed`. + +## Baseline field ownership matrix + +This table records pre-migration storage and problems; current ownership is tracked in Approved migration progress above. + +| Field | Baseline writers / complete phase | Main consumers | Baseline contract problem | +| --- | --- | --- | --- | +| `BlockScopes` | resolver / `Resolved` | typechecker, CFG constant evaluation, flow, definite-init, ownership, usage, HIR, LSP | Scope topology is resolver-owned, but contained symbols later gain types, `Used`, and `RequiresMutable` state consumed by usage/HIR. | +| `ResolvedSymbols` | collector, resolver, typechecker / `Typechecked` | typechecker, semantic fingerprint, flow, definite-init, ownership, HIR, LSP | Name suggests resolver result, but enum declarations, selectors, and expanded defaults have different writers. | +| `ExpandedDefaultBindings` | typechecker / `Typechecked` | typechecker, ownership, HIR | Marker requires paired symbol provenance, with copied type/lowering evidence where available. Parsed reset can delete marker while retaining expanded AST. | +| `ExprTypes` | typechecker / `Typechecked` | typechecker, const evaluation, flow, ownership, HIR, LSP | Base type is distinct from `Flow.ExprTypes`; direct base-map reads coexist with `EffectiveExprType` precedence. | +| `CaseTests` | typechecker / `Typechecked` | const evaluation and flow transfer | Uses `flowresult` type but is stored in base semantic aggregate; flow creates second case-test map. | +| `Matches` | typechecker / `Typechecked` | CFG, flow, definite-init, ownership, HIR | Uses `flowresult` type despite being base typechecker evidence. Presence can coexist with some diagnostics. | +| `ConstValues` | constant evaluation, post-typecheck finalization, later constant queries / no global completion phase | const evaluator cache, semantic fingerprint, CFG and HIR expression evaluation, MIR | One map mixes finalized module constants with working-cache entries that can still appear during CFG/HIR. | +| `MethodSets` | collector membership; binder/resolver/typechecker mutate symbols / `Typechecked` symbol state | typechecker, semantic fingerprint, LSP | Catalog ownership differs from mutable `Type`, `Used`, `Initializing`, and `RequiresMutable` state of symbols inside it. | +| `MethodSymbol` | collector mapping; binder/resolver/typechecker mutate symbol / `Typechecked` symbol state | binder, resolver, typechecker, flow, ownership, HIR, LSP | Stable declaration identity is collection output; pointed-to mutable symbol state advances later. | +| `InterfaceImplementations` | typechecker / `Typechecked` | HIR | Clear typechecker proof; strongest first extraction candidate. | +| `ImplicitConversions` | typechecker / `Typechecked` | HIR | Clear typechecker proof. | +| `ImplicitCallArguments` | typechecker / `Typechecked` | typechecker borrow checks, HIR | Clear typechecker call-adaptation proof. | +| `CompilerCalls` | typechecker / `Typechecked` | HIR | Clear typechecker dispatch proof; entry may coexist with later call diagnostics. | +| `StringConcatenations` | typechecker / `Typechecked` | ownership, HIR | Clear typechecker operation-classification proof. | +| `VariantConstructions` | typechecker / `Typechecked` | const evaluation, flow, ownership, HIR | Clear typechecker construction proof. | +| `OperationFunctions` | binder append and sort / `Bound` | LSP completion | Binder-owned catalog derived from collected top-level function symbols. | + +## Exact producer groups + +### Collection + +Baseline collection output formerly inside `SemanticInfo`: + +- `MethodSets` +- `MethodSymbol` +- enum variant declaration entries in `ResolvedSymbols` + +Collection creates symbol shells. Binder and typechecker later complete or mutate +those symbol objects. Moving maps does not make pointed-to symbols immutable. + +### Binding + +Binder produces: + +- bound types on collected symbols; +- sorted `OperationFunctions` catalog. + +`OperationFunctions` is only used by LSP completion. Compiler semantic phases use +normal scopes and symbols instead. + +### Resolution + +Resolver produces: + +- `BlockScopes` +- most `ResolvedSymbols` entries. + +Typechecker later extends `ResolvedSymbols` for type-dependent selector resolution +and cloned default expressions. Therefore `ResolvedSymbols` cannot honestly move to +a resolver-only result unchanged. + +### Constant evaluation + +Constant evaluation owns `ConstValues`, but map mixes two lifetimes: + +1. finalized module-scope constants after typechecking calls `FinalizeValues`; +2. lazy working-cache entries created by later constant queries during CFG and HIR. + +`FinalizeValues` deletes and recomputes module constants but intentionally retains +local cache entries. `EvaluateExpr` can add entries after `Typechecked`, so map has no +global completion phase. Fingerprinting and MIR need authoritative module constants; +constant queries need mutable cache. Migration must distinguish these contracts even +if implementation retains one staged artifact. + +### Typechecking + +Clear typechecker-owned base types and lowering proofs: + +- `ExprTypes` +- `CaseTests` +- `Matches` +- `InterfaceImplementations` +- `ImplicitConversions` +- `ImplicitCallArguments` +- `CompilerCalls` +- `StringConcatenations` +- `VariantConstructions` +- `ExpandedDefaultBindings` +- type-dependent and cloned-default entries added to `ResolvedSymbols`. + +Flow-refined facts remain in separate `flowresult.Result`. Ownership cleanup remains +in separate `ownershipresult.Result`. + +## Consumer boundaries + +### Direct evidence consumption + +Current downstream phases mostly consume recorded evidence correctly: + +- HIR consumes conversions, compiler-call dispatch, interface slots, string + concatenation, variant construction, expanded defaults, match evidence, and + resolved symbols. +- ownership consumes resolved symbols, expanded-default provenance, operation + classification, variant construction, effective types, and match evidence. +- CFG consumes resolved match-case indexes through `typecheckresult.Result.MatchCases`. +- MIR consumes finalized constant values and separate ownership cleanup plans. + +Migration must preserve these direct evidence paths. It must not make downstream +phases resolve methods, conversions, variants, or call adaptation again. + +### Existing fallbacks + +Some consumers intentionally or defensively fall back when evidence is missing: + +- HIR and ownership may perform lexical symbol lookup for identifiers. +- flow assignment lookup may fall back to scope lookup. +- LSP symbol/type queries reconstruct import, field, or lexical context when exact + semantic evidence is unavailable. + +Each fallback requires review during migration. Compiler fallbacks may hide broken +phase contracts; LSP fallbacks may be necessary for incomplete source. Do not remove +both categories mechanically. + +### Partial results after diagnostics + +Pipeline advances modules through CFG, flow, definite initialization, and ownership +before project error gate. HIR alone is suppressed when diagnostics already contain +errors. Therefore `Module.Phase == Typechecked` does not mean every typechecker proof +exists or whole result is valid. + +Missing evidence may mean: + +- construct does not require that evidence; +- invalid source prevented publication; +- producer skipped after an earlier diagnostic; +- compiler violated internal contract. + +Extracted results need explicit per-entry presence and failure semantics. Validators +must not convert expected diagnostic-driven absence into internal panic, while valid +source missing mandatory proof must fail clearly. + +### Shared mutable symbol and scope graph + +Maps are not only ownership concern. Many results point to same `*symbols.Symbol` +and `*symbols.Scope` graph. State mutates across phases: + +- binder sets symbol types; +- resolver sets `Initializing`, `Used`, and scope contents; +- typechecker may infer types and set `RequiresMutable`; +- usage and HIR consume later state. + +LSP shallow reuse preserves these pointers. Splitting maps into result structs does +not make symbols immutable or reset-safe. Migration needs explicit stable identity, +mutation owner, and snapshot contract for shared symbol graph. + +## Reset and incremental findings + +### Finding 1: parsed reset can retain expanded AST without provenance + +Severity: **high correctness risk**. + +`typechecker.expandCallDefaults` mutates caller AST by appending cloned default +expressions to `CallExpr.Args`. It also records: + +- cloned `ResolvedSymbols` entries; +- `ExpandedDefaultBindings` markers; +- copied expression types, conversions, and interface evidence. + +Incremental invalidation can reset module to `Parsed`. That reset retains AST but +clears complete `SemanticInfo`. Re-typechecking sees argument count already expanded, +so expansion does not run again and deleted provenance is not reconstructed. + +Potential impact: imported defaults referencing declaration-module bindings may be +resolved as caller syntax, remain unresolved, or lose lowering/ownership evidence. +Current tests cover initial imported-default compilation, not parsed reset and +recompile. This failure path is source-derived; no failing reset/recompile case was +executed during inventory. + +Relevant code: + +- `internal/semantics/typechecker/check_call.go`: `expandCallDefaults`, + `copyExpressionEvidence` +- `internal/frontend/ast/clone.go`: `SubstituteExpr` +- `internal/project/modules.go`: `resetToPhase` +- `internal/lsp/state.go`: `seedReusableModules` +- `internal/pipeline/pipeline.go`: `invalidateSemanticDependents` + +### Finding 2: intermediate semantic reset is non-idempotent + +Severity: **high correctness and contract risk**. + +Reset to any phase after `Parsed` retains complete `SemanticInfo`, scopes, and symbol +state. Re-entering later phases can corrupt results: + +- reset to `Collected`, then binder appends duplicate `OperationFunctions`; +- reset to `Bound`, then resolver redeclares parameters in retained scopes; +- reset to `Resolved`, then const evaluation can return stale cached values; +- reset to `ConstEval`, then typechecker runs with retained proof maps and symbol + mutations. + +Current production downgrade paths appear limited to exact-phase reuse or `Parsed`, +so no production intermediate re-entry was established. `ResetModule` still claims +general retained-phase behavior. Existing tests cover `Parsed` and later retained +artifacts, but no reset/re-entry tests cover unsafe `Collected`, `Bound`, `Resolved`, +or `ConstEval` checkpoints. Those intermediate checkpoints are presently +non-reconstructible. Result separation must give each artifact exact reset gate or +reduce supported reset contract to safe checkpoints. + +### Finding 3: LSP snapshots alias retained compiler state + +Severity: **medium architecture risk**. + +LSP reuse stores module pointers or shallow module copies. Retained maps and pointers +can still alias: + +- AST +- imports +- module scope +- semantic maps +- typed AST index +- CFG/HIR/MIR artifacts + +LSP compilation is mostly serialized, so no concrete race was reproduced. Still, +old context does not mean immutable snapshot: new compilation can mutate AST and +artifacts reachable from older context. Snapshot-generation correctness is unproven. +Result migration must state whether artifacts are immutable, uniquely owned, cloned, +or transferred. + +### Finding 4: synthetic clone IDs are unique but schedule-dependent + +Synthetic AST IDs use atomic allocation, preventing concurrent duplicate allocation +under practical node counts before counter wrap. Exact values depend on goroutine +scheduling; no wrap or parser-namespace bound is enforced. Current semantic +fingerprints do not include them. Future serialized artifacts, tests, or caches must +not assume stable or unconditionally collision-free synthetic IDs. + +## Scheduler and concurrency contract + +`advanceModulesThrough` advances ready modules concurrently, one phase per batch, +then waits before invalidation. Import prerequisites ensure imported module semantic +facts are complete before caller typechecking reads imported defaults. + +Current safe assumptions: + +- independent modules mutate separate semantic maps; +- imported semantic reads happen after required import phase; +- context module/type indexes use `CompilerContext.mu`; +- diagnostics and metrics are synchronized; +- semantic invalidation runs after worker join. + +Every extracted result must preserve per-module ownership and import readiness. A +result must not introduce shared mutable maps across independently scheduled modules. +Focused race tests are required for each migration slice. + +## Minimal proposed boundaries + +These are design candidates, not approved types. + +### Candidate A: declaration catalog + +Cohesive current facts: + +- method membership by receiver; +- declaration-to-method symbol identity; +- operation-function catalog after binding. + +Open question: one staged callable catalog from `Collected` through `Bound`, or +separate collection and binding outputs? Source proves distinct production points; +review must weigh that boundary against added navigation/type cost and whether either +output has independent consumers, reset, or reuse. + +### Candidate B: binding table + +Cohesive current fact: AST identity to semantic symbol identity. + +A strict resolver result is inaccurate because collector and typechecker also add +entries. Options: + +1. one staged binding table with explicit writer phases; +2. separate declaration, resolution, and type-selection maps plus one canonical + query operation; +3. change selector/default representation so final binding table has one producer. + +Option 2 adds maps and query policy. It is acceptable only if it removes ambiguity +rather than spreading lookups. Option 3 may produce cleanest contract but has largest +behavioral scope. + +### Candidate C: constant result + +Distinguish published module constants from evaluator working cache. One staged +artifact remains possible, but API must expose which entries are authoritative and +which may appear after typecheck. Downstream fingerprint and MIR consumers need +finalized module values; CFG/HIR queries need mutable cache. + +### Candidate D: typechecker result + +Strong cohesive output: + +- base expression types; +- conversion and call-adaptation proof; +- intrinsic/compiler-call dispatch; +- string operation classification; +- interface implementation slots; +- variant construction and match evidence; +- base case-test evidence; +- default-expansion provenance if AST expansion remains. + +Possible extraction sizes range from one proof map to cohesive typechecker result. +Choice must compare migration blast radius, import-cycle constraints, partial-result +semantics, and actual reset lifetime. Exact package location must avoid import cycles +without creating wrapper accessors. + +## Recommended migration order after review + +1. **Fix or redesign default expansion lifetime first.** Do not move provenance maps + while AST/reset contract is inconsistent. +2. **Define supported reset checkpoints.** Either implement field/result-specific + reset or reject unsafe intermediate retention. +3. **Extract approved typechecker proof group at smallest safe migration size.** Move + fields and all consumers together; delete old maps immediately. +4. **Make authoritative module constants distinct from post-typecheck query cache.** +5. **Resolve binding-table ownership.** Do not label current multi-writer map as + resolver-only result. +6. **Move callable catalog only if resulting boundary reduces coupling and does not + create decorative result types.** +7. **Add result validators and concurrency/determinism tests after each owner is + explicit.** + +## Required validation per migration slice + +- focused producer and consumer package tests; +- diagnostic codes, text, spans, ordering, and deduplication preservation; +- `ResetModule` tests at every supported retained checkpoint; +- unchanged semantic fingerprint tests; +- imported default expansion followed by parsed reset and recompile; +- LSP unchanged-module reuse and semantic-dependent invalidation; +- repeated-output determinism where generated names or dumps are affected; +- `go test -race` for project, pipeline, LSP, and touched semantic packages; +- full `go test ./...` before review completion. + +## Approved manual decisions + +Maintainer approved these directions before product migration: + +1. **Parsed versus transformed AST:** Must parsed AST remain immutable, with expanded + defaults stored in a separate typed artifact, or may typechecker mutate it if reset + restores pristine syntax and provenance? +2. **Reset contract:** Keep every intermediate semantic checkpoint, or restrict reuse + to checkpoints proven reconstructible given current non-idempotent re-entry? +3. **Binding ownership:** Prefer one staged binding table or separate phase maps with + canonical query policy? +4. **Migration granularity:** Move one proof group or one cohesive typechecker result? + Choose smallest slice that preserves consumers without duplicate maps/wrappers. +5. **LSP snapshots:** Are retained artifacts immutable, uniquely owned by latest + context, or copied on reuse? What generation may mutate shared AST/symbol graph? +6. **Partial results:** After semantic diagnostics, which evidence remains published + and which downstream analyses may consume it? +7. **Missing evidence:** Which absence means prior user diagnostic, not-applicable, + recoverable LSP state, or internal invariant violation? +8. **Symbol graph:** Which phase owns mutable symbol/scope state, and must stable + pointer/`SymbolID` identity survive result boundaries and resets? +9. **Constants:** Separate authoritative module values from query cache physically, or + retain one artifact with explicit entry-kind/finalization contract? +10. **Synthetic IDs:** Are practical atomic uniqueness and schedule dependence enough, + or should namespace/wrap guarantees become enforced invariants? + +Approved answers: parsed AST immutable; exact artifact reuse or reset to `Parsed`; one staged binding table; smallest coherent typechecker slices; old LSP generations immutable; partial semantic evidence may continue through semantic analyses but not HIR/backend; missing mandatory evidence on valid input is invariant failure; symbol identity is stable per generation and read-only after typecheck; finalized module constants differ from mutable query cache; synthetic IDs require namespace/wrap enforcement before persistence. diff --git a/internal/ir/hir/lower/lower_interface.go b/internal/ir/hir/lower/lower_interface.go index f85eab29..56905d98 100644 --- a/internal/ir/hir/lower/lower_interface.go +++ b/internal/ir/hir/lower/lower_interface.go @@ -32,13 +32,13 @@ func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Modul dataType = target } slots := make([]ir.InterfaceSlot, 0, len(iface.Methods)) - implementations := module.Semantics.InterfaceImplementations[expr.ID()] + implementations := module.Typechecking.InterfaceImplementations[expr.ID()] if len(implementations) != len(iface.Methods) { return &ir.InvalidExpr{Message: "missing interface implementation evidence", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } for index, method := range iface.Methods { implementation := implementations[index] - if implementation.MethodName != method.Name || implementation.CallableType == nil || implementation.Symbol == nil || implementation.OwnerKey == "" { + if implementation.Symbol == nil || implementation.Symbol.Name != method.Name || implementation.CallableType == nil { return &ir.InvalidExpr{Message: "missing interface method implementation", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } slotType, ok := interfaceSlotTypeID(ctx, module, method) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 4df53715..29d2e0d4 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -15,6 +15,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/source" "compiler/pkg/numeric" @@ -48,7 +49,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu } var sym *symbols.Symbol if fn.Receiver != nil { - sym = module.Semantics.MethodSymbol[fn.ID()] + sym = module.Bindings.MethodsByDecl[fn.ID()] } else { sym, _ = module.ModuleScope.Lookup(fn.Name.Name) } @@ -159,8 +160,8 @@ func appendBlock(module *project.Module, parentScope *symbols.Scope, out *hir.Bl out.Location = ast.LocOf(block) out.NodeID = hir.NodeID(block.ID()) scope := parentScope - if module.Semantics != nil { - if s, ok := module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if module.Bindings != nil { + if s, ok := module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s } } @@ -237,7 +238,7 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st case *ast.ForStmt: out.Stmts = append(out.Stmts, lowerForStmt(ctx, module, scope, node, returnType)) case *ast.MatchStmt: - evidence, found := module.Semantics.Matches[node.ID()] + evidence, found := module.Typechecking.Matches[node.ID()] if !found || len(evidence.Arms) != len(node.Arms) { out.Stmts = append(out.Stmts, &hir.Invalid{Message: "match statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) return @@ -257,13 +258,22 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st if arm.Payload != nil { caseBlock.PayloadType = loweredTypeID(ctx, module, arm.Payload) } - for _, field := range arm.Fields { + for _, field := range arm.Bindings { + wholePayload := false + switch field.Projection { + case typecheckresult.MatchPayloadField: + case typecheckresult.MatchWholePayload: + wholePayload = true + default: + out.Stmts = append(out.Stmts, &hir.Invalid{Message: "match binding has invalid projection", NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)}) + return + } if field.Binding == nil { continue } caseBlock.Bindings = append(caseBlock.Bindings, hir.VariantBinding{ FieldIndex: field.Field, - WholePayload: field.WholePayload, + WholePayload: wholePayload, Name: symbolName(module, field.Binding), Type: loweredTypeID(ctx, module, field.Type), SymbolID: field.Binding.ID, @@ -323,7 +333,7 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s return loop } - evidence, found := module.Semantics.ForIterations[node.ID()] + evidence, found := module.Typechecking.ForIterations[node.ID()] if !found || evidence.Cursor == nil || evidence.Value == nil { return &hir.Invalid{Message: "for-in statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: location} } @@ -333,7 +343,7 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s boolType := loweredTypeID(ctx, module, &typeinfo.BoolType{}) switch evidence.Kind { - case project.ForIterationRange: + case typecheckresult.ForIterationRange: rangeExpr, ok := node.Iterable.(*ast.RangeExpr) if !ok || rangeExpr.Start == nil || rangeExpr.End == nil || evidence.End == nil { return &hir.Invalid{Message: "range iteration evidence does not match syntax", NodeID: hir.NodeID(node.ID()), Location: location} @@ -361,7 +371,7 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s if evidence.Ordinal != nil { loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Ordinal, location)) } - case project.ForIterationSequence: + case typecheckresult.ForIterationSequence: if evidence.Carrier == nil { return &hir.Invalid{Message: "sequence iteration missing carrier evidence", NodeID: hir.NodeID(node.ID()), Location: location} } @@ -539,7 +549,7 @@ func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, s exprType := func(node ast.Expr) typeinfo.Type { return exprResolvedType(module, node) } - if !place.Addressable(scope, expr, exprType, expandedDefaultBindingResolver(module)) { + if !place.Addressable(scope, expr, exprType, module.ExpandedDefaultBinding) { return &ir.TempBorrow{ Value: lowerASTExpr(ctx, module, scope, expr, target), Slice: borrowAsView, @@ -614,8 +624,8 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } expectedTypeID := loweredTypeID(ctx, module, expectedType) conversion, converting := typeinfo.Conversion{}, false - if module != nil && module.Semantics != nil { - conversion, converting = module.Semantics.ImplicitConversions[expr.ID()] + if module != nil && module.Typechecking != nil { + conversion, converting = module.Typechecking.ImplicitConversions[expr.ID()] } if module != nil && module.Flow != nil { if test, ok := module.Flow.CaseTests[expr.ID()]; ok { @@ -657,7 +667,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } } } - if construction, ok := module.Semantics.VariantConstructions[expr.ID()]; ok { + if construction, ok := module.Typechecking.VariantConstructions[expr.ID()]; ok { variant := &ir.VariantMake{ Case: construction.Case, Type: loweredTypeID(ctx, module, construction.EnumType), @@ -707,8 +717,8 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s case *ast.ScopeResolution: var sym *symbols.Symbol - if module != nil && module.Semantics != nil { - sym = module.Semantics.ResolvedSymbols[node.ID()] + if module != nil && module.Bindings != nil { + sym = module.Bindings.NodeSymbols[node.ID()] } if sym == nil { if qualifier, member, imported := node.ImportValueMember(); imported { @@ -760,7 +770,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s return &ir.AddrOf{Place: lowerPlace(ctx, module, scope, node.Expr), Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.BinaryExpr: - if _, concat := module.Semantics.StringConcatenations[node.ID()]; concat { + if _, concat := module.Typechecking.StringConcatenations[node.ID()]; concat { return &ir.StringConcat{ Left: lowerASTExpr(ctx, module, scope, node.Left, &typeinfo.StringType{}), Right: lowerASTExpr(ctx, module, scope, node.Right, &typeinfo.RefType{Target: &typeinfo.StringType{}}), @@ -800,35 +810,39 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s return &ir.Binary{Op: node.Op, Left: left, Right: right, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.CallExpr: - if compilerCall, ok := module.Semantics.CompilerCalls[node.ID()]; ok { + effectiveArgs, ok := module.Typechecking.EffectiveCallArguments[node.ID()] + if !ok { + return &ir.InvalidExpr{Message: "call missing effective argument evidence", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} + } + if compilerCall, ok := module.Typechecking.CompilerCalls[node.ID()]; ok { switch compilerCall.Kind { case intrinsics.FunctionAlloc: - return lowerAllocCall(ctx, module, scope, node) + return lowerAllocCall(ctx, module, scope, node, effectiveArgs) case intrinsics.FunctionCollection: - return lowerCollectionCall(ctx, module, scope, node, compilerCall.Operation) + return lowerCollectionCall(ctx, module, scope, node, effectiveArgs, compilerCall.Operation) case intrinsics.FunctionDynamicArrayOwner: - return lowerDynamicArrayOwnerCall(ctx, module, scope, node, compilerCall.Operation) + return lowerDynamicArrayOwnerCall(ctx, module, scope, node, effectiveArgs, compilerCall.Operation) case intrinsics.FunctionFromBytes: - return lowerStringFromBytesCall(ctx, module, scope, node) + return lowerStringFromBytesCall(ctx, module, scope, node, effectiveArgs) default: panic(fmt.Sprintf("unsupported intrinsic function kind %d for %q", compilerCall.Kind, compilerCall.Operation)) } } if selector, ok := node.Callee.(*ast.SelectorExpr); ok && selector != nil { - return lowerSelectorMethodCall(ctx, module, scope, selector, node) + return lowerSelectorMethodCall(ctx, module, scope, selector, node, effectiveArgs) } calleeExpr := lowerASTExpr(ctx, module, scope, node.Callee, nil) - args := make([]ir.Expr, 0, len(node.Args)) + args := make([]ir.Expr, 0, len(effectiveArgs)) var fnType *typeinfo.FuncType if resolved := exprResolvedType(module, node.Callee); resolved != nil { fnType, _ = typeinfo.Underlying(resolved).(*typeinfo.FuncType) } - for _, arg := range node.Args { + for _, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && len(args) < len(fnType.Params) { paramExpected = fnType.Params[len(args)] } - if implicit := module.Semantics.ImplicitCallArguments[arg.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[arg.ID()]; implicit != nil { args = append(args, lowerImplicitReferenceValue(ctx, module, scope, arg, implicit)) } else { args = append(args, lowerASTExpr(ctx, module, scope, arg, paramExpected)) @@ -897,14 +911,14 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } } -func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, effectiveArgs []ast.Expr, op symbols.CompilerOp) ir.Expr { fnType, _ := exprResolvedType(module, call.Callee).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != 1 { - return &ir.InvalidExpr{Message: "collection function type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} + if fnType == nil || len(fnType.Params) != 1 || len(effectiveArgs) != 1 { + return &ir.InvalidExpr{Message: "collection function type or arguments missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} } - value := call.Args[0] + value := effectiveArgs[0] var receiver ir.Expr - if implicit := module.Semantics.ImplicitCallArguments[value.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[value.ID()]; implicit != nil { receiver = lowerImplicitReferenceValue(ctx, module, scope, value, implicit) } else { receiver = lowerASTExpr(ctx, module, scope, value, fnType.Params[0]) @@ -954,14 +968,14 @@ func optionalPromotionInnerType(expectedType, resolvedType typeinfo.Type, expr a return expected.Inner } -func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) ir.Expr { +func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { if module == nil || selector == nil || selector.Expr == nil || selector.Name == nil { return &ir.InvalidExpr{Message: "invalid selector call", Type: ir.InvalidType} } baseType := exprResolvedType(module, selector.Expr) if iface, slot, ok := lookupInterfaceMethod(module, baseType, selector.Name.Name); ok { - args := make([]ir.Expr, 0, len(call.Args)) - for i, arg := range call.Args { + args := make([]ir.Expr, 0, len(effectiveArgs)) + for i, arg := range effectiveArgs { var argExpected typeinfo.Type if i+1 < len(iface.Params) { argExpected = iface.Params[i+1].Type @@ -982,7 +996,7 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } } - methodSym := module.Semantics.ResolvedSymbols[selector.Name.ID()] + methodSym := module.Bindings.NodeSymbols[selector.Name.ID()] fnType, _ := exprResolvedType(module, selector).(*typeinfo.FuncType) if methodSym == nil || fnType == nil || len(fnType.Params) == 0 { return &ir.InvalidExpr{Message: "unsupported selector call lowering", Type: ir.InvalidType} @@ -991,14 +1005,14 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul return &ir.InvalidExpr{Message: "selector method receiver missing", Type: ir.InvalidType} } var baseExpr ir.Expr - if implicit := module.Semantics.ImplicitCallArguments[selector.Expr.ID()]; implicit != nil { + if implicit := module.Typechecking.ImplicitCallArguments[selector.Expr.ID()]; implicit != nil { baseExpr = lowerImplicitReferenceValue(ctx, module, scope, selector.Expr, implicit) } else { baseExpr = lowerASTExpr(ctx, module, scope, selector.Expr, nil) } - args := make([]ir.Expr, 0, len(call.Args)+1) + args := make([]ir.Expr, 0, len(effectiveArgs)+1) args = append(args, baseExpr) - for i, arg := range call.Args { + for i, arg := range effectiveArgs { var argExpected typeinfo.Type if i+1 < len(fnType.Params) { argExpected = fnType.Params[i+1] @@ -1036,7 +1050,7 @@ func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, sco exprType := func(expr ast.Expr) typeinfo.Type { return exprResolvedType(module, expr) } - if throughPtr || place.Addressable(scope, selector.Expr, exprType, expandedDefaultBindingResolver(module)) { + if throughPtr || place.Addressable(scope, selector.Expr, exprType, module.ExpandedDefaultBinding) { return &ir.Load{Place: lowerPlace(ctx, module, scope, selector), SourceInfo: ir.SourceInfo{NodeID: ir.NodeID(selector.ID()), Location: ast.LocOf(selector)}} } return &ir.Field{ @@ -1150,14 +1164,14 @@ func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, } } -func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr, op symbols.CompilerOp) ir.Expr { fnType, _ := typeinfo.Underlying(exprResolvedType(module, node.Callee)).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != len(node.Args) || len(node.Args) < 2 { - return &ir.InvalidExpr{Message: "dynamic-array operation type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} + if fnType == nil || len(fnType.Params) != len(effectiveArgs) || len(effectiveArgs) < 2 { + return &ir.InvalidExpr{Message: "dynamic-array operation type or arguments missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } - args := make([]ir.Expr, 0, len(node.Args)) - for i, arg := range node.Args { - if implicit := module.Semantics.ImplicitCallArguments[arg.ID()]; implicit != nil { + args := make([]ir.Expr, 0, len(effectiveArgs)) + for i, arg := range effectiveArgs { + if implicit := module.Typechecking.ImplicitCallArguments[arg.ID()]; implicit != nil { args = append(args, lowerImplicitReferenceValue(ctx, module, scope, arg, implicit)) } else { args = append(args, lowerASTExpr(ctx, module, scope, arg, fnType.Params[i])) @@ -1191,14 +1205,14 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo return out } -func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { - if len(node.Args) < 1 || len(node.Args) > 2 { - return &ir.InvalidExpr{Message: "alloc requires 1 or 2 arguments", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} +func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { + if len(effectiveArgs) < 1 || len(effectiveArgs) > 2 { + return &ir.InvalidExpr{Message: "alloc requires 1 or 2 effective arguments", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } - value := lowerASTExpr(ctx, module, scope, node.Args[0], nil) + value := lowerASTExpr(ctx, module, scope, effectiveArgs[0], nil) var allocator ir.Expr - if len(node.Args) > 1 { - allocator = lowerASTExpr(ctx, module, scope, node.Args[1], &typeinfo.AllocatorType{}) + if len(effectiveArgs) > 1 { + allocator = lowerASTExpr(ctx, module, scope, effectiveArgs[1], &typeinfo.AllocatorType{}) } resultType := loweredTypeID(ctx, module, exprResolvedType(module, node)) return &ir.AllocExpr{ @@ -1209,15 +1223,15 @@ func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope } } -func lowerStringFromBytesCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { +func lowerStringFromBytesCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, effectiveArgs []ast.Expr) ir.Expr { fnType, _ := exprResolvedType(module, node.Callee).(*typeinfo.FuncType) - if fnType == nil || len(fnType.Params) != 2 || len(node.Args) < 1 || len(node.Args) > 2 { - panic("validated from_bytes call missing intrinsic signature or arguments") + if fnType == nil || len(fnType.Params) != 2 || len(effectiveArgs) < 1 || len(effectiveArgs) > 2 { + panic("validated from_bytes call missing intrinsic signature or effective arguments") } - bytes := lowerASTExpr(ctx, module, scope, node.Args[0], fnType.Params[0]) + bytes := lowerASTExpr(ctx, module, scope, effectiveArgs[0], fnType.Params[0]) var allocator ir.Expr - if len(node.Args) == 2 { - allocator = lowerASTExpr(ctx, module, scope, node.Args[1], fnType.Params[1]) + if len(effectiveArgs) == 2 { + allocator = lowerASTExpr(ctx, module, scope, effectiveArgs[1], fnType.Params[1]) } return &ir.StringFromBytes{ Bytes: bytes, @@ -1239,8 +1253,8 @@ func lowerIdentExpr(ctx *project.CompilerContext, module *project.Module, scope return &ir.InvalidExpr{Message: "nil identifier", Type: ir.InvalidType} } var sym *symbols.Symbol - if module != nil && module.Semantics != nil { - sym = module.Semantics.ResolvedSymbols[node.ID()] + if module != nil && module.Bindings != nil { + sym = module.Bindings.NodeSymbols[node.ID()] } if sym == nil && scope != nil { sym, _ = scope.Lookup(node.Name) @@ -1343,18 +1357,6 @@ func callableName(module *project.Module, sym *symbols.Symbol) (string, bool) { return b.String(), false } -func expandedDefaultBindingResolver(module *project.Module) place.BindingResolver { - return func(ident *ast.Ident) (place.Binding, bool) { - if module == nil || module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: module.Semantics.ResolvedSymbols[ident.ID()]}, true - } -} - func shouldDiscardBindingValue(sym *symbols.Symbol) bool { if sym == nil || sym.Used { return false diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 32ef8d12..4af96b6f 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -18,6 +18,7 @@ import ( "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typechecker" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -40,10 +41,10 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = typechecker.CheckFlow(ctx, module) if diag.HasErrors() { @@ -138,7 +139,7 @@ func TestGenerateHIRLowersSequenceForIntoStructuredSegments(t *testing.T) { func TestGenerateHIRRejectsForInWithoutSemanticEvidence(t *testing.T) { out := generateTestHIR(t, "hir_for_evidence_test"+peeper.SourceExt, "hir_for_evidence_test", `fn main() { for value in 0..2 {} }`, func(module *project.Module) { - module.Semantics.ForIterations = make(map[ast.NodeID]project.ForIteration) + module.Typechecking.ForIterations = make(map[ast.NodeID]typecheckresult.ForIteration) }) invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) if !ok || !strings.Contains(invalid.Message, "missing semantic evidence") { @@ -1097,7 +1098,7 @@ fn main() -> i32 { return reader.read(); }` out := generateTestHIR(t, "hir_interface_evidence_test"+peeper.SourceExt, "hir_interface_evidence_test", src, - func(module *project.Module) { module.Semantics.MethodSets = nil }) + func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] binding, ok := mainFn.Body.Stmts[1].(*hir.Binding) if !ok { @@ -1126,7 +1127,7 @@ fn main() -> i32 { return use(&counter); }` out := generateTestHIR(t, "hir_default_interface_evidence_test"+peeper.SourceExt, "hir_default_interface_evidence_test", src, - func(module *project.Module) { module.Semantics.MethodSets = nil }) + func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] ret := mainFn.Body.Stmts[1].(*hir.Return) call := ret.Value.(*ir.Call) @@ -1189,7 +1190,7 @@ fn (self: &Counter) Read() -> i32 { return self.value; } fn main() -> i32 { let counter: Counter = .{ value = 7 }; return counter.Read(); -}`, func(module *project.Module) { module.Semantics.MethodSets = nil }) +}`, func(module *project.Module) { module.Bindings.MethodsByReceiver = nil }) mainFn := out.Funcs[len(out.Funcs)-1] ret := mainFn.Body.Stmts[1].(*hir.Return) call, ok := ret.Value.(*ir.Call) @@ -1310,6 +1311,29 @@ fn Read(result: Result) -> i32 { } } +func TestGenerateHIRRejectsInvalidMatchProjectionEvidence(t *testing.T) { + out := generateTestHIR(t, "hir_invalid_match_projection_test"+peeper.SourceExt, "hir_invalid_match_projection_test", `enum Result { + Ok: { value: i32 }, +} +fn Read(result: Result) -> i32 { + match result { + Result::Ok with { value = payload } => { return payload; } + } +}`, func(module *project.Module) { + for _, match := range module.Typechecking.Matches { + match.Arms[0].Bindings[0].Projection = typecheckresult.MatchProjectionInvalid + return + } + }) + if out == nil || len(out.Funcs) != 1 || len(out.Funcs[0].Body.Stmts) != 1 { + t.Fatalf("unexpected HIR shape: %#v", out) + } + invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) + if !ok || invalid.Message != "match binding has invalid projection" { + t.Fatalf("invalid match evidence lowered as %#v", out.Funcs[0].Body.Stmts[0]) + } +} + func TestGenerateHIRLowersDirectEnumPayload(t *testing.T) { out := generateTestHIR(t, "hir_enum_direct_payload_test"+peeper.SourceExt, "hir_enum_direct_payload_test", `enum Code { Value: i32, diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 2a7db7b9..a31068e8 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -88,7 +88,7 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte return []CompletionItem{}, nil } ctx, module := s.currentCompiledModule(filePath) - if ctx == nil || module == nil || module.Semantics == nil { + if ctx == nil || module == nil { return []CompletionItem{}, nil } parsed := parseCompletionContext(sourceText, params.Position) @@ -104,7 +104,7 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte return qualifiedCompletionItems(ctx, module, parsed.qualifier, parsed.prefix, replacement), nil case completionOperation: sentinelCtx, sentinelModule := compileCompletionSource(ctx.Config, s.completionOverlays(filePath), filePath, parsed.sentinel) - if sentinelCtx == nil || sentinelModule == nil || sentinelModule.Semantics == nil { + if sentinelCtx == nil || sentinelModule == nil || sentinelModule.Bindings == nil { return []CompletionItem{}, nil } rewrite := Range{Start: positionAtOffset(sourceText, parsed.rewriteStart), End: replacement.End} @@ -379,7 +379,7 @@ func isCompletionBoundary(ch byte) bool { } func lexicalCompletionItems(module *project.Module, cursor source.Position, prefix string, replacement Range) []CompletionItem { - if module == nil || module.ModuleScope == nil || module.Semantics == nil { + if module == nil || module.ModuleScope == nil || module.Bindings == nil { return []CompletionItem{} } scope := completionScope(module, cursor.Line, cursor.Column) @@ -414,7 +414,7 @@ func completionScope(module *project.Module, line, col int) *symbols.Scope { if !ok || !locContains(ast.LocOf(block), line, col) { return true } - if blockScope := module.Semantics.BlockScopes[block.ID()]; blockScope != nil { + if blockScope := module.Bindings.BlockScopes[block.ID()]; blockScope != nil { scope = blockScope } return true @@ -528,7 +528,7 @@ func completionQualifierSegments(qualifier string) []string { } func matchArmCompletionItems(ctx *project.CompilerContext, module *project.Module, cursor source.Position, replacement Range) ([]CompletionItem, bool) { - if module == nil || module.Semantics == nil { + if module == nil || module.Typechecking == nil { return nil, false } var match *ast.MatchStmt @@ -686,7 +686,7 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu } } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - for _, method := range module.Semantics.MethodSets[key] { + for _, method := range module.Bindings.MethodsByReceiver[key] { if method == nil { continue } @@ -703,7 +703,7 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu items = appendOperationCompletion(items, seen, function, function.Name, fnType, replacement, rewrite, pipe, preserveArguments) } } - for _, function := range operationFunctionsWithPrefix(module.Semantics.OperationFunctions, prefix) { + for _, function := range operationFunctionsWithPrefix(module.Bindings.OperationFunctions, prefix) { fnType, callable := function.Type.(*typeinfo.FuncType) if !callable { continue @@ -717,10 +717,10 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu continue } imported, found := ctx.ModuleByKey(resolved.Key) - if !found || imported == nil || imported.Semantics == nil { + if !found || imported == nil || imported.Bindings == nil { continue } - for _, function := range operationFunctionsWithPrefix(imported.Semantics.OperationFunctions, prefix) { + for _, function := range operationFunctionsWithPrefix(imported.Bindings.OperationFunctions, prefix) { fnType, callable := function.Type.(*typeinfo.FuncType) if !function.IsPub || !callable { continue diff --git a/internal/lsp/cursor.go b/internal/lsp/cursor.go index bfcd46e8..17092982 100644 --- a/internal/lsp/cursor.go +++ b/internal/lsp/cursor.go @@ -88,18 +88,20 @@ func buildCursorContext(ctx *project.CompilerContext, module *project.Module, po } func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) *symbols.Symbol { - if ident == nil { + if ident == nil || module == nil { return nil } - if module != nil && module.Semantics != nil { - if sym := module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil { + if module.Bindings != nil { + if sym := module.Bindings.NodeSymbols[ident.ID()]; sym != nil { return sym } } parent := parents[ident.ID()] if parent == nil { - if sym, ok := module.ModuleScope.Lookup(ident.Name); ok { - return sym + if module.ModuleScope != nil { + if sym, ok := module.ModuleScope.Lookup(ident.Name); ok { + return sym + } } return nil } @@ -145,8 +147,8 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul var scope *symbols.Scope curr := parent for curr != nil { - if block, ok := curr.(*ast.BlockStmt); ok { - if s, ok := module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if block, ok := curr.(*ast.BlockStmt); ok && module.Bindings != nil { + if s, ok := module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s break } @@ -163,7 +165,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } curr = parents[curr.ID()] } - if containingFn != nil { + if containingFn != nil && module.ModuleScope != nil { if sym, ok := module.ModuleScope.Lookup(containingFn.Name.Name); ok && sym != nil && sym.Scope != nil { scope = sym.Scope } @@ -181,7 +183,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) *symbols.Symbol { - if sel == nil || ident == nil || module == nil || ctx == nil || module.Semantics == nil { + if sel == nil || ident == nil || module == nil || ctx == nil { return nil } baseType, ok := selectorBaseType(sel.Expr, parents, module, ctx) @@ -191,8 +193,11 @@ func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parent if fieldSym := lookupStructFieldSymbol(baseType, ident.Name, ctx); fieldSym != nil { return fieldSym } + if module.Bindings == nil { + return nil + } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - if methods, ok := module.Semantics.MethodSets[key]; ok { + if methods, ok := module.Bindings.MethodsByReceiver[key]; ok { for _, method := range methods { if method != nil && method.Name == ident.Name { return method @@ -204,7 +209,7 @@ func resolveSelectorMemberSymbol(sel *ast.SelectorExpr, ident *ast.Ident, parent } func selectorBaseType(expr ast.Expr, parents map[ast.NodeID]ast.Node, module *project.Module, ctx *project.CompilerContext) (typeinfo.Type, bool) { - if expr == nil || module == nil || module.Semantics == nil { + if expr == nil || module == nil { return nil, false } baseType, ok := normalizedSelectorBaseType(module.EffectiveExprType(expr.ID())) diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index b0c25b13..c0d83582 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -419,12 +419,12 @@ func documentedDeclAncestor(node ast.Node, parents map[ast.NodeID]ast.Node) ast. } func resolveDeclNameSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, module *project.Module) *symbols.Symbol { - if ident == nil || module == nil || module.Semantics == nil { + if ident == nil || module == nil || module.Bindings == nil { return nil } parent := parents[ident.ID()] if fn, ok := parent.(*ast.FnDecl); ok && fn != nil && fn.Name == ident && fn.Receiver != nil { - if sym, ok := module.Semantics.MethodSymbol[fn.ID()]; ok && sym != nil { + if sym, ok := module.Bindings.MethodsByDecl[fn.ID()]; ok && sym != nil { return sym } } @@ -479,11 +479,11 @@ func lookupMethodSet(ctx *project.CompilerContext, typ typeinfo.Type, keys []str seen := make(map[string]struct{}) var methods []*symbols.Symbol for _, module := range ctx.Modules() { - if module == nil || module.Semantics == nil { + if module == nil || module.Bindings == nil { continue } for key := range keySet { - for _, sym := range module.Semantics.MethodSets[key] { + for _, sym := range module.Bindings.MethodsByReceiver[key] { if sym == nil { continue } @@ -506,7 +506,7 @@ func lookupMethodSet(ctx *project.CompilerContext, typ typeinfo.Type, keys []str } func resolveExprHoverSubject(cc *cursorContext) *hoverSubject { - if cc == nil || cc.node == nil || cc.module == nil || cc.module.Semantics == nil { + if cc == nil || cc.node == nil || cc.module == nil || cc.module.Typechecking == nil { return nil } if _, ok := cc.node.(ast.Expr); !ok { diff --git a/internal/lsp/workspace_test.go b/internal/lsp/workspace_test.go index 0ba66153..a4229478 100644 --- a/internal/lsp/workspace_test.go +++ b/internal/lsp/workspace_test.go @@ -562,6 +562,85 @@ func TestWorkspaceReusePhasesDowngradesDependentToParsed(t *testing.T) { } } +func TestServerStateParsedResetRebuildsImportedDefaultProvenance(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + fileMain := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + fileExternal := filepath.Join(root, peeper.SourceDirName, "external"+peeper.SourceExt) + writeWorkspaceFile(t, fileMain, `import "app/external"; + +fn main() -> i32 { + return external::Read(); +} +`) + const external = `const value: i32 = 7; + +fn Read(input: i32 = value) -> i32 { + return input; +} +` + writeWorkspaceFile(t, fileExternal, external) + + state := NewServerState() + state.RootDir = root + ctx, mod := state.recompile(fileMain) + if ctx == nil || mod == nil { + t.Fatal("initial compile returned nil context or module") + } + if ctx.Diagnostics.HasErrors() { + t.Fatalf("initial compile diagnostics:\n%s", ctx.Diagnostics.EmitAllToString()) + } + + updated := external + "\nfn Added() {}\n" + state.applyDocumentSnapshot(fileExternal, &updated, nil) + ctx, mod = state.recompile(fileExternal) + if ctx == nil || mod == nil { + t.Fatal("incremental compile returned nil context or module") + } + if got := state.LastMetrics.ModulesDowngraded; got != 1 { + t.Fatalf("modules downgraded = %d, want 1 dependent reset to Parsed", got) + } + if ctx.Diagnostics.HasErrors() { + t.Fatalf("unexpected diagnostics after dependent Parsed reset:\n%s", ctx.Diagnostics.EmitAllToString()) + } + + mainModule := state.modules[project.CanonicalPath(fileMain)] + if mainModule == nil { + t.Fatal("missing recompiled dependent module") + } + if mainModule.Phase != phase.Backend { + t.Fatalf("dependent phase = %v, want %v", mainModule.Phase, phase.Backend) + } + var call *ast.CallExpr + for _, stmt := range mainModule.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + candidate, ok := node.(*ast.CallExpr) + if ok && ast.ExprText(candidate.Callee) == "external::Read" { + call = candidate + return false + } + return call == nil + }) + if call != nil { + break + } + } + if call == nil || len(call.Args) != 0 { + t.Fatalf("source call after reset = %#v, want zero source arguments", call) + } + effectiveArgs := mainModule.Typechecking.EffectiveCallArguments[call.ID()] + if len(effectiveArgs) != 1 { + t.Fatalf("effective arguments after reset = %#v, want one rebuilt default", effectiveArgs) + } + ident, ok := effectiveArgs[0].(*ast.Ident) + if !ok || mainModule.Bindings == nil || mainModule.Bindings.NodeSymbols[ident.ID()] == nil { + t.Fatalf("rebuilt default = %#v, want resolved imported identifier", effectiveArgs[0]) + } + if _, ok := mainModule.Typechecking.ExpandedDefaultBindings[ident.ID()]; !ok { + t.Fatalf("rebuilt default identifier %d missing declaration-binding provenance", ident.ID()) + } +} + func TestWorkspaceIndexRebuildParsesOnlyChangedFiles(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 76fa76d3..a7cf6bc9 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -412,7 +412,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if module.Phase < phase.Typechecked { typechecker.Check(phaseCtx, module) consteval.FinalizeValues(phaseCtx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.SemanticExportFingerprint = project.SemanticExportFingerprint(module) module.Phase = phase.Typechecked ctx.Metrics.AddPhaseAdvance() @@ -420,8 +420,8 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di } if module.Phase < phase.CFG { module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) cfg.Analyze(module.CFG, phaseDiag, func(conditionID, scopeID ir.NodeID) (bool, bool) { node := module.TypedASTNodes[ast.NodeID(conditionID)] @@ -432,7 +432,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di value, ok := consteval.EvaluateExpr( phaseCtx, module, - module.Semantics.BlockScopes[ast.NodeID(scopeID)], + module.Bindings.BlockScopes[ast.NodeID(scopeID)], expr, &typeinfo.BoolType{}, ) @@ -455,9 +455,9 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di definiteinit.Check( module.CFG, module.TypedASTNodes, - module.Semantics.BlockScopes, - module.Semantics.ResolvedSymbols, - module.Semantics.Matches, + module.Bindings.BlockScopes, + module.Bindings.NodeSymbols, + module.Typechecking.Matches, phaseDiag, ) module.Phase = phase.DefiniteInit @@ -493,7 +493,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if diag != nil && diag.HasErrors() { return false } - module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Semantics.ConstValues) + module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.ConstValues) module.Phase = phase.MIR ctx.Metrics.AddPhaseAdvance() return true diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 92c107fe..be1018da 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -887,11 +887,11 @@ fn main() -> i32 { return Value; } if !ok { t.Fatal("failed to construct stale const value") } - entry.Semantics.ConstValues[sym.ID] = stale + entry.ConstValues[sym.ID] = stale if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.Typechecked { t.Fatalf("phase = %v, want typechecked", entry.Phase) } - if got := entry.Semantics.ConstValues[sym.ID]; got == nil || got.TypeText() != "i32" { + if got := entry.ConstValues[sym.ID]; got == nil || got.TypeText() != "i32" { t.Fatalf("final const value = %#v, want i32", got) } } @@ -924,9 +924,9 @@ const WaitingIsReady: bool = Waiting is Status::Ready; if !found || readySymbol == nil { t.Fatal("missing const symbol Ready") } - ready, ok := entry.Semantics.ConstValues[readySymbol.ID].(*constvalue.VariantConst) + ready, ok := entry.ConstValues[readySymbol.ID].(*constvalue.VariantConst) if !ok || ready == nil || ready.NominalIdentity() == "" || ready.CaseIndex() != 0 || len(ready.FieldValues()) != 2 { - t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.Semantics.ConstValues[readySymbol.ID]) + t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.ConstValues[readySymbol.ID]) } code, ok := ready.FieldValues()[0].(*constvalue.IntConst) if !ok || code.Text() != "7" { @@ -975,9 +975,9 @@ func assertPipelineBoolConst(t *testing.T, module *project.Module, name string, if !found || sym == nil { t.Fatalf("missing const symbol %s", name) } - value, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.BoolConst) + value, ok := module.ConstValues[sym.ID].(*constvalue.BoolConst) if !ok || value == nil || value.Bool() != want { - t.Fatalf("%s = %#v, want bool %t", name, module.Semantics.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %t", name, module.ConstValues[sym.ID], want) } } @@ -2509,7 +2509,7 @@ fn main() -> i32 { } observed := make(map[symbols.CompilerOp]struct{}) - for _, symbol := range entry.Semantics.ResolvedSymbols { + for _, symbol := range entry.Bindings.NodeSymbols { if symbol != nil && symbol.CompilerOp != "" { observed[symbol.CompilerOp] = struct{}{} } diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index dcb550ab..3cc905e3 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -25,13 +25,13 @@ func SemanticExportFingerprint(module *Module) string { part += fmt.Sprintf(":mutable=%t", sym.IsMutable()) } part += semanticExportMetadata(module, sym) - if sym.Kind == symbols.SymbolConst && module.Semantics != nil { - part += ":value=" + constantKey(module.Semantics.ConstValues[sym.ID]) + if sym.Kind == symbols.SymbolConst { + part += ":value=" + constantKey(module.ConstValues[sym.ID]) } parts = append(parts, part) } - if module.Semantics != nil { - for receiver, methods := range module.Semantics.MethodSets { + if module.Bindings != nil { + for receiver, methods := range module.Bindings.MethodsByReceiver { for _, method := range methods { if method == nil || !method.IsPub { continue @@ -76,16 +76,16 @@ func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { facts := make([]string, 0) ast.Inspect(param.Default, func(node ast.Node) bool { ident, ok := node.(*ast.Ident) - if !ok || ident == nil || module.Semantics == nil { + if !ok || ident == nil || module.Bindings == nil { return true } - resolved := module.Semantics.ResolvedSymbols[ident.ID()] + resolved := module.Bindings.NodeSymbols[ident.ID()] if resolved == nil { return true } fact := resolved.Name + ":" + semanticTypeKey(resolved.Type, make(map[typeinfo.Type]bool)) if resolved.Kind == symbols.SymbolConst { - fact += "=" + constantKey(module.Semantics.ConstValues[resolved.ID]) + fact += "=" + constantKey(module.ConstValues[resolved.ID]) } facts = append(facts, fact) return true diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index be18ffa7..d699d123 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -5,6 +5,7 @@ import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" + "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) @@ -14,16 +15,24 @@ type unexpectedSemanticType struct{} func (*unexpectedSemanticType) TypeNode() {} func (*unexpectedSemanticType) Text() string { return "unexpected" } -func fingerprintModule(t *testing.T, exported *symbols.Symbol, semantics *SemanticInfo) *Module { +func fingerprintModule( + t *testing.T, + exported *symbols.Symbol, + bindings *bindingresult.Result, + constValues map[symbols.SymbolID]constvalue.Value, +) *Module { t.Helper() scope := symbols.NewScope(nil) if err := scope.Declare(exported); err != nil { t.Fatalf("declare export: %v", err) } - if semantics == nil { - semantics = NewSemanticInfo() + if bindings == nil { + bindings = bindingresult.New() } - return &Module{ModuleScope: scope, Semantics: semantics} + if constValues == nil { + constValues = make(map[symbols.SymbolID]constvalue.Value) + } + return &Module{ModuleScope: scope, Bindings: bindings, ConstValues: constValues} } func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) { @@ -32,9 +41,9 @@ func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) decl.SetDeclSurface("const:Value::number") sym := symbols.New("Value", symbols.SymbolConst, decl, nil) sym.Type = typ - semantic := NewSemanticInfo() - semantic.ConstValues[sym.ID], _ = constvalue.NewIntText(value, typeinfo.TypeText(typ)) - return SemanticExportFingerprint(fingerprintModule(t, sym, semantic)) + constValues := make(map[symbols.SymbolID]constvalue.Value) + constValues[sym.ID], _ = constvalue.NewIntText(value, typeinfo.TypeText(typ)) + return SemanticExportFingerprint(fingerprintModule(t, sym, nil, constValues)) } i32One := makeConst(&typeinfo.IntegerType{Signed: true, Bits: 32}, "1") @@ -48,13 +57,34 @@ func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) } } +func TestSemanticExportFingerprintIncludesConstValueWithoutBindings(t *testing.T) { + fingerprint := func(value string) string { + decl := &ast.ConstDecl{Name: &ast.Ident{Name: "Value"}} + decl.SetDeclSurface("const:Value::number") + sym := symbols.New("Value", symbols.SymbolConst, decl, nil) + sym.Type = &typeinfo.IntegerType{Signed: true, Bits: 32} + scope := symbols.NewScope(nil) + if err := scope.Declare(sym); err != nil { + t.Fatalf("declare export: %v", err) + } + constant, _ := constvalue.NewIntText(value, "i32") + return SemanticExportFingerprint(&Module{ + ModuleScope: scope, + ConstValues: map[symbols.SymbolID]constvalue.Value{sym.ID: constant}, + }) + } + if fingerprint("1") == fingerprint("2") { + t.Fatal("binding-independent const value did not change semantic fingerprint") + } +} + func TestSemanticExportFingerprintIgnoresFunctionBodyChanges(t *testing.T) { makeFunction := func(body *ast.BlockStmt) string { decl := &ast.FnDecl{Name: &ast.Ident{Name: "Read"}, Body: body} decl.SetDeclSurface("fn::Read:::") sym := symbols.New("Read", symbols.SymbolFunc, decl, nil) sym.Type = &typeinfo.FuncType{Return: &typeinfo.IntegerType{Signed: true, Bits: 32}} - return SemanticExportFingerprint(fingerprintModule(t, sym, nil)) + return SemanticExportFingerprint(fingerprintModule(t, sym, nil, nil)) } first := makeFunction(&ast.BlockStmt{}) second := makeFunction(&ast.BlockStmt{Stmts: []ast.Stmt{&ast.ReturnStmt{Value: &ast.NumberLit{Value: "1"}}}}) @@ -76,10 +106,11 @@ func TestSemanticExportFingerprintIncludesPrivateFactsUsedByPublicDefault(t *tes fn.Type = &typeinfo.FuncType{Params: []typeinfo.Type{i32}, ParamNames: []string{"value"}} private := symbols.New("limit", symbols.SymbolConst, nil, nil) private.Type = i32 - semantic := NewSemanticInfo() - semantic.ResolvedSymbols[defaultIdent.ID()] = private - semantic.ConstValues[private.ID], _ = constvalue.NewIntText(value, "i32") - return SemanticExportFingerprint(fingerprintModule(t, fn, semantic)) + bindings := bindingresult.New() + bindings.NodeSymbols[defaultIdent.ID()] = private + constValues := make(map[symbols.SymbolID]constvalue.Value) + constValues[private.ID], _ = constvalue.NewIntText(value, "i32") + return SemanticExportFingerprint(fingerprintModule(t, fn, bindings, constValues)) } if makeFunction("1") == makeFunction("2") { t.Fatal("private const used by public default did not change fingerprint") @@ -90,10 +121,10 @@ func TestSemanticExportFingerprintChangesWithPublicMethodSignature(t *testing.T) makeMethod := func(returnType typeinfo.Type) string { method := symbols.New("Read", symbols.SymbolMethod, nil, nil) method.Type = &typeinfo.FuncType{Return: returnType} - semantics := NewSemanticInfo() - semantics.MethodSets["Buffer"] = []*symbols.Symbol{method} + bindings := bindingresult.New() + bindings.MethodsByReceiver["Buffer"] = []*symbols.Symbol{method} return SemanticExportFingerprint(fingerprintModule(t, - symbols.New("Buffer", symbols.SymbolType, nil, nil), semantics)) + symbols.New("Buffer", symbols.SymbolType, nil, nil), bindings, nil)) } i32 := &typeinfo.IntegerType{Signed: true, Bits: 32} i64 := &typeinfo.IntegerType{Signed: true, Bits: 64} @@ -113,7 +144,7 @@ func TestSemanticExportFingerprintHandlesRecursiveTypesDeterministically(t *test decl.SetDeclSurface("type:Node:recursive") sym := symbols.New("Node", symbols.SymbolType, decl, nil) sym.Type = defined - return SemanticExportFingerprint(fingerprintModule(t, sym, nil)) + return SemanticExportFingerprint(fingerprintModule(t, sym, nil, nil)) } if first, second := makeType(), makeType(); first == "" || first != second { t.Fatalf("recursive fingerprints unstable: %q, %q", first, second) diff --git a/internal/project/modules.go b/internal/project/modules.go index ea448c8f..5207a576 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -11,10 +11,12 @@ import ( "compiler/internal/ir/hir" "compiler/internal/ir/mir" "compiler/internal/phase" + "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/flowresult" - "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/ownershipresult" + "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -65,7 +67,7 @@ type Module struct { Phase phase.Phase // Parsed syntax tree. AST *ast.Module - // TypedASTNodes indexes final AST after semantic expansion. + // TypedASTNodes indexes source and typechecker-generated expressions. TypedASTNodes map[ast.NodeID]ast.Node // Canonical IR slots. HIR *hir.Module @@ -79,86 +81,16 @@ type Module struct { // Generic declaration syntax and semantic shells produced by collection. // Fresh incremental contexts reindex this immutable phase artifact. namedTypeDeclarations map[string]namedTypeDeclaration - // Grouped semantic analysis metadata. - Semantics *SemanticInfo + // Staged symbol/scope graph for current semantic generation. + Bindings *bindingresult.Result + // Finalized module constants plus mutable constant-query cache. + ConstValues map[symbols.SymbolID]constvalue.Value + // Base typechecker result for current semantic generation. + Typechecking *typecheckresult.Result // Import alias -> resolved module import. Imports map[string]ResolvedImport } -type SemanticInfo struct { - BlockScopes map[ast.NodeID]*symbols.Scope - ResolvedSymbols map[ast.NodeID]*symbols.Symbol - // ExpandedDefaultBindings marks cloned NodeIDs injected by - // call-site default expansion. These idents must resolve - // through the declaration module's ResolvedSymbols instead of - // caller scope. The Binding.Local gate prevents pointer-escape - // misclassification. - ExpandedDefaultBindings map[ast.NodeID]struct{} - ExprTypes map[ast.NodeID]typeinfo.Type - CaseTests map[ast.NodeID]flowresult.CaseTest - Matches map[ast.NodeID]flowresult.Match - ConstValues map[symbols.SymbolID]constvalue.Value - MethodSets map[string][]*symbols.Symbol - MethodSymbol map[ast.NodeID]*symbols.Symbol - InterfaceImplementations map[ast.NodeID][]InterfaceImplementation - // ImplicitConversions is typechecker proof consumed directly by HIR. - ImplicitConversions map[ast.NodeID]typeinfo.Conversion - ImplicitCallArguments map[ast.NodeID]typeinfo.Type - CompilerCalls map[ast.NodeID]CompilerCall - StringConcatenations map[ast.NodeID]struct{} - VariantConstructions map[ast.NodeID]VariantConstruction - ForIterations map[ast.NodeID]ForIteration - OperationFunctions []*symbols.Symbol -} - -// VariantConstruction is typechecker proof consumed by HIR without resolving -// source paths or revalidating constructor fields. -type VariantConstruction struct { - EnumType typeinfo.Type - Case int - Payload typeinfo.Type - Value ast.Expr -} - -type ForIterationKind uint8 - -const ( - ForIterationRange ForIterationKind = iota - ForIterationSequence -) - -// ForIteration is typechecker-owned evidence consumed by HIR lowering. -// Generated symbols carry hidden loop state; source bindings remain body-scoped. -type ForIteration struct { - Kind ForIterationKind - GuaranteedEntry bool - - ElementType typeinfo.Type - CarrierType typeinfo.Type - Carrier *symbols.Symbol - Cursor *symbols.Symbol - End *symbols.Symbol - Ordinal *symbols.Symbol - Index *symbols.Symbol - Value *symbols.Symbol -} - -// CompilerCall is typechecker-owned dispatch evidence consumed by HIR. -type CompilerCall struct { - Operation symbols.CompilerOp - Kind intrinsics.FunctionKind -} - -// InterfaceImplementation is typechecker proof that one declared method can -// materialize an interface slot. HIR consumes this proof without resolving the -// concrete method set again. -type InterfaceImplementation struct { - MethodName string - Symbol *symbols.Symbol - CallableType *typeinfo.FuncType - OwnerKey string -} - func (m *Module) DefiningModuleKey() symbols.DefiningModuleKey { if m == nil { return symbols.DefiningModuleKey{} @@ -179,63 +111,54 @@ func (m *Module) TypeDeclarationIdentity(name string) string { return m.Key + "::" + name } -func NewSemanticInfo() *SemanticInfo { - return &SemanticInfo{ - BlockScopes: make(map[ast.NodeID]*symbols.Scope), - ResolvedSymbols: make(map[ast.NodeID]*symbols.Symbol), - ExpandedDefaultBindings: make(map[ast.NodeID]struct{}), - ExprTypes: make(map[ast.NodeID]typeinfo.Type), - CaseTests: make(map[ast.NodeID]flowresult.CaseTest), - Matches: make(map[ast.NodeID]flowresult.Match), - ConstValues: make(map[symbols.SymbolID]constvalue.Value), - MethodSets: make(map[string][]*symbols.Symbol), - MethodSymbol: make(map[ast.NodeID]*symbols.Symbol), - InterfaceImplementations: make(map[ast.NodeID][]InterfaceImplementation), - ImplicitConversions: make(map[ast.NodeID]typeinfo.Conversion), - ImplicitCallArguments: make(map[ast.NodeID]typeinfo.Type), - CompilerCalls: make(map[ast.NodeID]CompilerCall), - StringConcatenations: make(map[ast.NodeID]struct{}), - VariantConstructions: make(map[ast.NodeID]VariantConstruction), - ForIterations: make(map[ast.NodeID]ForIteration), - OperationFunctions: make([]*symbols.Symbol, 0), +// ExpandedDefaultBinding resolves declaration-module symbols paired with generated +// default-expression markers. Local remains false for caller escape analysis. +func (m *Module) ExpandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { + if m == nil || m.Bindings == nil || m.Typechecking == nil || ident == nil { + return place.Binding{}, false } -} - -// MatchCases exposes resolved case indexes without leaking match artifacts -// into CFG's source-topology package. -// ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its -// body before its first condition check. -func (s *SemanticInfo) ForLoopGuaranteedEntry(id ast.NodeID) bool { - if s == nil { - return false + if _, ok := m.Typechecking.ExpandedDefaultBindings[ident.ID()]; !ok { + return place.Binding{}, false } - iteration, found := s.ForIterations[id] - return found && iteration.GuaranteedEntry + return place.Binding{Symbol: m.Bindings.NodeSymbols[ident.ID()]}, true } -func (s *SemanticInfo) MatchCases(id ast.NodeID) ([]int, bool) { - if s == nil { - return nil, false +// RebuildTypedASTIndex publishes canonical node lookup after typechecking. +func (m *Module) RebuildTypedASTIndex() { + if m == nil { + return } - match, found := s.Matches[id] - if !found { - return nil, false + m.TypedASTNodes = ast.Index(m.AST) + if m.Typechecking == nil { + return } - cases := make([]int, len(match.Arms)) - for index, arm := range match.Arms { - if arm.Case < 0 || arm.Case >= len(match.Cases) { - return nil, false + for _, args := range m.Typechecking.EffectiveCallArguments { + for _, arg := range args { + ast.Inspect(arg, func(node ast.Node) bool { + if node != nil { + m.TypedASTNodes[node.ID()] = node + } + return true + }) } - cases[index] = arm.Case } - return cases, true } func (m *Module) ResetSemanticData() { if m == nil { return } - m.Semantics = NewSemanticInfo() + m.Bindings = bindingresult.New() + m.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + m.Typechecking = nil +} + +// BaseExprType returns canonical base typechecker evidence when available. +func (m *Module) BaseExprType(id ast.NodeID) typeinfo.Type { + if m == nil || m.Typechecking == nil { + return nil + } + return m.Typechecking.ExprTypes[id] } // EffectiveExprType returns per-use flow refinement when available and falls @@ -249,10 +172,7 @@ func (m *Module) EffectiveExprType(id ast.NodeID) typeinfo.Type { return typ } } - if m.Semantics == nil { - return nil - } - return m.Semantics.ExprTypes[id] + return m.BaseExprType(id) } // resetToPhase retains artifacts through phase and invalidates downstream data. @@ -263,12 +183,14 @@ func (m *Module) resetToPhase(retained phase.Phase) { m.Phase = retained if retained <= phase.Parsed { m.ModuleScope = nil - m.Semantics = nil + m.Bindings = nil + m.ConstValues = nil } if retained < phase.Collected { m.namedTypeDeclarations = nil } if retained < phase.Typechecked { + m.Typechecking = nil m.SemanticExportFingerprint = "" m.TypedASTNodes = nil } diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index 02f84644..bb342692 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -13,6 +13,7 @@ import ( "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -33,11 +34,10 @@ func TestCompilerContextAddModuleCanonicalizesFilePath(t *testing.T) { } func moduleWithArtifacts() *Module { - return &Module{ + module := &Module{ Phase: phase.Backend, SemanticExportFingerprint: "semantic API", ModuleScope: symbols.NewScope(nil), - Semantics: NewSemanticInfo(), TypedASTNodes: map[ast.NodeID]ast.Node{1: &ast.BadStmt{}}, HIR: &hir.Module{}, CFG: &cfg.Module{Functions: []*cfg.Graph{{}}}, @@ -46,38 +46,46 @@ func moduleWithArtifacts() *Module { MIR: &mir.Module{}, LLVMIR: "stale IR", } + module.ResetSemanticData() + module.Typechecking = typecheckresult.New() + module.Typechecking.ExprTypes[1] = typeinfo.DefaultIntegerType() + return module } func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { tests := []struct { - phase phase.Phase - scope bool - semantics bool - exportAPI bool - astNodes bool - hir bool - cfg bool - flow bool - ownership bool - mir bool - llvm bool + phase phase.Phase + scope bool + bindings bool + constValues bool + typechecking bool + exportAPI bool + astNodes bool + hir bool + cfg bool + flow bool + ownership bool + mir bool + llvm bool }{ {phase: phase.Parsed}, - {phase: phase.Typechecked, scope: true, semantics: true, exportAPI: true, astNodes: true}, - {phase: phase.CFG, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true}, - {phase: phase.FlowTyped, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.DefiniteInit, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.Ownership, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.Usage, scope: true, semantics: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.HIR, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, - {phase: phase.MIR, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, - {phase: phase.Backend, scope: true, semantics: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, + {phase: phase.Typechecked, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true}, + {phase: phase.CFG, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true}, + {phase: phase.FlowTyped, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, + {phase: phase.DefiniteInit, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, + {phase: phase.Ownership, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, + {phase: phase.Usage, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, + {phase: phase.HIR, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, + {phase: phase.MIR, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, + {phase: phase.Backend, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, } for _, test := range tests { module := moduleWithArtifacts() module.resetToPhase(test.phase) if module.Phase != test.phase || (module.ModuleScope != nil) != test.scope || - (module.Semantics != nil) != test.semantics || (module.HIR != nil) != test.hir || + (module.Bindings != nil) != test.bindings || (module.ConstValues != nil) != test.constValues || + (module.Typechecking != nil) != test.typechecking || + (module.HIR != nil) != test.hir || (module.TypedASTNodes != nil) != test.astNodes || (module.SemanticExportFingerprint != "") != test.exportAPI || (module.CFG != nil) != test.cfg || @@ -90,6 +98,51 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { } } +func TestModuleResetSemanticDataInitializesCurrentResults(t *testing.T) { + module := &Module{Typechecking: typecheckresult.New()} + module.ResetSemanticData() + if module.Bindings == nil || module.Bindings.BlockScopes == nil || module.Bindings.NodeSymbols == nil || + module.Bindings.MethodsByReceiver == nil || module.Bindings.MethodsByDecl == nil || + module.Bindings.OperationFunctions == nil || module.ConstValues == nil || module.Typechecking != nil { + t.Fatalf("semantic reset = %#v", module) + } +} + +func TestModuleExprTypeEvidenceFollowsPhaseLifecycle(t *testing.T) { + module := moduleWithArtifacts() + base := module.BaseExprType(1) + if base == nil { + t.Fatal("typechecked module has no base expression type") + } + if got := module.EffectiveExprType(1); got != module.Flow.ExprTypes[1] { + t.Fatalf("effective type = %#v, want flow refinement", got) + } + + module.Flow = nil + if got := module.EffectiveExprType(1); got != base { + t.Fatalf("effective type without flow = %#v, want base type %#v", got, base) + } + module.resetToPhase(phase.Typechecked) + if module.BaseExprType(1) != base { + t.Fatal("typechecked reset discarded base expression type") + } + module.resetToPhase(phase.Parsed) + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("parsed reset retained expression type evidence") + } +} + +func TestModuleExprTypeEvidenceHandlesMissingTypecheckResult(t *testing.T) { + var module *Module + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("nil module returned expression type evidence") + } + module = &Module{} + if module.BaseExprType(1) != nil || module.EffectiveExprType(1) != nil { + t.Fatal("module without typecheck result returned expression type evidence") + } +} + func TestModuleResetToPhaseRetainsCFGIdentity(t *testing.T) { module := moduleWithArtifacts() graph := module.CFG.Functions[0] diff --git a/internal/semantics/binder/binder.go b/internal/semantics/binder/binder.go index 8cb40651..d8e68efd 100644 --- a/internal/semantics/binder/binder.go +++ b/internal/semantics/binder/binder.go @@ -39,7 +39,7 @@ func (b *binder) bindModule() { } return true }) - slices.SortFunc(b.module.Semantics.OperationFunctions, func(left, right *symbols.Symbol) int { + slices.SortFunc(b.module.Bindings.OperationFunctions, func(left, right *symbols.Symbol) int { return cmp.Compare(left.Name, right.Name) }) b.validateTypeDeclCycles() @@ -52,7 +52,7 @@ func (b *binder) bindFunctionDecl(fn *ast.FnDecl) { } fnType := typeinfo.FuncTypeFromDeclWithOptions(fn, project.TypeSyntaxOptions(b.ctx, b.module, nil, false)) if fn.Receiver != nil { - if sym := b.module.Semantics.MethodSymbol[fn.ID()]; sym != nil { + if sym := b.module.Bindings.MethodsByDecl[fn.ID()]; sym != nil { sym.BindType(fnType) } return @@ -60,7 +60,7 @@ func (b *binder) bindFunctionDecl(fn *ast.FnDecl) { if sym := b.moduleScopeSymbol(fn.Name.Name); sym != nil { sym.BindType(fnType) if len(fnType.Params) > 0 { - b.module.Semantics.OperationFunctions = append(b.module.Semantics.OperationFunctions, sym) + b.module.Bindings.OperationFunctions = append(b.module.Bindings.OperationFunctions, sym) } } } diff --git a/internal/semantics/binder/binder_test.go b/internal/semantics/binder/binder_test.go index 898364de..722a7b28 100644 --- a/internal/semantics/binder/binder_test.go +++ b/internal/semantics/binder/binder_test.go @@ -39,7 +39,7 @@ fn Alpha(value: Value, extra: i32) {}` if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } - functions := module.Semantics.OperationFunctions + functions := module.Bindings.OperationFunctions if len(functions) != 2 || functions[0].Name != "Alpha" || functions[1].Name != "Zebra" { t.Fatalf("operation functions = %#v, want [Alpha Zebra]", functions) } diff --git a/internal/semantics/bindingresult/result.go b/internal/semantics/bindingresult/result.go new file mode 100644 index 00000000..865fc760 --- /dev/null +++ b/internal/semantics/bindingresult/result.go @@ -0,0 +1,25 @@ +// Package bindingresult defines one staged symbol/scope graph completed by collection, binding, resolution, and typechecking. +package bindingresult + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/semantics/symbols" +) + +type Result struct { + BlockScopes map[ast.NodeID]*symbols.Scope + NodeSymbols map[ast.NodeID]*symbols.Symbol + MethodsByReceiver map[string][]*symbols.Symbol + MethodsByDecl map[ast.NodeID]*symbols.Symbol + OperationFunctions []*symbols.Symbol +} + +func New() *Result { + return &Result{ + BlockScopes: make(map[ast.NodeID]*symbols.Scope), + NodeSymbols: make(map[ast.NodeID]*symbols.Symbol), + MethodsByReceiver: make(map[string][]*symbols.Symbol), + MethodsByDecl: make(map[ast.NodeID]*symbols.Symbol), + OperationFunctions: make([]*symbols.Symbol, 0), + } +} diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index 039bd59a..863cbf0d 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -74,7 +74,7 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { } targetKey := typeinfo.TypeText(targetType) var previous *symbols.Symbol - for _, item := range c.module.Semantics.MethodSets[targetKey] { + for _, item := range c.module.Bindings.MethodsByReceiver[targetKey] { if item != nil && item.Name == fn.Name.Name { previous = item break @@ -88,8 +88,8 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) sym.DefiningModule = c.module.DefiningModuleKey() sym.Scope = symbols.NewScope(c.module.ModuleScope) - c.module.Semantics.MethodSets[targetKey] = append(c.module.Semantics.MethodSets[targetKey], sym) - c.module.Semantics.MethodSymbol[fn.ID()] = sym + c.module.Bindings.MethodsByReceiver[targetKey] = append(c.module.Bindings.MethodsByReceiver[targetKey], sym) + c.module.Bindings.MethodsByDecl[fn.ID()] = sym return } sym := symbols.New(fn.Name.Name, symbols.SymbolFunc, fn, ast.LocOf(fn.Name)) @@ -157,7 +157,7 @@ func (c *collector) collectConcreteTypeDecl(decl ast.TypeDecl) { problems.ReportRedeclaration(c.ctx.Diagnostics, sym.Scope, err.Error(), variant.Name.Name, variant.Name.Location) continue } - c.module.Semantics.ResolvedSymbols[variant.Name.ID()] = variantSymbol + c.module.Bindings.NodeSymbols[variant.Name.ID()] = variantSymbol } } } diff --git a/internal/semantics/collector/collector_test.go b/internal/semantics/collector/collector_test.go index 118050ba..112c463a 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -47,7 +47,7 @@ fn (self: Counter) Read() -> i32 { return self.value; }` if !ok || function == nil || function.DefiningModule != want { t.Fatalf("function defining module = %#v, want %#v", function, want) } - methods := module.Semantics.MethodSets["Counter"] + methods := module.Bindings.MethodsByReceiver["Counter"] if len(methods) != 1 || methods[0] == nil || methods[0].DefiningModule != want { t.Fatalf("method defining module = %#v, want %#v", methods, want) } @@ -121,7 +121,7 @@ type Alias = Result;` } enumType := enumDecl.Type.(*ast.EnumType) for index, variant := range enumType.Variants { - if module.Semantics.ResolvedSymbols[variant.Name.ID()] != children[index] { + if module.Bindings.NodeSymbols[variant.Name.ID()] != children[index] { t.Fatalf("variant %s identifier does not resolve to child symbol", variant.Name.Name) } } diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index 32dbb506..517fa018 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -23,11 +23,8 @@ func Evaluate(ctx *project.CompilerContext, module *project.Module) { if ctx == nil || module == nil || module.ModuleScope == nil { return } - if module.Semantics == nil { - module.Semantics = project.NewSemanticInfo() - } - if module.Semantics.ConstValues == nil { - module.Semantics.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + if module.ConstValues == nil { + module.ConstValues = make(map[symbols.SymbolID]constvalue.Value) } e := &evaluator{ ctx: ctx, @@ -44,12 +41,12 @@ func Evaluate(ctx *project.CompilerContext, module *project.Module) { // FinalizeValues recomputes module constants after typechecking assigns final // symbol types. Local const cache entries remain available to later queries. func FinalizeValues(ctx *project.CompilerContext, module *project.Module) { - if ctx == nil || module == nil || module.ModuleScope == nil || module.Semantics == nil { + if ctx == nil || module == nil || module.ModuleScope == nil { return } for _, sym := range module.ModuleScope.Symbols() { if sym != nil && sym.Kind == symbols.SymbolConst { - delete(module.Semantics.ConstValues, sym.ID) + delete(module.ConstValues, sym.ID) } } Evaluate(ctx, module) @@ -64,11 +61,8 @@ func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *s if scope == nil && module.ModuleScope == nil { return nil, false } - if module.Semantics == nil { - module.Semantics = project.NewSemanticInfo() - } - if module.Semantics.ConstValues == nil { - module.Semantics.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + if module.ConstValues == nil { + module.ConstValues = make(map[symbols.SymbolID]constvalue.Value) } e := &evaluator{ ctx: ctx, @@ -79,10 +73,10 @@ func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *s } func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) (constvalue.Value, bool) { - if e == nil || e.module == nil || e.module.Semantics == nil || sym == nil { + if e == nil || e.module == nil || sym == nil { return nil, false } - if value, ok := e.module.Semantics.ConstValues[sym.ID]; ok { + if value, ok := e.module.ConstValues[sym.ID]; ok { return value, true } if _, ok := e.inProgress[sym.ID]; ok { @@ -117,56 +111,61 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) ( if !ok { return nil, false } - e.module.Semantics.ConstValues[sym.ID] = value + e.module.ConstValues[sym.ID] = value return value, true } func (e *evaluator) evalExpr(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { - if construction, ok := e.module.Semantics.VariantConstructions[expr.ID()]; ok { - if !typeinfo.IsImplicitCopyType(construction.EnumType) { - return nil, false - } - descriptor, variant := typeinfo.VariantDescriptorOf(construction.EnumType) - if !variant || construction.Case < 0 || construction.Case >= len(descriptor.Cases) { - return nil, false - } - if construction.Value != nil { - if literal, ok := construction.Value.(*ast.StructLit); ok { - payload, structured := typeinfo.Underlying(construction.Payload).(*typeinfo.StructType) - if !structured || payload == nil { - return nil, false - } - valuesByName := make(map[string]ast.Expr, len(literal.Fields)) - for _, field := range literal.Fields { - if field.Name != nil { - valuesByName[field.Name.Name] = field.Value - } - } - fields := make([]constvalue.Value, len(payload.Fields)) - for index, field := range payload.Fields { - valueExpr := valuesByName[field.Name] - value, ok := e.evalExpr(scope, valueExpr, field.Type) - if !ok { + if e.module.Typechecking != nil { + if construction, ok := e.module.Typechecking.VariantConstructions[expr.ID()]; ok { + if !typeinfo.IsImplicitCopyType(construction.EnumType) { + return nil, false + } + descriptor, variant := typeinfo.VariantDescriptorOf(construction.EnumType) + if !variant || construction.Case < 0 || construction.Case >= len(descriptor.Cases) { + return nil, false + } + if construction.Value != nil { + if literal, ok := construction.Value.(*ast.StructLit); ok { + payload, structured := typeinfo.Underlying(construction.Payload).(*typeinfo.StructType) + if !structured || payload == nil { return nil, false } - fields[index] = value + valuesByName := make(map[string]ast.Expr, len(literal.Fields)) + for _, field := range literal.Fields { + if field.Name != nil { + valuesByName[field.Name.Name] = field.Value + } + } + fields := make([]constvalue.Value, len(payload.Fields)) + for index, field := range payload.Fields { + valueExpr := valuesByName[field.Name] + value, ok := e.evalExpr(scope, valueExpr, field.Type) + if !ok { + return nil, false + } + fields[index] = value + } + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, fields) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, fields) - } - value, ok := e.evalExpr(scope, construction.Value, construction.Payload) - if !ok { - return nil, false + value, ok := e.evalExpr(scope, construction.Value, construction.Payload) + if !ok { + return nil, false + } + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, []constvalue.Value{value}) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, []constvalue.Value{value}) + return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, nil) } - return constvalue.NewVariant(descriptor.Identity, typeinfo.TypeText(construction.EnumType), construction.Case, nil) } if node, ok := expr.(*ast.IsExpr); ok { - test, found := e.module.Semantics.CaseTests[node.ID()] + if e.module.Typechecking == nil { + return nil, false + } + test, found := e.module.Typechecking.CaseTests[node.ID()] if !found || test.Family != typeinfo.VariantFamilyNamed { return nil, false } - value, ok := e.evalExpr(scope, node.Value, e.module.Semantics.ExprTypes[node.Value.ID()]) + value, ok := e.evalExpr(scope, node.Value, e.module.BaseExprType(node.Value.ID())) variant, constant := value.(*constvalue.VariantConst) if !ok || !constant || variant == nil { return nil, false diff --git a/internal/semantics/consteval/consteval_test.go b/internal/semantics/consteval/consteval_test.go index a614b042..b8ada0db 100644 --- a/internal/semantics/consteval/consteval_test.go +++ b/internal/semantics/consteval/consteval_test.go @@ -11,6 +11,7 @@ import ( "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/resolver" + "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -37,6 +38,20 @@ func constevalModule(t *testing.T, src string) (*project.Module, *diagnostics.Di return module, diag } +func TestEvaluateInitializesOnlyConstValues(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + module := &project.Module{ModuleScope: symbols.NewScope(nil)} + + Evaluate(project.New(".", peeper.SourceExt, diag), module) + + if module.ConstValues == nil { + t.Fatal("Evaluate did not initialize ConstValues") + } + if module.Bindings != nil { + t.Fatalf("Evaluate initialized Bindings: %#v", module.Bindings) + } +} + func TestEvaluateTopLevelConstExpressions(t *testing.T) { module, diag := constevalModule(t, `const A = 1 + 2 * 3; const B = A + 4; @@ -189,9 +204,9 @@ func TestEvaluateStringConst(t *testing.T) { if !ok || sym == nil { t.Fatalf("missing symbol Name") } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.StringConst) + got, ok := module.ConstValues[sym.ID].(*constvalue.StringConst) if !ok || got == nil || got.Text() != "puts" || got.TypeText() != "cstr" { - t.Fatalf("Name = %#v, want str puts cstr", module.Semantics.ConstValues[sym.ID]) + t.Fatalf("Name = %#v, want str puts cstr", module.ConstValues[sym.ID]) } } @@ -201,9 +216,9 @@ func assertIntConst(t *testing.T, module *project.Module, name, want, wantType s if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.IntConst) + got, ok := module.ConstValues[sym.ID].(*constvalue.IntConst) if !ok || got == nil || got.Text() != want || (wantType != "" && got.TypeText() != wantType) { - t.Fatalf("%s = %#v, want int %s %s", name, module.Semantics.ConstValues[sym.ID], want, wantType) + t.Fatalf("%s = %#v, want int %s %s", name, module.ConstValues[sym.ID], want, wantType) } } @@ -213,8 +228,8 @@ func assertBoolConst(t *testing.T, module *project.Module, name string, want boo if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.Semantics.ConstValues[sym.ID].(*constvalue.BoolConst) + got, ok := module.ConstValues[sym.ID].(*constvalue.BoolConst) if !ok || got == nil || got.Bool() != want { - t.Fatalf("%s = %#v, want bool %v", name, module.Semantics.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %v", name, module.ConstValues[sym.ID], want) } } diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index bd4e1c86..c21787c1 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -5,8 +5,8 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir" "compiler/internal/ir/cfg" - "compiler/internal/semantics/flowresult" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" ) type state map[symbols.SymbolID]struct{} @@ -29,7 +29,7 @@ func Check( nodes map[ast.NodeID]ast.Node, blockScopes map[ast.NodeID]*symbols.Scope, resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]flowresult.Match, + matches map[ast.NodeID]typecheckresult.Match, diag *diagnostics.DiagnosticBag, ) { if graphs == nil { @@ -53,7 +53,7 @@ func analyzeFunction( nodes map[ast.NodeID]ast.Node, blockScopes map[ast.NodeID]*symbols.Scope, resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]flowresult.Match, + matches map[ast.NodeID]typecheckresult.Match, diag *diagnostics.DiagnosticBag, ) *functionResult { sites, order, tracked := indexSites(fn, graph, nodes, blockScopes) @@ -99,7 +99,7 @@ func analyzeFunction( match, found := matches[ast.NodeID(node.cfgSite.NodeID)] if found { if arm, armFound := match.Arm(edge.Case); armFound { - for _, field := range arm.Fields { + for _, field := range arm.Bindings { if field.Binding == nil { continue } diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index edd210d0..96cfe3c4 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -37,10 +37,10 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) symbol, found := module.ModuleScope.Lookup("choose") if !found || symbol == nil { @@ -58,9 +58,9 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, fn, graph, module.TypedASTNodes, - module.Semantics.BlockScopes, - module.Semantics.ResolvedSymbols, - module.Semantics.Matches, + module.Bindings.BlockScopes, + module.Bindings.NodeSymbols, + module.Typechecking.Matches, diag, ) return result, diag, module @@ -185,7 +185,7 @@ fn choose(result: Result) -> i32 { } fn := module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) - binding := module.Semantics.ResolvedSymbols[match.Arms[0].Fields[0].Binding.ID()] + binding := module.Bindings.NodeSymbols[match.Arms[0].Fields[0].Binding.ID()] returnID := ir.NodeID(match.Arms[0].Body.Stmts[0].ID()) for _, block := range module.CFG.Function(ir.NodeID(fn.ID())).Blocks { for _, cfgSite := range block.Sites { diff --git a/internal/semantics/flowresult/result.go b/internal/semantics/flowresult/result.go index 6f872faf..c34bf693 100644 --- a/internal/semantics/flowresult/result.go +++ b/internal/semantics/flowresult/result.go @@ -8,6 +8,7 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -42,12 +43,8 @@ func (p PayloadAccess) AppliesTo(storage []place.Origin) bool { } type CaseTest struct { - SubjectID ast.NodeID - Case int - CaseWhenTrue bool - CaseCount int - PayloadPath []int - Family typeinfo.VariantFamily + typecheckresult.CaseTest + PayloadPath []int } type VariantFieldAccess struct { @@ -58,41 +55,6 @@ type VariantFieldAccess struct { Type typeinfo.Type } -// Match is typechecker-owned case and binding evidence consumed by CFG and -// later semantic phases without resolving source paths again. -type Match struct { - SubjectID ast.NodeID - EnumType typeinfo.Type - Cases []typeinfo.VariantCase - Arms []MatchArm -} - -type MatchArm struct { - ArmID ast.NodeID - BodyID ast.NodeID - Case int - Payload typeinfo.Type - Fields []MatchField -} - -type MatchField struct { - Field int - WholePayload bool - Type typeinfo.Type - Binding *symbols.Symbol - Discard bool -} - -// Arm returns resolved evidence for one case-labelled CFG edge. -func (m Match) Arm(caseIndex int) (MatchArm, bool) { - for _, arm := range m.Arms { - if arm.Case == caseIndex { - return arm, true - } - } - return MatchArm{}, false -} - type Result struct { SiteFacts map[ir.NodeID]map[cfg.SiteID]Facts ExprTypes map[ast.NodeID]typeinfo.Type diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index e63ca068..8c3a1216 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -34,7 +34,7 @@ func (a *analyzer) checkExpr( switch e := expr.(type) { case *ast.Ident: a.checkIdent(scope, e, st, use) - sym := a.module.Semantics.ResolvedSymbols[e.ID()] + sym := a.module.Bindings.NodeSymbols[e.ID()] if _, reference := referenceMutability(sym); reference { loans.useReference(sym) return @@ -108,7 +108,7 @@ func (a *analyzer) checkExpr( case *ast.UnaryExpr: a.checkExpr(scope, e.Expr, st, useRead, loans, false) case *ast.BinaryExpr: - if _, concat := a.module.Semantics.StringConcatenations[e.ID()]; concat { + if _, concat := a.module.Typechecking.StringConcatenations[e.ID()]; concat { a.checkExpr(scope, e.Left, st, useConsume, loans, false) a.checkExpr(scope, e.Right, st, useRead, loans, false) return @@ -132,16 +132,6 @@ func (a *analyzer) checkLiteralFields(scope *symbols.Scope, fields []ast.StructL } } -func (a *analyzer) expandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { - if a == nil || a.module == nil || a.module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := a.module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: a.module.Semantics.ResolvedSymbols[ident.ID()]}, true -} - func (a *analyzer) checkAddressExpr( scope *symbols.Scope, expr *ast.AddressExpr, @@ -171,8 +161,8 @@ func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, } var sym *symbols.Symbol var ok bool - if a.module != nil && a.module.Semantics != nil { - sym = a.module.Semantics.ResolvedSymbols[ident.ID()] + if a.module != nil && a.module.Bindings != nil { + sym = a.module.Bindings.NodeSymbols[ident.ID()] ok = sym != nil } if !ok { @@ -257,6 +247,7 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, if call == nil { return } + args := a.module.Typechecking.CallArgumentsOrSource(call) temporaryMark := len(loans.temporary) reservationMark := len(loans.reserved) defer func() { @@ -264,16 +255,16 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, loans.reserved = loans.reserved[:reservationMark] }() if selector, ok := call.Callee.(*ast.SelectorExpr); ok && selector != nil { - if a.checkMethodCall(scope, selector, call, st, loans) { + if a.checkMethodCall(scope, selector, call, args, st, loans) { a.activateCallReservations(call, reservationMark, loans) } return } a.checkExpr(scope, call.Callee, st, useRead, loans, false) if ident, ok := call.Callee.(*ast.Ident); ok && ident != nil { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] + sym := a.module.Bindings.NodeSymbols[ident.ID()] if sym != nil && sym.CompilerOp == symbols.CompilerOpAlloc { - for i, arg := range call.Args { + for i, arg := range args { use := useRead if i == 0 { use = useConsume @@ -288,7 +279,7 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, panic("missing from_bytes intrinsic definition") } fn := definition.Signature(nil, a.ctx.Target) - for i, arg := range call.Args { + for i, arg := range args { if i >= len(fn.Params) { a.checkExpr(scope, arg, st, useRead, loans, false) continue @@ -299,13 +290,13 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, } } fn, ok := a.exprType(call.Callee).(*typeinfo.FuncType) - if !ok || fn == nil || len(call.Args) != len(fn.Params) { - for _, arg := range call.Args { + if !ok || fn == nil || len(args) != len(fn.Params) { + for _, arg := range args { a.checkExpr(scope, arg, st, useRead, loans, false) } return } - for i, arg := range call.Args { + for i, arg := range args { a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) } a.activateCallReservations(call, reservationMark, loans) @@ -315,6 +306,7 @@ func (a *analyzer) checkMethodCall( scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr, + args []ast.Expr, st state, loans *loanContext, ) bool { @@ -323,19 +315,19 @@ func (a *analyzer) checkMethodCall( if selector != nil { a.checkExpr(scope, selector.Expr, st, useRead, loans, false) } - for _, arg := range call.Args { + for _, arg := range args { a.checkExpr(scope, arg, st, useRead, loans, false) } return false } a.checkCallArgument(scope, selector.Expr, fn.Params[0], call, st, loans) - if len(call.Args)+1 != len(fn.Params) { - for _, arg := range call.Args { + if len(args)+1 != len(fn.Params) { + for _, arg := range args { a.checkExpr(scope, arg, st, useRead, loans, false) } return false } - for i, arg := range call.Args { + for i, arg := range args { a.checkCallArgument(scope, arg, fn.Params[i+1], call, st, loans) } return true @@ -452,7 +444,7 @@ func (a *analyzer) pointerOrigin(scope *symbols.Scope, expr ast.Expr, st state) if e.Mode != ast.AddressRaw { return pointerOrigin{}, false } - root, ok := place.LocalRoot(scope, a.module.ModuleScope, e.Expr, a.exprType, a.expandedDefaultBinding) + root, ok := place.LocalRoot(scope, a.module.ModuleScope, e.Expr, a.exprType, a.module.ExpandedDefaultBinding) if !ok || root == nil { return pointerOrigin{}, false } @@ -482,8 +474,8 @@ func (a *analyzer) pointerOrigin(scope *symbols.Scope, expr ast.Expr, st state) } var sym *symbols.Symbol var found bool - if a.module != nil && a.module.Semantics != nil { - sym = a.module.Semantics.ResolvedSymbols[e.ID()] + if a.module != nil && a.module.Bindings != nil { + sym = a.module.Bindings.NodeSymbols[e.ID()] found = sym != nil } if !found { diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 4cc36d94..30f0b842 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -9,10 +9,10 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" - "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -57,7 +57,7 @@ type state struct { // value-flow rules from becoming ad hoc type rules. func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult.Result { result := make(ownershipresult.Result) - if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Semantics == nil || module.CFG == nil { + if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Bindings == nil || module.CFG == nil { return result } for _, graph := range module.CFG.Functions { @@ -89,7 +89,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult case *ast.FnDecl: var sym *symbols.Symbol if node.Receiver != nil { - sym = module.Semantics.MethodSymbol[node.ID()] + sym = module.Bindings.MethodsByDecl[node.ID()] } else { sym, _ = module.ModuleScope.Lookup(node.Name.Name) } @@ -107,7 +107,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult } func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast.FnDecl, scope *symbols.Scope, cfgFn *cfg.Graph, cleanup *ownershipresult.CleanupPlan) { - if ctx == nil || module == nil || module.Semantics == nil || fn == nil || fn.Body == nil || scope == nil || cfgFn == nil || cleanup == nil { + if ctx == nil || module == nil || module.Bindings == nil || fn == nil || fn.Body == nil || scope == nil || cfgFn == nil || cleanup == nil { return } sites, order := indexSites(module, cfgFn, scope) @@ -127,7 +127,7 @@ func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) (map[cfg.SiteID]*site, []cfg.SiteID) { sites := make(map[cfg.SiteID]*site) order := make([]cfg.SiteID, 0) - if module == nil || module.Semantics == nil || cfgFn == nil || scope == nil { + if module == nil || module.Bindings == nil || cfgFn == nil || scope == nil { return sites, order } nodes := module.TypedASTNodes @@ -139,7 +139,7 @@ func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) if flowSite == nil { continue } - resolvedScope := module.Semantics.BlockScopes[ast.NodeID(flowSite.ScopeID)] + resolvedScope := module.Bindings.BlockScopes[ast.NodeID(flowSite.ScopeID)] if resolvedScope == nil { resolvedScope = scope } @@ -196,8 +196,8 @@ func (a *analyzer) run() { next := copyState(a.inStates[id]) if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockNormal { loopID := ast.NodeID(node.cfgBlock.NodeID) - if evidence, found := a.module.Semantics.ForIterations[loopID]; found && - evidence.Kind == project.ForIterationSequence && evidence.Carrier != nil { + if evidence, found := a.module.Typechecking.ForIterations[loopID]; found && + evidence.Kind == typecheckresult.ForIterationSequence && evidence.Carrier != nil { releaseIterationLoans(next, nil, loopID) } } @@ -248,7 +248,7 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { if !ok || node.cfgSite == nil || node.cfgSite.Kind != cfg.SiteTerminator { continue } - match, found := a.module.Semantics.Matches[matchStmt.ID()] + match, found := a.module.Typechecking.Matches[matchStmt.ID()] if !found { continue } @@ -542,8 +542,8 @@ func (a *analyzer) applyStmt(node *site, st state) { a.checkExpr(scope, s.Cond, st, useRead, loans, false) break } - evidence, found := a.module.Semantics.ForIterations[s.ID()] - if !found || evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil { + evidence, found := a.module.Typechecking.ForIterations[s.ID()] + if !found || evidence.Kind != typecheckresult.ForIterationSequence || evidence.Carrier == nil { a.checkExpr(scope, s.Iterable, st, useRead, loans, false) break } @@ -551,7 +551,7 @@ func (a *analyzer) applyStmt(node *site, st state) { a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) origins := a.originsForExpr(s.Iterable) if ident, ok := s.Iterable.(*ast.Ident); ok { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] + sym := a.module.Bindings.NodeSymbols[ident.ID()] if value, found := st.references[sym]; found { origins = referenceOrigins(value) } @@ -572,7 +572,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { if a == nil || node == nil || node.cfgSite == nil || edge.Kind != cfg.EdgeVariantCase { return } - match, found := a.module.Semantics.Matches[ast.NodeID(node.cfgSite.NodeID)] + match, found := a.module.Typechecking.Matches[ast.NodeID(node.cfgSite.NodeID)] if !found { return } @@ -585,10 +585,14 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { return } movesCarrier := matchArmMovesCarrier(arm) - listed := make(map[int]bool, len(arm.Fields)) - for _, field := range arm.Fields { - if !field.WholePayload { + listed := make(map[int]bool, len(arm.Bindings)) + for _, field := range arm.Bindings { + switch field.Projection { + case typecheckresult.MatchPayloadField: listed[field.Field] = field.Discard + case typecheckresult.MatchWholePayload: + default: + panic("ownership: invalid match binding projection") } } if movesCarrier && carrier == nil { @@ -605,8 +609,8 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { delete(st.live, carrier) delete(st.references, carrier) a.cleanup.MatchCarrierMoves[ir.NodeID(arm.BodyID)] = carrier.ID - if len(arm.Fields) == 1 && arm.Fields[0].WholePayload { - if arm.Fields[0].Discard && typeinfo.NeedsDrop(arm.Fields[0].Type) { + if len(arm.Bindings) == 1 && arm.Bindings[0].Projection == typecheckresult.MatchWholePayload { + if arm.Bindings[0].Discard && typeinfo.NeedsDrop(arm.Bindings[0].Type) { a.cleanup.MatchWholePayloadDrops[ir.NodeID(arm.BodyID)] = struct{}{} } } else if payload, payloadFound := typeinfo.Underlying(arm.Payload).(*typeinfo.StructType); payloadFound && payload != nil { @@ -623,7 +627,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { } } } - for _, field := range arm.Fields { + for _, field := range arm.Bindings { binding := field.Binding if binding == nil { continue @@ -644,21 +648,21 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { } } -func (a *analyzer) matchSubjectCarrier(match flowresult.Match) (ast.Expr, *symbols.Symbol) { +func (a *analyzer) matchSubjectCarrier(match typecheckresult.Match) (ast.Expr, *symbols.Symbol) { subject, _ := a.module.TypedASTNodes[match.SubjectID].(ast.Expr) ident, direct := subject.(*ast.Ident) if !direct { return subject, nil } - carrier := a.module.Semantics.ResolvedSymbols[ident.ID()] + carrier := a.module.Bindings.NodeSymbols[ident.ID()] if carrier == nil || (carrier.Kind != symbols.SymbolVar && carrier.Kind != symbols.SymbolConst && carrier.Kind != symbols.SymbolParam) { return subject, nil } return subject, carrier } -func matchArmMovesCarrier(arm flowresult.MatchArm) bool { - for _, field := range arm.Fields { +func matchArmMovesCarrier(arm typecheckresult.MatchArm) bool { + for _, field := range arm.Bindings { if !typeinfo.IsImplicitCopyType(field.Type) { return true } diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 580c6ced..c82c6674 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -48,10 +48,10 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { binder.Bind(ctx, module) resolver.Resolve(ctx, module) typechecker.Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = typechecker.CheckFlow(ctx, module) module.Ownership = Check(ctx, module) @@ -160,8 +160,8 @@ func cleanupSymbolNames(module *project.Module, cleanup []symbols.SymbolID) []st } } } - if module != nil && module.Semantics != nil { - for _, scope := range module.Semantics.BlockScopes { + if module != nil && module.Bindings != nil { + for _, scope := range module.Bindings.BlockScopes { for _, sym := range scope.Symbols() { if sym != nil { names[sym.ID] = sym.Name diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index b5c4b8f9..4d2959fb 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -296,7 +296,7 @@ func overlappingLoan(origins []place.Origin, facts []loanFact, exempt *symbols.S } func (a *analyzer) referenceHolder(expr ast.Expr) *symbols.Symbol { - if a == nil || a.module == nil || a.module.Semantics == nil { + if a == nil || a.module == nil || a.module.Bindings == nil { return nil } for { @@ -320,7 +320,7 @@ func (a *analyzer) referenceHolder(expr ast.Expr) *symbols.Symbol { if node == nil { return nil } - sym := a.module.Semantics.ResolvedSymbols[node.ID()] + sym := a.module.Bindings.NodeSymbols[node.ID()] if _, reference := referenceMutability(sym); reference { return sym } @@ -336,7 +336,7 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo return []referenceLoan{}, false } if ident, ok := expr.(*ast.Ident); ok { - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] + sym := a.module.Bindings.NodeSymbols[ident.ID()] if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.ContainsStoredReference(typ) { if value, found := st.references[sym]; found { return copyReferenceLoans(value), true @@ -366,7 +366,7 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo } return loans, len(loans) > 0 } - construction, constructed := a.module.Semantics.VariantConstructions[expr.ID()] + construction, constructed := a.module.Typechecking.VariantConstructions[expr.ID()] if !constructed || construction.Payload == nil { return []referenceLoan{}, false } @@ -633,7 +633,7 @@ func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) bool) []symbolUse { if a == nil || node == nil || node.cfgSite == nil || (node.cfgSite.Kind != cfg.SiteStatement && node.cfgSite.Kind != cfg.SiteTerminator) || node.stmt == nil || - a.module == nil || a.module.Semantics == nil || include == nil { + a.module == nil || a.module.Bindings == nil || include == nil { return nil } var expressions []ast.Expr @@ -660,22 +660,33 @@ func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) b } var uses []symbolUse - for _, expr := range expressions { + var inspectExpr func(ast.Expr) + inspectExpr = func(expr ast.Expr) { if expr == nil { - continue + return } ast.Inspect(expr, func(current ast.Node) bool { + if call, ok := current.(*ast.CallExpr); ok && call != nil { + inspectExpr(call.Callee) + for _, arg := range a.module.Typechecking.CallArgumentsOrSource(call) { + inspectExpr(arg) + } + return false + } ident, ok := current.(*ast.Ident) if !ok || ident == nil { return true } - sym := a.module.Semantics.ResolvedSymbols[ident.ID()] + sym := a.module.Bindings.NodeSymbols[ident.ID()] if include(sym) { uses = append(uses, symbolUse{symbol: sym, site: ident}) } return true }) } + for _, expr := range expressions { + inspectExpr(expr) + } return uses } diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index f4c075fb..60f73b68 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -7,6 +7,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/problems" "compiler/internal/project" + "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/symbols" "compiler/internal/source" ) @@ -20,8 +21,8 @@ func (r *resolver) resolveModule() { if r == nil || r.module == nil || r.module.AST == nil { return } - if r.module.Semantics == nil { - r.module.Semantics = project.NewSemanticInfo() + if r.module.Bindings == nil { + r.module.Bindings = bindingresult.New() } r.markPendingTopLevelBindings() ast.ForEachDecl(r.module.AST, func(decl ast.Decl) bool { @@ -77,7 +78,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { } var sym *symbols.Symbol if fn.Receiver != nil { - sym = r.module.Semantics.MethodSymbol[fn.ID()] + sym = r.module.Bindings.MethodsByDecl[fn.ID()] } else { sym, _ = r.module.ModuleScope.Lookup(fn.Name.Name) } @@ -119,7 +120,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { name = fn.Receiver.Name.Name } if source, ok := funcScope.Lookup(name); ok && source != nil && source.Kind == symbols.SymbolParam { - r.module.Semantics.ResolvedSymbols[origin.ID()] = source + r.module.Bindings.NodeSymbols[origin.ID()] = source source.Used = true } } @@ -133,7 +134,7 @@ func (r *resolver) resolveBlock(scope *symbols.Scope, block *ast.BlockStmt) { if block == nil { return } - r.module.Semantics.BlockScopes[block.ID()] = scope + r.module.Bindings.BlockScopes[block.ID()] = scope for _, stmt := range block.Stmts { r.resolveStmt(scope, stmt) } @@ -174,12 +175,12 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { bodyScope := symbols.NewScope(scope) if node.Index != nil { if binding := r.resolveLocalBinding(bodyScope, node.Index, symbols.SymbolVar, nil, node.Index, ast.LocOf(node.Index)); binding != nil { - r.module.Semantics.ResolvedSymbols[node.Index.ID()] = binding + r.module.Bindings.NodeSymbols[node.Index.ID()] = binding } } if node.Value != nil { if binding := r.resolveLocalBinding(bodyScope, node.Value, symbols.SymbolVar, nil, node.Value, ast.LocOf(node.Value)); binding != nil { - r.module.Semantics.ResolvedSymbols[node.Value.ID()] = binding + r.module.Bindings.NodeSymbols[node.Value.ID()] = binding } } r.resolveBlock(bodyScope, node.Body) @@ -200,7 +201,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { armScope := symbols.NewScope(scope) if arm.Binding != nil && !arm.Discard { if binding := r.resolveLocalBinding(armScope, arm.Binding, symbols.SymbolVar, nil, arm.Binding, arm.Location); binding != nil { - r.module.Semantics.ResolvedSymbols[arm.Binding.ID()] = binding + r.module.Bindings.NodeSymbols[arm.Binding.ID()] = binding } } for _, field := range arm.Fields { @@ -208,7 +209,7 @@ func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { continue } if binding := r.resolveLocalBinding(armScope, field.Binding, symbols.SymbolVar, nil, field.Binding, field.Location); binding != nil { - r.module.Semantics.ResolvedSymbols[field.Binding.ID()] = binding + r.module.Bindings.NodeSymbols[field.Binding.ID()] = binding } } r.resolveBlock(armScope, arm.Body) @@ -263,7 +264,7 @@ func (r *resolver) resolveExpr(scope *symbols.Scope, expr ast.Expr) { case *ast.Ident: sym, ok := scope.Lookup(node.Name) if ok && sym != nil { - r.module.Semantics.ResolvedSymbols[node.ID()] = sym + r.module.Bindings.NodeSymbols[node.ID()] = sym sym.Used = true if sym.Kind == symbols.SymbolImport { r.ctx.Diagnostics.AddError(diagnostics.ErrInvalidExpression, "import alias must be qualified with `::`", ast.LocOf(node), "") @@ -387,7 +388,7 @@ func (r *resolver) resolveScopeResolution(node *ast.ScopeResolution, allowTypeAr if !ok { return false } - r.module.Semantics.ResolvedSymbols[node.ID()] = resolved + r.module.Bindings.NodeSymbols[node.ID()] = resolved return true } @@ -443,9 +444,9 @@ func (r *resolver) resolveVariantPath(scope *symbols.Scope, path *ast.ScopeResol qualifierSymbol.Used = true enumSymbol.Used = true variant.Used = true - r.module.Semantics.ResolvedSymbols[enumName.ID()] = qualifierSymbol - r.module.Semantics.ResolvedSymbols[path.ID()] = variant - r.module.Semantics.ResolvedSymbols[caseName.ID()] = variant + r.module.Bindings.NodeSymbols[enumName.ID()] = qualifierSymbol + r.module.Bindings.NodeSymbols[path.ID()] = variant + r.module.Bindings.NodeSymbols[caseName.ID()] = variant return true } diff --git a/internal/semantics/resolver/resolver_test.go b/internal/semantics/resolver/resolver_test.go index 10ac8125..43a5fea7 100644 --- a/internal/semantics/resolver/resolver_test.go +++ b/internal/semantics/resolver/resolver_test.go @@ -82,7 +82,7 @@ fn main() { okPath := fn.Body.Stmts[0].(*ast.LetDecl).Value.(*ast.VariantLit).Case pendingPath := fn.Body.Stmts[1].(*ast.LetDecl).Value.(*ast.ScopeResolution) for _, path := range []*ast.ScopeResolution{okPath, pendingPath} { - sym := module.Semantics.ResolvedSymbols[path.ID()] + sym := module.Bindings.NodeSymbols[path.ID()] if sym == nil { t.Fatalf("resolved %s = nil, want child variant symbol", path.TypeText()) } @@ -90,7 +90,7 @@ fn main() { if sym.Kind != symbols.SymbolVariant || !variant || sym.Name != path.Segments[len(path.Segments)-1].Name.Name { t.Fatalf("resolved %s = %#v, want child variant symbol", path.TypeText(), sym) } - if module.Semantics.ResolvedSymbols[path.Segments[len(path.Segments)-1].Name.ID()] != sym { + if module.Bindings.NodeSymbols[path.Segments[len(path.Segments)-1].Name.ID()] != sym { t.Fatalf("final segment of %s does not resolve to variant symbol", path.TypeText()) } } @@ -123,7 +123,7 @@ fn main() { t.Fatalf("invalid variant path %s", path.TypeText()) } canonical, _ := result.Scope.LookupLocal(caseName.Name) - if got := module.Semantics.ResolvedSymbols[path.ID()]; got == nil || got != canonical { + if got := module.Bindings.NodeSymbols[path.ID()]; got == nil || got != canonical { t.Fatalf("resolved %s = %#v, want canonical %#v", path.TypeText(), got, canonical) } } @@ -171,11 +171,11 @@ fn Read(result: Result) -> i32 { match := fn.Body.Stmts[0].(*ast.MatchStmt) binding := match.Arms[0].Fields[0].Binding use := match.Arms[0].Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.Ident) - bindingSymbol := module.Semantics.ResolvedSymbols[binding.ID()] - if bindingSymbol == nil || module.Semantics.ResolvedSymbols[use.ID()] != bindingSymbol { - t.Fatalf("pattern binding = %#v, use = %#v", bindingSymbol, module.Semantics.ResolvedSymbols[use.ID()]) + bindingSymbol := module.Bindings.NodeSymbols[binding.ID()] + if bindingSymbol == nil || module.Bindings.NodeSymbols[use.ID()] != bindingSymbol { + t.Fatalf("pattern binding = %#v, use = %#v", bindingSymbol, module.Bindings.NodeSymbols[use.ID()]) } - if _, found := module.Semantics.BlockScopes[match.Arms[0].Body.ID()].Lookup("payload"); !found { + if _, found := module.Bindings.BlockScopes[match.Arms[0].Body.ID()].Lookup("payload"); !found { t.Fatal("pattern binding missing from arm body scope") } } diff --git a/internal/semantics/typechecker/assignability.go b/internal/semantics/typechecker/assignability.go index 8221c662..17cb6309 100644 --- a/internal/semantics/typechecker/assignability.go +++ b/internal/semantics/typechecker/assignability.go @@ -9,6 +9,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -47,15 +48,15 @@ func (c *checker) assignable(dst, src typeinfo.Type, site ast.Expr) bool { } func (c *checker) recordImplicitConversion(expr ast.Expr, conversion typeinfo.Conversion) { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil || + if c == nil || c.module == nil || c.module.Typechecking == nil || expr == nil || conversion.Kind == typeinfo.ConversionNone || conversion.Kind == typeinfo.ConversionRecovery || conversion.Kind == typeinfo.ConversionIdentity || conversion.Compatibility != typeinfo.Compatible { return } - c.module.Semantics.ImplicitConversions[expr.ID()] = conversion + c.module.Typechecking.ImplicitConversions[expr.ID()] = conversion } -func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, src typeinfo.Type) ([]project.InterfaceImplementation, []string, bool) { +func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, src typeinfo.Type) ([]typecheckresult.InterfaceImplementation, []string, bool) { if c == nil || iface == nil || src == nil { return nil, nil, false } @@ -67,7 +68,7 @@ func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, } return nil, missing, false } - implementations := make([]project.InterfaceImplementation, 0, len(iface.Methods)) + implementations := make([]typecheckresult.InterfaceImplementation, 0, len(iface.Methods)) missing := make([]string, 0) for _, required := range iface.Methods { requiredType := typeinfo.ReplaceAbstractSelf(required.CallableType(), owner) @@ -92,19 +93,18 @@ func (c *checker) resolveInterfaceImplementations(iface *typeinfo.InterfaceType, missing = append(missing, required.Name) continue } - implementations = append(implementations, project.InterfaceImplementation{ - MethodName: required.Name, Symbol: actual.Symbol, - CallableType: actualType, OwnerKey: actual.OwnerKey, + implementations = append(implementations, typecheckresult.InterfaceImplementation{ + Symbol: actual.Symbol, CallableType: actualType, }) } return implementations, missing, len(missing) == 0 } -func (c *checker) storeInterfaceImplementations(expr ast.Expr, implementations []project.InterfaceImplementation) { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil { +func (c *checker) storeInterfaceImplementations(expr ast.Expr, implementations []typecheckresult.InterfaceImplementation) { + if c == nil || c.module == nil || c.module.Typechecking == nil || expr == nil { return } - c.module.Semantics.InterfaceImplementations[expr.ID()] = implementations + c.module.Typechecking.InterfaceImplementations[expr.ID()] = implementations } func (c *checker) addInterfaceHint(d *diagnostics.Diagnostic, dst, src typeinfo.Type) { @@ -154,13 +154,12 @@ func (c *checker) matchesReceiverTarget(target, arg typeinfo.Type) bool { } type callableMember struct { - Type typeinfo.Type - Symbol *symbols.Symbol - OwnerKey string + Type typeinfo.Type + Symbol *symbols.Symbol } func (c *checker) lookupCallableMember(baseType typeinfo.Type, name string) (callableMember, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil { return callableMember{}, false } if iface, ok := typeinfo.InterfaceTypeOf(baseType); ok { @@ -175,18 +174,18 @@ func (c *checker) lookupCallableMember(baseType typeinfo.Type, name string) (cal } func (c *checker) lookupDeclaredCallableMember(baseType typeinfo.Type, name string) (callableMember, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil || c.module == nil || c.module.Bindings == nil { return callableMember{}, false } for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - methods := c.module.Semantics.MethodSets[key] + methods := c.module.Bindings.MethodsByReceiver[key] for _, method := range methods { if method == nil || method.Name != name { continue } typ, ok := symbols.GetSymbolType(method) if ok && typ != nil { - return callableMember{Type: typ, Symbol: method, OwnerKey: key}, true + return callableMember{Type: typ, Symbol: method}, true } } } @@ -195,7 +194,7 @@ func (c *checker) lookupDeclaredCallableMember(baseType typeinfo.Type, name stri // availableMethods returns the names of all methods defined on baseType. func (c *checker) availableMethods(baseType typeinfo.Type) []string { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil { return nil } var names []string @@ -204,10 +203,12 @@ func (c *checker) availableMethods(baseType typeinfo.Type) []string { names = append(names, m.Name) } } - for _, key := range typeinfo.GetMethodLookupKeys(baseType) { - for _, method := range c.module.Semantics.MethodSets[key] { - if method != nil { - names = append(names, method.Name) + if c.module != nil && c.module.Bindings != nil { + for _, key := range typeinfo.GetMethodLookupKeys(baseType) { + for _, method := range c.module.Bindings.MethodsByReceiver[key] { + if method != nil { + names = append(names, method.Name) + } } } } @@ -250,7 +251,7 @@ func (c *checker) mutableAddressableExpr(scope *symbols.Scope, expr ast.Expr) (b } return place.MutableAddressable(scope, expr, func(e ast.Expr) typeinfo.Type { return c.typeExpr(scope, e, nil) - }, c.expandedDefaultBinding) + }, c.module.ExpandedDefaultBinding) } func (c *checker) mutableImplicitArgumentDiagnostic(scope *symbols.Scope, expr ast.Expr) (ast.Node, string, bool) { @@ -293,8 +294,8 @@ func (c *checker) qualifiedScopeType(scope *symbols.Scope, node *ast.ScopeResolu return &typeinfo.InvalidType{} } var sym *symbols.Symbol - if c.module != nil && c.module.Semantics != nil { - sym = c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module != nil && c.module.Bindings != nil { + sym = c.module.Bindings.NodeSymbols[node.ID()] } if sym == nil { qualifier, member, imported := node.ImportValueMember() diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index 39b537bd..c727fea9 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -10,6 +10,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -55,8 +56,13 @@ func (c *checker) typePrintExpr(scope *symbols.Scope, node *ast.PrintExpr) typei } func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinfo.Type { - if path, ok := node.Callee.(*ast.ScopeResolution); ok && path != nil { - if sym := c.module.Semantics.ResolvedSymbols[path.ID()]; sym != nil && sym.Kind == symbols.SymbolVariant { + effectiveArgs := c.module.Typechecking.CallArgumentsOrSource(node) + if c.flow == nil { + effectiveArgs = append([]ast.Expr(nil), node.Args...) + c.module.Typechecking.EffectiveCallArguments[node.ID()] = effectiveArgs + } + if path, ok := node.Callee.(*ast.ScopeResolution); ok && path != nil && c.module.Bindings != nil { + if sym := c.module.Bindings.NodeSymbols[path.ID()]; sym != nil && sym.Kind == symbols.SymbolVariant { for _, arg := range node.Args { c.typeExpr(scope, arg, nil) } @@ -68,13 +74,13 @@ func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinf if selector, ok := node.Callee.(*ast.SelectorExpr); ok && selector != nil { return c.typeSelectorCall(scope, selector, node) } - if ident, ok := node.Callee.(*ast.Ident); ok && ident != nil { - if sym := c.module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil && sym.CompilerOp != "" { + if ident, ok := node.Callee.(*ast.Ident); ok && ident != nil && c.module.Bindings != nil { + if sym := c.module.Bindings.NodeSymbols[ident.ID()]; sym != nil && sym.CompilerOp != "" { definition, found := intrinsics.LookupFunction(sym.CompilerOp) if !found { panic(fmt.Sprintf("missing intrinsic definition for compiler operation %q", sym.CompilerOp)) } - c.module.Semantics.CompilerCalls[node.ID()] = project.CompilerCall{Operation: definition.Operation, Kind: definition.Kind} + c.module.Typechecking.CompilerCalls[node.ID()] = typecheckresult.CompilerCall{Operation: definition.Operation, Kind: definition.Kind} switch definition.Kind { case intrinsics.FunctionAlloc: return c.typeAllocCall(scope, node) @@ -90,19 +96,20 @@ func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr) typeinf } } calleeType := c.typePayloadExpr(scope, node.Callee, nil) - if sym := c.callableSymbol(node.Callee); sym != nil { - c.expandCallDefaults(node, sym, c.callableModule(node.Callee)) + if sym := c.callableSymbol(node.Callee); sym != nil && c.flow == nil { + effectiveArgs = c.expandCallDefaults(node, effectiveArgs, sym, c.callableModule(node.Callee)) + c.module.Typechecking.EffectiveCallArguments[node.ID()] = effectiveArgs } - argTypes := make([]typeinfo.Type, 0, len(node.Args)) + argTypes := make([]typeinfo.Type, 0, len(effectiveArgs)) fnType, _ := calleeType.(*typeinfo.FuncType) - for i, arg := range node.Args { + for i, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && i < len(fnType.Params) { paramExpected = fnType.Params[i] } argTypes = append(argTypes, c.typeExpr(scope, arg, paramExpected)) } - c.checkCall(scope, nil, node, calleeType, argTypes) + c.checkCall(scope, nil, node, calleeType, effectiveArgs, argTypes) return c.callReturnType(node, calleeType) } @@ -115,7 +122,7 @@ func (c *checker) typeFromBytesCall(scope *symbols.Scope, node *ast.CallExpr, de if fnType == nil { panic("missing from_bytes signature") } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType bytesType := c.typeExpr(scope, node.Args[0], fnType.Params[0]) if !typeinfo.IsInvalidOrUnknown(bytesType) && !typeinfo.SameType(bytesType, fnType.Params[0]) { c.ctx.Diagnostics.Add(invalidTypeError(node.Args[0], @@ -153,8 +160,8 @@ func (c *checker) typeCollectionCall(scope *symbols.Scope, node *ast.CallExpr, d fmt.Sprintf("`%s` does not support %s", definition.Operation, typeinfo.TypeText(baseType)))) return &typeinfo.InvalidType{} } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType - c.checkCall(scope, nil, node, fnType, []typeinfo.Type{baseType}) + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType + c.checkCall(scope, nil, node, fnType, node.Args, []typeinfo.Type{baseType}) return c.callReturnType(node, fnType) } @@ -198,13 +205,13 @@ func (c *checker) typeDynamicArrayOwnerCall(scope *symbols.Scope, node *ast.Call panic(fmt.Sprintf("missing dynamic-array signature for %q", op)) } - c.module.Semantics.ExprTypes[node.Callee.ID()] = fnType + c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType argTypes := make([]typeinfo.Type, 0, len(node.Args)) argTypes = append(argTypes, firstArgType) for i, arg := range node.Args[1:] { argTypes = append(argTypes, c.typeExpr(scope, arg, fnType.Params[i+1])) } - c.checkCall(scope, nil, node, fnType, argTypes) + c.checkCall(scope, nil, node, fnType, node.Args, argTypes) if op == symbols.CompilerOpResize && !typeinfo.IsImplicitCopyType(array.Elem) { c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "resize requires implicitly copyable elements; grow Category B arrays with append", @@ -277,26 +284,30 @@ func (c *checker) typeSelectorCall(scope *symbols.Scope, selector *ast.SelectorE method, ok := c.lookupCallableMember(baseType, selector.Name.Name) if ok { methodType, methodSym := method.Type, method.Symbol - if methodSym != nil && methodSym.CompilerOp == "" { - c.expandCallDefaults(call, methodSym, c.module) - } - if c.module != nil && c.module.Semantics != nil { - c.module.Semantics.ExprTypes[selector.ID()] = methodType - if methodSym != nil { - c.module.Semantics.ResolvedSymbols[selector.Name.ID()] = methodSym + effectiveArgs := c.module.Typechecking.EffectiveCallArguments[call.ID()] + if methodSym != nil && methodSym.CompilerOp == "" && c.flow == nil { + effectiveArgs = c.expandCallDefaults(call, effectiveArgs, methodSym, c.module) + c.module.Typechecking.EffectiveCallArguments[call.ID()] = effectiveArgs + } + if c.module != nil { + if c.module.Typechecking != nil { + c.module.Typechecking.ExprTypes[selector.ID()] = methodType + } + if methodSym != nil && c.module.Bindings != nil { + c.module.Bindings.NodeSymbols[selector.Name.ID()] = methodSym } } - argTypes := make([]typeinfo.Type, 0, len(call.Args)+1) + argTypes := make([]typeinfo.Type, 0, len(effectiveArgs)+1) argTypes = append(argTypes, baseType) fnType, _ := methodType.(*typeinfo.FuncType) - for i, arg := range call.Args { + for i, arg := range effectiveArgs { var paramExpected typeinfo.Type if fnType != nil && i+1 < len(fnType.Params) { paramExpected = fnType.Params[i+1] } argTypes = append(argTypes, c.typeExpr(scope, arg, paramExpected)) } - c.checkCall(scope, selector.Expr, call, methodType, argTypes) + c.checkCall(scope, selector.Expr, call, methodType, effectiveArgs, argTypes) return c.callReturnType(call, methodType) } if field, _, fieldOK := typeinfo.LookupStructField(baseType, selector.Name.Name); fieldOK { @@ -318,7 +329,7 @@ func (c *checker) typeSelectorCall(scope *symbols.Scope, selector *ast.SelectorE return &typeinfo.InvalidType{} } -func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, args []typeinfo.Type) { +func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, argExprs []ast.Expr, args []typeinfo.Type) { if c == nil || callExpr == nil || calleeType == nil { return } @@ -354,17 +365,26 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp return } for i, argType := range args { + argIndex := i - callArgOffset + var argExpr ast.Expr + if argIndex >= 0 && argIndex < len(argExprs) { + argExpr = argExprs[argIndex] + } var implicitExpr ast.Expr if i == 0 { if receiverExpr != nil { implicitExpr = receiverExpr - } else if callExpr.Piped && len(callExpr.Args) > 0 { - implicitExpr = callExpr.Args[0] + } else if callExpr.Piped && len(argExprs) > 0 { + implicitExpr = argExprs[0] } } if argType == nil { if i >= callArgOffset { - c.ctx.Diagnostics.Add(invalidExpressionError(callExpr.Args[i-callArgOffset], + site := ast.Node(callExpr) + if argExpr != nil { + site = argExpr + } + c.ctx.Diagnostics.Add(invalidExpressionError(site, "argument requires a value-producing expression")) } continue @@ -374,7 +394,7 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp continue } if implicitExpr != nil && c.acceptImplicitCallArgument(scope, implicitExpr, argType, paramType) { - c.module.Semantics.ImplicitCallArguments[implicitExpr.ID()] = paramType + c.module.Typechecking.ImplicitCallArguments[implicitExpr.ID()] = paramType continue } site := ast.Node(callExpr) @@ -382,9 +402,9 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp if implicitExpr != nil { conversion = implicitExpr } - if i >= callArgOffset && i-callArgOffset < len(callExpr.Args) { - conversion = callExpr.Args[i-callArgOffset] - site = conversion + if argExpr != nil { + conversion = argExpr + site = argExpr } if !c.assignable(paramType, argType, conversion) { d := typeMismatchError(site, @@ -406,7 +426,7 @@ func (c *checker) acceptImplicitCallArgument(scope *symbols.Scope, expr ast.Expr } addressable := place.Addressable(scope, expr, func(e ast.Expr) typeinfo.Type { return c.typeExpr(scope, e, nil) - }, c.expandedDefaultBinding) + }, c.module.ExpandedDefaultBinding) var mutableBinding *symbols.Symbol if mutable { addressable, _, mutableBinding = c.mutableAddressableExpr(scope, expr) @@ -445,8 +465,8 @@ func (c *checker) callableSymbol(callee ast.Expr) *symbols.Symbol { } switch node := callee.(type) { case *ast.Ident: - if c.module.Semantics != nil { - return c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module.Bindings != nil { + return c.module.Bindings.NodeSymbols[node.ID()] } case *ast.ScopeResolution: qualifier, member, imported := node.ImportValueMember() @@ -473,13 +493,14 @@ func (c *checker) callableModule(callee ast.Expr) *project.Module { return c.module } -func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, declModule *project.Module) { - if c == nil || c.module == nil || c.module.Semantics == nil || call == nil || sym == nil { - return +func (c *checker) expandCallDefaults(call *ast.CallExpr, args []ast.Expr, sym *symbols.Symbol, declModule *project.Module) []ast.Expr { + effectiveArgs := append([]ast.Expr(nil), args...) + if c == nil || c.module == nil || c.module.Bindings == nil || c.module.Typechecking == nil || call == nil || sym == nil { + return effectiveArgs } fn, ok := sym.ASTNode.(*ast.FnDecl) if !ok || fn == nil { - return + return effectiveArgs } params := fn.ParamsWithReceiver() offset := 0 @@ -495,16 +516,21 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de break } } - provided := len(call.Args) + offset + provided := len(effectiveArgs) + offset if firstDefault < 0 || provided < firstDefault || provided >= len(params) { - return + return effectiveArgs + } + for i := provided; i < len(params); i++ { + if params[i].Default == nil { + return effectiveArgs + } } substitutions := make(map[string]ast.Expr, len(params)) slotExprs := make([]ast.Expr, len(params)) if receiver != nil && len(slotExprs) > 0 { slotExprs[0] = receiver } - for i, arg := range call.Args { + for i, arg := range effectiveArgs { slot := i + offset if slot >= len(slotExprs) { break @@ -518,9 +544,6 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de substitutions[params[i].Name.Name] = slotExprs[i] } for i := provided; i < len(params); i++ { - if params[i].Default == nil { - return - } ast.Inspect(params[i].Default, func(node ast.Node) bool { ident, ok := node.(*ast.Ident) if !ok || ident == nil { @@ -533,44 +556,48 @@ func (c *checker) expandCallDefaults(call *ast.CallExpr, sym *symbols.Symbol, de return true }) expanded, defaultClones, argumentClones := ast.SubstituteExpr(params[i].Default, substitutions) - if declModule != nil && declModule.Semantics != nil { + if declModule != nil && declModule.Bindings != nil { for clonedID, originalID := range defaultClones { - if resolved := declModule.Semantics.ResolvedSymbols[originalID]; resolved != nil { - c.module.Semantics.ResolvedSymbols[clonedID] = resolved - c.module.Semantics.ExpandedDefaultBindings[clonedID] = struct{}{} + if resolved := declModule.Bindings.NodeSymbols[originalID]; resolved != nil { + c.module.Bindings.NodeSymbols[clonedID] = resolved + c.module.Typechecking.ExpandedDefaultBindings[clonedID] = struct{}{} } - copyExpressionEvidence(c.module.Semantics, declModule.Semantics, clonedID, originalID) + copyExpressionEvidence(c.module, declModule, clonedID, originalID) } } for clonedID, originalID := range argumentClones { - if resolved := c.module.Semantics.ResolvedSymbols[originalID]; resolved != nil { - c.module.Semantics.ResolvedSymbols[clonedID] = resolved + if resolved := c.module.Bindings.NodeSymbols[originalID]; resolved != nil { + c.module.Bindings.NodeSymbols[clonedID] = resolved } - if _, ok := c.module.Semantics.ExpandedDefaultBindings[originalID]; ok { - c.module.Semantics.ExpandedDefaultBindings[clonedID] = struct{}{} + if _, ok := c.module.Typechecking.ExpandedDefaultBindings[originalID]; ok { + c.module.Typechecking.ExpandedDefaultBindings[clonedID] = struct{}{} } - copyExpressionEvidence(c.module.Semantics, c.module.Semantics, clonedID, originalID) + copyExpressionEvidence(c.module, c.module, clonedID, originalID) } - call.Args = append(call.Args, expanded) + effectiveArgs = append(effectiveArgs, expanded) slotExprs[i] = expanded if params[i].Name != nil { substitutions[params[i].Name.Name] = expanded } } + return effectiveArgs } -func copyExpressionEvidence(dst, src *project.SemanticInfo, dstID, srcID ast.NodeID) { - if dst == nil || src == nil { +func copyExpressionEvidence(dst, src *project.Module, dstID, srcID ast.NodeID) { + if dst == nil || dst.Typechecking == nil || src == nil { return } - if typ := src.ExprTypes[srcID]; typ != nil { - dst.ExprTypes[dstID] = typ + if typ := src.BaseExprType(srcID); typ != nil { + dst.Typechecking.ExprTypes[dstID] = typ + } + if src.Typechecking == nil { + return } - if implementations := src.InterfaceImplementations[srcID]; implementations != nil { - dst.InterfaceImplementations[dstID] = implementations + if implementations := src.Typechecking.InterfaceImplementations[srcID]; implementations != nil { + dst.Typechecking.InterfaceImplementations[dstID] = implementations } - if conversion, ok := src.ImplicitConversions[srcID]; ok { - dst.ImplicitConversions[dstID] = conversion + if conversion, ok := src.Typechecking.ImplicitConversions[srcID]; ok { + dst.Typechecking.ImplicitConversions[dstID] = conversion } } diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 31e5f03c..b57b4327 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -16,6 +16,7 @@ import ( "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) @@ -37,8 +38,8 @@ func (c *checker) typeExpr(scope *symbols.Scope, expr ast.Expr, expected typeinf if base == nil || expr == nil { return base } - if c.module != nil && c.module.Semantics != nil && c.flow == nil { - c.module.Semantics.ExprTypes[expr.ID()] = base + if c.module != nil && c.module.Typechecking != nil && c.flow == nil { + c.module.Typechecking.ExprTypes[expr.ID()] = base } resolved := c.effectiveExpressionType(scope, expr, base, expected) if c.flow != nil && resolved != nil { @@ -84,8 +85,8 @@ func (c *checker) typeExprBase(scope *symbols.Scope, expr ast.Expr, expected typ case *ast.Ident: var sym *symbols.Symbol var ok bool - if c.module != nil && c.module.Semantics != nil { - sym = c.module.Semantics.ResolvedSymbols[node.ID()] + if c.module != nil && c.module.Bindings != nil { + sym = c.module.Bindings.NodeSymbols[node.ID()] ok = sym != nil } if !ok { @@ -228,9 +229,9 @@ func (c *checker) typeAddressExpr(scope *symbols.Scope, node *ast.AddressExpr, e exprType := func(expr ast.Expr) typeinfo.Type { return c.module.EffectiveExprType(expr.ID()) } - addressable := place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + addressable := place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) if node.Mode == ast.AddressMutable { - mutable, sharedReference, mutableBinding := place.MutableAddressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + mutable, sharedReference, mutableBinding := place.MutableAddressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) if addressable && !mutable { diagnostic := c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidExpression, "mutable reference requires mutable addressable storage", ast.LocOf(node.Expr), "") @@ -329,7 +330,7 @@ func (c *checker) typeBinaryExpr(scope *symbols.Scope, node *ast.BinaryExpr, exp if leftString || rightString || leftView || rightView { wantRight := &typeinfo.RefType{Target: &typeinfo.StringType{}} if leftString && typeinfo.SameType(right, wantRight) { - c.module.Semantics.StringConcatenations[node.ID()] = struct{}{} + c.module.Typechecking.StringConcatenations[node.ID()] = struct{}{} return &typeinfo.StringType{} } c.ctx.Diagnostics.Add(invalidOperationError(node, @@ -338,11 +339,11 @@ func (c *checker) typeBinaryExpr(scope *symbols.Scope, node *ast.BinaryExpr, exp } } leftBase, rightBase := left, right - if c.module != nil && c.module.Semantics != nil { - if typ := c.module.Semantics.ExprTypes[node.Left.ID()]; typ != nil { + if c.module != nil { + if typ := c.module.BaseExprType(node.Left.ID()); typ != nil { leftBase = typ } - if typ := c.module.Semantics.ExprTypes[node.Right.ID()]; typ != nil { + if typ := c.module.BaseExprType(node.Right.ID()); typ != nil { rightBase = typ } } @@ -456,7 +457,7 @@ func (c *checker) typeIsExpr(scope *symbols.Scope, node *ast.IsExpr) typeinfo.Ty return &typeinfo.InvalidType{} } if c.flow != nil { - test, found := c.module.Semantics.CaseTests[node.ID()] + test, found := c.module.Typechecking.CaseTests[node.ID()] if !found { return &typeinfo.InvalidType{} } @@ -488,15 +489,15 @@ type resolvedNamedVariant struct { // identity. Expanded defaults retain declaration-module symbols even when their // cloned syntax is typechecked inside a caller module. func (c *checker) resolveNamedVariant(path *ast.ScopeResolution) (resolvedNamedVariant, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil || path == nil { + if c == nil || c.module == nil || c.module.Bindings == nil || path == nil { return resolvedNamedVariant{}, false } typePath, caseName, ok := path.EnumVariantMember() - caseSymbol := c.module.Semantics.ResolvedSymbols[path.ID()] + caseSymbol := c.module.Bindings.NodeSymbols[path.ID()] if !ok || caseName == nil || caseSymbol == nil || caseSymbol.Kind != symbols.SymbolVariant || caseSymbol.Name != caseName.Name { return resolvedNamedVariant{}, false } - qualifierSymbol := c.module.Semantics.ResolvedSymbols[typePath.ID()] + qualifierSymbol := c.module.Bindings.NodeSymbols[typePath.ID()] if qualifierSymbol == nil || qualifierSymbol.Kind != symbols.SymbolType { return resolvedNamedVariant{}, false } @@ -595,8 +596,8 @@ func (c *checker) typeSelectorExpr(scope *symbols.Scope, node *ast.SelectorExpr) return field.Type } if method, ok := c.lookupCallableMember(baseType, node.Name.Name); ok { - if method.Symbol != nil { - c.module.Semantics.ResolvedSymbols[node.Name.ID()] = method.Symbol + if method.Symbol != nil && c.module.Bindings != nil { + c.module.Bindings.NodeSymbols[node.Name.ID()] = method.Symbol } return method.Type } @@ -723,7 +724,7 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, return c.typeExpr(scope, expr, nil) } if shape == indexableFixedArray || shape == indexableDynamicArray { - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { c.ctx.Diagnostics.Add(invalidExpressionError(node.Expr, "slicing requires addressable array storage")) return &typeinfo.InvalidType{} @@ -732,7 +733,7 @@ func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, mutable := shape == indexableMutableSliceView var mutableBinding *symbols.Symbol if shape == indexableFixedArray || shape == indexableDynamicArray { - mutable, _, mutableBinding = place.MutableAddressable(scope, node.Expr, exprType, c.expandedDefaultBinding) + mutable, _, mutableBinding = place.MutableAddressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) } if mutableBinding != nil { mutableBinding.RequiresMutable = true @@ -909,7 +910,7 @@ func (c *checker) typeVariantConstruction(scope *symbols.Scope, site ast.Expr, p "payloadless enum variant `"+resolved.CaseName.Name+"` does not accept a payload", ast.LocOf(site), "remove `with` and its value") return &typeinfo.InvalidType{} } - c.module.Semantics.VariantConstructions[site.ID()] = project.VariantConstruction{EnumType: resolved.EnumType, Case: resolved.CaseIndex} + c.module.Typechecking.VariantConstructions[site.ID()] = typecheckresult.VariantConstruction{EnumType: resolved.EnumType, Case: resolved.CaseIndex} return resolved.EnumType } if !initialized { @@ -932,7 +933,7 @@ func (c *checker) typeVariantConstruction(scope *symbols.Scope, site ast.Expr, p fmt.Sprintf("cannot assign %s to enum variant payload of type %s", typeinfo.TypeText(valueType), typeinfo.TypeText(resolved.Case.Payload)), ast.LocOf(value), "") return &typeinfo.InvalidType{} } - c.module.Semantics.VariantConstructions[site.ID()] = project.VariantConstruction{ + c.module.Typechecking.VariantConstructions[site.ID()] = typecheckresult.VariantConstruction{ EnumType: resolved.EnumType, Case: resolved.CaseIndex, Payload: resolved.Case.Payload, diff --git a/internal/semantics/typechecker/check_fn.go b/internal/semantics/typechecker/check_fn.go index 3b1f7d6a..41d4c891 100644 --- a/internal/semantics/typechecker/check_fn.go +++ b/internal/semantics/typechecker/check_fn.go @@ -77,7 +77,7 @@ func (c *checker) checkDefaultParameters(scope *symbols.Scope, fn *ast.FnDecl) { } func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.FnDecl, current int, expr ast.Expr) { - if c == nil || c.module == nil || c.module.Semantics == nil || fn == nil || expr == nil { + if c == nil || c.module == nil || c.module.Bindings == nil || fn == nil || expr == nil { return } params := fn.ParamsWithReceiver() @@ -95,7 +95,7 @@ func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.F if !ok || ident == nil { return true } - sym := c.module.Semantics.ResolvedSymbols[ident.ID()] + sym := c.module.Bindings.NodeSymbols[ident.ID()] index, isParam := paramIndexes[sym] if !isParam || index >= current || index < 0 || index >= len(params) { return true @@ -429,7 +429,10 @@ func (c *checker) checkEnumDecl(decl *ast.EnumDecl) { if decl.Name == nil { return } - for _, method := range c.module.Semantics.MethodSets[decl.Name.Name] { + if c.module == nil || c.module.Bindings == nil { + return + } + for _, method := range c.module.Bindings.MethodsByReceiver[decl.Name.Name] { if method == nil || dataFields[method.Name] == nil { continue } diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index e593b261..61dc6c87 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -8,9 +8,9 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/project" "compiler/internal/semantics/consteval" - "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -19,8 +19,8 @@ func (c *checker) checkBlock(parentScope *symbols.Scope, block *ast.BlockStmt, r return } scope := parentScope - if c.module.Semantics != nil { - if s, ok := c.module.Semantics.BlockScopes[block.ID()]; ok && s != nil { + if c.module.Bindings != nil { + if s, ok := c.module.Bindings.BlockScopes[block.ID()]; ok && s != nil { scope = s } } @@ -150,11 +150,11 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu "ownership-bearing match subject must be a named place"). WithHelp("bind subject to a local before matching it")) } - evidence := flowresult.Match{ + evidence := typecheckresult.Match{ SubjectID: node.Subject.ID(), EnumType: subjectType, - Cases: append([]typeinfo.VariantCase(nil), descriptor.Cases...), - Arms: make([]flowresult.MatchArm, 0, len(node.Arms)), + CaseCount: len(descriptor.Cases), + Arms: make([]typecheckresult.MatchArm, 0, len(node.Arms)), } seenCases := make(map[int]ast.Node, len(node.Arms)) for _, arm := range node.Arms { @@ -162,7 +162,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu evidenceComplete = false continue } - armEvidence := flowresult.MatchArm{ArmID: arm.ID(), Case: -1} + armEvidence := typecheckresult.MatchArm{ArmID: arm.ID(), Case: -1} if arm.Body != nil { armEvidence.BodyID = arm.Body.ID() } @@ -204,14 +204,14 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu c.ctx.Diagnostics.AddError(diagnostics.ErrMissingInitializer, "data match case `"+resolved.CaseName.Name+"` requires a payload pattern", ast.LocOf(arm), "add `with ` or `with _`") } else if arm.Binding != nil || arm.Discard { - fieldEvidence := flowresult.MatchField{WholePayload: true, Type: resolved.Case.Payload, Discard: arm.Discard} + fieldEvidence := typecheckresult.MatchBinding{Projection: typecheckresult.MatchWholePayload, Type: resolved.Case.Payload, Discard: arm.Discard} if arm.Binding != nil { - fieldEvidence.Binding = c.module.Semantics.ResolvedSymbols[arm.Binding.ID()] + fieldEvidence.Binding = c.module.Bindings.NodeSymbols[arm.Binding.ID()] if fieldEvidence.Binding != nil { fieldEvidence.Binding.BindType(resolved.Case.Payload) } } - armEvidence.Fields = append(armEvidence.Fields, fieldEvidence) + armEvidence.Bindings = append(armEvidence.Bindings, fieldEvidence) } else { payload, payloadOK := typeinfo.Underlying(resolved.Case.Payload).(*typeinfo.StructType) if !payloadOK || payload == nil { @@ -238,14 +238,14 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu "unknown match pattern field `"+name+"`", ast.LocOf(pattern.Name), "") continue } - fieldEvidence := flowresult.MatchField{Field: fieldIndex, Type: field.Type, Discard: pattern.Discard} + fieldEvidence := typecheckresult.MatchBinding{Projection: typecheckresult.MatchPayloadField, Field: fieldIndex, Type: field.Type, Discard: pattern.Discard} if !pattern.Discard && pattern.Binding != nil { - fieldEvidence.Binding = c.module.Semantics.ResolvedSymbols[pattern.Binding.ID()] + fieldEvidence.Binding = c.module.Bindings.NodeSymbols[pattern.Binding.ID()] if fieldEvidence.Binding != nil { fieldEvidence.Binding.BindType(field.Type) } } - armEvidence.Fields = append(armEvidence.Fields, fieldEvidence) + armEvidence.Bindings = append(armEvidence.Bindings, fieldEvidence) } } } @@ -261,7 +261,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu "match is missing case `"+variant.Name+"`", ast.LocOf(node), "add one arm for every enum case") } if evidenceComplete && len(evidence.Arms) == len(node.Arms) { - c.module.Semantics.Matches[node.ID()] = evidence + c.module.Typechecking.Matches[node.ID()] = evidence } } @@ -505,12 +505,12 @@ func (c *checker) checkBinding(scope *symbols.Scope, node ast.Stmt, requireIniti // element access requires an explicit as_bytes/as_chars view. func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, returnType typeinfo.Type) { indexType := typeinfo.DefaultIntegerType() - evidence := project.ForIteration{} + evidence := typecheckresult.ForIteration{} if node.Index != nil { - evidence.Index = c.module.Semantics.ResolvedSymbols[node.Index.ID()] + evidence.Index = c.module.Bindings.NodeSymbols[node.Index.ID()] } if node.Value != nil { - evidence.Value = c.module.Semantics.ResolvedSymbols[node.Value.ID()] + evidence.Value = c.module.Bindings.NodeSymbols[node.Value.ID()] } valid := node.Value != nil && node.Value.Name != "" && evidence.Value != nil if node.Index != nil && (node.Index.Name == "" || evidence.Index == nil) { @@ -519,7 +519,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return var elemType typeinfo.Type if rangeExpr, ok := node.Iterable.(*ast.RangeExpr); ok { - evidence.Kind = project.ForIterationRange + evidence.Kind = typecheckresult.ForIterationRange if !rangeExpr.EndExclusive { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "for range requires an exclusive end; use `..` instead of `..=`")) @@ -578,7 +578,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } evidence.ElementType = elemType } else { - evidence.Kind = project.ForIterationSequence + evidence.Kind = typecheckresult.ForIterationSequence var ok bool indexType, ok = typeinfo.NumericTypeFromName("usize", c.ctx.Target) if !ok { @@ -599,7 +599,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return exprType := func(expr ast.Expr) typeinfo.Type { return c.typeExpr(scope, expr, nil) } - if !place.Addressable(scope, node.Iterable, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Iterable, exprType, c.module.ExpandedDefaultBinding) { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "for-in requires addressable array storage")) @@ -631,11 +631,11 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return if c.siteOnly { return } - delete(c.module.Semantics.ForIterations, node.ID()) + delete(c.module.Typechecking.ForIterations, node.ID()) if valid && elemType != nil && !typeinfo.IsInvalidOrUnknown(elemType) { location := ast.LocOf(node) evidence.Cursor = symbols.New("$for.cursor", symbols.SymbolVar, nil, location) - if evidence.Kind == project.ForIterationRange { + if evidence.Kind == typecheckresult.ForIterationRange { evidence.Cursor.BindType(elemType) evidence.End = symbols.New("$for.end", symbols.SymbolVar, nil, location) evidence.End.BindType(elemType) @@ -648,7 +648,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return evidence.Carrier = symbols.New("$for.carrier", symbols.SymbolVar, nil, location) evidence.Carrier.BindType(evidence.CarrierType) } - c.module.Semantics.ForIterations[node.ID()] = evidence + c.module.Typechecking.ForIterations[node.ID()] = evidence } c.loopDepth++ c.checkBlock(scope, node.Body, returnType) @@ -659,7 +659,7 @@ func (c *checker) bindLoopVariable(name *ast.Ident, typ typeinfo.Type) { if typ == nil { return } - if sym := c.module.Semantics.ResolvedSymbols[name.ID()]; sym != nil { + if sym := c.module.Bindings.NodeSymbols[name.ID()]; sym != nil { sym.BindType(typ) } } @@ -716,7 +716,7 @@ func (c *checker) rejectReferenceStorage(typ typeinfo.Type, site ast.Node, conte } func (c *checker) rejectTemporaryBorrowEscape(scope *symbols.Scope, expr ast.Expr, context string) bool { - if c.module == nil || c.module.Semantics == nil { + if c.module == nil { return false } temporary := c.temporaryBorrowSource(scope, expr) @@ -733,31 +733,32 @@ func (c *checker) rejectTemporaryBorrowEscape(scope *symbols.Scope, expr ast.Exp } func (c *checker) temporaryBorrowSource(scope *symbols.Scope, expr ast.Expr) ast.Expr { - if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil { + if c == nil || c.module == nil || expr == nil { return nil } exprType := func(node ast.Expr) typeinfo.Type { if node == nil { return nil } - return c.module.Semantics.ExprTypes[node.ID()] + return c.module.BaseExprType(node.ID()) } if _, _, reference := typeinfo.ReferenceValueTarget(exprType(expr)); !reference { return nil } switch node := expr.(type) { case *ast.AddressExpr: - if node == nil || node.Expr == nil || node.Mode == ast.AddressRaw || place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if node == nil || node.Expr == nil || node.Mode == ast.AddressRaw || place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return nil } return node case *ast.CallExpr: fn, _ := typeinfo.Underlying(exprType(node.Callee)).(*typeinfo.FuncType) - for _, source := range typeinfo.ReturnOriginSources(node, fn) { + args := c.module.Typechecking.CallArgumentsOrSource(node) + for _, source := range typeinfo.ReturnOriginSources(node, args, fn) { if temporary := c.temporaryBorrowSource(scope, source); temporary != nil { return temporary } - if c.module.Semantics.ImplicitCallArguments[source.ID()] != nil && !place.Addressable(scope, source, exprType, c.expandedDefaultBinding) { + if c.module.Typechecking.ImplicitCallArguments[source.ID()] != nil && !place.Addressable(scope, source, exprType, c.module.ExpandedDefaultBinding) { if _, _, reference := typeinfo.ReferenceValueTarget(exprType(source)); !reference { return source } @@ -769,14 +770,14 @@ func (c *checker) temporaryBorrowSource(scope *symbols.Scope, expr ast.Expr) ast if temporary := c.temporaryBorrowSource(scope, node.Expr); temporary != nil { return temporary } - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return node } case *ast.IndexExpr: if temporary := c.temporaryBorrowSource(scope, node.Expr); temporary != nil { return temporary } - if !place.Addressable(scope, node.Expr, exprType, c.expandedDefaultBinding) { + if !place.Addressable(scope, node.Expr, exprType, c.module.ExpandedDefaultBinding) { return node } } diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 3fb02b8c..4e22d023 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -10,6 +10,7 @@ import ( "compiler/internal/semantics/flowresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -78,7 +79,7 @@ func CheckFlow(ctx *project.CompilerContext, module *project.Module) *flowresult ResolvedStorageOrigins: make(map[ast.NodeID][]place.Origin), ResolvedValueOrigins: make(map[ast.NodeID][]place.Origin), } - if ctx == nil || module == nil || module.CFG == nil || module.Semantics == nil || module.ModuleScope == nil { + if ctx == nil || module == nil || module.CFG == nil || module.Bindings == nil || module.ModuleScope == nil { return result } for _, graph := range module.CFG.Functions { @@ -91,7 +92,7 @@ func CheckFlow(ctx *project.CompilerContext, module *project.Module) *flowresult } var sym *symbols.Symbol if fn.Receiver != nil { - sym = module.Semantics.MethodSymbol[fn.ID()] + sym = module.Bindings.MethodsByDecl[fn.ID()] } else if fn.Name != nil { sym, _ = module.ModuleScope.Lookup(fn.Name.Name) } @@ -233,28 +234,28 @@ func (c *checker) recordCaseTest(node ast.Expr, subject ast.Expr, caseIndex, cas if c == nil || node == nil || subject == nil { return } - test := flowresult.CaseTest{ + test := typecheckresult.CaseTest{ SubjectID: subject.ID(), Case: caseIndex, CaseWhenTrue: caseWhenTrue, CaseCount: caseCount, Family: family, } if c.flow == nil { - if c.module != nil && c.module.Semantics != nil { - c.module.Semantics.CaseTests[node.ID()] = test + if c.module != nil && c.module.Typechecking != nil { + c.module.Typechecking.CaseTests[node.ID()] = test } return } - base, found := c.module.Semantics.CaseTests[node.ID()] + base, found := c.module.Typechecking.CaseTests[node.ID()] if !found || base.SubjectID != subject.ID() { return } - test = base + refined := flowresult.CaseTest{CaseTest: base} if payload, ok := c.flow.result.Payloads[subject.ID()]; ok { storage := c.flow.result.ResolvedStorageOrigins[subject.ID()] if payload.AppliesTo(storage) { - test.PayloadPath = append([]int(nil), payload.Cases...) + refined.PayloadPath = append([]int(nil), payload.Cases...) } } - c.flow.result.CaseTests[node.ID()] = test + c.flow.result.CaseTests[node.ID()] = refined if c.flow.events != nil { c.flow.events.next++ c.flow.events.tests[node.ID()] = c.flow.events.next @@ -284,7 +285,7 @@ func (c *checker) recordFlowResolution(expr ast.Expr, resolution place.Resolutio } func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowState) place.Resolution { - if c == nil || c.module == nil || c.module.Semantics == nil { + if c == nil || c.module == nil { return place.Resolution{} } return place.Resolve(scope, expr, place.ResolveOptions{ @@ -297,9 +298,9 @@ func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowS return typ } } - return c.module.Semantics.ExprTypes[node.ID()] + return c.module.BaseExprType(node.ID()) }, - ResolveBinding: c.expandedDefaultBinding, + ResolveBinding: c.module.ExpandedDefaultBinding, ReferenceOrigins: func(storage []place.Origin) []place.Origin { return originValues(st.references, storage) }, @@ -310,19 +311,20 @@ func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowS if call == nil || call.Callee == nil { return nil } - calleeType := c.module.Semantics.ExprTypes[call.Callee.ID()] + calleeType := c.module.BaseExprType(call.Callee.ID()) if c.flow != nil && c.flow.result.ExprTypes[call.Callee.ID()] != nil { calleeType = c.flow.result.ExprTypes[call.Callee.ID()] } fn, _ := typeinfo.Underlying(calleeType).(*typeinfo.FuncType) var origins []place.Origin - for _, source := range typeinfo.ReturnOriginSources(call, fn) { + args := c.module.Typechecking.CallArgumentsOrSource(call) + for _, source := range typeinfo.ReturnOriginSources(call, args, fn) { origins = place.MergeOrigins(origins, c.resolveFlowPlace(scope, source, st).ValueOrigins) } return origins }, ConstantIndex: func(index ast.Expr) (string, bool) { - expected := c.module.Semantics.ExprTypes[index.ID()] + expected := c.module.BaseExprType(index.ID()) if !typeinfo.IsIntegral(expected) { expected = typeinfo.DefaultIntegerType() } @@ -451,7 +453,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f if a == nil || site == nil || st == nil || edge.Kind != cfg.EdgeVariantCase { return } - match, found := a.module.Semantics.Matches[ast.NodeID(site.NodeID)] + match, found := a.module.Typechecking.Matches[ast.NodeID(site.NodeID)] if !found { return } @@ -459,7 +461,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f if subject == nil { return } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } @@ -469,7 +471,7 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f restrictVariantFact(st, variantStateFact{ origins: resolution.StorageOrigins, cases: []int{edge.Case}, - caseCount: len(match.Cases), + caseCount: match.CaseCount, dependencies: append([]*symbols.Symbol(nil), resolution.Dependencies...), }) } @@ -478,17 +480,21 @@ func (a *flowAnalyzer) applyVariantCaseEdge(site *cfg.Site, edge cfg.Edge, st *f return } payloadOrigins := place.VariantPayloadOrigins(resolution.ValueOrigins, []int{edge.Case}) - for _, field := range arm.Fields { - if field.Binding == nil { - continue - } + for _, field := range arm.Bindings { fieldOrigins := payloadOrigins - if !field.WholePayload { + switch field.Projection { + case typecheckresult.MatchPayloadField: payload, payloadFound := typeinfo.Underlying(arm.Payload).(*typeinfo.StructType) if !payloadFound || payload == nil || field.Field < 0 || field.Field >= len(payload.Fields) { continue } fieldOrigins = place.FieldOrigins(payloadOrigins, payload.Fields[field.Field].Name) + case typecheckresult.MatchWholePayload: + default: + panic("flow typechecking: invalid match binding projection") + } + if field.Binding == nil { + continue } bindingOrigins := []place.Origin{{Root: field.Binding}} valueOrigins := fieldOrigins @@ -564,7 +570,7 @@ func (a *flowAnalyzer) applySite(site *cfg.Site, st *flowState) *flowExpressionE if site == nil || st == nil { return events } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } @@ -583,7 +589,7 @@ func (a *flowAnalyzer) applySite(site *cfg.Site, st *flowState) *flowExpressionE case cfg.SiteScopeExit: block, _ := a.module.TypedASTNodes[ast.NodeID(site.NodeID)].(*ast.BlockStmt) if block != nil { - blockScope := a.module.Semantics.BlockScopes[block.ID()] + blockScope := a.module.Bindings.BlockScopes[block.ID()] if blockScope == nil { return events } @@ -614,7 +620,7 @@ func (a *flowAnalyzer) applyStatementEffects(c *checker, scope *symbols.Scope, s invalidateVariantOrigins(st, resolution.StorageOrigins) typ := a.result.ExprTypes[node.Target.ID()] if typ == nil { - typ = a.module.Semantics.ExprTypes[node.Target.ID()] + typ = a.module.BaseExprType(node.Target.ID()) } a.updateOriginPlace(c, scope, resolution.StorageOrigins, typ, node.Value, sourceState, st) } @@ -625,7 +631,7 @@ func (a *flowAnalyzer) assignedSymbol(scope *symbols.Scope, expr ast.Expr) *symb if !ok || ident == nil { return nil } - if sym := a.module.Semantics.ResolvedSymbols[ident.ID()]; sym != nil { + if sym := a.module.Bindings.NodeSymbols[ident.ID()]; sym != nil { return sym } sym, _ := scope.Lookup(ident.Name) @@ -674,7 +680,7 @@ func (a *flowAnalyzer) updateOriginPlace( } return } - construction, constructed := a.module.Semantics.VariantConstructions[value.ID()] + construction, constructed := a.module.Typechecking.VariantConstructions[value.ID()] if !constructed || construction.Payload == nil || construction.Case < 0 { source := c.resolveFlowPlace(scope, value, sourceState) a.copyStoredOriginPlace(storage, source.ValueOrigins, typ, sourceState, st) @@ -729,10 +735,10 @@ func (a *flowAnalyzer) invalidateCall(c *checker, scope *symbols.Scope, call *as } calleeType := a.result.ExprTypes[call.Callee.ID()] if calleeType == nil { - calleeType = a.module.Semantics.ExprTypes[call.Callee.ID()] + calleeType = a.module.BaseExprType(call.Callee.ID()) } fn, _ := typeinfo.Underlying(calleeType).(*typeinfo.FuncType) - args := append([]ast.Expr(nil), call.Args...) + args := a.module.Typechecking.CallArgumentsOrSource(call) if selector, method := call.Callee.(*ast.SelectorExpr); method && selector != nil { args = append([]ast.Expr{selector.Expr}, args...) } @@ -792,7 +798,7 @@ func (a *flowAnalyzer) applyConditionEdge(site *cfg.Site, edge cfg.EdgeKind, st if condition == nil { return } - scope := a.module.Semantics.BlockScopes[ast.NodeID(site.ScopeID)] + scope := a.module.Bindings.BlockScopes[ast.NodeID(site.ScopeID)] if scope == nil { scope = a.functionScope } diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index af684611..0d9643b3 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -38,10 +38,10 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di binder.Bind(ctx, module) resolver.Resolve(ctx, module) Check(ctx, module) - module.TypedASTNodes = ast.Index(module.AST) + module.RebuildTypedASTIndex() module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ - MatchCases: module.Semantics.MatchCases, - LoopGuaranteedEntry: module.Semantics.ForLoopGuaranteedEntry, + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = CheckFlow(ctx, module) return module, diag @@ -69,7 +69,7 @@ fn Read(choice: Choice) -> i32 { fn := module.AST.Stmts[1].(*ast.FnDecl) leftBranch := fn.Body.Stmts[0].(*ast.IfStmt) leftTest := leftBranch.Cond.(*ast.IsExpr) - baseTest, baseFound := module.Semantics.CaseTests[leftTest.ID()] + baseTest, baseFound := module.Typechecking.CaseTests[leftTest.ID()] flowTest, flowFound := module.Flow.CaseTests[leftTest.ID()] if !baseFound || !flowFound || baseTest.Case != 0 || flowTest.Case != baseTest.Case || flowTest.SubjectID != baseTest.SubjectID || flowTest.CaseCount != baseTest.CaseCount { @@ -333,7 +333,7 @@ func TestInvalidateCallClearsMutableModuleVariableFacts(t *testing.T) { } state := flowState{variants: []variantStateFact{{origins: []place.Origin{{Root: global}}, cases: []int{1}, caseCount: 2}}} analyzer := flowAnalyzer{ - module: &project.Module{ModuleScope: moduleScope, Semantics: project.NewSemanticInfo()}, + module: &project.Module{ModuleScope: moduleScope}, result: &flowresult.Result{ExprTypes: make(map[ast.NodeID]typeinfo.Type)}, } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index 48616e7f..95616fb7 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -5,7 +5,7 @@ import ( "testing" "compiler/internal/frontend/ast" - "compiler/internal/project" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/target" ) @@ -24,7 +24,7 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[1].(*ast.ForStmt) - binding := module.Semantics.ResolvedSymbols[loop.Value.ID()] + binding := module.Bindings.NodeSymbols[loop.Value.ID()] if binding == nil { t.Fatal("missing resolved loop binding") } @@ -41,7 +41,7 @@ return total; if reference == nil { t.Fatal("missing loop binding reference") } - if resolved := module.Semantics.ResolvedSymbols[reference.ID()]; resolved != binding { + if resolved := module.Bindings.NodeSymbols[reference.ID()]; resolved != binding { t.Fatalf("loop reference resolved to %#v, want declaration symbol %#v", resolved, binding) } } @@ -60,14 +60,14 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[1].(*ast.ForStmt) - evidence, ok := module.Semantics.ForIterations[loop.ID()] + evidence, ok := module.Typechecking.ForIterations[loop.ID()] if !ok { t.Fatal("missing range iteration evidence") } - if evidence.Kind != project.ForIterationRange || evidence.Cursor == nil || evidence.End == nil || evidence.Ordinal == nil { + if evidence.Kind != typecheckresult.ForIterationRange || evidence.Cursor == nil || evidence.End == nil || evidence.Ordinal == nil { t.Fatalf("range iteration evidence = %#v", evidence) } - if evidence.Index != module.Semantics.ResolvedSymbols[loop.Index.ID()] || evidence.Value != module.Semantics.ResolvedSymbols[loop.Value.ID()] { + if evidence.Index != module.Bindings.NodeSymbols[loop.Index.ID()] || evidence.Value != module.Bindings.NodeSymbols[loop.Value.ID()] { t.Fatal("range evidence does not preserve source binding symbols") } for name, symbol := range map[string]string{ @@ -98,7 +98,7 @@ func TestCheckForInRangeTypeIsBoundOrderIndependent(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence, found := module.Semantics.ForIterations[loop.ID()] + evidence, found := module.Typechecking.ForIterations[loop.ID()] if !found { t.Fatal("missing range iteration evidence") } @@ -132,7 +132,7 @@ return 0i64; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence := module.Semantics.ForIterations[loop.ID()] + evidence := module.Typechecking.ForIterations[loop.ID()] for name, typ := range map[string]typeinfo.Type{ "element": evidence.ElementType, "cursor": evidence.Cursor.Type, @@ -163,7 +163,7 @@ func TestCheckForInRecordsGuaranteedRangeEntry(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - evidence, found := module.Semantics.ForIterations[loop.ID()] + evidence, found := module.Typechecking.ForIterations[loop.ID()] if !found { t.Fatal("missing range iteration evidence") } @@ -189,11 +189,11 @@ return total; } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[2].(*ast.ForStmt) - evidence, ok := module.Semantics.ForIterations[loop.ID()] + evidence, ok := module.Typechecking.ForIterations[loop.ID()] if !ok { t.Fatal("missing sequence iteration evidence") } - if evidence.Kind != project.ForIterationSequence || evidence.Carrier == nil || evidence.Cursor == nil { + if evidence.Kind != typecheckresult.ForIterationSequence || evidence.Carrier == nil || evidence.Cursor == nil { t.Fatalf("sequence iteration evidence = %#v", evidence) } if got := typeinfo.TypeText(evidence.Carrier.Type); got != "&[3]i32" { @@ -331,7 +331,7 @@ func TestRejectedForInDoesNotPublishIterationEvidence(t *testing.T) { if loop == nil { t.Fatal("missing recovered for-in loop") } - if _, found := module.Semantics.ForIterations[loop.ID()]; found { + if _, found := module.Typechecking.ForIterations[loop.ID()]; found { t.Fatal("rejected for-in loop retained semantic evidence") } }) @@ -350,7 +350,7 @@ func TestRejectedForInStillChecksBody(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) loop := fn.Body.Stmts[0].(*ast.ForStmt) - if _, found := module.Semantics.ForIterations[loop.ID()]; found { + if _, found := module.Typechecking.ForIterations[loop.ID()]; found { t.Fatal("rejected loop retained semantic evidence") } } diff --git a/internal/semantics/typechecker/typechecker.go b/internal/semantics/typechecker/typechecker.go index 29ca1f3d..26e42209 100644 --- a/internal/semantics/typechecker/typechecker.go +++ b/internal/semantics/typechecker/typechecker.go @@ -3,8 +3,8 @@ package typechecker import ( "compiler/internal/frontend/ast" "compiler/internal/project" - "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" ) @@ -51,16 +51,6 @@ func (c *checker) requireValueType(expr ast.Expr, typ typeinfo.Type, context str return &typeinfo.InvalidType{} } -func (c *checker) expandedDefaultBinding(ident *ast.Ident) (place.Binding, bool) { - if c == nil || c.module == nil || c.module.Semantics == nil || ident == nil { - return place.Binding{}, false - } - if _, ok := c.module.Semantics.ExpandedDefaultBindings[ident.ID()]; !ok { - return place.Binding{}, false - } - return place.Binding{Symbol: c.module.Semantics.ResolvedSymbols[ident.ID()]}, true -} - func (c *checker) checkModule() { if c == nil || c.module == nil || c.module.AST == nil { return @@ -102,7 +92,9 @@ func (c *checker) checkModule() { } var sym *symbols.Symbol if node.Receiver != nil { - sym = c.module.Semantics.MethodSymbol[node.ID()] + if c.module.Bindings != nil { + sym = c.module.Bindings.MethodsByDecl[node.ID()] + } c.checkReceiverFunction(node) } else { sym, _ = c.module.ModuleScope.Lookup(node.Name.Name) @@ -120,6 +112,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) { if module == nil || ctx == nil { return } + module.Typechecking = typecheckresult.New() (&checker{ctx: ctx, module: module}).checkModule() } diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 8669c767..851dda60 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -15,6 +15,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/peeper" @@ -221,12 +222,12 @@ fn main() { fn := module.AST.Stmts[1].(*ast.FnDecl) ok := fn.Body.Stmts[0].(*ast.LetDecl).Value pending := fn.Body.Stmts[1].(*ast.LetDecl).Value - okEvidence, found := module.Semantics.VariantConstructions[ok.ID()] + okEvidence, found := module.Typechecking.VariantConstructions[ok.ID()] if !found || typeinfo.TypeText(okEvidence.EnumType) != "Result" || okEvidence.Case != 0 || okEvidence.Payload == nil || ast.ExprText(okEvidence.Value) != ".{code = 7, value = 42}" { t.Fatalf("Ok construction evidence = %#v", okEvidence) } - pendingEvidence, found := module.Semantics.VariantConstructions[pending.ID()] + pendingEvidence, found := module.Typechecking.VariantConstructions[pending.ID()] if !found || typeinfo.TypeText(pendingEvidence.EnumType) != "Result" || pendingEvidence.Case != 1 || pendingEvidence.Payload != nil || pendingEvidence.Value != nil { t.Fatalf("Pending construction evidence = %#v", pendingEvidence) @@ -354,14 +355,16 @@ fn Read(result: Result) -> i32 { } fn := module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) - evidence, found := module.Semantics.Matches[match.ID()] + evidence, found := module.Typechecking.Matches[match.ID()] if !found || evidence.SubjectID != match.Subject.ID() || typeinfo.TypeText(evidence.EnumType) != "Result" || len(evidence.Arms) != 3 { t.Fatalf("match evidence = %#v", evidence) } - if evidence.Arms[0].Case != 0 || len(evidence.Arms[0].Fields) != 1 || evidence.Arms[0].Fields[0].Field != 0 || evidence.Arms[0].Fields[0].Binding == nil { + if evidence.CaseCount != 3 || evidence.Arms[0].Case != 0 || len(evidence.Arms[0].Bindings) != 1 || + evidence.Arms[0].Bindings[0].Projection != typecheckresult.MatchPayloadField || + evidence.Arms[0].Bindings[0].Field != 0 || evidence.Arms[0].Bindings[0].Binding == nil { t.Fatalf("Ok arm evidence = %#v", evidence.Arms[0]) } - if evidence.Arms[1].Case != 1 || !evidence.Arms[1].Fields[0].Discard || evidence.Arms[2].Case != 2 { + if evidence.Arms[1].Case != 1 || !evidence.Arms[1].Bindings[0].Discard || evidence.Arms[2].Case != 2 { t.Fatalf("remaining arm evidence = %#v", evidence.Arms[1:]) } } @@ -1499,7 +1502,7 @@ func TestIndexExprRejectsFloatPostfixBeforeConstEvaluation(t *testing.T) { fn := module.AST.Stmts[0].(*ast.FnDecl) ret := fn.Body.Stmts[0].(*ast.ReturnStmt) index := ret.Value.(*ast.IndexExpr) - if !typeinfo.IsInvalidOrUnknown(module.Semantics.ExprTypes[index.ID()]) { + if !typeinfo.IsInvalidOrUnknown(module.Typechecking.ExprTypes[index.ID()]) { t.Fatalf("index expression should have invalid semantic type") } } @@ -1549,7 +1552,7 @@ func TestArrayLiteralTypechecksExplicitLength(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[3]i32" { t.Fatalf("array literal type = %s, want [3]i32", typeinfo.TypeText(got)) } @@ -1565,7 +1568,7 @@ func TestArrayLiteralTypechecksInferredLength(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[3]i32" { t.Fatalf("array literal type = %s, want [3]i32", typeinfo.TypeText(got)) } @@ -1581,7 +1584,7 @@ func TestArrayLiteralTypechecksDynamicArray(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - got := module.Semantics.ExprTypes[letDecl.Value.ID()] + got := module.Typechecking.ExprTypes[letDecl.Value.ID()] if typeinfo.TypeText(got) != "[]i32" { t.Fatalf("array literal type = %s, want []i32", typeinfo.TypeText(got)) } @@ -1608,9 +1611,9 @@ func TestDynamicArrayOwnerOperationsTypecheck(t *testing.T) { } for i, stmt := range fn.Body.Stmts[1:] { call := stmt.(*ast.ExprStmt).Expr.(*ast.CallExpr) - fnType, ok := module.Semantics.ExprTypes[call.Callee.ID()].(*typeinfo.FuncType) + fnType, ok := module.Typechecking.ExprTypes[call.Callee.ID()].(*typeinfo.FuncType) if !ok { - t.Fatalf("operation %d callee type = %#v, want function", i, module.Semantics.ExprTypes[call.Callee.ID()]) + t.Fatalf("operation %d callee type = %#v, want function", i, module.Typechecking.ExprTypes[call.Callee.ID()]) } if fnType.Return != nil { t.Fatalf("operation %d return = %s, want void", i, typeinfo.TypeText(fnType.Return)) @@ -1733,7 +1736,7 @@ func TestAllocTypecheck(t *testing.T) { } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[1].(*ast.LetDecl) - if got := typeinfo.TypeText(module.Semantics.ExprTypes[letDecl.Value.ID()]); got != "*i32" { + if got := typeinfo.TypeText(module.Typechecking.ExprTypes[letDecl.Value.ID()]); got != "*i32" { t.Fatalf("alloc type = %s, want *i32", got) } } @@ -1785,7 +1788,7 @@ func TestArrayLiteralRejectsFloatPostfixLengthBeforeElementChecks(t *testing.T) } fn := module.AST.Stmts[0].(*ast.FnDecl) letDecl := fn.Body.Stmts[0].(*ast.LetDecl) - if !typeinfo.IsInvalidOrUnknown(module.Semantics.ExprTypes[letDecl.Value.ID()]) { + if !typeinfo.IsInvalidOrUnknown(module.Typechecking.ExprTypes[letDecl.Value.ID()]) { t.Fatalf("array literal should have invalid semantic type") } } @@ -2869,11 +2872,11 @@ func TestIntrinsicFunctionResolutionStoredForLaterPhases(t *testing.T) { if callee == nil || callee.Name != "len" { t.Fatal("len function missing from parsed module") } - resolved := module.Semantics.ResolvedSymbols[callee.ID()] + resolved := module.Bindings.NodeSymbols[callee.ID()] if resolved == nil || resolved.CompilerOp != symbols.CompilerOpLen { t.Fatalf("resolved function = %#v, want len intrinsic", resolved) } - evidence, ok := module.Semantics.CompilerCalls[call.ID()] + evidence, ok := module.Typechecking.CompilerCalls[call.ID()] if !ok || evidence.Operation != symbols.CompilerOpLen || evidence.Kind != intrinsics.FunctionCollection { t.Fatalf("compiler call evidence = %#v, want collection len", evidence) } @@ -2916,13 +2919,12 @@ fn main() { if conversion == nil { t.Fatal("reader interface conversion missing from parsed module") } - implementations := module.Semantics.InterfaceImplementations[conversion.ID()] + implementations := module.Typechecking.InterfaceImplementations[conversion.ID()] if len(implementations) != 1 { t.Fatalf("implementation evidence = %#v, want one method", implementations) } implementation := implementations[0] - if implementation.MethodName != "read" || implementation.Symbol == nil || - implementation.CallableType == nil || implementation.OwnerKey != "Counter" { + if implementation.Symbol == nil || implementation.Symbol.Name != "read" || implementation.CallableType == nil { t.Fatalf("implementation evidence = %#v, want exact Counter.read symbol and type", implementation) } } @@ -2947,7 +2949,7 @@ fn main() -> i32 { for _, stmt := range module.AST.Stmts { ast.Inspect(stmt, func(node ast.Node) bool { candidate, ok := node.(*ast.CallExpr) - if ok && len(candidate.Args) == 3 { + if ok && ast.ExprText(candidate.Callee) == "use" { call = candidate } return call == nil @@ -2957,14 +2959,22 @@ fn main() -> i32 { } } if call == nil { - t.Fatal("expanded use call not found") + t.Fatal("use call not found") } - first := module.Semantics.InterfaceImplementations[call.Args[1].ID()] - second := module.Semantics.InterfaceImplementations[call.Args[2].ID()] - if call.Args[1].ID() == call.Args[2].ID() || len(first) != 1 || len(second) != 1 { - t.Fatalf("default evidence IDs/evidence = %d:%#v %d:%#v", call.Args[1].ID(), first, call.Args[2].ID(), second) + if len(call.Args) != 1 { + t.Fatalf("source argument count = %d, want 1", len(call.Args)) } - if first[0].MethodName != "read_a" || second[0].MethodName != "read_b" { + effectiveArgs := module.Typechecking.EffectiveCallArguments[call.ID()] + if len(effectiveArgs) != 3 { + t.Fatalf("effective argument count = %d, want 3", len(effectiveArgs)) + } + first := module.Typechecking.InterfaceImplementations[effectiveArgs[1].ID()] + second := module.Typechecking.InterfaceImplementations[effectiveArgs[2].ID()] + if effectiveArgs[1].ID() == effectiveArgs[2].ID() || len(first) != 1 || len(second) != 1 { + t.Fatalf("default evidence IDs/evidence = %d:%#v %d:%#v", effectiveArgs[1].ID(), first, effectiveArgs[2].ID(), second) + } + if first[0].Symbol == nil || second[0].Symbol == nil || + first[0].Symbol.Name != "read_a" || second[0].Symbol.Name != "read_b" { t.Fatalf("default evidence overwritten: %#v %#v", first, second) } } @@ -3001,7 +3011,7 @@ fn valid() -> i32 { func TestCanAdaptFirstCallArgumentUsesCallConversionRules(t *testing.T) { ctx := project.New(".", peeper.SourceExt, diagnostics.NewDiagnosticBag()) - module := &project.Module{Semantics: project.NewSemanticInfo()} + module := &project.Module{} element, ok := typeinfo.NumericTypeFromName("i32", ctx.Target) if !ok { t.Fatal("missing i32 type") diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go new file mode 100644 index 00000000..67b39f45 --- /dev/null +++ b/internal/semantics/typecheckresult/result.go @@ -0,0 +1,180 @@ +// Package typecheckresult defines semantic evidence produced by base typechecking. +package typecheckresult + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/semantics/intrinsics" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" +) + +// InterfaceImplementation proves that one declared method materializes an interface slot. +type InterfaceImplementation struct { + Symbol *symbols.Symbol + CallableType *typeinfo.FuncType +} + +type CaseTest struct { + SubjectID ast.NodeID + Case int + CaseWhenTrue bool + CaseCount int + Family typeinfo.VariantFamily +} + +// Match records typechecker-owned case and binding evidence consumed by CFG +// and later semantic phases without resolving source paths again. +type Match struct { + SubjectID ast.NodeID + EnumType typeinfo.Type + CaseCount int + Arms []MatchArm +} + +type MatchProjection uint8 + +const ( + MatchProjectionInvalid MatchProjection = iota + MatchPayloadField + MatchWholePayload +) + +type MatchArm struct { + ArmID ast.NodeID + BodyID ast.NodeID + Case int + Payload typeinfo.Type + Bindings []MatchBinding +} + +type MatchBinding struct { + Projection MatchProjection + Field int + Type typeinfo.Type + Binding *symbols.Symbol + Discard bool +} + +func (m Match) Arm(caseIndex int) (MatchArm, bool) { + for _, arm := range m.Arms { + if arm.Case == caseIndex { + return arm, true + } + } + return MatchArm{}, false +} + +type ForIterationKind uint8 + +const ( + ForIterationRange ForIterationKind = iota + ForIterationSequence +) + +// ForIteration records typechecker-owned loop lowering and CFG evidence. +// Generated symbols carry hidden loop state; source bindings remain body-scoped. +type ForIteration struct { + Kind ForIterationKind + GuaranteedEntry bool + + ElementType typeinfo.Type + CarrierType typeinfo.Type + Carrier *symbols.Symbol + Cursor *symbols.Symbol + End *symbols.Symbol + Ordinal *symbols.Symbol + Index *symbols.Symbol + Value *symbols.Symbol +} + +// VariantConstruction records resolved enum construction without later path or field resolution. +type VariantConstruction struct { + EnumType typeinfo.Type + Case int + Payload typeinfo.Type + Value ast.Expr +} + +// CompilerCall records intrinsic dispatch selected by typechecking. +type CompilerCall struct { + Operation symbols.CompilerOp + Kind intrinsics.FunctionKind +} + +// Result owns base semantic evidence for one typecheck generation. +type Result struct { + ExpandedDefaultBindings map[ast.NodeID]struct{} + EffectiveCallArguments map[ast.NodeID][]ast.Expr + InterfaceImplementations map[ast.NodeID][]InterfaceImplementation + ImplicitConversions map[ast.NodeID]typeinfo.Conversion + ImplicitCallArguments map[ast.NodeID]typeinfo.Type + CompilerCalls map[ast.NodeID]CompilerCall + StringConcatenations map[ast.NodeID]struct{} + VariantConstructions map[ast.NodeID]VariantConstruction + CaseTests map[ast.NodeID]CaseTest + Matches map[ast.NodeID]Match + ForIterations map[ast.NodeID]ForIteration + ExprTypes map[ast.NodeID]typeinfo.Type +} + +func New() *Result { + return &Result{ + ExpandedDefaultBindings: make(map[ast.NodeID]struct{}), + EffectiveCallArguments: make(map[ast.NodeID][]ast.Expr), + InterfaceImplementations: make(map[ast.NodeID][]InterfaceImplementation), + ImplicitConversions: make(map[ast.NodeID]typeinfo.Conversion), + ImplicitCallArguments: make(map[ast.NodeID]typeinfo.Type), + CompilerCalls: make(map[ast.NodeID]CompilerCall), + StringConcatenations: make(map[ast.NodeID]struct{}), + VariantConstructions: make(map[ast.NodeID]VariantConstruction), + CaseTests: make(map[ast.NodeID]CaseTest), + Matches: make(map[ast.NodeID]Match), + ForIterations: make(map[ast.NodeID]ForIteration), + ExprTypes: make(map[ast.NodeID]typeinfo.Type), + } +} + +// MatchCases exposes resolved case indexes without leaking match artifacts +// into CFG's source-topology package. +func (r *Result) MatchCases(id ast.NodeID) ([]int, bool) { + if r == nil { + return nil, false + } + match, found := r.Matches[id] + if !found { + return nil, false + } + cases := make([]int, len(match.Arms)) + for index, arm := range match.Arms { + if arm.Case < 0 || arm.Case >= match.CaseCount { + return nil, false + } + cases[index] = arm.Case + } + return cases, true +} + +// ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its +// body before its first condition check. +func (r *Result) ForLoopGuaranteedEntry(id ast.NodeID) bool { + if r == nil { + return false + } + iteration, found := r.ForIterations[id] + return found && iteration.GuaranteedEntry +} + +// CallArgumentsOrSource returns published effective arguments when available. +// Semantic phases that continue after diagnostics use source arguments when +// typechecking could not publish complete call evidence. +func (r *Result) CallArgumentsOrSource(call *ast.CallExpr) []ast.Expr { + if call == nil { + return nil + } + if r != nil { + if args, found := r.EffectiveCallArguments[call.ID()]; found { + return args + } + } + return call.Args +} diff --git a/internal/semantics/typeinfo/syntax.go b/internal/semantics/typeinfo/syntax.go index b2d04c80..2ee431b2 100644 --- a/internal/semantics/typeinfo/syntax.go +++ b/internal/semantics/typeinfo/syntax.go @@ -336,7 +336,7 @@ func returnOriginContract(clause *ast.ReturnOriginClause, params []ast.Param, ha return contract } -func ReturnOriginSources(call *ast.CallExpr, fn *FuncType) []ast.Expr { +func ReturnOriginSources(call *ast.CallExpr, args []ast.Expr, fn *FuncType) []ast.Expr { if call == nil || call.Callee == nil || fn == nil || fn.ReturnOrigins == nil { return nil } @@ -346,11 +346,11 @@ func ReturnOriginSources(call *ast.CallExpr, fn *FuncType) []ast.Expr { if methodCall { if slot == 0 { sources = append(sources, selector.Expr) - } else if slot > 0 && slot <= len(call.Args) { - sources = append(sources, call.Args[slot-1]) + } else if slot > 0 && slot <= len(args) { + sources = append(sources, args[slot-1]) } - } else if slot >= 0 && slot < len(call.Args) { - sources = append(sources, call.Args[slot]) + } else if slot >= 0 && slot < len(args) { + sources = append(sources, args[slot]) } } return sources diff --git a/internal/semantics/typeinfo/types_test.go b/internal/semantics/typeinfo/types_test.go index 1782931f..9112d0e0 100644 --- a/internal/semantics/typeinfo/types_test.go +++ b/internal/semantics/typeinfo/types_test.go @@ -308,19 +308,17 @@ func TestTypeFromSyntaxPreservesReferenceReturnContract(t *testing.T) { func TestReturnOriginSourcesMapDirectAndMethodSlots(t *testing.T) { first := &ast.Ident{Name: "first"} second := &ast.Ident{Name: "second"} - direct := &ast.CallExpr{Callee: &ast.Ident{Name: "choose"}, Args: []ast.Expr{first, second}} + args := []ast.Expr{first, second} + direct := &ast.CallExpr{Callee: &ast.Ident{Name: "choose"}} fn := &FuncType{ReturnOrigins: &ReturnOriginContract{Sources: []int{1, 0, -1, 2}}} - if got := ReturnOriginSources(direct, fn); !slices.Equal(got, []ast.Expr{second, first}) { + if got := ReturnOriginSources(direct, args, fn); !slices.Equal(got, []ast.Expr{second, first}) { t.Fatalf("direct return sources = %#v", got) } receiver := &ast.Ident{Name: "receiver"} - method := &ast.CallExpr{ - Callee: &ast.SelectorExpr{Expr: receiver, Name: &ast.Ident{Name: "choose"}}, - Args: []ast.Expr{first, second}, - } + method := &ast.CallExpr{Callee: &ast.SelectorExpr{Expr: receiver, Name: &ast.Ident{Name: "choose"}}} fn.ReturnOrigins.Sources = []int{0, 2, 3, -1} - if got := ReturnOriginSources(method, fn); !slices.Equal(got, []ast.Expr{receiver, second}) { + if got := ReturnOriginSources(method, args, fn); !slices.Equal(got, []ast.Expr{receiver, second}) { t.Fatalf("method return sources = %#v", got) } } diff --git a/internal/semantics/usage/usage.go b/internal/semantics/usage/usage.go index fbbf4b6a..7f294e08 100644 --- a/internal/semantics/usage/usage.go +++ b/internal/semantics/usage/usage.go @@ -56,8 +56,8 @@ func Analyze(ctx *project.CompilerContext, module *project.Module) { } // 3. Check for unused local variables and parameters - if module.Semantics != nil { - for _, scope := range module.Semantics.BlockScopes { + if module.Bindings != nil { + for _, scope := range module.Bindings.BlockScopes { if scope == nil { continue } diff --git a/x_test/import_default_parameters/src/external.peep b/x_test/import_default_parameters/src/external.peep index e26c4dfc..23572fc6 100644 --- a/x_test/import_default_parameters/src/external.peep +++ b/x_test/import_default_parameters/src/external.peep @@ -37,3 +37,11 @@ fn StateCode(status: State = State::Ready with .{ value = 42 }) -> i32 fn IsPending(pending: bool = State::Pending is State::Pending) -> bool { return pending; } + +fn Base(value: i32 = 3) -> i32 { + return value; +} + +fn Nested(value: i32 = Base()) -> i32 { + return value; +} diff --git a/x_test/import_default_parameters/src/main.peep b/x_test/import_default_parameters/src/main.peep index c2be7743..82ce0c5f 100644 --- a/x_test/import_default_parameters/src/main.peep +++ b/x_test/import_default_parameters/src/main.peep @@ -9,5 +9,5 @@ fn main() -> i32 { if !(external::IsPending()) { return 1; } - return external::Add(4) - 10 + external::StateCode() - 42; + return external::Add(4) - 10 + external::StateCode() - 42 + external::Nested() - 3; } From 03b4004e141b2f9b9145fbc78016d44df7b063b4 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 03:23:42 +0600 Subject: [PATCH 02/80] Unify canonical module identity and constant results Replace path-based Module.Key and semantic DefiningModuleKey with one comparable moduleid.ID of origin, namespace, dependency, and import path. Identity is now logical rather than positional: it survives filesystem relocation, file path is a secondary index, and graph and diagnostics boundaries consume the length-framed ID.String() encoding so component delimiters cannot collide. ModuleKeyFor, ModuleByKey, and loader-side identity backfill are deleted; NewModuleForFile and prelude.ModuleID are the only identity derivations. ID.Valid is the single identity predicate and requires both origin and import path. That invariant keeps ctx.modules keys distinct, since an identity carrying only an origin would collapse every local module onto one entry. No IsZero counterpart exists: a partially populated identity is invalid, not empty. Split mixed Module.ConstValues into constantresult.Result, which keeps authoritative post-typecheck ModuleValues physically separate from the mutable evaluator QueryCache. Foreign constants resolve through the defining module and are read from the owner rather than copied into the consumer, and fingerprints and MIR consume only authoritative values. Both slices land together because they interleave in project/modules.go and pipeline/pipeline.go; splitting them by hunk would produce intermediate commits that do not compile. Preserve diagnostics, import resolution, symbol provenance, reset lifetime, fingerprints, and LSP behavior. Validated with gofmt, go vet, go test -count=1 ./..., focused race suites, a fresh compiler bundle, and the bundled-binary x_test suite. --- cmd/build.go | 4 +- cmd/dump.go | 6 +- cmd/dump_test.go | 5 +- docs/compiler-framework/README.md | 57 ++++-- docs/compiler-framework/semantic-results.md | 38 ++-- internal/ir/hir/lower/module_lower.go | 4 +- internal/ir/hir/lower/module_lower_test.go | 23 +-- internal/lsp/completion.go | 10 +- internal/lsp/cursor.go | 2 +- internal/lsp/hover.go | 4 +- internal/lsp/server_test.go | 14 +- internal/lsp/state.go | 12 +- internal/lsp/workspace.go | 4 +- internal/moduleid/identity.go | 38 ++++ internal/moduleid/identity_test.go | 30 ++++ internal/pipeline/loader.go | 46 ++--- internal/pipeline/pipeline.go | 75 ++++---- internal/pipeline/pipeline_test.go | 162 +++++++----------- internal/prelude/prelude.go | 14 +- internal/project/context.go | 25 +-- internal/project/context_test.go | 8 +- internal/project/export_fingerprint.go | 14 +- internal/project/export_fingerprint_test.go | 35 +++- internal/project/generic_types.go | 15 +- internal/project/imports.go | 36 +--- internal/project/imports_test.go | 16 +- internal/project/modules.go | 98 +++++------ internal/project/modules_test.go | 104 ++++++++--- internal/project/type_lookup.go | 4 +- internal/semantics/binder/binder_test.go | 13 +- internal/semantics/binder/type_decl_cycles.go | 15 +- internal/semantics/collector/collector.go | 7 +- .../semantics/collector/collector_test.go | 71 ++++---- internal/semantics/constantresult/result.go | 20 +++ internal/semantics/consteval/consteval.go | 84 +++++---- .../semantics/consteval/consteval_test.go | 140 +++++++++++++-- .../definiteinit/initialization_test.go | 12 +- .../semantics/ownership/ownership_test.go | 12 +- internal/semantics/resolver/resolver_test.go | 12 +- internal/semantics/symbols/symbol.go | 10 +- internal/semantics/typechecker/flow_test.go | 12 +- .../semantics/typechecker/typechecker_test.go | 49 +++--- internal/semantics/usage/usage.go | 3 +- internal/semantics/usage/usage_test.go | 29 ++-- 44 files changed, 825 insertions(+), 567 deletions(-) create mode 100644 internal/moduleid/identity.go create mode 100644 internal/moduleid/identity_test.go create mode 100644 internal/semantics/constantresult/result.go diff --git a/cmd/build.go b/cmd/build.go index e3a13622..bc9601c4 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -82,14 +82,14 @@ func buildExecutable(ctx *project.CompilerContext, entry *project.Module, output } ir := strings.TrimSpace(module.LLVMIR) if ir == "" { - return fmt.Errorf("empty LLVM IR for module %s", module.ImportPath) + return fmt.Errorf("empty LLVM IR for module %s", module.ID.ImportPath) } llPath := filepath.Join(artifactDir, fmt.Sprintf("mod_%d.ll", i)) if err := os.WriteFile(llPath, []byte(ir), 0o644); err != nil { return fmt.Errorf("write llvm ir: %w", err) } objectPath := filepath.Join(artifactDir, fmt.Sprintf("mod_%d.o", i)) - if err := runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, ctx.Config.BuildDebug), "compile LLVM module "+module.ImportPath); err != nil { + if err := runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, ctx.Config.BuildDebug), "compile LLVM module "+module.ID.ImportPath); err != nil { return err } objectPaths = append(objectPaths, objectPath) diff --git a/cmd/dump.go b/cmd/dump.go index 4a991086..3944452b 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -61,17 +61,17 @@ func saveIRs(ctx *project.CompilerContext, dir string) error { } func moduleArtifactBase(stage string, module *project.Module) (string, error) { - origin := string(module.Origin) + origin := module.ID.Origin if origin == "" { origin = string(project.ModuleOriginLocal) } - identity := strings.TrimSpace(module.ImportPath) + identity := strings.TrimSpace(module.ID.ImportPath) if identity == "" { return "", fmt.Errorf("module %q has no import identity", module.FilePath) } identity = filepath.Clean(filepath.FromSlash(strings.ReplaceAll(identity, ":", "/"))) if identity == "." || filepath.IsAbs(identity) || identity == ".." || strings.HasPrefix(identity, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("invalid module import identity %q", module.ImportPath) + return "", fmt.Errorf("invalid module import identity %q", module.ID.ImportPath) } return filepath.Join(stage, origin, identity), nil } diff --git a/cmd/dump_test.go b/cmd/dump_test.go index a94b22de..5382bdaa 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -5,13 +5,14 @@ import ( "path/filepath" "testing" + "compiler/internal/moduleid" "compiler/internal/project" ) func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) - ctx.AddModule(&project.Module{Key: "one", FilePath: "/one/common.peep", ImportPath: "app/one/common", Origin: project.ModuleOriginLocal, LLVMIR: "one"}) - ctx.AddModule(&project.Module{Key: "two", FilePath: "/two/common.peep", ImportPath: "app/two/common", Origin: project.ModuleOriginLocal, LLVMIR: "two"}) + ctx.AddModule(&project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/one/common"}, FilePath: "/one/common.peep", LLVMIR: "one"}) + ctx.AddModule(&project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/two/common"}, FilePath: "/two/common.peep", LLVMIR: "two"}) target := filepath.Join(t.TempDir(), "_gen") if err := os.MkdirAll(target, 0o755); err != nil { t.Fatal(err) diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index b51f08db..00cb4146 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -115,7 +115,7 @@ add a uniform `Pass.Run` abstraction that hides these differences. | Parsed syntax | `Module.AST` | parser | semantic phases, CFG, HIR | | Module symbols | `Module.ModuleScope` | collector and binder | resolver onward | | Staged binding graph | `Module.Bindings` / `bindingresult.Result` | collector through typechecker | CFG, flow, ownership, HIR, LSP | -| Constant values and query cache | `Module.ConstValues` | const evaluation and later constant queries | fingerprinting, CFG, flow, HIR, MIR | +| Constant evaluation | `Module.Constants` / `constantresult.Result` | const evaluation and later constant queries | fingerprinting, CFG, flow, HIR, MIR | | Base typechecker evidence | `Module.Typechecking` / `typecheckresult.Result` | base typechecker | CFG, consteval, flow, definite-init, ownership, HIR | | Typed AST index | `Module.TypedASTNodes` | pipeline after typecheck | CFG-sensitive phases | | Control-flow graph | `Module.CFG` | CFG builder | flow, definite-init, ownership, MIR | @@ -129,6 +129,35 @@ add a uniform `Pass.Run` abstraction that hides these differences. Framework work should split mixed result ownership where useful without wrapping the aggregate or forcing every artifact into a generic result interface. +### Module identity + +**Current.** `moduleid.ID` is the one canonical module identity. It is a comparable +value of `Origin`, `Namespace`, `Dependency`, and `ImportPath`, and it is the key for +`ctx.modules`, `ctx.fileIndex`, `ctx.semanticExportBaselines`, import resolution, +`symbols.Symbol.DefiningModule`, and type-declaration identity. + +Identity is logical, not positional: it survives filesystem relocation, and file path +is a secondary index only. Path-based `Module.Key`, `symbols.DefiningModuleKey`, +`ModuleKeyFor`, `ModuleByKey`, and loader-side identity backfill no longer exist. + +`ID.Valid()` is the single identity predicate and requires both `Origin` and +`ImportPath`. That is what keeps map keys distinct: an identity carrying only an +origin would collapse every local module onto one entry. Do not add an `IsZero()` +counterpart; a partially populated identity is invalid, not empty. + +String-only boundaries take `ID.String()`, which length-frames each component in hex +so no delimiter collision is possible. Graph node IDs and diagnostics module scoping +are such boundaries and consume the encoding rather than the struct. `internal/diagnostics` +still names these parameters `moduleKey`; it is deliberately identity-agnostic and +that rename is outstanding terminology debt. + +Module construction derives identity once, in `CompilerContext.NewModuleForFile` or +`prelude.ModuleID()`. `NewModuleForFile` returns nil when no import path can be +derived, so callers must establish project root containment first; `cmd/build.go` +and both LSP entry paths do this through `manifest.ResolveSourceFileProject` and +`manifest.PathWithinSourceDir`, and report a source-root diagnostic rather than an +identity failure. + ### Structural traversal **Current.** Reuse these APIs: @@ -150,14 +179,15 @@ walkers only after concrete consumers need identical traversal semantics. ### Existing phase-owned results -**Current.** Four semantic results have purposeful packages or direct owners: +**Current.** Five semantic results have purposeful packages or direct owners: - `bindingresult.Result` owns block scopes, node-to-symbol bindings, method receiver/declaration indexes, and operation-function catalog over one staged symbol graph. Collector initializes it; collector, binder, resolver, and typechecker complete it; reset to `Parsed` discards it. -- `typecheckresult.Result` owns base expression types, effective call arguments, generated-default binding markers, implicit conversions, implicit call arguments, interface implementation slots, intrinsic dispatch, string concatenation classification, variant construction, base case tests, and match evidence for one base-typecheck generation. It also owns `CaseTest` and match evidence models. `typechecker.Check` publishes a fresh result; reset below `Typechecked` discards it. +- `constantresult.Result` physically separates authoritative post-typecheck `ModuleValues` from mutable pretypecheck/local `QueryCache` entries. `FinalizeValues` republishes top-level constants without retaining duplicate cache entries; fingerprints and MIR consume only `ModuleValues`. Foreign constant queries resolve the defining module and read its published values without copying into consumer cache. +- `typecheckresult.Result` owns base expression types, effective call arguments, generated-default binding markers, implicit conversions, implicit call arguments, interface implementation slots, intrinsic dispatch, string concatenation classification, variant construction, base case tests, match evidence, and for-iteration evidence for one base-typecheck generation. It also owns `CaseTest`, match, and iteration evidence models. `typechecker.Check` publishes a fresh result; reset below `Typechecked` discards it. - `flowresult.Result` owns flow-refined types, origins, payload access, flow-sensitive case tests, and variant-field evidence. Its case-test entries use the earlier `typecheckresult.CaseTest` model while remaining a distinct flow result map. - `internal/semantics/ownershipresult` owns cleanup plans consumed by MIR. -`project.SemanticInfo` has been removed. `Module.ConstValues` remains one mutable map combining finalized module constants with later query-cache entries; separating those lifetimes is remaining semantic-result migration target. +`project.SemanticInfo` and mixed `Module.ConstValues` storage have been removed. All semantic evidence and constant-evaluation artifacts now have explicit owners and reset contracts. ### Existing validation @@ -216,7 +246,7 @@ code or repository policy: ## Workstream 1: Separate phase-owned semantic results -**Current.** Field inventory and approved ownership/lifetime decisions are tracked in [`semantic-results.md`](semantic-results.md). Completed migration slices extracted all base-typechecker evidence into `typecheckresult.Result` and the staged collection/binding/resolution graph into `bindingresult.Result`. `SemanticInfo` no longer exists. Remaining constant work must split finalized module values from mutable query cache without duplicate storage. +**Complete.** Field inventory and approved ownership/lifetime decisions are tracked in [`semantic-results.md`](semantic-results.md). Base-typechecker evidence lives in `typecheckresult.Result`; staged collection/binding/resolution state lives in `bindingresult.Result`; authoritative constants and mutable evaluator cache live in separate maps inside `constantresult.Result`. Module bindings carry defining identity, and dependencies reach `Typechecked` before consumer constant evaluation so foreign reads are authoritative and race-free. `SemanticInfo`, mixed `Module.ConstValues`, compatibility maps, and forwarding accessors no longer exist. For each field record: @@ -369,10 +399,10 @@ edge/site APIs express required fact directly. If they do, keep them. If two or consumers need same missing structured-control fact, propose smallest immutable descriptor owned by CFG construction. -Condition and infinite loops on current `main` are initial verification corpus. -Range/sequence loops and `break`/`continue` are contingent on PR #124 or later -merged language work and must be re-audited against resulting code before shaping -public CFG evidence. +Condition, infinite, range, and sequence loops plus `break`/`continue` are current +verification corpus. CFG construction consumes typechecker-owned guaranteed-entry +evidence through `cfg.BuildQueries`; any public construct metadata must be justified +against these merged topology and query contracts. Builder-local active target state remains construction state, not public CFG evidence. Rename or restructure it only when touched by concrete behavior change; @@ -450,10 +480,11 @@ For each evidence type with a kind/tag plus nullable or optional fields, record: - whether constructor, validator, separate variants, or simpler flat shape best protects actual invariant. -PR #124 introduces for-iteration evidence on its feature branch. After it merges, -inspect exact merged artifact and consumers before choosing package or type shape. -Range/sequence plans, guaranteed-entry proof, and target-sized cursors are not -current `main` contracts and must not be encoded here in advance. +For-iteration evidence now lives in `typecheckresult.Result`. It records range or +sequence kind, guaranteed-entry proof, target-sized cursor state, hidden carrier +symbols, and source binding symbols. CFG, ownership, and HIR consume this published +evidence directly. Remaining work must validate its nullable kind-dependent fields +or replace them with smaller validated variants without duplicating semantic proof. Candidate evidence includes conversions, compiler calls, interface conformance, variant construction, and merged for-iteration evidence. Typechecker remains owner diff --git a/docs/compiler-framework/semantic-results.md b/docs/compiler-framework/semantic-results.md index c182e258..04b06373 100644 --- a/docs/compiler-framework/semantic-results.md +++ b/docs/compiler-framework/semantic-results.md @@ -1,6 +1,6 @@ # Semantic Result Ownership Inventory -Status: **design approved; migration active**. +Status: **migration complete**. This document records baseline `project.SemanticInfo` ownership before framework migration. It is the Step 1 design record for the [compiler framework roadmap](README.md). Facts below came from inspected producers, consumers, reset paths, scheduler prerequisites, and incremental LSP reuse. Approved migration progress is recorded below; baseline tables remain for rationale and traceability. @@ -15,28 +15,32 @@ Completed slices: 5. Base `CaseTests` and `Matches` moved into `typecheckresult.Result`, along with `CaseTest`, `Match`, `MatchArm`, `MatchBinding`, explicit match projections, and canonical `MatchCases` validation. `flowresult.Result.CaseTests` uses `flowresult.CaseTest`, which embeds base case evidence and owns flow-only payload paths. 6. Base `ExprTypes` moved into `typecheckresult.Result`. `Module.BaseExprType` is canonical base lookup; `Module.EffectiveExprType` gives flow evidence precedence and falls back to base evidence. `flowresult.Result.ExprTypes` remains distinct flow-refined evidence. 7. Staged collection, binding, resolution, and type-dependent symbol evidence moved into `bindingresult.Result`: `BlockScopes`, `NodeSymbols`, `MethodsByReceiver`, `MethodsByDecl`, and `OperationFunctions`. Generated defaults and selectors write the same canonical node-symbol table; no precedence accessor or duplicate map exists. -8. `SemanticInfo` was deleted. Constant storage moved directly to `Module.ConstValues` without changing behavior or lifetime. Dedicated constant migration remains: finalized module constants and mutable query cache still require separate ownership. +8. `SemanticInfo` and mixed `Module.ConstValues` storage were deleted. `Module.Constants` now owns `constantresult.Result`, physically separating authoritative post-typecheck `ModuleValues` from mutable pretypecheck/local `QueryCache` entries. `FinalizeValues` republishes top-level constants without duplicate cache entries; fingerprints and MIR consume only authoritative values. Module bindings carry defining identity, so foreign queries read owner publication without copying into consumer cache. 9. Typechecker evidence cleanup removed redundant interface method name/owner keys, replaced copied match case descriptors with `CaseCount`, moved `PayloadPath` to flow-owned evidence, and made match field/whole-payload projection explicit with an invalid sentinel consumed exhaustively. -All dedicated typechecker and staged binding fields/models have explicit owners. Constant-result separation remains unmigrated. +All inventoried semantic fields and constant-evaluation artifacts now have explicit owners. Semantic-result migration is complete. ## Current lifecycle `collector.collectModule` calls `Module.ResetSemanticData`, publishing fresh -`Module.Bindings` and `Module.ConstValues` at start of one semantic generation. -Collector, binder, resolver, and typechecker stage one shared binding/scope graph; -constant evaluation and later CFG/flow/HIR queries mutate the separate constant map. +`Module.Bindings` and `Module.Constants` at start of one semantic generation. +Collector, binder, resolver, and typechecker stage one shared binding/scope graph. +Eager constant evaluation stores provisional top-level values in `QueryCache`; +`FinalizeValues` recomputes them with final types and publishes them exclusively in +`ModuleValues`. Imports and prelude reach `Typechecked` before consumer constant +evaluation; foreign symbols read defining-module publication directly. Later +CFG/flow/HIR queries may add only consumer-local entries to `QueryCache`. `Module.resetToPhase` follows approved production contract: ```text -retained <= Parsed -> clear ModuleScope, Bindings, ConstValues, and later results +retained <= Parsed -> clear ModuleScope, Bindings, Constants, and later results exact later reuse -> retain completed artifacts without phase re-entry ``` -Intermediate semantic re-entry remains unsupported because shared symbol objects and -constant cache cannot be rewound independently. Production invalidation uses exact -artifact reuse or resets to `Parsed`. +Intermediate semantic re-entry remains unsupported because shared symbol objects +cannot be rewound independently. Production invalidation uses exact artifact reuse +or resets to `Parsed`; that reset discards both constant stores together. ## Baseline field ownership matrix @@ -321,12 +325,14 @@ Option 2 adds maps and query policy. It is acceptable only if it removes ambigui rather than spreading lookups. Option 3 may produce cleanest contract but has largest behavioral scope. -### Candidate C: constant result +### Implemented C: constant result -Distinguish published module constants from evaluator working cache. One staged -artifact remains possible, but API must expose which entries are authoritative and -which may appear after typecheck. Downstream fingerprint and MIR consumers need -finalized module values; CFG/HIR queries need mutable cache. +`constantresult.Result` owns physically separate `ModuleValues` and `QueryCache` +maps. A top-level symbol lives in the query cache during eager pretypecheck +evaluation, then moves to authoritative module values during finalization. Local and +lazy entries remain cache-only. Foreign symbols resolve their defining module and +read its published values without consumer caching. Fingerprinting and MIR consume +only module values; CFG/HIR queries evaluate without duplicating published entries. ### Candidate D: typechecker result @@ -354,7 +360,7 @@ without creating wrapper accessors. reset or reject unsafe intermediate retention. 3. **Extract approved typechecker proof group at smallest safe migration size.** Move fields and all consumers together; delete old maps immediately. -4. **Make authoritative module constants distinct from post-typecheck query cache.** +4. **Completed:** authoritative module constants are distinct from post-typecheck query cache. 5. **Resolve binding-table ownership.** Do not label current multi-writer map as resolver-only result. 6. **Move callable catalog only if resulting boundary reduces coupling and does not diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 29d2e0d4..172b7bb3 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -27,7 +27,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu return nil } out := &hir.Module{ - Name: module.ImportPath, + Name: module.ID.ImportPath, FilePath: module.FilePath, Types: ctx.Types, Externs: make([]hir.Extern, 0), @@ -1324,7 +1324,7 @@ func callableName(module *project.Module, sym *symbols.Symbol) (string, bool) { return name, true } } - if module != nil && module.IsEntry && sym.Kind == symbols.SymbolFunc && sym.Name == "main" && sym.DefiningModule == module.DefiningModuleKey() { + if module != nil && module.IsEntry && sym.Kind == symbols.SymbolFunc && sym.Name == "main" && sym.DefiningModule == module.ID { return "main", false } receiver := "" diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 4af96b6f..86ff5440 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -12,6 +12,7 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/ir/hir" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -28,13 +29,15 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: importPath, - FilePath: filePath, - IsEntry: true, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginLocal), + ImportPath: importPath, + }, + FilePath: filePath, + IsEntry: true, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -172,9 +175,9 @@ fn (self: Counter) Read() -> i32 { return self.value; }` func TestCallableNameFramesModuleIdentityComponents(t *testing.T) { first := symbols.New("Value", symbols.SymbolFunc, nil, nil) - first.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} + first.DefiningModule = moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} second := symbols.New("Value", symbols.SymbolFunc, nil, nil) - second.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} + second.DefiningModule = moduleid.ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} firstName, _ := callableName(nil, first) secondName, _ := callableName(nil, second) if firstName == secondName { @@ -364,7 +367,7 @@ fn read(value: ?i32, other: Holder) -> i32 { } func TestLoweredRuntimeTypeDoesNotInventUseSiteVariantIdentity(t *testing.T) { - consumer := &project.Module{Key: "local:consumer.peep", ModuleScope: symbols.NewScope(nil)} + consumer := &project.Module{ID: moduleid.ID{Origin: "local", ImportPath: "consumer.peep"}, ModuleScope: symbols.NewScope(nil)} typ := &typeinfo.DefinedType{ Name: "Status", Underlying: &typeinfo.EnumType{Cases: []typeinfo.VariantCase{{Name: "Ready"}}}, diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index a31068e8..52c06836 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -444,10 +444,10 @@ func qualifiedCompletionItems(ctx *project.CompilerContext, module *project.Modu return sortCompletionItems(items) } resolved, ok := module.Imports[qualifier] - if !ok || resolved.DependencyAlias != "" { + if !ok || resolved.ID.Dependency != "" { return []CompletionItem{} } - imported, ok := ctx.ModuleByKey(resolved.Key) + imported, ok := ctx.ModuleByID(resolved.ID) if !ok || imported == nil || imported.ModuleScope == nil { return []CompletionItem{} } @@ -561,7 +561,7 @@ func matchArmCompletionItems(ctx *project.CompilerContext, module *project.Modul if owner != module { aliases := make([]string, 0) for alias, imported := range module.Imports { - if imported.Key == owner.Key { + if imported.ID == owner.ID { aliases = append(aliases, alias) } } @@ -713,10 +713,10 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu } } for alias, resolved := range module.Imports { - if resolved.DependencyAlias != "" { + if resolved.ID.Dependency != "" { continue } - imported, found := ctx.ModuleByKey(resolved.Key) + imported, found := ctx.ModuleByID(resolved.ID) if !found || imported == nil || imported.Bindings == nil { continue } diff --git a/internal/lsp/cursor.go b/internal/lsp/cursor.go index 17092982..0e7cea78 100644 --- a/internal/lsp/cursor.go +++ b/internal/lsp/cursor.go @@ -120,7 +120,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul if imported && memberNode == ident { qualifier := qualifierNode.Name if imp, ok := module.Imports[qualifier]; ok { - if mod, ok := ctx.ModuleByKey(imp.Key); ok && mod.ModuleScope != nil { + if mod, ok := ctx.ModuleByID(imp.ID); ok && mod.ModuleScope != nil { if sym, ok := mod.ModuleScope.LookupLocal(ident.Name); ok { return sym } diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index c0d83582..ea74e804 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -559,12 +559,12 @@ func renderHoverSubject(subject *hoverSubject) string { if subject.ResolvedImport == nil { return "" } - name := subject.ResolvedImport.ImportPath + name := subject.ResolvedImport.ID.ImportPath if ident, ok := subject.Node.(*ast.Ident); ok && ident != nil && ident.Name != "" { name = ident.Name } importSymbol := &symbols.Symbol{Name: name, Kind: symbols.SymbolImport} - text = renderSymbol(importSymbol, symbolRenderContext{ImportPath: subject.ResolvedImport.ImportPath}) + text = renderSymbol(importSymbol, symbolRenderContext{ImportPath: subject.ResolvedImport.ID.ImportPath}) case hoverSubjectAttribute: if subject.Attribute == nil { return "" diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 78e2fe0a..72bda0ae 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -15,6 +15,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/driver" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -348,17 +349,8 @@ func TestParseBundledPreludeFileKeepsStdlibIdentity(t *testing.T) { if mod == nil { t.Fatalf("expected compiled bundled library module") } - if mod.Origin != project.ModuleOriginStdlib { - t.Fatalf("origin = %q, want %q", mod.Origin, project.ModuleOriginStdlib) - } - if mod.Namespace != "core" { - t.Fatalf("namespace = %q, want %q", mod.Namespace, "core") - } - if mod.Key != "core:prelude/global" { - t.Fatalf("key = %q, want %q", mod.Key, "core:prelude/global") - } - if mod.ImportPath != "prelude/global" { - t.Fatalf("import path = %q, want %q", mod.ImportPath, "prelude/global") + if mod.ID != prelude.ModuleID() { + t.Fatalf("module ID = %#v, want canonical prelude ID %#v", mod.ID, prelude.ModuleID()) } } diff --git a/internal/lsp/state.go b/internal/lsp/state.go index e7908e80..44f43cf0 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -282,7 +282,7 @@ func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFil } for _, module := range s.modules { if module != nil { - ctx.SetSemanticExportBaseline(module.Key, module.SemanticExportFingerprint) + ctx.SetSemanticExportBaseline(module.ID, module.SemanticExportFingerprint) } } reusePhases := map[string]phase.Phase{} @@ -322,10 +322,10 @@ func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFil // Cached artifacts may be ahead of this run's project barrier. Keep their // later diagnostics inactive so failures can retain them for a future run // without publishing them in the current one. - ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.Key, phase.None, min(retainedPhase, phase.Ownership), true) + ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.ID.String(), phase.None, min(retainedPhase, phase.Ownership), true) if retainedPhase > phase.Ownership { - ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.Key, phase.Usage, retainedPhase, false) - deferredDiagnostics[reused.Key] = retainedPhase + ctx.Diagnostics.CopyModuleRange(previousDiagnostics, reused.ID.String(), phase.Usage, retainedPhase, false) + deferredDiagnostics[reused.ID.String()] = retainedPhase } } return deferredDiagnostics @@ -369,8 +369,8 @@ func (s *ServerState) captureModules(ctx *project.CompilerContext) { } } if existing := s.modules[module.FilePath]; existing != nil && - existing.Origin == project.ModuleOriginStdlib && - module.Origin == project.ModuleOriginLocal { + existing.ID.Origin == string(project.ModuleOriginStdlib) && + module.ID.Origin == string(project.ModuleOriginLocal) { continue } s.modules[module.FilePath] = module diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index 0dc9ce4b..70f93ac6 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -150,7 +150,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) error { continue } resolved, err := ctx.ResolveImportPath(rawPath) - if err != nil || resolved == nil || resolved.Origin != project.ModuleOriginLocal { + if err != nil || resolved == nil || resolved.ID.Origin != string(project.ModuleOriginLocal) { continue } target := resolved.FilePath @@ -212,7 +212,7 @@ func (w *workspaceIndex) syntheticEntry(filePath string) (string, string, bool) } builder.WriteString("fn WorkspaceEntry() {}\n") - virtualPath := filepath.Join(w.rootDir, ".peeper-lsp", "__workspace__"+peeper.SourceExt) + virtualPath := filepath.Join(manifest.SourceDir(w.rootDir), ".peeper-lsp", "__workspace__"+peeper.SourceExt) return virtualPath, builder.String(), true } diff --git a/internal/moduleid/identity.go b/internal/moduleid/identity.go new file mode 100644 index 00000000..b92690d8 --- /dev/null +++ b/internal/moduleid/identity.go @@ -0,0 +1,38 @@ +// Package moduleid defines canonical compiler module identity. +package moduleid + +import ( + "encoding/hex" + "strconv" + "strings" +) + +// ID is stable across filesystem relocation and shared by imports, symbols, +// compiler registries, graph boundaries, and linkage naming. +type ID struct { + Origin string + Namespace string + Dependency string + ImportPath string +} + +func (id ID) Valid() bool { + return id.Origin != "" && id.ImportPath != "" +} + +// String returns collision-safe deterministic encoding for string-only boundaries. +func (id ID) String() string { + return Frame(id.Origin, id.Namespace, id.Dependency, id.ImportPath) +} + +// Frame encodes ordered identity and linkage components without delimiter collisions. +func Frame(components ...string) string { + var b strings.Builder + for _, component := range components { + b.WriteString(strconv.Itoa(len(component))) + b.WriteByte('_') + b.WriteString(hex.EncodeToString([]byte(component))) + b.WriteByte('_') + } + return b.String() +} diff --git a/internal/moduleid/identity_test.go b/internal/moduleid/identity_test.go new file mode 100644 index 00000000..03d9179b --- /dev/null +++ b/internal/moduleid/identity_test.go @@ -0,0 +1,30 @@ +package moduleid + +import "testing" + +func TestIDStringFramesComponentsWithoutCollisions(t *testing.T) { + first := ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} + second := ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} + if first.String() == second.String() { + t.Fatalf("length-ambiguous module identities collide: %q", first.String()) + } + if first.String() != first.String() { + t.Fatal("module identity encoding is not deterministic") + } +} + +func TestIDDependsOnLogicalIdentityOnly(t *testing.T) { + id := ID{Origin: "local", ImportPath: "app/math/counter"} + if id.String() == "" || !id.Valid() { + t.Fatalf("valid module identity = %#v", id) + } + if (ID{}).Valid() { + t.Fatal("zero module identity accepted as valid") + } + if (ID{Origin: "local"}).Valid() { + t.Fatal("module identity without import path accepted as valid") + } + if (ID{ImportPath: "app/math/counter"}).Valid() { + t.Fatal("module identity without origin accepted as valid") + } +} diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index d7582784..ea7e83ab 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -11,6 +11,7 @@ import ( "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/graph" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/project" ) @@ -18,7 +19,7 @@ import ( type moduleLoader struct { ctx *project.CompilerContext mu sync.Mutex - scheduled map[string]struct{} + scheduled map[moduleid.ID]struct{} wg sync.WaitGroup } @@ -38,20 +39,19 @@ func (l *moduleLoader) enqueue(module *project.Module) { if l == nil || l.ctx == nil || module == nil { return } - l.ensureModuleIdentity(module) - if module.Key == "" { + if !module.ID.Valid() { return } l.mu.Lock() - if _, ok := l.scheduled[module.Key]; ok { + if _, ok := l.scheduled[module.ID]; ok { l.mu.Unlock() return } - l.scheduled[module.Key] = struct{}{} + l.scheduled[module.ID] = struct{}{} l.mu.Unlock() - if existing, ok := l.ctx.ModuleByKey(module.Key); ok && existing != module { + if existing, ok := l.ctx.ModuleByID(module.ID); ok { module = existing } else { l.ctx.AddModule(module) @@ -61,26 +61,12 @@ func (l *moduleLoader) enqueue(module *project.Module) { go l.loadModule(module) } -func (l *moduleLoader) ensureModuleIdentity(module *project.Module) { - if module == nil || l == nil || l.ctx == nil { - return - } - if module.Key == "" && module.FilePath != "" { - module.Key = project.ModuleKeyFor(module.Origin, module.FilePath) - } - if module.ImportPath == "" && module.FilePath != "" { - if importPath, err := l.ctx.ImportPathForFile(module.Origin, module.Namespace, module.FilePath); err == nil { - module.ImportPath = importPath - } - } -} - func (l *moduleLoader) loadModule(module *project.Module) { defer l.wg.Done() if module == nil || l == nil { return } - loadDiag := l.ctx.Diagnostics.BeginPhase(phase.Load, module.Key) + loadDiag := l.ctx.Diagnostics.BeginPhase(phase.Load, module.ID.String()) if module.AST != nil { if module.ImportFingerprint == "" { module.ImportFingerprint = module.AST.ImportFingerprint @@ -107,7 +93,7 @@ func (l *moduleLoader) loadModule(module *project.Module) { l.ctx.Diagnostics.AddSourceContent(module.FilePath, module.Content) } module.ContentHash = ast.HashText(module.Content) - parseDiag := l.ctx.Diagnostics.BeginPhase(phase.Parsed, module.Key) + parseDiag := l.ctx.Diagnostics.BeginPhase(phase.Parsed, module.ID.String()) toks := lexer.New(module.FilePath, module.Content, parseDiag).Tokenize() // Content is no longer needed after lexing; free the string. module.Content = "" @@ -137,12 +123,12 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. l.addImportResolveError(diag, imp, err) continue } - alias := importAlias(imp, resolved.ImportPath) + alias := importAlias(imp, resolved.ID.ImportPath) if alias == "" { l.addImportError(diag, imp, diagnostics.ErrInvalidImportPath, "missing import alias") continue } - if existing, ok := module.Imports[alias]; ok && existing.Key != resolved.Key { + if existing, ok := module.Imports[alias]; ok && existing.ID != resolved.ID { l.addImportError(diag, imp, diagnostics.ErrAmbiguousImport, "import alias already in use") continue } @@ -150,20 +136,14 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. resolvedImport.Decl = imp module.Imports[alias] = resolvedImport if l.ctx.Graph != nil { - l.ctx.Graph.AddEdge(graph.NodeID(module.Key), graph.NodeID(resolved.Key)) + l.ctx.Graph.AddEdge(graph.NodeID(module.ID.String()), graph.NodeID(resolved.ID.String())) } - if existing, ok := l.ctx.ModuleByKey(resolved.Key); ok { + if existing, ok := l.ctx.ModuleByID(resolved.ID); ok { l.enqueue(existing) continue } - l.enqueue(&project.Module{ - Key: resolved.Key, - ImportPath: resolved.ImportPath, - FilePath: resolved.FilePath, - Namespace: resolved.Namespace, - Origin: resolved.Origin, - }) + l.enqueue(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index a7cf6bc9..cd2229fb 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -15,7 +15,9 @@ import ( "compiler/internal/ir/hir/fold" "compiler/internal/ir/hir/lower" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + preludepkg "compiler/internal/prelude" "compiler/internal/problems" "compiler/internal/project" "compiler/internal/semantics/binder" @@ -37,6 +39,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { } entry.IsEntry = true + // Explicit entry content must replace any overlay stub registered for same ID. ctx.AddModule(entry) ctx.CompletedProjectPhase = phase.Load diag := ctx.Diagnostics @@ -45,14 +48,14 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { loader := &moduleLoader{ ctx: ctx, - scheduled: make(map[string]struct{}), + scheduled: make(map[moduleid.ID]struct{}), } - preludeKey := "" - if preludeMod, ok := ctx.ModuleByKey("core:prelude/global"); ok { + preludeID := moduleid.ID{} + if preludeMod, ok := ctx.ModuleByID(preludepkg.ModuleID()); ok { if err := loader.Load(preludeMod); err != nil { return err } - preludeKey = preludeMod.Key + preludeID = preludeMod.ID } if err := loader.Load(entry); err != nil { return err @@ -60,11 +63,11 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { // Ensure topo-sort puts prelude first by making all non-prelude modules // depend on it. This removes the need for any special-case ordering logic. - if preludeKey != "" { + if preludeID.Valid() { for _, mod := range ctx.Modules() { - if mod != nil && mod.Key != preludeKey { + if mod != nil && mod.ID != preludeID { if ctx.Graph != nil { - ctx.Graph.AddEdge(graph.NodeID(mod.Key), graph.NodeID(preludeKey)) + ctx.Graph.AddEdge(graph.NodeID(mod.ID.String()), graph.NodeID(preludeID.String())) } } } @@ -74,10 +77,10 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { moduleIndex := make(map[graph.NodeID]*project.Module, len(modules)) moduleIDs := make([]graph.NodeID, 0, len(modules)) for _, mod := range modules { - if mod == nil || mod.Key == "" { + if mod == nil || !mod.ID.Valid() { continue } - id := graph.NodeID(mod.Key) + id := graph.NodeID(mod.ID.String()) moduleIDs = append(moduleIDs, id) moduleIndex[id] = mod } @@ -95,9 +98,15 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { if len(cycle) > 0 { parts := make([]string, 0, len(cycle)) for _, id := range cycle { - if id != "" { - parts = append(parts, string(id)) + module := moduleIndex[id] + if module == nil { + continue } + name := module.FilePath + if name == "" { + name = module.ID.ImportPath + } + parts = append(parts, name) } msg = "cyclic import detected: " + strings.Join(parts, " -> ") } @@ -109,13 +118,13 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { orderedModules := make([]*project.Module, 0, len(orderedIDs)) for _, id := range orderedIDs { module := moduleIndex[id] - if module != nil && module.Key != "" { + if module != nil && module.ID.Valid() { orderedModules = append(orderedModules, module) } } var prelude *project.Module - if preludeKey != "" { - prelude = moduleIndex[graph.NodeID(preludeKey)] + if preludeID.Valid() { + prelude = moduleIndex[graph.NodeID(preludeID.String())] } preludeInjected := advanceModulesThrough(ctx, orderedModules, prelude, prelude == nil, phase.Ownership, diag) ctx.CompletedProjectPhase = phase.Ownership @@ -129,7 +138,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { if module == nil || module.Phase < phase.Ownership || module.Phase >= phase.Usage { continue } - usageDiag := diag.BeginPhase(phase.Usage, module.Key) + usageDiag := diag.BeginPhase(phase.Usage, module.ID.String()) usage.Analyze(ctx.WithDiagnostics(usageDiag), module) module.Phase = phase.Usage ctx.Metrics.AddPhaseAdvance() @@ -139,7 +148,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { } ctx.CompletedProjectPhase = phase.Usage if ctx.Config.RequireEntrypoint { - validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.Key)) + validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.ID.String())) if diag.HasErrors() { return nil } @@ -239,7 +248,7 @@ func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, if ctx == nil || ctx.GlobalScope == nil || prelude == nil || prelude.ModuleScope == nil { return } - preludeDiag := diag.AppendPhase(phase.Collected, prelude.Key) + preludeDiag := diag.AppendPhase(phase.Collected, prelude.ID.String()) for _, sym := range prelude.ModuleScope.Symbols() { if err := ctx.GlobalScope.Declare(sym); err == nil { continue @@ -254,15 +263,15 @@ func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, // requireScheduledModulesAtLeast reports scheduled modules that stalled before // a required project-wide phase barrier without user diagnostics. -func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[string]struct{}, phase phase.Phase) error { +func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[moduleid.ID]struct{}, phase phase.Phase) error { for _, module := range modules { if module == nil || module.Phase >= phase { continue } - if _, ok := scheduled[module.Key]; !ok { + if _, ok := scheduled[module.ID]; !ok { continue } - name := module.Key + name := module.ID.ImportPath if name == "" { name = module.FilePath } @@ -287,7 +296,7 @@ func moduleReadyForNextPhase(ctx *project.CompilerContext, module, prelude *proj return true } for _, imp := range module.Imports { - imported, ok := ctx.ModuleByKey(imp.Key) + imported, ok := ctx.ModuleByID(imp.ID) if !ok || imported == nil || imported.Phase < required { return false } @@ -296,14 +305,16 @@ func moduleReadyForNextPhase(ctx *project.CompilerContext, module, prelude *proj } func preludeReadyForPhase(module, prelude *project.Module, preludeInjected bool, next phase.Phase) bool { - if module == nil || prelude == nil || module.Key == prelude.Key { + if module == nil || prelude == nil || module.ID == prelude.ID { return true } switch next { case phase.Collected, phase.Bound: return true - default: + case phase.Resolved: return preludeInjected && prelude.Phase >= phase.Resolved + default: + return preludeInjected && prelude.Phase >= phase.Typechecked } } @@ -347,7 +358,7 @@ func importPrerequisitePhase(next phase.Phase) phase.Phase { case phase.Bound: return phase.Bound case phase.ConstEval: - return phase.ConstEval + return phase.Typechecked case phase.Typechecked: return phase.Typechecked case phase.Resolved: @@ -383,7 +394,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if next == phase.None || next == phase.Usage { return false } - phaseDiag := diag.BeginPhase(next, module.Key) + phaseDiag := diag.BeginPhase(next, module.ID.String()) phaseCtx := ctx.WithDiagnostics(phaseDiag) if module.Phase < phase.Collected { collector.Collect(phaseCtx, module) @@ -493,7 +504,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if diag != nil && diag.HasErrors() { return false } - module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.ConstValues) + module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Constants.ModuleValues) module.Phase = phase.MIR ctx.Metrics.AddPhaseAdvance() return true @@ -518,15 +529,21 @@ func invalidateSemanticDependents(ctx *project.CompilerContext, advanced []*proj } queue := make([]graph.NodeID, 0) seen := make(map[graph.NodeID]struct{}) + modules := make(map[graph.NodeID]*project.Module) + for _, module := range ctx.Modules() { + if module != nil && module.ID.Valid() { + modules[graph.NodeID(module.ID.String())] = module + } + } for _, module := range advanced { if module == nil || module.Phase != phase.Typechecked { continue } - baseline, ok := ctx.SemanticExportBaseline(module.Key) + baseline, ok := ctx.SemanticExportBaseline(module.ID) if !ok || baseline == module.SemanticExportFingerprint { continue } - id := graph.NodeID(module.Key) + id := graph.NodeID(module.ID.String()) queue = append(queue, id) seen[id] = struct{}{} } @@ -539,7 +556,7 @@ func invalidateSemanticDependents(ctx *project.CompilerContext, advanced []*proj } seen[dependentID] = struct{}{} queue = append(queue, dependentID) - dependent, found := ctx.ModuleByKey(string(dependentID)) + dependent, found := modules[dependentID] if !found || dependent == nil || dependent.Phase < phase.Typechecked { continue } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index be1018da..2e1c83af 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -17,7 +17,9 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" @@ -27,11 +29,13 @@ import ( func parseModuleSource(filePath, src string, diag *diagnostics.DiagnosticBag) *project.Module { return &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt), - FilePath: filePath, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginLocal), + ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt), + }, + FilePath: filePath, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } } @@ -46,15 +50,11 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e ctx := project.NewWithConfig(cfg, diag) // Register the prelude so the pipeline loader can find it. - prelude := parseModuleSource(preludePath, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource(preludePath, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID() + ctx.AddModule(preludeModule) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -88,10 +88,8 @@ func runImportedRuntimeSymbolPipeline(t *testing.T, entrySrc, runtimeSrc string) Extension: peeper.SourceExt, }, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, entryPath), - ImportPath: "app/main", - FilePath: entryPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: entryPath, } if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -456,12 +454,10 @@ fn main() -> i32 { LibraryBaseDir: libraryBase, }, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, entryPath), - ImportPath: "entry", - FilePath: entryPath, - Content: entrySrc, - Origin: project.ModuleOriginLocal, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "entry"}, + FilePath: entryPath, + Content: entrySrc, + Imports: make(map[string]project.ResolvedImport), } if err := Run(ctx, entry); err != nil { @@ -566,16 +562,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID() + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -612,16 +604,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID() + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -674,16 +662,12 @@ fn main() -> i32 { Extension: peeper.SourceExt, }, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID() + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -707,7 +691,6 @@ fn main() -> i32 { return 0; }`) ctx := project.New(".", peeper.SourceExt, diag) entry := parseModuleSource(filePath, `fn unused() {} fn main() -> i32 { return 0; }`, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("first Pipeline.Run: %v", err) @@ -744,7 +727,6 @@ func TestPipelineRunReplacesStaleFinalizeDiagnostics(t *testing.T) { diag.AddSourceContent(filePath, sourceText) ctx := project.New(".", peeper.SourceExt, diag) entry := parseModuleSource(filePath, sourceText, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("Pipeline.Run: %v", err) @@ -777,16 +759,12 @@ func TestPipelineDebugBuildEmitsLLVMMetadata(t *testing.T) { diag.AddSourceContent("entry"+peeper.SourceExt, entrySrc) ctx := project.NewWithConfig(cfg, diag) - prelude := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - prelude.Key = "core:prelude/global" - prelude.ImportPath = "prelude/global" - prelude.Namespace = "core" - prelude.Origin = project.ModuleOriginStdlib - ctx.AddModule(prelude) + preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) + preludeModule.ID = prelude.ModuleID() + ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) - entry.ImportPath = "entry" - entry.Origin = project.ModuleOriginLocal + entry.ID.ImportPath = "entry" if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -808,7 +786,6 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx.AddModule(entry) @@ -864,13 +841,12 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { } } -func TestTypecheckedPhaseFinalizesModuleConstValues(t *testing.T) { +func TestTypecheckedPhasePublishesModuleConstants(t *testing.T) { diag := diagnostics.NewDiagnosticBag() const entryPath = "entry" + peeper.SourceExt entry := parseModuleSource(entryPath, `const Value = 1; fn main() -> i32 { return Value; } `, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -887,13 +863,16 @@ fn main() -> i32 { return Value; } if !ok { t.Fatal("failed to construct stale const value") } - entry.ConstValues[sym.ID] = stale + entry.Constants.QueryCache[sym.ID] = stale if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.Typechecked { t.Fatalf("phase = %v, want typechecked", entry.Phase) } - if got := entry.ConstValues[sym.ID]; got == nil || got.TypeText() != "i32" { + if got := entry.Constants.ModuleValues[sym.ID]; got == nil || got.TypeText() != "i32" { t.Fatalf("final const value = %#v, want i32", got) } + if _, found := entry.Constants.QueryCache[sym.ID]; found { + t.Fatal("published module constant remains duplicated in query cache") + } } func TestTypecheckedPhaseFinalizesNamedVariantConstants(t *testing.T) { @@ -908,7 +887,6 @@ const Waiting: Status = Status::Waiting; const ReadyIsReady: bool = Ready is Status::Ready; const WaitingIsReady: bool = Waiting is Status::Ready; `, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -924,9 +902,9 @@ const WaitingIsReady: bool = Waiting is Status::Ready; if !found || readySymbol == nil { t.Fatal("missing const symbol Ready") } - ready, ok := entry.ConstValues[readySymbol.ID].(*constvalue.VariantConst) + ready, ok := entry.Constants.ModuleValues[readySymbol.ID].(*constvalue.VariantConst) if !ok || ready == nil || ready.NominalIdentity() == "" || ready.CaseIndex() != 0 || len(ready.FieldValues()) != 2 { - t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.ConstValues[readySymbol.ID]) + t.Fatalf("Ready constant = %#v, want named case 0 with two fields", entry.Constants.ModuleValues[readySymbol.ID]) } code, ok := ready.FieldValues()[0].(*constvalue.IntConst) if !ok || code.Text() != "7" { @@ -949,7 +927,6 @@ fn main() -> i32 { return 0; } `, diag) - entry.Origin = project.ModuleOriginLocal ctx := project.NewWithConfig(project.Config{ RootDir: ".", Extension: peeper.SourceExt, @@ -975,9 +952,9 @@ func assertPipelineBoolConst(t *testing.T, module *project.Module, name string, if !found || sym == nil { t.Fatalf("missing const symbol %s", name) } - value, ok := module.ConstValues[sym.ID].(*constvalue.BoolConst) + value, ok := module.Constants.ModuleValues[sym.ID].(*constvalue.BoolConst) if !ok || value == nil || value.Bool() != want { - t.Fatalf("%s = %#v, want bool %t", name, module.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %t", name, module.Constants.ModuleValues[sym.ID], want) } } @@ -989,7 +966,6 @@ func TestPipelineFinalizesMissingReturnDiagnosticInCFGPhase(t *testing.T) { return 7; } }`, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -1019,7 +995,6 @@ func TestPipelineReportsConstantConditionInCFGPhase(t *testing.T) { } return 0; }`, diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) @@ -1085,22 +1060,22 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { module *project.Module want string }{ - {name: "blocked prerequisite", module: &project.Module{Key: "local:main", Phase: phase.Resolved}, want: "resolved phase"}, - {name: "missing HIR", module: &project.Module{Key: "local:main", Phase: phase.Ownership}, want: "ownership phase"}, - {name: "missing MIR", module: &project.Module{Key: "local:main", Phase: phase.HIR}, want: "HIR phase"}, + {name: "blocked prerequisite", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Resolved}, want: "resolved phase"}, + {name: "missing HIR", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Ownership}, want: "ownership phase"}, + {name: "missing MIR", module: &project.Module{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.HIR}, want: "HIR phase"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[string]struct{}{test.module.Key: {}}, phase.Backend) + err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[moduleid.ID]struct{}{test.module.ID: {}}, phase.Backend) if err == nil || !strings.Contains(err.Error(), "local:main") || !strings.Contains(err.Error(), test.want) { t.Fatalf("terminal error = %v, want module and %q", err, test.want) } }) } - if err := requireScheduledModulesAtLeast([]*project.Module{{Key: "local:main", Phase: phase.Backend}}, map[string]struct{}{"local:main": {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Backend}}, map[moduleid.ID]struct{}{moduleid.ID{ImportPath: "local:main"}: {}}, phase.Backend); err != nil { t.Fatalf("completed module rejected: %v", err) } - if err := requireScheduledModulesAtLeast([]*project.Module{{Key: "overlay:stub", Phase: phase.None}}, map[string]struct{}{"local:main": {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "overlay:stub"}, Phase: phase.None}}, map[moduleid.ID]struct{}{moduleid.ID{ImportPath: "local:main"}: {}}, phase.Backend); err != nil { t.Fatalf("unscheduled overlay rejected: %v", err) } } @@ -1108,7 +1083,6 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { func TestPipelineDiagnosticStopReturnsNormally(t *testing.T) { diag := diagnostics.NewDiagnosticBag() entry := parseModuleSource("invalid"+peeper.SourceExt, "fn main() -> Missing { return 0; }", diag) - entry.Origin = project.ModuleOriginLocal ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) if err := Run(ctx, entry); err != nil { t.Fatalf("diagnostic-driven stop returned pipeline error: %v", err) @@ -1181,7 +1155,6 @@ fn main() -> i32 { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1205,19 +1178,15 @@ func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: "."}, diag) imported := parseModuleSource("util"+peeper.SourceExt, "fn Helper() -> i32 { return 1; }", diag) - imported.Origin = project.ModuleOriginLocal imported.Phase = phase.Parsed ctx.AddModule(imported) entry := parseModuleSource("main"+peeper.SourceExt, "import \"util\";\nfn main() -> i32 { return util::Helper(); }\n", diag) - entry.Origin = project.ModuleOriginLocal entry.Phase = phase.Parsed entry.Imports = map[string]project.ResolvedImport{ "util": { - Key: imported.Key, - ImportPath: "util", - FilePath: imported.FilePath, - Origin: project.ModuleOriginLocal, + ID: imported.ID, + FilePath: imported.FilePath, }, } ctx.AddModule(entry) @@ -1249,12 +1218,17 @@ func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { entry.Phase = phase.Resolved if moduleReadyForNextPhase(ctx, entry, nil, true) { - t.Fatalf("resolved importer should wait for const-evaluated import before consteval") + t.Fatal("resolved importer should wait for typechecked import before consteval") } imported.Phase = phase.ConstEval + if moduleReadyForNextPhase(ctx, entry, nil, true) { + t.Fatal("resolved importer should not read provisional import constants") + } + + imported.Phase = phase.Typechecked if !moduleReadyForNextPhase(ctx, entry, nil, true) { - t.Fatalf("resolved importer should be ready for consteval when import is const-evaluated") + t.Fatal("resolved importer should be ready for consteval when import constants are published") } } @@ -1278,10 +1252,8 @@ fn main() -> i32 { } entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, mainPath), - ImportPath: "app/main", - FilePath: mainPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: mainPath, } if err := os.WriteFile(utilPath, []byte(utilSrc), 0o644); err != nil { @@ -1349,10 +1321,8 @@ fn Value() -> i32 { mainPath := filepath.Join(srcDir, peeper.MainFileName) ctx := project.NewWithConfig(project.Config{RootDir: root, ProjectName: "app", Extension: peeper.SourceExt}, diag) entry := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, mainPath), - ImportPath: "app/main", - FilePath: mainPath, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, + FilePath: mainPath, } if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1625,7 +1595,6 @@ fn main() -> i32 { RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, }, diag) entry := parseModuleSource(filePath, src, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } @@ -1757,7 +1726,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1854,7 +1822,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -1938,7 +1905,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -2035,7 +2001,6 @@ fn main() -> i32 { diag.AddSourceContent(entryPath, entrySrc) ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) entry := parseModuleSource(entryPath, entrySrc, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) @@ -2500,7 +2465,6 @@ fn main() -> i32 { TargetArch: compilerTarget.arch, }, diag) entry := parseModuleSource(filePath, src, diag) - entry.Origin = project.ModuleOriginLocal if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index 5fa62100..98a32def 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/pkg/manifest" "compiler/pkg/peeper" @@ -13,6 +14,10 @@ import ( // Auto-loaded Peeper prelude file within the stdlib root. const GlobalPreludeFile = "global" + peeper.SourceExt +func ModuleID() moduleid.ID { + return moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "core", ImportPath: "prelude/global"} +} + func globalPreludePath(ctx *project.CompilerContext) (string, bool) { if ctx == nil { return "", false @@ -26,19 +31,16 @@ func globalPreludePath(ctx *project.CompilerContext) (string, bool) { // ModuleForFile returns the canonical prelude module identity when a file path // points at the auto-loaded global prelude source. Direct-open and overlay -// paths must reuse this exact key/import-path so the same file does not appear -// twice in compiler and LSP caches. +// paths must reuse this exact identity so the same file does not appear twice +// in compiler and LSP caches. func ModuleForFile(ctx *project.CompilerContext, filePath, content string) (*project.Module, bool) { preludePath, ok := globalPreludePath(ctx) if !ok || project.CanonicalPath(preludePath) != project.CanonicalPath(filePath) { return nil, false } return &project.Module{ - Key: "core:prelude/global", - ImportPath: "prelude/global", + ID: ModuleID(), FilePath: preludePath, - Namespace: "core", - Origin: project.ModuleOriginStdlib, Content: content, ContentProvided: true, }, true diff --git a/internal/project/context.go b/internal/project/context.go index 099d14c1..5c1c1b3e 100644 --- a/internal/project/context.go +++ b/internal/project/context.go @@ -11,6 +11,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/graph" "compiler/internal/ir" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" @@ -41,12 +42,12 @@ type CompilerContext struct { // Predeclared symbols visible before user/prelude code. GlobalScope *symbols.Scope - // Module key -> module. - modules map[string]*Module - // Canonical file path -> module key. - fileIndex map[string]string + // Canonical module identity -> module. + modules map[moduleid.ID]*Module + // Canonical file path -> module identity. + fileIndex map[string]moduleid.ID // Prior semantic API fingerprints supplied by incremental clients. - semanticExportBaselines map[string]string + semanticExportBaselines map[moduleid.ID]string // Named declaration identity -> collected module declaration index. typeDeclarations map[string]*Module // Concrete semantic application identity -> canonical instance. @@ -170,9 +171,9 @@ func NewWithConfig(cfg Config, diag *diagnostics.DiagnosticBag) *CompilerContext Graph: graph.New(GraphEdgeImport), mu: &sync.RWMutex{}, - modules: make(map[string]*Module), - fileIndex: make(map[string]string), - semanticExportBaselines: make(map[string]string), + modules: make(map[moduleid.ID]*Module), + fileIndex: make(map[string]moduleid.ID), + semanticExportBaselines: make(map[moduleid.ID]string), typeDeclarations: make(map[string]*Module), typeInstances: make(map[string]namedTypeInstance), } @@ -196,7 +197,7 @@ func (ctx *CompilerContext) ResetModule(module *Module, retained phase.Phase) { module.resetToPhase(retained) ctx.mu.Lock() for identity, instance := range ctx.typeInstances { - if instance.ownerModuleKey == module.Key { + if instance.ownerModuleID == module.ID { if !instance.complete && instance.ready != nil { close(instance.ready) } @@ -205,14 +206,14 @@ func (ctx *CompilerContext) ResetModule(module *Module, retained phase.Phase) { } if retained < phase.Collected { for identity, owner := range ctx.typeDeclarations { - if owner != nil && owner.Key == module.Key { + if owner != nil && owner.ID == module.ID { delete(ctx.typeDeclarations, identity) } } } ctx.mu.Unlock() - if ctx.Diagnostics != nil && module.Key != "" { - ctx.Diagnostics.DiscardModuleAfter(module.Key, retained) + if ctx.Diagnostics != nil && module.ID.Valid() { + ctx.Diagnostics.DiscardModuleAfter(module.ID.String(), retained) } } diff --git a/internal/project/context_test.go b/internal/project/context_test.go index 4e0f3a46..9ea3660d 100644 --- a/internal/project/context_test.go +++ b/internal/project/context_test.go @@ -6,6 +6,7 @@ import ( "testing" "compiler/internal/diagnostics" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -14,13 +15,14 @@ import ( func TestWithDiagnosticsSharesCompilerStateAndLock(t *testing.T) { ctx := New(".", ".peep", diagnostics.NewDiagnosticBag()) - scopedBag := ctx.Diagnostics.BeginPhase(phase.Typechecked, "main") + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "main"} + scopedBag := ctx.Diagnostics.BeginPhase(phase.Typechecked, id.String()) scoped := ctx.WithDiagnostics(scopedBag) - module := &Module{Key: "main"} + module := &Module{ID: id} scoped.AddModule(module) scoped.Diagnostics.Add(diagnostics.NewError("typed")) - if got, ok := ctx.ModuleByKey("main"); !ok || got != module { + if got, ok := ctx.ModuleByID(id); !ok || got != module { t.Fatal("scoped context did not share module index") } if got := ctx.Diagnostics.Diagnostics(); len(got) != 1 || got[0].Message != "typed" { diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index 3cc905e3..2d5dee18 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -15,6 +15,10 @@ func SemanticExportFingerprint(module *Module) string { if module == nil || module.ModuleScope == nil { return ast.FingerprintParts(nil) } + var moduleValues map[symbols.SymbolID]constvalue.Value + if module.Constants != nil { + moduleValues = module.Constants.ModuleValues + } parts := make([]string, 0) for _, sym := range module.ModuleScope.Symbols() { if sym == nil || !sym.IsPub { @@ -24,9 +28,9 @@ func SemanticExportFingerprint(module *Module) string { if sym.Kind == symbols.SymbolVar { part += fmt.Sprintf(":mutable=%t", sym.IsMutable()) } - part += semanticExportMetadata(module, sym) + part += semanticExportMetadata(module, sym, moduleValues) if sym.Kind == symbols.SymbolConst { - part += ":value=" + constantKey(module.ConstValues[sym.ID]) + part += ":value=" + constantKey(moduleValues[sym.ID]) } parts = append(parts, part) } @@ -37,14 +41,14 @@ func SemanticExportFingerprint(module *Module) string { continue } parts = append(parts, "method:"+receiver+":"+method.Name+":"+ - semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(module, method)) + semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(module, method, moduleValues)) } } } return ast.FingerprintParts(parts) } -func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { +func semanticExportMetadata(module *Module, sym *symbols.Symbol, moduleValues map[symbols.SymbolID]constvalue.Value) string { decl, ok := sym.ASTNode.(ast.Decl) if !ok || decl == nil { return "" @@ -85,7 +89,7 @@ func semanticExportMetadata(module *Module, sym *symbols.Symbol) string { } fact := resolved.Name + ":" + semanticTypeKey(resolved.Type, make(map[typeinfo.Type]bool)) if resolved.Kind == symbols.SymbolConst { - fact += "=" + constantKey(module.ConstValues[resolved.ID]) + fact += "=" + constantKey(moduleValues[resolved.ID]) } facts = append(facts, fact) return true diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index d699d123..3b89b227 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -6,6 +6,7 @@ import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/constantresult" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) @@ -29,10 +30,11 @@ func fingerprintModule( if bindings == nil { bindings = bindingresult.New() } - if constValues == nil { - constValues = make(map[symbols.SymbolID]constvalue.Value) + constants := constantresult.New() + if constValues != nil { + constants.ModuleValues = constValues } - return &Module{ModuleScope: scope, Bindings: bindings, ConstValues: constValues} + return &Module{ModuleScope: scope, Bindings: bindings, Constants: constants} } func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) { @@ -68,16 +70,35 @@ func TestSemanticExportFingerprintIncludesConstValueWithoutBindings(t *testing.T t.Fatalf("declare export: %v", err) } constant, _ := constvalue.NewIntText(value, "i32") - return SemanticExportFingerprint(&Module{ - ModuleScope: scope, - ConstValues: map[symbols.SymbolID]constvalue.Value{sym.ID: constant}, - }) + constants := constantresult.New() + constants.ModuleValues[sym.ID] = constant + return SemanticExportFingerprint(&Module{ModuleScope: scope, Constants: constants}) } if fingerprint("1") == fingerprint("2") { t.Fatal("binding-independent const value did not change semantic fingerprint") } } +func TestSemanticExportFingerprintIgnoresQueryCache(t *testing.T) { + fingerprint := func(value string) string { + decl := &ast.ConstDecl{Name: &ast.Ident{Name: "Value"}} + decl.SetDeclSurface("const:Value::number") + sym := symbols.New("Value", symbols.SymbolConst, decl, nil) + sym.Type = &typeinfo.IntegerType{Signed: true, Bits: 32} + scope := symbols.NewScope(nil) + if err := scope.Declare(sym); err != nil { + t.Fatalf("declare export: %v", err) + } + constant, _ := constvalue.NewIntText(value, "i32") + constants := constantresult.New() + constants.QueryCache[sym.ID] = constant + return SemanticExportFingerprint(&Module{ModuleScope: scope, Constants: constants}) + } + if fingerprint("1") != fingerprint("2") { + t.Fatal("query-cache-only value changed semantic fingerprint") + } +} + func TestSemanticExportFingerprintIgnoresFunctionBodyChanges(t *testing.T) { makeFunction := func(body *ast.BlockStmt) string { decl := &ast.FnDecl{Name: &ast.Ident{Name: "Read"}, Body: body} diff --git a/internal/project/generic_types.go b/internal/project/generic_types.go index c37e571e..5dc8a7dd 100644 --- a/internal/project/generic_types.go +++ b/internal/project/generic_types.go @@ -6,6 +6,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/internal/semantics/typeinfo" ) @@ -15,10 +16,10 @@ type namedTypeDeclaration struct { } type namedTypeInstance struct { - ownerModuleKey string - typ *typeinfo.DefinedType - ready chan struct{} - complete bool + ownerModuleID moduleid.ID + typ *typeinfo.DefinedType + ready chan struct{} + complete bool } type typeInstantiationFrame struct { @@ -136,9 +137,9 @@ func (ctx *CompilerContext) instantiateType(base *typeinfo.DefinedType, argument // Cache provisional shell before substitution. Recursive pointer/reference // applications resolve back to this exact object. ctx.typeInstances[identity] = namedTypeInstance{ - ownerModuleKey: declarationModule.Key, - typ: instance, - ready: make(chan struct{}), + ownerModuleID: declarationModule.ID, + typ: instance, + ready: make(chan struct{}), } ctx.mu.Unlock() diff --git a/internal/project/imports.go b/internal/project/imports.go index 34895083..346030b1 100644 --- a/internal/project/imports.go +++ b/internal/project/imports.go @@ -9,26 +9,19 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/pkg/manifest" "compiler/pkg/remotes" ) // Canonical file-backed import after resolver lookup. type ResolvedImport struct { - // Stable graph identity. - Key string - // Module path as written in source. - ImportPath string + // Canonical imported module identity. + ID moduleid.ID // Source import declaration, when resolved from parsed syntax. Decl *ast.ImportDecl // Absolute slash-separated source path. FilePath string - // Local, stdlib, or dependency. - Origin ModuleOrigin - // Optional namespace for packaged libraries such as core/vendor. - Namespace string - // Manifest alias for dependency imports. - DependencyAlias string } // ImportCandidate is one source-level import path visible from a compiler @@ -52,18 +45,6 @@ func (e *ImportError) Error() string { return e.Msg } -// ModuleKeyFor builds a stable module key for a file path and origin. -func ModuleKeyFor(origin ModuleOrigin, filePath string) string { - if filePath == "" { - return "" - } - prefix := string(origin) - if prefix == "" { - prefix = string(ModuleOriginLocal) - } - return prefix + ":" + CanonicalPath(filePath) -} - // ImportCandidates returns immediate import paths matching prefix. Import root // selection stays beside resolution so editor features cannot invent different // project, namespace, source-directory, or extension rules. @@ -334,11 +315,12 @@ func (ctx *CompilerContext) ResolveImportPath(rawPath string) (*ResolvedImport, } return &ResolvedImport{ - Key: ModuleKeyFor(origin, absPath), - ImportPath: resolvedImportPath, - FilePath: absPath, - Origin: origin, - Namespace: namespace, + ID: moduleid.ID{ + Origin: string(origin), + Namespace: namespace, + ImportPath: resolvedImportPath, + }, + FilePath: absPath, }, nil } diff --git a/internal/project/imports_test.go b/internal/project/imports_test.go index b709c5f6..0f7f8ef2 100644 --- a/internal/project/imports_test.go +++ b/internal/project/imports_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "compiler/internal/moduleid" "compiler/pkg/manifest" "compiler/pkg/peeper" ) @@ -30,15 +31,13 @@ func TestResolveImportPathUsesLibraryNamespaceRoots(t *testing.T) { if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } - if resolved.Namespace != "vendor" { - t.Fatalf("resolved namespace = %q, want %q", resolved.Namespace, "vendor") + wantID := moduleid.ID{Origin: string(ModuleOriginStdlib), Namespace: "vendor", ImportPath: "json"} + if resolved.ID != wantID { + t.Fatalf("resolved ID = %#v, want %#v", resolved.ID, wantID) } if want := CanonicalPath(libraryFile); resolved.FilePath != want { t.Fatalf("resolved file path = %q, want %q", resolved.FilePath, want) } - if resolved.ImportPath != "json" { - t.Fatalf("resolved import path = %q, want %q", resolved.ImportPath, "json") - } } func TestResolveImportPathRequiresProjectConfigForLocalImports(t *testing.T) { @@ -77,12 +76,13 @@ func TestResolveImportPathStripsProjectPrefix(t *testing.T) { if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } + wantID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "app/util"} + if resolved.ID != wantID { + t.Fatalf("resolved ID = %#v, want %#v", resolved.ID, wantID) + } if want := CanonicalPath(utilPath); resolved.FilePath != want { t.Fatalf("resolved file path = %q, want %q", resolved.FilePath, want) } - if resolved.ImportPath != "app/util" { - t.Fatalf("resolved import path = %q, want %q", resolved.ImportPath, "app/util") - } } func TestImportCandidatesEnumeratesRootsAndImmediateChildren(t *testing.T) { diff --git a/internal/project/modules.go b/internal/project/modules.go index 5207a576..1809188f 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -4,14 +4,15 @@ import ( "path/filepath" "strings" - "compiler/internal/constvalue" "compiler/internal/frontend/ast" "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/constantresult" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" @@ -36,20 +37,12 @@ const GraphEdgeImport graph.EdgeKind = "import" // Source unit shared by every compiler phase. type Module struct { - // Unique graph identity. - Key string - // Module path used by imports. - ImportPath string + // Canonical semantic, import, graph, and ownership identity. + ID moduleid.ID // Absolute slash-separated source path. FilePath string - // Optional namespace for packaged libraries such as core/vendor. - Namespace string // User-selected entry module. IsEntry bool - // Local, stdlib, or dependency. - Origin ModuleOrigin - // Dependency alias, when any. - Dependency string // Loaded source text. Content string // ContentProvided distinguishes an explicit empty source from a module that @@ -83,32 +76,20 @@ type Module struct { namedTypeDeclarations map[string]namedTypeDeclaration // Staged symbol/scope graph for current semantic generation. Bindings *bindingresult.Result - // Finalized module constants plus mutable constant-query cache. - ConstValues map[symbols.SymbolID]constvalue.Value + // Constant-evaluation artifacts for current semantic generation. + Constants *constantresult.Result // Base typechecker result for current semantic generation. Typechecking *typecheckresult.Result // Import alias -> resolved module import. Imports map[string]ResolvedImport } -func (m *Module) DefiningModuleKey() symbols.DefiningModuleKey { - if m == nil { - return symbols.DefiningModuleKey{} - } - return symbols.DefiningModuleKey{ - Origin: string(m.Origin), - Namespace: m.Namespace, - Dependency: m.Dependency, - ImportPath: m.ImportPath, - } -} - // TypeDeclarationIdentity anchors nominal type identity at its declaring module. func (m *Module) TypeDeclarationIdentity(name string) string { - if m == nil || m.Key == "" || name == "" { + if m == nil || !m.ID.Valid() || name == "" { return name } - return m.Key + "::" + name + return m.ID.String() + "::" + name } // ExpandedDefaultBinding resolves declaration-module symbols paired with generated @@ -149,7 +130,7 @@ func (m *Module) ResetSemanticData() { return } m.Bindings = bindingresult.New() - m.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + m.Constants = constantresult.New() m.Typechecking = nil } @@ -184,7 +165,7 @@ func (m *Module) resetToPhase(retained phase.Phase) { if retained <= phase.Parsed { m.ModuleScope = nil m.Bindings = nil - m.ConstValues = nil + m.Constants = nil } if retained < phase.Collected { m.namedTypeDeclarations = nil @@ -238,39 +219,48 @@ func PathWithinRoot(rootPath, path string) bool { return strings.HasPrefix(path, rootPath+"/") } -// NewModuleForFile builds one file-backed module with canonical origin, -// namespace, key, and import path derived from compiler config. +// NewModuleForFile builds one file-backed module with canonical identity derived from compiler config. func (ctx *CompilerContext) NewModuleForFile(filePath, content string) *Module { if ctx == nil || filePath == "" { return nil } origin, namespace := ctx.ModuleOriginForFile(filePath) - module := &Module{ - Key: ModuleKeyFor(origin, filePath), + importPath, err := ctx.ImportPathForFile(origin, namespace, filePath) + if err != nil { + return nil + } + return &Module{ + ID: moduleid.ID{ + Origin: string(origin), + Namespace: namespace, + ImportPath: importPath, + }, FilePath: filePath, - Namespace: namespace, - Origin: origin, Content: content, ContentProvided: true, } - if importPath, err := ctx.ImportPathForFile(origin, namespace, filePath); err == nil { - module.ImportPath = importPath - } - return module } // Register a module in shared compiler state. func (ctx *CompilerContext) AddModule(module *Module) { - if ctx == nil || module == nil || module.Key == "" { + if ctx == nil || module == nil || !module.ID.Valid() { return } module.FilePath = CanonicalPath(module.FilePath) ctx.mu.Lock() defer ctx.mu.Unlock() - ctx.modules[module.Key] = module + if previous := ctx.modules[module.ID]; previous != nil && previous.FilePath != "" && previous.FilePath != module.FilePath { + if ctx.fileIndex[previous.FilePath] == module.ID { + delete(ctx.fileIndex, previous.FilePath) + } + } + if previousID, found := ctx.fileIndex[module.FilePath]; module.FilePath != "" && found && previousID != module.ID { + panic("module file registered with multiple identities") + } + ctx.modules[module.ID] = module if module.FilePath != "" { - ctx.fileIndex[module.FilePath] = module.Key + ctx.fileIndex[module.FilePath] = module.ID } if module.Phase >= phase.Collected { for identity := range module.namedTypeDeclarations { @@ -279,35 +269,35 @@ func (ctx *CompilerContext) AddModule(module *Module) { } } -// Lookup by graph identity. -func (ctx *CompilerContext) ModuleByKey(key string) (*Module, bool) { - if ctx == nil || key == "" { +// ModuleByID resolves canonical module identity. +func (ctx *CompilerContext) ModuleByID(id moduleid.ID) (*Module, bool) { + if ctx == nil || !id.Valid() { return nil, false } ctx.mu.RLock() defer ctx.mu.RUnlock() - module, ok := ctx.modules[key] + module, ok := ctx.modules[id] return module, ok } // SetSemanticExportBaseline records prior semantic API state for incremental comparison. -func (ctx *CompilerContext) SetSemanticExportBaseline(key, fingerprint string) { - if ctx == nil || key == "" || fingerprint == "" { +func (ctx *CompilerContext) SetSemanticExportBaseline(id moduleid.ID, fingerprint string) { + if ctx == nil || !id.Valid() || fingerprint == "" { return } ctx.mu.Lock() defer ctx.mu.Unlock() - ctx.semanticExportBaselines[key] = fingerprint + ctx.semanticExportBaselines[id] = fingerprint } // SemanticExportBaseline returns prior semantic API state when supplied by a client. -func (ctx *CompilerContext) SemanticExportBaseline(key string) (string, bool) { - if ctx == nil || key == "" { +func (ctx *CompilerContext) SemanticExportBaseline(id moduleid.ID) (string, bool) { + if ctx == nil || !id.Valid() { return "", false } ctx.mu.RLock() defer ctx.mu.RUnlock() - fingerprint, ok := ctx.semanticExportBaselines[key] + fingerprint, ok := ctx.semanticExportBaselines[id] return fingerprint, ok } @@ -318,11 +308,11 @@ func (ctx *CompilerContext) ModuleByFile(filePath string) (*Module, bool) { } ctx.mu.RLock() defer ctx.mu.RUnlock() - key, ok := ctx.fileIndex[CanonicalPath(filePath)] + id, ok := ctx.fileIndex[CanonicalPath(filePath)] if !ok { return nil, false } - module, ok := ctx.modules[key] + module, ok := ctx.modules[id] return module, ok } diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index bb342692..3b020d8d 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -9,6 +9,7 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/ir/hir" "compiler/internal/ir/mir" + "compiler/internal/moduleid" "compiler/internal/phase" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" @@ -21,16 +22,57 @@ func TestCompilerContextAddModuleCanonicalizesFilePath(t *testing.T) { ctx := New(".", ".peep", nil) filePath := filepath.Join("nested", "..", "main.peep") want := CanonicalPath(filePath) - module := &Module{ - Key: "local:test", - FilePath: filePath, - } + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "test"} + module := &Module{ID: id, FilePath: filePath} ctx.AddModule(module) if module.FilePath != want { t.Fatalf("module path = %q, want canonical %q", module.FilePath, want) } + byID, foundByID := ctx.ModuleByID(id) + byFile, foundByFile := ctx.ModuleByFile(filePath) + if !foundByID || !foundByFile || byID != module || byFile != module { + t.Fatalf("module lookups by ID/file = (%p, %t), (%p, %t), want %p", byID, foundByID, byFile, foundByFile, module) + } +} + +func TestCompilerContextRejectsZeroModuleID(t *testing.T) { + ctx := New(".", ".peep", nil) + module := &Module{FilePath: "zero.peep"} + + ctx.AddModule(module) + + if len(ctx.Modules()) != 0 { + t.Fatalf("modules after zero-ID add = %#v", ctx.Modules()) + } + if _, found := ctx.ModuleByID(moduleid.ID{}); found { + t.Fatal("zero ID resolved a module") + } + if _, found := ctx.ModuleByFile(module.FilePath); found { + t.Fatal("rejected zero-ID module remained in file index") + } +} + +func TestCompilerContextModuleIDsKeepComponentsCollisionSafe(t *testing.T) { + ctx := New(".", ".peep", nil) + firstID := moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "value"} + secondID := moduleid.ID{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "value"} + first := &Module{ID: firstID} + second := &Module{ID: secondID} + + ctx.AddModule(first) + ctx.AddModule(second) + + if firstID.String() == secondID.String() { + t.Fatalf("component-distinct IDs collide: %q", firstID.String()) + } + if got, found := ctx.ModuleByID(firstID); !found || got != first { + t.Fatalf("first module lookup = (%p, %t), want %p", got, found, first) + } + if got, found := ctx.ModuleByID(secondID); !found || got != second { + t.Fatalf("second module lookup = (%p, %t), want %p", got, found, second) + } } func moduleWithArtifacts() *Module { @@ -57,7 +99,7 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { phase phase.Phase scope bool bindings bool - constValues bool + constants bool typechecking bool exportAPI bool astNodes bool @@ -69,21 +111,21 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { llvm bool }{ {phase: phase.Parsed}, - {phase: phase.Typechecked, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true}, - {phase: phase.CFG, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true}, - {phase: phase.FlowTyped, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.DefiniteInit, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.Ownership, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.Usage, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.HIR, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, - {phase: phase.MIR, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, - {phase: phase.Backend, scope: true, bindings: true, constValues: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, + {phase: phase.Typechecked, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true}, + {phase: phase.CFG, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true}, + {phase: phase.FlowTyped, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, + {phase: phase.DefiniteInit, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, + {phase: phase.Ownership, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, + {phase: phase.Usage, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, + {phase: phase.HIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, + {phase: phase.MIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, + {phase: phase.Backend, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, } for _, test := range tests { module := moduleWithArtifacts() module.resetToPhase(test.phase) if module.Phase != test.phase || (module.ModuleScope != nil) != test.scope || - (module.Bindings != nil) != test.bindings || (module.ConstValues != nil) != test.constValues || + (module.Bindings != nil) != test.bindings || (module.Constants != nil) != test.constants || (module.Typechecking != nil) != test.typechecking || (module.HIR != nil) != test.hir || (module.TypedASTNodes != nil) != test.astNodes || @@ -103,7 +145,8 @@ func TestModuleResetSemanticDataInitializesCurrentResults(t *testing.T) { module.ResetSemanticData() if module.Bindings == nil || module.Bindings.BlockScopes == nil || module.Bindings.NodeSymbols == nil || module.Bindings.MethodsByReceiver == nil || module.Bindings.MethodsByDecl == nil || - module.Bindings.OperationFunctions == nil || module.ConstValues == nil || module.Typechecking != nil { + module.Bindings.OperationFunctions == nil || module.Constants == nil || module.Constants.ModuleValues == nil || + module.Constants.QueryCache == nil || module.Typechecking != nil { t.Fatalf("semantic reset = %#v", module) } } @@ -157,12 +200,14 @@ func TestModuleResetToPhaseRetainsCFGIdentity(t *testing.T) { func TestCompilerContextResetModuleDiscardsOnlyDownstreamDiagnostics(t *testing.T) { bag := diagnostics.NewDiagnosticBag() - bag.BeginPhase(phase.Parsed, "a").Add(diagnostics.NewWarning("a parse")) - bag.BeginPhase(phase.Typechecked, "a").Add(diagnostics.NewError("a type")) - bag.BeginPhase(phase.Typechecked, "b").Add(diagnostics.NewError("b type")) + aID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "a"} + bID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "b"} + bag.BeginPhase(phase.Parsed, aID.String()).Add(diagnostics.NewWarning("a parse")) + bag.BeginPhase(phase.Typechecked, aID.String()).Add(diagnostics.NewError("a type")) + bag.BeginPhase(phase.Typechecked, bID.String()).Add(diagnostics.NewError("b type")) ctx := New(".", ".peep", bag) module := moduleWithArtifacts() - module.Key = "a" + module.ID = aID ctx.ResetModule(module, phase.Parsed) @@ -177,17 +222,19 @@ func TestCompilerContextResetModuleDiscardsOnlyDownstreamDiagnostics(t *testing. func TestCompilerContextResetPurgesOwnedNamedTypeInstances(t *testing.T) { ctx := New(".", ".peep", nil) - module := &Module{Key: "owner"} + ownerID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "owner"} + otherID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "other"} + module := &Module{ID: ownerID} ctx.typeInstances["owner::Box"] = namedTypeInstance{ - ownerModuleKey: module.Key, - typ: &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box"}, + ownerModuleID: ownerID, + typ: &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box"}, } ctx.typeInstances["other::Box"] = namedTypeInstance{ - ownerModuleKey: "other", - typ: &typeinfo.DefinedType{Name: "Box", Identity: "other::Box"}, + ownerModuleID: otherID, + typ: &typeinfo.DefinedType{Name: "Box", Identity: "other::Box"}, } - ctx.ResetModule(&Module{Key: module.Key}, phase.Parsed) + ctx.ResetModule(&Module{ID: module.ID}, phase.Parsed) if _, found := ctx.typeInstances["owner::Box"]; found { t.Fatal("reset retained instance owned by reset module") @@ -198,7 +245,10 @@ func TestCompilerContextResetPurgesOwnedNamedTypeInstances(t *testing.T) { } func TestCompilerContextReindexesCollectedTypeDeclarations(t *testing.T) { - module := &Module{Key: "owner", Phase: phase.Collected} + module := &Module{ + ID: moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "owner"}, + Phase: phase.Collected, + } base := &typeinfo.DefinedType{Name: "Box", Identity: "owner::Box", Kind: typeinfo.DefinedKindStruct} declaration := &ast.StructDecl{Name: &ast.Ident{Name: "Box"}} original := New(".", ".peep", nil) diff --git a/internal/project/type_lookup.go b/internal/project/type_lookup.go index e3b5064c..b1cb0fe3 100644 --- a/internal/project/type_lookup.go +++ b/internal/project/type_lookup.go @@ -30,7 +30,7 @@ func LookupImportedSymbol(ctx *CompilerContext, currentModule *Module, importedM if impSym, ok := currentModule.ModuleScope.LookupLocal(importedModule); ok && impSym != nil { impSym.Used = true } - imported, ok := ctx.ModuleByKey(imp.Key) + imported, ok := ctx.ModuleByID(imp.ID) if !ok || imported == nil || imported.ModuleScope == nil { return out, false } @@ -66,7 +66,7 @@ func CanonicalEnumDeclaration(ctx *CompilerContext, typ typeinfo.Type) (*Module, if owner == nil { instance, found := ctx.typeInstances[defined.Identity] if found && instance.typ == defined && instance.complete { - owner = ctx.modules[instance.ownerModuleKey] + owner = ctx.modules[instance.ownerModuleID] } } return owner diff --git a/internal/semantics/binder/binder_test.go b/internal/semantics/binder/binder_test.go index 722a7b28..d1223c7e 100644 --- a/internal/semantics/binder/binder_test.go +++ b/internal/semantics/binder/binder_test.go @@ -11,6 +11,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/collector" "compiler/internal/semantics/symbols" @@ -28,7 +29,7 @@ fn Alpha(value: Value, extra: i32) {}` diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -90,7 +91,7 @@ struct B { a: A }`, diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), @@ -124,7 +125,7 @@ fn Use(box: Box, again: Box, other: Box, nested: Box>, n diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -207,7 +208,7 @@ fn Use(alias: Choice, canonical: Choice) {}` diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -254,7 +255,7 @@ fn Use(value: &Swap) {}`, diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), @@ -300,7 +301,7 @@ func TestBindRequiresExactNamedTypeArguments(t *testing.T) { diag := diagnostics.NewDiagnosticBag() ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: test.source, AST: parser.New(filePath, lexer.New(filePath, test.source, diag).Tokenize(), diag).ParseModule(), diff --git a/internal/semantics/binder/type_decl_cycles.go b/internal/semantics/binder/type_decl_cycles.go index 3b5b8f72..4d58246c 100644 --- a/internal/semantics/binder/type_decl_cycles.go +++ b/internal/semantics/binder/type_decl_cycles.go @@ -7,6 +7,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/graph" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/symbols" ) @@ -20,7 +21,7 @@ func (b *binder) registerTypeDecl(name string, typ ast.TypeExpr) { if b == nil || b.ctx == nil || b.ctx.Graph == nil || b.module == nil || name == "" { return } - owner := typeDeclNodeID(b.module.Key, name) + owner := typeDeclNodeID(b.module.ID, name) // Value edges require full layout; indirect references do not force target expansion. b.addTypeDeclEdges(owner, typ, false) } @@ -34,7 +35,7 @@ func (b *binder) validateTypeDeclCycles() { if sym == nil || sym.Kind != symbols.SymbolType { continue } - nodeIDs = append(nodeIDs, typeDeclNodeID(b.module.Key, sym.Name)) + nodeIDs = append(nodeIDs, typeDeclNodeID(b.module.ID, sym.Name)) } if len(nodeIDs) == 0 { return @@ -69,11 +70,11 @@ func (b *binder) validateTypeDeclCycles() { } } -func typeDeclNodeID(moduleKey, name string) graph.NodeID { - if moduleKey == "" || name == "" { +func typeDeclNodeID(moduleID moduleid.ID, name string) graph.NodeID { + if !moduleID.Valid() || name == "" { return "" } - return graph.NodeID("type:" + moduleKey + ":" + name) + return graph.NodeID("type:" + moduleID.String() + ":" + name) } func (b *binder) addTypeDeclEdges(owner graph.NodeID, typ ast.TypeExpr, indirect bool) { @@ -145,7 +146,7 @@ func (b *binder) lookupTypeDeclNodeID(name string) graph.NodeID { if !ok || sym == nil || sym.Kind != symbols.SymbolType { return "" } - return typeDeclNodeID(b.module.Key, sym.Name) + return typeDeclNodeID(b.module.ID, sym.Name) } func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) graph.NodeID { @@ -160,7 +161,7 @@ func (b *binder) lookupQualifiedTypeDeclNodeID(node *ast.ScopeResolution) graph. if !ok || resolved.Module == nil || resolved.Symbol == nil || resolved.Symbol.Kind != symbols.SymbolType { return "" } - return typeDeclNodeID(resolved.Module.Key, resolved.Symbol.Name) + return typeDeclNodeID(resolved.Module.ID, resolved.Symbol.Name) } func typeDeclNameFromNodeID(id graph.NodeID) string { diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index 863cbf0d..d10b1c64 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -86,14 +86,14 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { return } sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) - sym.DefiningModule = c.module.DefiningModuleKey() + sym.DefiningModule = c.module.ID sym.Scope = symbols.NewScope(c.module.ModuleScope) c.module.Bindings.MethodsByReceiver[targetKey] = append(c.module.Bindings.MethodsByReceiver[targetKey], sym) c.module.Bindings.MethodsByDecl[fn.ID()] = sym return } sym := symbols.New(fn.Name.Name, symbols.SymbolFunc, fn, ast.LocOf(fn.Name)) - sym.DefiningModule = c.module.DefiningModuleKey() + sym.DefiningModule = c.module.ID sym.Scope = symbols.NewScope(c.module.ModuleScope) if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), fn.Name.Name, fn.Name.Location) @@ -152,7 +152,7 @@ func (c *collector) collectConcreteTypeDecl(decl ast.TypeDecl) { } variantSymbol := symbols.New(variant.Name.Name, symbols.SymbolVariant, variant.Name, variant.Name.Location) variantSymbol.Type = defined - variantSymbol.DefiningModule = c.module.DefiningModuleKey() + variantSymbol.DefiningModule = c.module.ID if err := sym.Scope.Declare(variantSymbol); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, sym.Scope, err.Error(), variant.Name.Name, variant.Name.Location) continue @@ -169,6 +169,7 @@ func (c *collector) collectModuleBinding(name *ast.Ident, kind symbols.Kind, nod return } sym := symbols.New(name.Name, kind, node, ast.LocOf(name)) + sym.DefiningModule = c.module.ID sym.Type = &typeinfo.UnknownType{} // binder fills real type if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), name.Name, name.Location) diff --git a/internal/semantics/collector/collector_test.go b/internal/semantics/collector/collector_test.go index 112c463a..01c49e1b 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -17,27 +18,28 @@ import ( var _ func(*collector, ast.TypeDecl) = (*collector).collectConcreteTypeDecl var _ func(*collector, *ast.Ident, symbols.Kind, ast.Node) = (*collector).collectModuleBinding -func TestCallableSymbolsKeepDefiningModuleKey(t *testing.T) { +func TestCallableSymbolsKeepDefiningModuleIdentity(t *testing.T) { const filePath = "collector_callable_module_test" + peeper.SourceExt const src = `struct Counter { value: i32 } fn Value() -> i32 { return 1; } fn (self: Counter) Read() -> i32 { return self.value; }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginDependency, filePath), - ImportPath: "math/counter", - FilePath: filePath, - Namespace: "vendor", - Origin: project.ModuleOriginDependency, - Dependency: "mathlib", - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{ + Origin: string(project.ModuleOriginDependency), + Namespace: "vendor", + Dependency: "mathlib", + ImportPath: "math/counter", + }, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx := project.New(".", peeper.SourceExt, diag) Collect(ctx, module) - want := symbols.DefiningModuleKey{ + want := moduleid.ID{ Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "mathlib", @@ -58,7 +60,7 @@ func TestCollectedDefinedTypeKeepsDeclaringModuleIdentity(t *testing.T) { const src = `enum Status { Ready }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -75,7 +77,7 @@ func TestCollectedDefinedTypeKeepsDeclaringModuleIdentity(t *testing.T) { if !ok || defined == nil { t.Fatalf("collected enum type = %T, want DefinedType", sym.Type) } - want := module.Key + "::Status" + want := module.ID.String() + "::Status" if defined.Identity != want { t.Fatalf("collected enum identity = %q, want %q", defined.Identity, want) } @@ -90,7 +92,7 @@ func TestCollectedEnumOwnsOrderedVariantSymbols(t *testing.T) { type Alias = Result;` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -136,7 +138,7 @@ func TestCollectedEnumRejectsDuplicateVariants(t *testing.T) { const src = `enum Status { Ready, Ready }` diag := diagnostics.NewDiagnosticBag() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: strings.TrimSuffix(filePath, peeper.SourceExt)}, FilePath: filePath, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), @@ -166,18 +168,15 @@ fn main() -> i32 { } module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_import_test", - FilePath: filePath, - Content: src, - AST: modAST, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_import_test"}, + FilePath: filePath, + Content: src, + AST: modAST, Imports: map[string]project.ResolvedImport{ "external": { - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Origin: project.ModuleOriginLocal, - Decl: modAST.Imports[0], + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, + Decl: modAST.Imports[0], }, }, } @@ -210,12 +209,11 @@ fn Platform() -> i32 { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux"}, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_target_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_target_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } Collect(ctx, module) @@ -249,12 +247,11 @@ func TestTargetOSImplMethodsStillCollide(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux"}, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "collector_method_target_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "collector_method_target_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } Collect(ctx, module) diff --git a/internal/semantics/constantresult/result.go b/internal/semantics/constantresult/result.go new file mode 100644 index 00000000..195aad70 --- /dev/null +++ b/internal/semantics/constantresult/result.go @@ -0,0 +1,20 @@ +// Package constantresult defines constant-evaluation artifacts for one semantic generation. +package constantresult + +import ( + "compiler/internal/constvalue" + "compiler/internal/semantics/symbols" +) + +// Result separates authoritative module constants from mutable lazy query entries. +type Result struct { + ModuleValues map[symbols.SymbolID]constvalue.Value + QueryCache map[symbols.SymbolID]constvalue.Value +} + +func New() *Result { + return &Result{ + ModuleValues: make(map[symbols.SymbolID]constvalue.Value), + QueryCache: make(map[symbols.SymbolID]constvalue.Value), + } +} diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index 517fa018..63f347f5 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -5,15 +5,18 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/project" + "compiler/internal/semantics/constantresult" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) type evaluator struct { - ctx *project.CompilerContext - module *project.Module - inProgress map[symbols.SymbolID]struct{} + ctx *project.CompilerContext + module *project.Module + constants *constantresult.Result + inProgress map[symbols.SymbolID]struct{} + publishModuleValues bool } // Evaluate performs the eager semantic const prepass after name resolution. @@ -23,33 +26,24 @@ func Evaluate(ctx *project.CompilerContext, module *project.Module) { if ctx == nil || module == nil || module.ModuleScope == nil { return } - if module.ConstValues == nil { - module.ConstValues = make(map[symbols.SymbolID]constvalue.Value) - } - e := &evaluator{ - ctx: ctx, - module: module, - inProgress: make(map[symbols.SymbolID]struct{}), - } - for _, sym := range module.ModuleScope.Symbols() { - if sym != nil && sym.Kind == symbols.SymbolConst { - e.evalConstSymbol(sym, module.ModuleScope) - } - } + e := newEvaluator(ctx, module, false) + e.evalModuleConstants() } -// FinalizeValues recomputes module constants after typechecking assigns final -// symbol types. Local const cache entries remain available to later queries. +// FinalizeValues recomputes and publishes authoritative module constants after +// typechecking assigns final symbol types. Local query-cache entries remain mutable. func FinalizeValues(ctx *project.CompilerContext, module *project.Module) { if ctx == nil || module == nil || module.ModuleScope == nil { return } + e := newEvaluator(ctx, module, true) + clear(e.constants.ModuleValues) for _, sym := range module.ModuleScope.Symbols() { if sym != nil && sym.Kind == symbols.SymbolConst { - delete(module.ConstValues, sym.ID) + delete(e.constants.QueryCache, sym.ID) } } - Evaluate(ctx, module) + e.evalModuleConstants() } // EvaluateExpr computes one semantic constant using expected type information @@ -61,22 +55,47 @@ func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *s if scope == nil && module.ModuleScope == nil { return nil, false } - if module.ConstValues == nil { - module.ConstValues = make(map[symbols.SymbolID]constvalue.Value) + e := newEvaluator(ctx, module, false) + return e.evalExpr(scope, expr, expected) +} + +func newEvaluator(ctx *project.CompilerContext, module *project.Module, publishModuleValues bool) *evaluator { + if module.Constants == nil { + module.Constants = constantresult.New() } - e := &evaluator{ - ctx: ctx, - module: module, - inProgress: make(map[symbols.SymbolID]struct{}), + return &evaluator{ + ctx: ctx, + module: module, + constants: module.Constants, + inProgress: make(map[symbols.SymbolID]struct{}), + publishModuleValues: publishModuleValues, + } +} + +func (e *evaluator) evalModuleConstants() { + for _, sym := range e.module.ModuleScope.Symbols() { + if sym != nil && sym.Kind == symbols.SymbolConst { + e.evalConstSymbol(sym, e.module.ModuleScope) + } } - return e.evalExpr(scope, expr, expected) } func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) (constvalue.Value, bool) { if e == nil || e.module == nil || sym == nil { return nil, false } - if value, ok := e.module.ConstValues[sym.ID]; ok { + if ownerID := sym.DefiningModule; ownerID.Valid() && ownerID != e.module.ID { + owner, found := e.ctx.ModuleByID(ownerID) + if !found || owner.Constants == nil { + return nil, false + } + value, found := owner.Constants.ModuleValues[sym.ID] + return value, found + } + if value, ok := e.constants.ModuleValues[sym.ID]; ok { + return value, true + } + if value, ok := e.constants.QueryCache[sym.ID]; ok { return value, true } if _, ok := e.inProgress[sym.ID]; ok { @@ -111,7 +130,14 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) ( if !ok { return nil, false } - e.module.ConstValues[sym.ID] = value + if e.publishModuleValues { + if topLevel, found := e.module.ModuleScope.LookupLocal(sym.Name); found && topLevel != nil && topLevel.ID == sym.ID { + e.constants.ModuleValues[sym.ID] = value + delete(e.constants.QueryCache, sym.ID) + return value, true + } + } + e.constants.QueryCache[sym.ID] = value return value, true } diff --git a/internal/semantics/consteval/consteval_test.go b/internal/semantics/consteval/consteval_test.go index b8ada0db..df64cd3b 100644 --- a/internal/semantics/consteval/consteval_test.go +++ b/internal/semantics/consteval/consteval_test.go @@ -5,8 +5,10 @@ import ( "compiler/internal/constvalue" "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -23,12 +25,11 @@ func constevalModule(t *testing.T, src string) (*project.Module, *diagnostics.Di diag.AddSourceContent(filePath, src) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "consteval_test", - FilePath: filePath, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "consteval_test"}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -38,14 +39,14 @@ func constevalModule(t *testing.T, src string) (*project.Module, *diagnostics.Di return module, diag } -func TestEvaluateInitializesOnlyConstValues(t *testing.T) { +func TestEvaluateInitializesOnlyConstantResult(t *testing.T) { diag := diagnostics.NewDiagnosticBag() module := &project.Module{ModuleScope: symbols.NewScope(nil)} Evaluate(project.New(".", peeper.SourceExt, diag), module) - if module.ConstValues == nil { - t.Fatal("Evaluate did not initialize ConstValues") + if module.Constants == nil || module.Constants.ModuleValues == nil || module.Constants.QueryCache == nil { + t.Fatal("Evaluate did not initialize constant result") } if module.Bindings != nil { t.Fatalf("Evaluate initialized Bindings: %#v", module.Bindings) @@ -177,11 +178,110 @@ func TestFinalizeValuesRecomputesConstantsWithFinalSymbolTypes(t *testing.T) { if !ok || sym == nil { t.Fatal("missing symbol Value") } + if _, found := module.Constants.QueryCache[sym.ID]; !found { + t.Fatal("eager constant missing provisional query-cache value") + } + if _, found := module.Constants.ModuleValues[sym.ID]; found { + t.Fatal("eager prepass published authoritative module value before typecheck") + } sym.BindType(&typeinfo.IntegerType{Signed: true, Bits: 64}) ctx := project.New(".", peeper.SourceExt, diag) ctx.AddModule(module) FinalizeValues(ctx, module) assertIntConst(t, module, "Value", "1", "i64") + if _, found := module.Constants.QueryCache[sym.ID]; found { + t.Fatal("finalized module constant remains duplicated in query cache") + } + if _, found := module.Constants.ModuleValues[sym.ID]; !found { + t.Fatal("finalized module constant was not published") + } +} + +func TestEvaluateExprCachesLocalConstantsWithoutChangingPublishedValues(t *testing.T) { + module, diag := constevalModule(t, `const Top = 1; +fn main() { + const Local = 2; + let value = Local; +} +`) + ctx := project.New(".", peeper.SourceExt, diag) + ctx.AddModule(module) + FinalizeValues(ctx, module) + fn := module.AST.Stmts[1].(*ast.FnDecl) + local := fn.Body.Stmts[0].(*ast.ConstDecl) + reference := fn.Body.Stmts[1].(*ast.LetDecl).Value.(*ast.Ident) + scope := module.Bindings.BlockScopes[fn.Body.ID()] + if _, ok := EvaluateExpr(ctx, module, scope, reference, nil); !ok { + t.Fatal("failed to evaluate local constant reference") + } + localSymbol, found := scope.LookupLocal(local.Name.Name) + if !found || localSymbol == nil { + t.Fatal("missing local constant symbol") + } + if _, found := module.Constants.QueryCache[localSymbol.ID]; !found { + t.Fatal("local constant missing query-cache entry") + } + if _, found := module.Constants.ModuleValues[localSymbol.ID]; found { + t.Fatal("local constant leaked into authoritative module values") + } + top, _ := module.ModuleScope.LookupLocal("Top") + if _, found := module.Constants.QueryCache[top.ID]; found { + t.Fatal("published module constant was duplicated by local query") + } +} + +func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(".", peeper.SourceExt, diag) + parse := func(filePath, importPath, src string) *project.Module { + module := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: importPath}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + ctx.AddModule(module) + return module + } + resolve := func(module *project.Module) { + collector.Collect(ctx, module) + binder.Bind(ctx, module) + resolver.Resolve(ctx, module) + } + + owner := parse("owner"+peeper.SourceExt, "owner", "const Shared: i32 = 7;") + resolve(owner) + Evaluate(ctx, owner) + FinalizeValues(ctx, owner) + shared, found := owner.ModuleScope.LookupLocal("Shared") + if !found || shared == nil || shared.DefiningModule != owner.ID { + t.Fatalf("foreign constant owner = %#v, want %v", shared, owner.ID) + } + if err := ctx.GlobalScope.Declare(shared); err != nil { + t.Fatalf("publish shared constant: %v", err) + } + + consumer := parse("consumer"+peeper.SourceExt, "consumer", "const Local = Shared;") + resolve(consumer) + Evaluate(ctx, consumer) + local, found := consumer.ModuleScope.LookupLocal("Local") + if !found || local == nil { + t.Fatal("missing consumer constant") + } + value, ok := consumer.Constants.QueryCache[local.ID].(*constvalue.IntConst) + if !ok || value == nil || value.Text() != "7" { + t.Fatalf("consumer value = %#v, want 7", consumer.Constants.QueryCache[local.ID]) + } + if _, found := consumer.Constants.QueryCache[shared.ID]; found { + t.Fatal("foreign constant duplicated in consumer query cache") + } + if _, found := owner.Constants.ModuleValues[shared.ID]; !found { + t.Fatal("owner lost published constant") + } + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } } func TestEvaluateUsesConstOperandTypeForNestedArithmetic(t *testing.T) { @@ -204,9 +304,10 @@ func TestEvaluateStringConst(t *testing.T) { if !ok || sym == nil { t.Fatalf("missing symbol Name") } - got, ok := module.ConstValues[sym.ID].(*constvalue.StringConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.StringConst) if !ok || got == nil || got.Text() != "puts" || got.TypeText() != "cstr" { - t.Fatalf("Name = %#v, want str puts cstr", module.ConstValues[sym.ID]) + t.Fatalf("Name = %#v, want str puts cstr", value) } } @@ -216,10 +317,18 @@ func assertIntConst(t *testing.T, module *project.Module, name, want, wantType s if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.ConstValues[sym.ID].(*constvalue.IntConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.IntConst) if !ok || got == nil || got.Text() != want || (wantType != "" && got.TypeText() != wantType) { - t.Fatalf("%s = %#v, want int %s %s", name, module.ConstValues[sym.ID], want, wantType) + t.Fatalf("%s = %#v, want int %s %s", name, value, want, wantType) + } +} + +func evaluatedConst(module *project.Module, id symbols.SymbolID) constvalue.Value { + if value := module.Constants.ModuleValues[id]; value != nil { + return value } + return module.Constants.QueryCache[id] } func assertBoolConst(t *testing.T, module *project.Module, name string, want bool) { @@ -228,8 +337,9 @@ func assertBoolConst(t *testing.T, module *project.Module, name string, want boo if !ok || sym == nil { t.Fatalf("missing symbol %s", name) } - got, ok := module.ConstValues[sym.ID].(*constvalue.BoolConst) + value := evaluatedConst(module, sym.ID) + got, ok := value.(*constvalue.BoolConst) if !ok || got == nil || got.Bool() != want { - t.Fatalf("%s = %#v, want bool %v", name, module.ConstValues[sym.ID], want) + t.Fatalf("%s = %#v, want bool %v", name, value, want) } } diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 96cfe3c4..ca478c19 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -10,6 +10,7 @@ import ( "compiler/internal/frontend/parser" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -25,12 +26,11 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, diag.AddSourceContent(filePath, source) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "definite_init_test", - FilePath: filePath, - Content: source, - AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "definite_init_test"}, + FilePath: filePath, + Content: source, + AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index c82c6674..28242d4b 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -11,6 +11,7 @@ import ( "compiler/internal/frontend/parser" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -36,12 +37,11 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "ownership_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "ownership_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) diff --git a/internal/semantics/resolver/resolver_test.go b/internal/semantics/resolver/resolver_test.go index 43a5fea7..6741a783 100644 --- a/internal/semantics/resolver/resolver_test.go +++ b/internal/semantics/resolver/resolver_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -23,12 +24,11 @@ func checkResolveSource(t *testing.T, src string) (*project.Module, *diagnostics ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "resolver_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "resolver_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) diff --git a/internal/semantics/symbols/symbol.go b/internal/semantics/symbols/symbol.go index 34a34f53..15837d3c 100644 --- a/internal/semantics/symbols/symbol.go +++ b/internal/semantics/symbols/symbol.go @@ -6,6 +6,7 @@ import ( "unicode/utf8" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/internal/source" ) @@ -49,13 +50,6 @@ type Type interface { Text() string } -type DefiningModuleKey struct { - Origin string - Namespace string - Dependency string - ImportPath string -} - type Symbol struct { ID SymbolID Name string @@ -68,7 +62,7 @@ type Symbol struct { Used bool RequiresMutable bool CompilerOp CompilerOp - DefiningModule DefiningModuleKey + DefiningModule moduleid.ID Location *source.Location MutableLocation *source.Location ASTNode ast.Node diff --git a/internal/semantics/typechecker/flow_test.go b/internal/semantics/typechecker/flow_test.go index 0d9643b3..044ad1a4 100644 --- a/internal/semantics/typechecker/flow_test.go +++ b/internal/semantics/typechecker/flow_test.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/ir/cfg" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -26,12 +27,11 @@ func checkFlowSource(t *testing.T, src string) (*project.Module, *diagnostics.Di diag.AddSourceContent(filePath, src) ctx := project.New(".", peeper.SourceExt, diag) module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "flow_test", - FilePath: filePath, - Content: src, - AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "flow_test"}, + FilePath: filePath, + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 851dda60..c216929d 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -9,6 +9,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -29,12 +30,11 @@ func checkTypeSource(t *testing.T, src string) *diagnostics.DiagnosticBag { ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) @@ -58,12 +58,11 @@ func checkTypeSourceWithExternalImport(t *testing.T, src string) (*project.Modul extAST := parser.New(externalPath, lexer.New(externalPath, externalSrc, diag).Tokenize(), diag).ParseModule() extModule := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, externalPath), - ImportPath: "external", - FilePath: externalPath, - Content: externalSrc, - AST: extAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: externalPath, + Content: externalSrc, + AST: extAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(extModule) collector.Collect(ctx, extModule) @@ -73,17 +72,14 @@ func checkTypeSourceWithExternalImport(t *testing.T, src string) (*project.Modul modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, Imports: map[string]project.ResolvedImport{ "external": { - Key: extModule.Key, - ImportPath: "external", - FilePath: externalPath, - Origin: project.ModuleOriginLocal, + ID: extModule.ID, + FilePath: externalPath, }, }, } @@ -169,12 +165,11 @@ func checkTypeModule(t *testing.T, src string) (*project.Module, *diagnostics.Di ctx := project.New(".", peeper.SourceExt, diag) modAST := parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "typechecker_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "typechecker_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(module) collector.Collect(ctx, module) diff --git a/internal/semantics/usage/usage.go b/internal/semantics/usage/usage.go index 7f294e08..5e8252f3 100644 --- a/internal/semantics/usage/usage.go +++ b/internal/semantics/usage/usage.go @@ -4,6 +4,7 @@ import ( "fmt" "compiler/internal/diagnostics" + "compiler/internal/prelude" "compiler/internal/project" "compiler/internal/semantics/symbols" ) @@ -25,7 +26,7 @@ func Analyze(ctx *project.CompilerContext, module *project.Module) { // 2. Check for unused private module-level symbols (functions, types, constants, variables) // Do not warn about prelude/global symbols since they represent a library - if module.Key != "core:prelude/global" { + if module.ID != prelude.ModuleID() { for _, sym := range module.ModuleScope.Symbols() { if sym.Kind == symbols.SymbolImport { continue diff --git a/internal/semantics/usage/usage_test.go b/internal/semantics/usage/usage_test.go index 9716935c..918f9eff 100644 --- a/internal/semantics/usage/usage_test.go +++ b/internal/semantics/usage/usage_test.go @@ -9,6 +9,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/moduleid" "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" @@ -32,12 +33,11 @@ func checkUsageSource(t *testing.T, src string, setupImports bool) *diagnostics. fn GetValue() -> i32 { return 42; }` extAST := parser.New("external"+peeper.SourceExt, lexer.New("external"+peeper.SourceExt, extSrc, diag).Tokenize(), diag).ParseModule() extMod := &project.Module{ - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Content: extSrc, - AST: extAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, + Content: extSrc, + AST: extAST, + Imports: make(map[string]project.ResolvedImport), } ctx.AddModule(extMod) collector.Collect(ctx, extMod) @@ -49,20 +49,17 @@ fn GetValue() -> i32 { return 42; }` stream := lexer.New(filePath, src, diag).Tokenize() modAST := parser.New(filePath, stream, diag).ParseModule() module := &project.Module{ - Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), - ImportPath: "usage_test", - FilePath: filePath, - Content: src, - AST: modAST, - Imports: make(map[string]project.ResolvedImport), + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "usage_test"}, + FilePath: filePath, + Content: src, + AST: modAST, + Imports: make(map[string]project.ResolvedImport), } if setupImports { module.Imports["external"] = project.ResolvedImport{ - Key: "local:external" + peeper.SourceExt, - ImportPath: "external", - FilePath: "external" + peeper.SourceExt, - Origin: project.ModuleOriginLocal, + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "external"}, + FilePath: "external" + peeper.SourceExt, } } From c8ebf84e38d921ed5f2fa132aabe7f21e82aed03 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 12:24:30 +0600 Subject: [PATCH 03/80] Derive prelude identity and diagnose identity conflicts Importing the prelude explicitly crashed the compiler. prelude.ModuleID hardcoded import path "prelude/global", but the prelude file resolves to /src/global.peep, whose canonical ImportPathForFile result is "global". An explicit `import "core:global"` therefore reached AddModule with the same file under a second identity. ModuleID now takes a context and derives the import path from the resolved prelude file, so the auto-loaded prelude and an explicit import agree. The hardcoded mismatch predates canonical module identity; it became a crash because AddModule gained a one-identity-per-file invariant. That conflict is reachable from user source and from two library roots sharing a directory, so AddModule now emits ErrAmbiguousImport and keeps the first registration instead of panicking. Identity conflicts are user-facing errors, not impossible states, and in the LSP the panic killed the server process for the whole session. AddModule releases the registry lock before reporting so the diagnostic bag is never taken under it. No x_test fixture used a core: import, which is why the full suite stayed green. Add import_prelude_identity covering an explicit prelude import, and a direct AddModule conflict-diagnostic test. Validated with gofmt, go vet, go test -count=1 ./..., focused race suites, a fresh compiler bundle, and the bundled-binary x_test suite. The failing input exits 0 on origin/main, panicked on the previous commit, and exits 0 again after this change. --- docs/compiler-framework/README.md | 14 ++++++++- internal/lsp/server_test.go | 4 +-- internal/pipeline/pipeline.go | 2 +- internal/pipeline/pipeline_test.go | 10 +++---- internal/prelude/prelude.go | 26 +++++++++++++--- internal/project/modules.go | 15 ++++++++-- internal/project/modules_test.go | 31 ++++++++++++++++++++ internal/semantics/usage/usage.go | 2 +- x_test/import_prelude_identity/peeper.toml | 6 ++++ x_test/import_prelude_identity/src/main.peep | 7 +++++ 10 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 x_test/import_prelude_identity/peeper.toml create mode 100644 x_test/import_prelude_identity/src/main.peep diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index 00cb4146..8997be16 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -152,12 +152,24 @@ still names these parameters `moduleKey`; it is deliberately identity-agnostic a that rename is outstanding terminology debt. Module construction derives identity once, in `CompilerContext.NewModuleForFile` or -`prelude.ModuleID()`. `NewModuleForFile` returns nil when no import path can be +`prelude.ModuleID(ctx)`. `NewModuleForFile` returns nil when no import path can be derived, so callers must establish project root containment first; `cmd/build.go` and both LSP entry paths do this through `manifest.ResolveSourceFileProject` and `manifest.PathWithinSourceDir`, and report a source-root diagnostic rather than an identity failure. +Every identity must be derivable from the file it names. `prelude.ModuleID` resolves +the prelude path and runs it back through `ImportPathForFile`, so the auto-loaded +prelude registers under exactly the identity `ResolveImportPath` produces for an +explicit `core:global` import. A hardcoded import path here registers the file under +an identity no import can reproduce, and the same file then arrives twice under two +identities. + +`AddModule` enforces one identity per file. That conflict is reachable from user +source and from library-root configuration, so it emits an `ErrAmbiguousImport` +diagnostic and keeps the first registration rather than panicking; identity +conflicts are user-facing errors, not impossible states. + ### Structural traversal **Current.** Reuse these APIs: diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 72bda0ae..ac31e2cf 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -349,8 +349,8 @@ func TestParseBundledPreludeFileKeepsStdlibIdentity(t *testing.T) { if mod == nil { t.Fatalf("expected compiled bundled library module") } - if mod.ID != prelude.ModuleID() { - t.Fatalf("module ID = %#v, want canonical prelude ID %#v", mod.ID, prelude.ModuleID()) + if mod.ID != prelude.ModuleID(ctx) { + t.Fatalf("module ID = %#v, want canonical prelude ID %#v", mod.ID, prelude.ModuleID(ctx)) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index cd2229fb..e9cc3252 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -51,7 +51,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { scheduled: make(map[moduleid.ID]struct{}), } preludeID := moduleid.ID{} - if preludeMod, ok := ctx.ModuleByID(preludepkg.ModuleID()); ok { + if preludeMod, ok := ctx.ModuleByID(preludepkg.ModuleID(ctx)); ok { if err := loader.Load(preludeMod); err != nil { return err } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 2e1c83af..7413975c 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -51,7 +51,7 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e // Register the prelude so the pipeline loader can find it. preludeModule := parseModuleSource(preludePath, preludeSrc, diag) - preludeModule.ID = prelude.ModuleID() + preludeModule.ID = prelude.ModuleID(ctx) ctx.AddModule(preludeModule) entry := parseModuleSource(entryPath, entrySrc, diag) @@ -563,7 +563,7 @@ fn main() -> i32 { }, diag) preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - preludeModule.ID = prelude.ModuleID() + preludeModule.ID = prelude.ModuleID(ctx) ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) @@ -605,7 +605,7 @@ fn main() -> i32 { }, diag) preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - preludeModule.ID = prelude.ModuleID() + preludeModule.ID = prelude.ModuleID(ctx) ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) @@ -663,7 +663,7 @@ fn main() -> i32 { }, diag) preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - preludeModule.ID = prelude.ModuleID() + preludeModule.ID = prelude.ModuleID(ctx) ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) @@ -760,7 +760,7 @@ func TestPipelineDebugBuildEmitsLLVMMetadata(t *testing.T) { ctx := project.NewWithConfig(cfg, diag) preludeModule := parseModuleSource("core/global"+peeper.SourceExt, preludeSrc, diag) - preludeModule.ID = prelude.ModuleID() + preludeModule.ID = prelude.ModuleID(ctx) ctx.AddModule(preludeModule) entry := parseModuleSource("entry"+peeper.SourceExt, entrySrc, diag) diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index 98a32def..aed60fc7 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -14,15 +14,33 @@ import ( // Auto-loaded Peeper prelude file within the stdlib root. const GlobalPreludeFile = "global" + peeper.SourceExt -func ModuleID() moduleid.ID { - return moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "core", ImportPath: "prelude/global"} +// Library namespace owning the auto-loaded prelude. +const preludeNamespace = "core" + +// ModuleID derives canonical prelude identity from the resolved prelude file so +// it matches what ResolveImportPath produces for the same source. A hardcoded +// import path would register the file under an identity no import can reproduce. +func ModuleID(ctx *project.CompilerContext) moduleid.ID { + preludePath, ok := globalPreludePath(ctx) + if !ok { + return moduleid.ID{} + } + importPath, err := ctx.ImportPathForFile(project.ModuleOriginStdlib, preludeNamespace, project.CanonicalPath(preludePath)) + if err != nil { + return moduleid.ID{} + } + return moduleid.ID{ + Origin: string(project.ModuleOriginStdlib), + Namespace: preludeNamespace, + ImportPath: importPath, + } } func globalPreludePath(ctx *project.CompilerContext) (string, bool) { if ctx == nil { return "", false } - coreRoot, ok := ctx.LibraryRoot("core") + coreRoot, ok := ctx.LibraryRoot(preludeNamespace) if !ok || coreRoot == "" { return "", false } @@ -39,7 +57,7 @@ func ModuleForFile(ctx *project.CompilerContext, filePath, content string) (*pro return nil, false } return &project.Module{ - ID: ModuleID(), + ID: ModuleID(ctx), FilePath: preludePath, Content: content, ContentProvided: true, diff --git a/internal/project/modules.go b/internal/project/modules.go index 1809188f..b824e37a 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -1,9 +1,11 @@ package project import ( + "fmt" "path/filepath" "strings" + "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/graph" "compiler/internal/ir/cfg" @@ -248,15 +250,23 @@ func (ctx *CompilerContext) AddModule(module *Module) { } module.FilePath = CanonicalPath(module.FilePath) ctx.mu.Lock() - defer ctx.mu.Unlock() if previous := ctx.modules[module.ID]; previous != nil && previous.FilePath != "" && previous.FilePath != module.FilePath { if ctx.fileIndex[previous.FilePath] == module.ID { delete(ctx.fileIndex, previous.FilePath) } } + // One source file must not carry two logical identities. Import paths and + // library-root configuration can both reach this, so it is a user-facing + // diagnostic rather than an internal error. Keep the first registration. if previousID, found := ctx.fileIndex[module.FilePath]; module.FilePath != "" && found && previousID != module.ID { - panic("module file registered with multiple identities") + ctx.mu.Unlock() + if ctx.Diagnostics != nil { + ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, fmt.Sprintf( + "module file %s is already registered as %s and cannot also be %s", + module.FilePath, previousID.ImportPath, module.ID.ImportPath), nil, "") + } + return } ctx.modules[module.ID] = module if module.FilePath != "" { @@ -267,6 +277,7 @@ func (ctx *CompilerContext) AddModule(module *Module) { ctx.typeDeclarations[identity] = module } } + ctx.mu.Unlock() } // ModuleByID resolves canonical module identity. diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index 3b020d8d..7e2b5602 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -54,6 +54,37 @@ func TestCompilerContextRejectsZeroModuleID(t *testing.T) { } } +func TestCompilerContextReportsConflictingFileIdentity(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + const shared = "shared.peep" + firstID := moduleid.ID{Origin: "stdlib", Namespace: "core", ImportPath: "global"} + secondID := moduleid.ID{Origin: "stdlib", Namespace: "core", ImportPath: "prelude/global"} + first := &Module{ID: firstID, FilePath: shared} + + ctx.AddModule(first) + ctx.AddModule(&Module{ID: secondID, FilePath: shared}) + + // Two identities for one file is reachable from imports and library-root + // configuration, so it must diagnose rather than abort the compiler. + found := false + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + found = true + break + } + } + if !found { + t.Fatalf("conflicting file identity produced no ambiguous-import diagnostic: %s", diag.EmitAllToString()) + } + if got, ok := ctx.ModuleByFile(first.FilePath); !ok || got != first { + t.Fatal("first registration was not retained after identity conflict") + } + if _, ok := ctx.ModuleByID(secondID); ok { + t.Fatal("conflicting identity was registered") + } +} + func TestCompilerContextModuleIDsKeepComponentsCollisionSafe(t *testing.T) { ctx := New(".", ".peep", nil) firstID := moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "value"} diff --git a/internal/semantics/usage/usage.go b/internal/semantics/usage/usage.go index 5e8252f3..792839ec 100644 --- a/internal/semantics/usage/usage.go +++ b/internal/semantics/usage/usage.go @@ -26,7 +26,7 @@ func Analyze(ctx *project.CompilerContext, module *project.Module) { // 2. Check for unused private module-level symbols (functions, types, constants, variables) // Do not warn about prelude/global symbols since they represent a library - if module.ID != prelude.ModuleID() { + if module.ID != prelude.ModuleID(ctx) { for _, sym := range module.ModuleScope.Symbols() { if sym.Kind == symbols.SymbolImport { continue diff --git a/x_test/import_prelude_identity/peeper.toml b/x_test/import_prelude_identity/peeper.toml new file mode 100644 index 00000000..984c22f1 --- /dev/null +++ b/x_test/import_prelude_identity/peeper.toml @@ -0,0 +1,6 @@ +name = "import_prelude_identity" +build = "program" + +[test] +mode = "check" +outcome = "success" diff --git a/x_test/import_prelude_identity/src/main.peep b/x_test/import_prelude_identity/src/main.peep new file mode 100644 index 00000000..07ccbbaf --- /dev/null +++ b/x_test/import_prelude_identity/src/main.peep @@ -0,0 +1,7 @@ +// The auto-loaded prelude must register under the same identity an explicit +// import resolves to, so importing it directly stays a normal compile. +import "core:global"; + +fn main() -> i32 { + return 0; +} From 499acc9a6915ecc8093fbff80450c9ef65e264e9 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 12:34:46 +0600 Subject: [PATCH 04/80] Resolve exported constant facts through defining module An exported function default that references an imported constant lost the constant's value from its semantic export fingerprint. Foreign constants are published only in the owning module, by design, so reading the consumer's ModuleValues yielded nothing and the fact degraded to an empty value. The exported surface then stopped changing when the imported constant changed. Add CompilerContext.PublishedConstant, which resolves a constant symbol through its defining identity and returns the owner's published value. It returns the value alone: constant evaluation never publishes nil, so nil is the absent case and a separate found flag would be redundant. Query cache entries stay excluded, since only published values are stable enough for cross-module reads. Constant evaluation now calls it for its foreign branch instead of repeating the owner lookup, so one join serves both. SemanticExportFingerprint takes a context because resolving imported values requires the module registry; a nil context still reads local values so callers without a registry keep working. Validated with gofmt, go vet, go test -count=1 ./..., focused race suites, a fresh compiler bundle, and the bundled-binary x_test suite. The added fingerprint test fails against the previous consumer-only lookup. --- internal/pipeline/pipeline.go | 2 +- internal/project/export_fingerprint.go | 18 ++++---- internal/project/export_fingerprint_test.go | 51 ++++++++++++++++++--- internal/project/modules.go | 29 ++++++++++++ internal/semantics/consteval/consteval.go | 8 +--- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index e9cc3252..4c8bd32c 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -424,7 +424,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di typechecker.Check(phaseCtx, module) consteval.FinalizeValues(phaseCtx, module) module.RebuildTypedASTIndex() - module.SemanticExportFingerprint = project.SemanticExportFingerprint(module) + module.SemanticExportFingerprint = project.SemanticExportFingerprint(ctx, module) module.Phase = phase.Typechecked ctx.Metrics.AddPhaseAdvance() return true diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index 2d5dee18..c6c40588 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -11,14 +11,12 @@ import ( ) // SemanticExportFingerprint identifies compiler-visible semantic facts exported by module. -func SemanticExportFingerprint(module *Module) string { +// Constant values resolve through their defining module, so an exported default that +// references an imported constant still changes when that constant changes. +func SemanticExportFingerprint(ctx *CompilerContext, module *Module) string { if module == nil || module.ModuleScope == nil { return ast.FingerprintParts(nil) } - var moduleValues map[symbols.SymbolID]constvalue.Value - if module.Constants != nil { - moduleValues = module.Constants.ModuleValues - } parts := make([]string, 0) for _, sym := range module.ModuleScope.Symbols() { if sym == nil || !sym.IsPub { @@ -28,9 +26,9 @@ func SemanticExportFingerprint(module *Module) string { if sym.Kind == symbols.SymbolVar { part += fmt.Sprintf(":mutable=%t", sym.IsMutable()) } - part += semanticExportMetadata(module, sym, moduleValues) + part += semanticExportMetadata(ctx, module, sym) if sym.Kind == symbols.SymbolConst { - part += ":value=" + constantKey(moduleValues[sym.ID]) + part += ":value=" + constantKey(ctx.PublishedConstant(module, sym)) } parts = append(parts, part) } @@ -41,14 +39,14 @@ func SemanticExportFingerprint(module *Module) string { continue } parts = append(parts, "method:"+receiver+":"+method.Name+":"+ - semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(module, method, moduleValues)) + semanticTypeKey(method.Type, make(map[typeinfo.Type]bool))+semanticExportMetadata(ctx, module, method)) } } } return ast.FingerprintParts(parts) } -func semanticExportMetadata(module *Module, sym *symbols.Symbol, moduleValues map[symbols.SymbolID]constvalue.Value) string { +func semanticExportMetadata(ctx *CompilerContext, module *Module, sym *symbols.Symbol) string { decl, ok := sym.ASTNode.(ast.Decl) if !ok || decl == nil { return "" @@ -89,7 +87,7 @@ func semanticExportMetadata(module *Module, sym *symbols.Symbol, moduleValues ma } fact := resolved.Name + ":" + semanticTypeKey(resolved.Type, make(map[typeinfo.Type]bool)) if resolved.Kind == symbols.SymbolConst { - fact += "=" + constantKey(moduleValues[resolved.ID]) + fact += "=" + constantKey(ctx.PublishedConstant(module, resolved)) } facts = append(facts, fact) return true diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index 3b89b227..f5269957 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -5,6 +5,7 @@ import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" + "compiler/internal/moduleid" "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/constantresult" "compiler/internal/semantics/symbols" @@ -45,7 +46,7 @@ func TestSemanticExportFingerprintChangesWithInferredTypeAndValue(t *testing.T) sym.Type = typ constValues := make(map[symbols.SymbolID]constvalue.Value) constValues[sym.ID], _ = constvalue.NewIntText(value, typeinfo.TypeText(typ)) - return SemanticExportFingerprint(fingerprintModule(t, sym, nil, constValues)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, constValues)) } i32One := makeConst(&typeinfo.IntegerType{Signed: true, Bits: 32}, "1") @@ -72,7 +73,7 @@ func TestSemanticExportFingerprintIncludesConstValueWithoutBindings(t *testing.T constant, _ := constvalue.NewIntText(value, "i32") constants := constantresult.New() constants.ModuleValues[sym.ID] = constant - return SemanticExportFingerprint(&Module{ModuleScope: scope, Constants: constants}) + return SemanticExportFingerprint(nil, &Module{ModuleScope: scope, Constants: constants}) } if fingerprint("1") == fingerprint("2") { t.Fatal("binding-independent const value did not change semantic fingerprint") @@ -92,7 +93,7 @@ func TestSemanticExportFingerprintIgnoresQueryCache(t *testing.T) { constant, _ := constvalue.NewIntText(value, "i32") constants := constantresult.New() constants.QueryCache[sym.ID] = constant - return SemanticExportFingerprint(&Module{ModuleScope: scope, Constants: constants}) + return SemanticExportFingerprint(nil, &Module{ModuleScope: scope, Constants: constants}) } if fingerprint("1") != fingerprint("2") { t.Fatal("query-cache-only value changed semantic fingerprint") @@ -105,7 +106,7 @@ func TestSemanticExportFingerprintIgnoresFunctionBodyChanges(t *testing.T) { decl.SetDeclSurface("fn::Read:::") sym := symbols.New("Read", symbols.SymbolFunc, decl, nil) sym.Type = &typeinfo.FuncType{Return: &typeinfo.IntegerType{Signed: true, Bits: 32}} - return SemanticExportFingerprint(fingerprintModule(t, sym, nil, nil)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, nil)) } first := makeFunction(&ast.BlockStmt{}) second := makeFunction(&ast.BlockStmt{Stmts: []ast.Stmt{&ast.ReturnStmt{Value: &ast.NumberLit{Value: "1"}}}}) @@ -131,20 +132,56 @@ func TestSemanticExportFingerprintIncludesPrivateFactsUsedByPublicDefault(t *tes bindings.NodeSymbols[defaultIdent.ID()] = private constValues := make(map[symbols.SymbolID]constvalue.Value) constValues[private.ID], _ = constvalue.NewIntText(value, "i32") - return SemanticExportFingerprint(fingerprintModule(t, fn, bindings, constValues)) + return SemanticExportFingerprint(nil, fingerprintModule(t, fn, bindings, constValues)) } if makeFunction("1") == makeFunction("2") { t.Fatal("private const used by public default did not change fingerprint") } } +func TestSemanticExportFingerprintTracksImportedConstantInDefault(t *testing.T) { + // An exported default referencing an imported constant must still change when + // that constant changes. Foreign values live only in the owning module, so the + // fingerprint has to resolve them through the defining identity. + makeFingerprint := func(value string) string { + ctx := New(".", ".peep", nil) + i32 := &typeinfo.IntegerType{Signed: true, Bits: 32} + ownerID := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "lib"} + imported := symbols.New("K", symbols.SymbolConst, nil, nil) + imported.Type = i32 + imported.DefiningModule = ownerID + ownerConstants := constantresult.New() + ownerConstants.ModuleValues[imported.ID], _ = constvalue.NewIntText(value, "i32") + ctx.AddModule(&Module{ID: ownerID, FilePath: "lib.peep", Constants: ownerConstants}) + + defaultIdent := &ast.Ident{Name: "K"} + decl := &ast.FnDecl{ + Name: &ast.Ident{Name: "Read"}, + Params: []ast.Param{{Name: &ast.Ident{Name: "value"}, Default: defaultIdent}}, + } + decl.SetDeclSurface("fn::Read::value:i32=K:") + fn := symbols.New("Read", symbols.SymbolFunc, decl, nil) + fn.Type = &typeinfo.FuncType{Params: []typeinfo.Type{i32}, ParamNames: []string{"value"}} + bindings := bindingresult.New() + bindings.NodeSymbols[defaultIdent.ID()] = imported + + consumer := fingerprintModule(t, fn, bindings, nil) + consumer.ID = moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "app"} + consumer.FilePath = "app.peep" + return SemanticExportFingerprint(ctx, consumer) + } + if makeFingerprint("1") == makeFingerprint("2") { + t.Fatal("imported const used by public default did not change fingerprint") + } +} + func TestSemanticExportFingerprintChangesWithPublicMethodSignature(t *testing.T) { makeMethod := func(returnType typeinfo.Type) string { method := symbols.New("Read", symbols.SymbolMethod, nil, nil) method.Type = &typeinfo.FuncType{Return: returnType} bindings := bindingresult.New() bindings.MethodsByReceiver["Buffer"] = []*symbols.Symbol{method} - return SemanticExportFingerprint(fingerprintModule(t, + return SemanticExportFingerprint(nil, fingerprintModule(t, symbols.New("Buffer", symbols.SymbolType, nil, nil), bindings, nil)) } i32 := &typeinfo.IntegerType{Signed: true, Bits: 32} @@ -165,7 +202,7 @@ func TestSemanticExportFingerprintHandlesRecursiveTypesDeterministically(t *test decl.SetDeclSurface("type:Node:recursive") sym := symbols.New("Node", symbols.SymbolType, decl, nil) sym.Type = defined - return SemanticExportFingerprint(fingerprintModule(t, sym, nil, nil)) + return SemanticExportFingerprint(nil, fingerprintModule(t, sym, nil, nil)) } if first, second := makeType(), makeType(); first == "" || first != second { t.Fatalf("recursive fingerprints unstable: %q, %q", first, second) diff --git a/internal/project/modules.go b/internal/project/modules.go index b824e37a..f1390a83 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "compiler/internal/constvalue" "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/graph" @@ -280,6 +281,34 @@ func (ctx *CompilerContext) AddModule(module *Module) { ctx.mu.Unlock() } +// PublishedConstant returns the authoritative value of a constant symbol, +// resolving symbols owned by another module through their defining identity, and +// nil when no value is published. Constant evaluation never publishes a nil value, +// so nil is the absent case and no separate found flag is needed. +// Query-cache entries are excluded on purpose: only published module values are +// stable enough for cross-module reads and export fingerprints. +func (ctx *CompilerContext) PublishedConstant(module *Module, sym *symbols.Symbol) constvalue.Value { + if sym == nil { + return nil + } + owner := module + // Only the cross-module hop needs a context; a local value stays readable + // from the module alone so callers without a registry still fingerprint. + if ownerID := sym.DefiningModule; ownerID.Valid() && (module == nil || ownerID != module.ID) { + if ctx == nil { + return nil + } + found := false + if owner, found = ctx.ModuleByID(ownerID); !found { + return nil + } + } + if owner == nil || owner.Constants == nil { + return nil + } + return owner.Constants.ModuleValues[sym.ID] +} + // ModuleByID resolves canonical module identity. func (ctx *CompilerContext) ModuleByID(id moduleid.ID) (*Module, bool) { if ctx == nil || !id.Valid() { diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index 63f347f5..f2eef430 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -85,12 +85,8 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) ( return nil, false } if ownerID := sym.DefiningModule; ownerID.Valid() && ownerID != e.module.ID { - owner, found := e.ctx.ModuleByID(ownerID) - if !found || owner.Constants == nil { - return nil, false - } - value, found := owner.Constants.ModuleValues[sym.ID] - return value, found + value := e.ctx.PublishedConstant(e.module, sym) + return value, value != nil } if value, ok := e.constants.ModuleValues[sym.ID]; ok { return value, true From 5b30c2903d7e418f98a9a3b4754ffa9de8e7abf8 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 12:42:10 +0600 Subject: [PATCH 05/80] Note redundant prelude imports and global qualifiers Global symbols are injected into every module's scope, so naming the prelude in an import list or reaching a global symbol through `global::` adds nothing. Both are style observations, not defects, so they report as info (S prefix) and leave compilation unchanged. S0004 reports an explicit prelude import at the import declaration. It is emitted where imports resolve, by comparing the resolved identity against prelude.ModuleID, so it cannot fire for an ordinary library. S0005 reports a redundant qualifier by comparing the resolved symbol against the same symbol in global scope. Keying it on symbol identity rather than name keeps a same-named unrelated export quiet, and needs no prelude dependency in the resolver: the note states exactly what is true, that this symbol is already in scope. It also fires alongside the existing unexported-symbol error, where dropping the qualifier is the actual fix rather than exporting the symbol. Every prelude symbol is currently lowercase, so a qualified global symbol cannot both resolve and be exported today. Cover the valid case with a pipeline test that builds a real temporary library root containing an exported global, plus a companion test proving an ordinary library qualifier stays quiet. Add source fixtures for the explicit import and for the qualified private symbol. Validated with gofmt, go vet, go test -count=1 ./..., focused race suites, a fresh compiler bundle, and the bundled-binary x_test suite. --- internal/diagnostics/codes.go | 8 +- internal/pipeline/loader.go | 9 ++ internal/pipeline/pipeline_test.go | 87 +++++++++++++++++++ internal/semantics/resolver/resolver.go | 19 ++++ x_test/import_prelude_identity/peeper.toml | 1 + x_test/negative_global_qualifier/peeper.toml | 7 ++ .../negative_global_qualifier/src/main.peep | 8 ++ 7 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 x_test/negative_global_qualifier/peeper.toml create mode 100644 x_test/negative_global_qualifier/src/main.peep diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index 71eb3398..c7330f38 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -85,9 +85,11 @@ const ( ErrInvalidEntrypoint = "M0006" // Style/Info codes (S prefix) - InfoTrailingComma = "S0001" - InfoUnnecessarySemicolon = "S0002" - InfoRedundantComma = "S0003" + InfoTrailingComma = "S0001" + InfoUnnecessarySemicolon = "S0002" + InfoRedundantComma = "S0003" + InfoRedundantPreludeImport = "S0004" + InfoRedundantGlobalQualifier = "S0005" // Warnings (W prefix) WarnUnreachableCode = "W0001" diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index ea7e83ab..9cd9911f 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -13,6 +13,7 @@ import ( "compiler/internal/graph" "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/prelude" "compiler/internal/project" ) @@ -132,6 +133,14 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. l.addImportError(diag, imp, diagnostics.ErrAmbiguousImport, "import alias already in use") continue } + // The prelude is injected into global scope for every module, so naming it + // in an import list adds nothing. Style note only: the import still works. + if resolved.ID == prelude.ModuleID(l.ctx) { + diag.Add(diagnostics.NewInfo("`"+rawPath+"` is imported automatically"). + WithCode(diagnostics.InfoRedundantPreludeImport). + WithPrimaryLabel(ast.LocOf(imp), "remove this import"). + WithNote("global symbols are always in scope without an import")) + } resolvedImport := *resolved resolvedImport.Decl = imp module.Imports[alias] = resolvedImport diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 7413975c..ec17d437 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -474,6 +474,93 @@ fn main() -> i32 { } } +// preludeQualifierPipeline compiles one entry against a real temp library root so +// `core:` imports resolve for real, and returns the resulting diagnostics. +func preludeQualifierPipeline(t *testing.T, libraryFile, librarySrc, entrySrc string) *diagnostics.DiagnosticBag { + t.Helper() + root := t.TempDir() + libraryBase := filepath.Join(root, "libs") + libraryPath := filepath.Join(libraryBase, "core", peeper.SourceDirName, libraryFile+peeper.SourceExt) + if err := os.MkdirAll(filepath.Dir(libraryPath), 0o755); err != nil { + t.Fatalf("mkdir library: %v", err) + } + if err := os.WriteFile(libraryPath, []byte(librarySrc), 0o644); err != nil { + t.Fatalf("write library: %v", err) + } + entryPath := filepath.Join(root, "entry"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.NewWithConfig(project.Config{ + RootDir: root, + Extension: peeper.SourceExt, + LibraryBaseDir: libraryBase, + }, diag) + // Production loads the prelude in the driver; do the same so global symbols + // reach global scope and redundant qualification is observable. + if err := prelude.Load(ctx); err != nil { + t.Fatalf("load prelude: %v", err) + } + entry := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "entry"}, + FilePath: entryPath, + Content: entrySrc, + Imports: make(map[string]project.ResolvedImport), + } + if err := Run(ctx, entry); err != nil { + t.Fatalf("pipeline.Run returned error: %v", err) + } + return diag +} + +func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == code { + return true + } + } + return false +} + +func TestPipelineNotesRedundantPreludeImportAndQualifier(t *testing.T) { + diag := preludeQualifierPipeline(t, "global", `fn Ping() -> i32 { + return 7; +} +`, `import "core:global"; + +fn main() -> i32 { + return global::Ping() - 7; +}`) + if diag.HasErrors() { + t.Fatalf("redundant prelude use must stay compilable:\n%s", diag.EmitAllToString()) + } + if !hasDiagnosticCode(diag, diagnostics.InfoRedundantPreludeImport) { + t.Fatalf("explicit prelude import produced no style note:\n%s", diag.EmitAllToString()) + } + if !hasDiagnosticCode(diag, diagnostics.InfoRedundantGlobalQualifier) { + t.Fatalf("global-qualified prelude symbol produced no style note:\n%s", diag.EmitAllToString()) + } +} + +func TestPipelineKeepsOrdinaryLibraryQualifierQuiet(t *testing.T) { + // A non-prelude library is qualified legitimately, so neither style note applies. + diag := preludeQualifierPipeline(t, "util", `fn Helper() -> i32 { + return 3; +} +`, `import "core:util"; + +fn main() -> i32 { + return util::Helper() - 3; +}`) + if diag.HasErrors() { + t.Fatalf("ordinary library import failed:\n%s", diag.EmitAllToString()) + } + if hasDiagnosticCode(diag, diagnostics.InfoRedundantPreludeImport) { + t.Fatalf("ordinary import reported as automatic:\n%s", diag.EmitAllToString()) + } + if hasDiagnosticCode(diag, diagnostics.InfoRedundantGlobalQualifier) { + t.Fatalf("ordinary qualifier reported as redundant:\n%s", diag.EmitAllToString()) + } +} + func TestPipelineScalarShrinkOwnedParameterReservesForeignFree(t *testing.T) { diag := runImportedRuntimeSymbolPipeline(t, `import "app/runtime"; diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index 60f73b68..26a51e51 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -468,7 +468,26 @@ func (r *resolver) lookupImportedMember(qualifierNode, memberNode *ast.Ident, si r.ctx.Diagnostics.AddError(diagnostics.ErrSymbolNotExported, "`"+member+"` is not exported from `"+qualifier+"`", ast.LocOf(site), "use of unexported symbol"). WithSecondaryLabel(resolved.Symbol.Location, "defined here"). WithNote("symbols with uppercase are exported otherwise private") + r.reportGlobalQualifier(resolved.Symbol, qualifier, site) return nil, false } + r.reportGlobalQualifier(resolved.Symbol, qualifier, site) return resolved.Symbol, true } + +// reportGlobalQualifier notes when a qualified name is the exact symbol already +// injected into global scope, so the `alias::` prefix adds nothing. Comparing +// symbol identity rather than name keeps a same-named unrelated export quiet. +func (r *resolver) reportGlobalQualifier(sym *symbols.Symbol, qualifier string, site ast.Node) { + if sym == nil || r.ctx == nil || r.ctx.GlobalScope == nil { + return + } + global, found := r.ctx.GlobalScope.Lookup(sym.Name) + if !found || global == nil || global.ID != sym.ID { + return + } + r.ctx.Diagnostics.Add(diagnostics.NewInfo("`"+sym.Name+"` is already in scope without `"+qualifier+"::`"). + WithCode(diagnostics.InfoRedundantGlobalQualifier). + WithPrimaryLabel(ast.LocOf(site), "drop the `"+qualifier+"::` prefix"). + WithNote("global symbols are always in scope")) +} diff --git a/x_test/import_prelude_identity/peeper.toml b/x_test/import_prelude_identity/peeper.toml index 984c22f1..485438eb 100644 --- a/x_test/import_prelude_identity/peeper.toml +++ b/x_test/import_prelude_identity/peeper.toml @@ -4,3 +4,4 @@ build = "program" [test] mode = "check" outcome = "success" +stderr_contains = ["S0004"] diff --git a/x_test/negative_global_qualifier/peeper.toml b/x_test/negative_global_qualifier/peeper.toml new file mode 100644 index 00000000..b34420f6 --- /dev/null +++ b/x_test/negative_global_qualifier/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_global_qualifier" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["M0004", "S0005"] diff --git a/x_test/negative_global_qualifier/src/main.peep b/x_test/negative_global_qualifier/src/main.peep new file mode 100644 index 00000000..790a7cdb --- /dev/null +++ b/x_test/negative_global_qualifier/src/main.peep @@ -0,0 +1,8 @@ +// Global symbols are already in scope, so qualifying one is redundant. This +// symbol is also private, so the qualified form additionally fails to resolve. +import "core:global"; + +fn main() -> i32 { + global::exit(0); + return 0; +} From de8185adff6db40fdcfbc844ca0f95c89a5c7a6b Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 13:08:54 +0600 Subject: [PATCH 06/80] Add a test-tier phase-coverage contract for AST node kinds Structural traversal prevents a forgotten child walk. Nothing prevented a forgotten semantic decision: adding a node kind and never teaching a phase about it. These tests parse the real sources, enumerate every type implementing stmtNode and exprNode, and require each dispatch site to either handle a kind or record a decision about it. This is the weakest of the mechanisms the roadmap permits. It lists compile-time visitor interfaces first, mechanically checked dispatch tables second, and completeness tests third, and warns only against visitor base types carrying no-op defaults. A test reports an omission on the next test run; an unsatisfied interface fails the build. Later work should promote this to the compile tier and keep these tests as the supplement the roadmap intends. Scope is the first experiment only: source AST statement and expression kinds, across manually listed dispatch functions. Declarations, type syntax, HIR, MIR and backend families are not covered. Module-level declaration handling is partitioned across separate binding, function and type-declaration passes per phase, so its contract needs a different shape than a flat per-kind table. Widening the scan past stmt.go was necessary: every declaration also implements stmtNode, so ast.Stmt has nineteen implementors rather than ten, and a stmt.go-only scan silently checked a subset. Test-only, no production code changes. Verified by adding a synthetic statement kind and a synthetic expression kind, each reported by every relevant site, with the tree restored afterward. --- internal/contracts/node_dispatch_test.go | 300 +++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 internal/contracts/node_dispatch_test.go diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go new file mode 100644 index 00000000..f3baaed8 --- /dev/null +++ b/internal/contracts/node_dispatch_test.go @@ -0,0 +1,300 @@ +// Package contracts owns the phase-coverage contract for AST node handling. +// +// Structural traversal is already centralized in ast.Inspect and friends, which +// prevents a forgotten child walk. It cannot prevent a forgotten *semantic* +// decision: adding a statement kind and never teaching a phase about it. These +// tests read the real sources and compare every declared statement kind against +// every phase that dispatches on statements, so an omission fails immediately +// instead of surfacing as wrong output much later. +package contracts + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +// dispatchSite is one function that switches over ast.Stmt. +type dispatchSite struct { + file string + fn string + // omitted lists statement kinds this site intentionally does not handle, + // each with the reason it is safe. An empty list means fully exhaustive. + omitted map[string]string + // inertDeclarations adds the shared parser-rejected declaration reason. Set + // it on sites that only ever see statements the parser actually produces + // inside a block, rather than repeating one rule at every such site. + inertDeclarations bool +} + +// Declarations all implement stmtNode, but the parser rejects every one except +// a binding inside a block with "unsupported statement" (P0004). They therefore +// never reach a CFG site or a lowering position in a well-formed tree. +var parserRejectedInStatementPosition = []string{ + "ImportDecl", "FnDecl", "TypeAliasDecl", "StructDecl", + "InterfaceDecl", "EnumDecl", "BadDecl", +} + +const parserRejectedReason = "parser rejects this declaration in statement position (P0004)" + +// omissions returns the site's declared omissions including the shared rule. +func (d dispatchSite) omissions() map[string]string { + merged := make(map[string]string, len(d.omitted)+len(parserRejectedInStatementPosition)) + for kind, reason := range d.omitted { + merged[kind] = reason + } + if d.inertDeclarations { + for _, kind := range parserRejectedInStatementPosition { + if _, declared := merged[kind]; !declared { + merged[kind] = parserRejectedReason + } + } + } + return merged +} + +// Sites are exhaustive unless they declare a reason per omitted kind. Adding a +// statement kind must either add a case here or record why the kind is inert. +var statementSites = []dispatchSite{ + {file: "semantics/resolver/resolver.go", fn: "resolveStmt"}, + {file: "semantics/typechecker/check_stmt.go", fn: "checkStmt"}, + {file: "ir/cfg/build.go", fn: "buildStmt"}, + {file: "ir/hir/lower/module_lower.go", fn: "appendStmt"}, + { + file: "ir/hir/lower/module_lower.go", + fn: "lowerElse", + omitted: map[string]string{ + "BreakStmt": "parser only produces a block or else-if in else position", + "ContinueStmt": "parser only produces a block or else-if in else position", + "MatchStmt": "parser only produces a block or else-if in else position", + }, + }, + // The remaining sites run per CFG site, where control flow is already + // decomposed into blocks and edges. They extract the expressions a statement + // evaluates at that site, so statements carrying no expression are inert. + { + file: "semantics/ownership/ownership.go", + fn: "applyStmt", + inertDeclarations: true, + omitted: map[string]string{ + "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", + "BadStmt": "recovery node carries no ownership effect", + "BreakStmt": "transfer is a CFG edge, not a site-level ownership effect", + "ContinueStmt": "transfer is a CFG edge, not a site-level ownership effect", + }, + }, + { + file: "semantics/ownership/reference.go", + fn: "symbolUseSequence", + inertDeclarations: true, + omitted: map[string]string{ + "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", + "BadStmt": "recovery node evaluates no expression", + "BreakStmt": "evaluates no expression", + "ContinueStmt": "evaluates no expression", + }, + }, + { + file: "semantics/definiteinit/initialization.go", + fn: "checkReads", + inertDeclarations: true, + omitted: map[string]string{ + "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", + "BadStmt": "recovery node reads nothing", + "IfStmt": "condition arrives separately through the CFG site condition", + "ForStmt": "condition arrives separately through the CFG site condition", + "MatchStmt": "subject arrives separately through the CFG site condition", + "BreakStmt": "reads nothing", + "ContinueStmt": "reads nothing", + }, + }, + { + file: "semantics/typechecker/flow.go", + fn: "applyConditionEdge", + inertDeclarations: true, + omitted: map[string]string{ + "BlockStmt": "carries no branch condition", + "ExprStmt": "carries no branch condition", + "AssignStmt": "carries no branch condition", + "ReturnStmt": "carries no branch condition", + "BadStmt": "carries no branch condition", + "BreakStmt": "carries no branch condition", + "ContinueStmt": "carries no branch condition", + "MatchStmt": "match narrowing uses case tests, not a true/false condition edge", + "LetDecl": "carries no branch condition", + "ConstDecl": "carries no branch condition", + }, + }, +} + +func internalDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate contracts package source") + } + return filepath.Dir(filepath.Dir(thisFile)) +} + +// declaredKinds scans the whole AST package for types implementing marker, so +// the contract tracks the real node set. Scanning the package rather than one +// file matters: every declaration also implements stmtNode, so statement kinds +// are split across stmt.go and decl.go. +func declaredKinds(t *testing.T, marker string) []string { + t.Helper() + dir := filepath.Join(internalDir(t), "frontend", "ast") + pkgs, err := parser.ParseDir(token.NewFileSet(), dir, func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse %s: %v", dir, err) + } + kinds := make([]string, 0) + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != marker || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + star, ok := fn.Recv.List[0].Type.(*ast.StarExpr) + if !ok { + continue + } + if name, ok := star.X.(*ast.Ident); ok { + kinds = append(kinds, name.Name) + } + } + } + } + if len(kinds) == 0 { + t.Fatalf("no types implement %s in %s", marker, dir) + } + slices.Sort(kinds) + return kinds +} + +func declaredStatementKinds(t *testing.T) []string { + t.Helper() + return declaredKinds(t, "stmtNode") +} + +// handledKinds returns the ast.X kinds named by type-switch cases in fn. +func handledKinds(t *testing.T, file, fn string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + handled := make([]string, 0) + found := false + ast.Inspect(parsed, func(node ast.Node) bool { + decl, ok := node.(*ast.FuncDecl) + if !ok || decl.Name.Name != fn { + return true + } + found = true + ast.Inspect(decl, func(inner ast.Node) bool { + clause, ok := inner.(*ast.CaseClause) + if !ok { + return true + } + for _, expr := range clause.List { + star, ok := expr.(*ast.StarExpr) + if !ok { + continue + } + selector, ok := star.X.(*ast.SelectorExpr) + if !ok { + continue + } + if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == "ast" { + handled = append(handled, selector.Sel.Name) + } + } + return true + }) + return false + }) + if !found { + t.Fatalf("function %s not found in %s", fn, file) + } + return handled +} + +// Expression dispatch is split between sites that must be total and narrow +// queries such as "which expressions can hold a reference". Only the total sites +// belong in this contract: demanding a declared reason from every predicate for +// all 23 kinds would be pure boilerplate, and a no-op default there defeats the +// exhaustiveness it pretends to add. +var expressionSites = []dispatchSite{ + {file: "semantics/resolver/resolver.go", fn: "resolveExpr"}, + {file: "semantics/typechecker/check_expr.go", fn: "typeExprBase"}, + {file: "semantics/ownership/expr.go", fn: "checkExpr"}, + { + file: "ir/hir/lower/module_lower.go", + fn: "lowerASTExpr", + omitted: map[string]string{ + "RangeExpr": "a range is only a loop iterable or a slice index, lowered by its parent, never a standalone value", + }, + }, +} + +func declaredExpressionKinds(t *testing.T) []string { + t.Helper() + return declaredKinds(t, "exprNode") +} + +func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { + t.Helper() + for _, site := range sites { + t.Run(site.fn, func(t *testing.T) { + handled := handledKinds(t, site.file, site.fn) + omitted := site.omissions() + for _, kind := range kinds { + if slices.Contains(handled, kind) { + if reason, declared := omitted[kind]; declared { + t.Errorf("%s handles %s but still declares it omitted (%q); delete the entry", + site.fn, kind, reason) + } + continue + } + if _, declared := omitted[kind]; !declared { + t.Errorf("%s makes no decision about ast.%s; add a case or declare why the kind is inert", + site.fn, kind) + } + } + }) + } +} + +func TestEveryStatementKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, statementSites, declaredStatementKinds(t)) +} + +func TestEveryExpressionKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, expressionSites, declaredExpressionKinds(t)) +} + +// A reason that no longer names a real statement kind is stale and must not +// silently excuse a future kind of the same name. +func TestOmissionReasonsNameRealNodeKinds(t *testing.T) { + kinds := append(declaredStatementKinds(t), declaredExpressionKinds(t)...) + for _, site := range append(slices.Clone(statementSites), expressionSites...) { + for kind, reason := range site.omissions() { + if !slices.Contains(kinds, kind) { + t.Errorf("%s declares omitted kind %s that no longer exists", site.fn, kind) + } + if strings.TrimSpace(reason) == "" { + t.Errorf("%s omits %s without a reason", site.fn, kind) + } + } + } +} From 84b8f0355cdd267768257ccc55f099cf64c01b88 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:37:28 +0600 Subject: [PATCH 07/80] Diagnose module identity conflicts before mutating indexes Two defects shared one root: the registry mutated its file index before validating the registration it was about to reject. AddModule deleted the previous file-index entry for an identity whose path changed, then checked whether the new path already belonged to another identity. A rejected registration therefore left the module reachable by ID but no longer by file. Under the earlier panic the process died and the inconsistency was unobservable; making the conflict recoverable exposed it. Both directions are now validated before any index changes. An identity claiming a second file was also silently relocated rather than diagnosed. Extensions compare case-insensitively while the import path keeps the file's own case, so foo.peep and foo.PEEP reduce to one logical identity while remaining distinct files on a case-sensitive filesystem. Import resolution reused whichever module loaded first, attaching graph edges, diagnostics, constants and symbols to the wrong file. Registration and import resolution now report ambiguity instead of choosing arbitrarily. Add coverage for the relocation-conflict path, which previous tests missed because they only exercised two identities sharing one file. --- internal/pipeline/loader.go | 10 +++++++ internal/project/modules.go | 25 +++++++++------- internal/project/modules_test.go | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index 9cd9911f..1c1c6116 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -149,6 +149,16 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. } if existing, ok := l.ctx.ModuleByID(resolved.ID); ok { + // Two distinct files deriving one identity must not silently resolve + // to whichever loaded first. Extensions compare case-insensitively + // while the import path keeps the file's own case, so foo.peep and + // foo.PEEP can both reduce to the same logical identity. + if existing.FilePath != "" && resolved.FilePath != "" && + existing.FilePath != project.CanonicalPath(resolved.FilePath) { + l.addImportError(diag, imp, diagnostics.ErrAmbiguousImport, + "import resolves to "+resolved.FilePath+" but identity is already registered for "+existing.FilePath) + continue + } l.enqueue(existing) continue } diff --git a/internal/project/modules.go b/internal/project/modules.go index f1390a83..60bb8122 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -252,20 +252,23 @@ func (ctx *CompilerContext) AddModule(module *Module) { module.FilePath = CanonicalPath(module.FilePath) ctx.mu.Lock() - if previous := ctx.modules[module.ID]; previous != nil && previous.FilePath != "" && previous.FilePath != module.FilePath { - if ctx.fileIndex[previous.FilePath] == module.ID { - delete(ctx.fileIndex, previous.FilePath) - } - } - // One source file must not carry two logical identities. Import paths and - // library-root configuration can both reach this, so it is a user-facing - // diagnostic rather than an internal error. Keep the first registration. + // Identity and file must agree in both directions, and both checks run + // before any index is touched so a rejected registration cannot leave the + // registry half-updated. Import paths and library-root configuration can + // both reach these, so they are user-facing diagnostics, not compiler bugs. + conflict := "" if previousID, found := ctx.fileIndex[module.FilePath]; module.FilePath != "" && found && previousID != module.ID { + conflict = fmt.Sprintf("module file %s is already registered as %s and cannot also be %s", + module.FilePath, previousID.ImportPath, module.ID.ImportPath) + } else if previous := ctx.modules[module.ID]; previous != nil && module.FilePath != "" && + previous.FilePath != "" && previous.FilePath != module.FilePath { + conflict = fmt.Sprintf("module identity %s is already registered for file %s and cannot also name %s", + module.ID.ImportPath, previous.FilePath, module.FilePath) + } + if conflict != "" { ctx.mu.Unlock() if ctx.Diagnostics != nil { - ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, fmt.Sprintf( - "module file %s is already registered as %s and cannot also be %s", - module.FilePath, previousID.ImportPath, module.ID.ImportPath), nil, "") + ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, conflict, nil, "") } return } diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index 7e2b5602..8157e648 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -85,6 +85,55 @@ func TestCompilerContextReportsConflictingFileIdentity(t *testing.T) { } } +func TestCompilerContextRejectsIdentityRelocationWithoutCorruptingIndexes(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + idA := moduleid.ID{Origin: "local", ImportPath: "a"} + idB := moduleid.ID{Origin: "local", ImportPath: "b"} + first := &Module{ID: idA, FilePath: "a.peep"} + ctx.AddModule(first) + ctx.AddModule(&Module{ID: idB, FilePath: "b.peep"}) + + // Moving A onto B's file must be rejected, and rejection must not disturb + // the indexes A already owns. + ctx.AddModule(&Module{ID: idA, FilePath: "b.peep"}) + + if got, ok := ctx.ModuleByID(idA); !ok || got != first { + t.Fatal("rejected relocation lost the original module registration") + } + if got, ok := ctx.ModuleByFile(first.FilePath); !ok || got != first { + t.Fatalf("rejected relocation removed the original file index entry: %#v", ctx.Modules()) + } + found := false + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + found = true + } + } + if !found { + t.Fatalf("relocation conflict produced no diagnostic: %s", diag.EmitAllToString()) + } +} + +func TestCompilerContextRejectsSecondFileForSameIdentity(t *testing.T) { + diag := diagnostics.NewDiagnosticBag() + ctx := New(".", ".peep", diag) + id := moduleid.ID{Origin: "local", ImportPath: "foo"} + first := &Module{ID: id, FilePath: "foo.peep"} + ctx.AddModule(first) + + // Case-differing extensions reduce to one logical identity; the second file + // must not silently take over the identity. + ctx.AddModule(&Module{ID: id, FilePath: "foo.PEEP"}) + + if got, ok := ctx.ModuleByID(id); !ok || got != first { + t.Fatal("second file for one identity replaced the first registration") + } + if _, ok := ctx.ModuleByFile("foo.PEEP"); ok { + t.Fatal("rejected file was indexed") + } +} + func TestCompilerContextModuleIDsKeepComponentsCollisionSafe(t *testing.T) { ctx := New(".", ".peep", nil) firstID := moduleid.ID{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "value"} From b08a64dea30a11da68d7165df4785d9a613eb274 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:37:42 +0600 Subject: [PATCH 08/80] Give every identity component a dump artifact path segment Artifact paths were built from origin and import path only, so identities differing solely by namespace or dependency wrote the same files and the survivor depended on map iteration order. Core json and vendor json both produced _gen/core/json.ll. Namespace and dependency now occupy their own segments. Empty components render as a placeholder rather than collapsing, because dropping them would reintroduce the same ambiguity from the other direction: namespace "a" with import path "b/c" would otherwise share a path with no namespace and import path "a/b/c". Paths are only written into a staging tree and never parsed back, so the shape change is contained. --- cmd/dump.go | 26 +++++++++++++++++++++- cmd/dump_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/cmd/dump.go b/cmd/dump.go index 3944452b..b15ef8c8 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -60,6 +60,12 @@ func saveIRs(ctx *project.CompilerContext, dir string) error { return replacePath(stage, target) } +// emptyIdentityComponent marks an absent namespace or dependency so every +// canonical component occupies its own path segment. Dropping empty components +// instead would let distinct identities share one artifact path, for example +// namespace "a" with import path "b/c" against no namespace with "a/b/c". +const emptyIdentityComponent = "_" + func moduleArtifactBase(stage string, module *project.Module) (string, error) { origin := module.ID.Origin if origin == "" { @@ -73,7 +79,25 @@ func moduleArtifactBase(stage string, module *project.Module) (string, error) { if identity == "." || filepath.IsAbs(identity) || identity == ".." || strings.HasPrefix(identity, ".."+string(filepath.Separator)) { return "", fmt.Errorf("invalid module import identity %q", module.ID.ImportPath) } - return filepath.Join(stage, origin, identity), nil + // Every canonical identity component participates, so two identities that + // differ only by namespace or dependency cannot write the same artifacts. + return filepath.Join(stage, origin, + identityComponent(module.ID.Namespace), + identityComponent(module.ID.Dependency), + identity), nil +} + +func identityComponent(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return emptyIdentityComponent + } + cleaned := filepath.Clean(filepath.FromSlash(value)) + if cleaned == "." || cleaned == ".." || filepath.IsAbs(cleaned) || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return emptyIdentityComponent + } + return cleaned } func replacePath(stage, target string) error { diff --git a/cmd/dump_test.go b/cmd/dump_test.go index 5382bdaa..d800d20d 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -25,8 +25,8 @@ func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) t.Fatalf("saveIRs: %v", err) } for _, artifact := range []string{ - filepath.Join("local", "app", "one", "common.ll"), - filepath.Join("local", "app", "two", "common.ll"), + filepath.Join("local", "_", "_", "app", "one", "common.ll"), + filepath.Join("local", "_", "_", "app", "two", "common.ll"), } { if _, err := os.Stat(filepath.Join(target, artifact)); err != nil { t.Fatalf("missing distinct artifact %s: %v", artifact, err) @@ -36,3 +36,57 @@ func TestSaveIRsKeepsSameBasenameModulesDistinctAndReplacesOldTree(t *testing.T) t.Fatalf("stale artifact survived replacement: %v", err) } } + +func TestSaveIRsSeparatesIdentitiesDifferingOnlyByNamespace(t *testing.T) { + // Namespace and dependency are canonical identity components. Artifacts that + // ignored them collided, and the surviving file depended on map order. + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "core", ImportPath: "json"}, + FilePath: "/core/json.peep", LLVMIR: "core", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginStdlib), Namespace: "vendor", ImportPath: "json"}, + FilePath: "/vendor/json.peep", LLVMIR: "vendor", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for namespace, want := range map[string]string{"core": "core", "vendor": "vendor"} { + path := filepath.Join(target, string(project.ModuleOriginStdlib), namespace, "_", "json.ll") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing artifact for namespace %s: %v", namespace, err) + } + if string(content) != want { + t.Fatalf("artifact %s = %q, want %q", path, content, want) + } + } +} + +func TestSaveIRsSeparatesIdentitiesDifferingOnlyByDependency(t *testing.T) { + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "left", ImportPath: "util"}, + FilePath: "/left/util.peep", LLVMIR: "left", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginDependency), Namespace: "vendor", Dependency: "right", ImportPath: "util"}, + FilePath: "/right/util.peep", LLVMIR: "right", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for _, dependency := range []string{"left", "right"} { + path := filepath.Join(target, string(project.ModuleOriginDependency), "vendor", dependency, "util.ll") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing artifact for dependency %s: %v", dependency, err) + } + if string(content) != dependency { + t.Fatalf("artifact %s = %q, want %q", path, content, dependency) + } + } +} From e4d87b195a7720e5663f32b27b7e4b51179932e9 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:37:42 +0600 Subject: [PATCH 09/80] Fail LLVM emission on unclassified MIR nodes Instruction emission was a chain of type assertions with no final branch, so an unrecognized instruction emitted nothing and silently dropped program behavior. The terminator switch had no default either, which is worse: the block was written without a terminator, producing malformed LLVM IR rather than a missing operation. Both now classify every node and panic on an unhandled one. Reaching either default means MIR carried a node the backend never learned to emit, which is a compiler bug rather than invalid source, so an immediate internal failure is the correct policy. Note that mir.Instr and mir.Terminator are structural interfaces with no marker method, so no closed set exists to check mechanically. These defaults are the available guard until those families are sealed. --- internal/backend/llvm/emitter.go | 53 +++++++++++++++----------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/internal/backend/llvm/emitter.go b/internal/backend/llvm/emitter.go index 69d965ae..e5207c64 100644 --- a/internal/backend/llvm/emitter.go +++ b/internal/backend/llvm/emitter.go @@ -290,37 +290,31 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo continue } lb.setLocation(instr.SourceLocation()) - if assign, ok := instr.(*mir.Assign); ok && assign != nil { - val := emitValueExpr(lb, assign.Value) - if ptr, ok := lb.localPtrs[assign.Name]; ok { + // Emitting nothing for an unrecognized instruction silently drops + // program behavior, so every MIR instruction must be classified + // here. A missing case is a compiler bug, not invalid source. + switch typed := instr.(type) { + case *mir.Assign: + val := emitValueExpr(lb, typed.Value) + if ptr, ok := lb.localPtrs[typed.Name]; ok { lb.store(ptr, val) } else { - lb.locals[assign.Name] = val + lb.locals[typed.Name] = val } - continue - } - if store, ok := instr.(*mir.Store); ok && store != nil { - emitStore(lb, store) - continue - } - if printInstr, ok := instr.(*mir.Print); ok && printInstr != nil { - emitPrint(lb, printInstr) - continue - } - if dropInstr, ok := instr.(*mir.Drop); ok && dropInstr != nil { - emitDrop(lb, dropInstr) - continue - } - if operation, ok := instr.(*mir.DynamicArrayOp); ok && operation != nil { - emitDynamicArrayOp(lb, operation) - continue - } - if call, ok := instr.(*mir.Call); ok && call != nil { - emitDiscardedCall(lb, call) - continue - } - if call, ok := instr.(*mir.InterfaceCall); ok && call != nil { - emitDiscardedInterfaceCall(lb, call) + case *mir.Store: + emitStore(lb, typed) + case *mir.Print: + emitPrint(lb, typed) + case *mir.Drop: + emitDrop(lb, typed) + case *mir.DynamicArrayOp: + emitDynamicArrayOp(lb, typed) + case *mir.Call: + emitDiscardedCall(lb, typed) + case *mir.InterfaceCall: + emitDiscardedInterfaceCall(lb, typed) + default: + panic(fmt.Sprintf("LLVM emission: unhandled MIR instruction %T", instr)) } } if block.Term != nil { @@ -345,6 +339,9 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo } val := emitRef(lb, term.Value) lb.ret(val, returnLayout) + default: + // A block without an emitted terminator is malformed LLVM IR. + panic(fmt.Sprintf("LLVM emission: unhandled MIR terminator %T", block.Term)) } } lb.setLocation(nil) From c88d02aa77b2ce9d63f19edb2e97fec77fadaa2b Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:40:27 +0600 Subject: [PATCH 10/80] Correct node-contract classifications and drop pass-throughs The shared declaration reason was factually wrong. parseStmt really does build FnDecl, StructDecl, InterfaceDecl, EnumDecl and TypeAliasDecl nodes inside a block; the resolver reports them as unsupported statements and HIR lowers them to hir.Invalid. CFG through ownership are not error-gated, so those nodes do reach the sites that skip them. They are skipped because a declaration evaluates no expression at a CFG site, not because the parser refuses them. The earlier reason was asserted from an observed P0004 without checking which phase emitted it. Adopt the roadmap's four-way vocabulary. Kinds without a case are now classified traverse, ignore or reject with a reason, rather than carrying an unlabelled excuse. Whether a case body handles or rejects is still not distinguishable from source shape; separating those needs the compile-time visitor interfaces the roadmap prefers. Verify the else-position reason before reusing it: parseIfStmt produces only a block or an else-if, so lowerElse rejecting anything else is sound. Delete declaredStatementKinds and declaredExpressionKinds, which forwarded to declaredKinds without adding behavior. --- internal/contracts/node_dispatch_test.go | 183 ++++++++++++++--------- 1 file changed, 109 insertions(+), 74 deletions(-) diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index f3baaed8..2f03fcdf 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -20,39 +20,81 @@ import ( "testing" ) -// dispatchSite is one function that switches over ast.Stmt. +// decision is the classification a phase must make for a node kind it does not +// implement a case for. A kind with a case is handled by definition. Whether a +// case body handles or rejects is not distinguishable from the source shape at +// this tier; compile-time visitor interfaces are needed to separate those. +type decision uint8 + +const ( + // traverse: canonical child walk is sufficient, no phase-specific semantics. + traverse decision = iota + // ignore: node is intentionally irrelevant at this site. + ignore + // reject: node is invalid here and another phase reports it. + reject +) + +func (d decision) String() string { + switch d { + case traverse: + return "traverse" + case ignore: + return "ignore" + case reject: + return "reject" + } + return "unknown" +} + +type classification struct { + decision decision + reason string +} + +// dispatchSite is one function that switches over an AST family. type dispatchSite struct { file string fn string - // omitted lists statement kinds this site intentionally does not handle, - // each with the reason it is safe. An empty list means fully exhaustive. - omitted map[string]string - // inertDeclarations adds the shared parser-rejected declaration reason. Set - // it on sites that only ever see statements the parser actually produces - // inside a block, rather than repeating one rule at every such site. + // omitted classifies kinds this site implements no case for. An empty map + // means the site is fully exhaustive. + omitted map[string]classification + // inertDeclarations adds the shared declaration-statement classification. + // Set it on CFG-site consumers rather than repeating one rule at each. inertDeclarations bool } -// Declarations all implement stmtNode, but the parser rejects every one except -// a binding inside a block with "unsupported statement" (P0004). They therefore -// never reach a CFG site or a lowering position in a well-formed tree. -var parserRejectedInStatementPosition = []string{ +// Declarations all implement stmtNode, and parseStmt really does build FnDecl, +// StructDecl, InterfaceDecl, EnumDecl and TypeAliasDecl nodes inside a block. +// The resolver reports them as unsupported statements and HIR lowers them to +// hir.Invalid, but CFG through ownership are not error-gated, so the nodes do +// reach those sites. They are skipped because a declaration evaluates no +// expression and performs no assignment at a CFG site, not because they are +// absent. +var declarationStatements = []string{ "ImportDecl", "FnDecl", "TypeAliasDecl", "StructDecl", "InterfaceDecl", "EnumDecl", "BadDecl", } -const parserRejectedReason = "parser rejects this declaration in statement position (P0004)" +const ( + decomposedByCFGReason = "blocks are decomposed by CFG construction and are never a site statement" + elsePositionReason = "parseIfStmt produces only a block or else-if in else position; the default rejects anything else" + noConditionReason = "carries no branch condition" +) -// omissions returns the site's declared omissions including the shared rule. -func (d dispatchSite) omissions() map[string]string { - merged := make(map[string]string, len(d.omitted)+len(parserRejectedInStatementPosition)) - for kind, reason := range d.omitted { - merged[kind] = reason +const declarationStatementReason = "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here" + +// omissions returns the site's declared classifications including the shared +// declaration-statement rule. +func (d dispatchSite) omissions() map[string]classification { + merged := make(map[string]classification, len(d.omitted)+len(declarationStatements)) + for kind, entry := range d.omitted { + merged[kind] = entry } if d.inertDeclarations { - for _, kind := range parserRejectedInStatementPosition { + for _, kind := range declarationStatements { if _, declared := merged[kind]; !declared { - merged[kind] = parserRejectedReason + merged[kind] = classification{decision: ignore, reason: declarationStatementReason} } } } @@ -69,10 +111,10 @@ var statementSites = []dispatchSite{ { file: "ir/hir/lower/module_lower.go", fn: "lowerElse", - omitted: map[string]string{ - "BreakStmt": "parser only produces a block or else-if in else position", - "ContinueStmt": "parser only produces a block or else-if in else position", - "MatchStmt": "parser only produces a block or else-if in else position", + omitted: map[string]classification{ + "BreakStmt": {reject, elsePositionReason}, + "ContinueStmt": {reject, elsePositionReason}, + "MatchStmt": {reject, elsePositionReason}, }, }, // The remaining sites run per CFG site, where control flow is already @@ -82,53 +124,53 @@ var statementSites = []dispatchSite{ file: "semantics/ownership/ownership.go", fn: "applyStmt", inertDeclarations: true, - omitted: map[string]string{ - "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", - "BadStmt": "recovery node carries no ownership effect", - "BreakStmt": "transfer is a CFG edge, not a site-level ownership effect", - "ContinueStmt": "transfer is a CFG edge, not a site-level ownership effect", + omitted: map[string]classification{ + "BlockStmt": {ignore, decomposedByCFGReason}, + "BadStmt": {ignore, "recovery node carries no ownership effect"}, + "BreakStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, + "ContinueStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, }, }, { file: "semantics/ownership/reference.go", fn: "symbolUseSequence", inertDeclarations: true, - omitted: map[string]string{ - "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", - "BadStmt": "recovery node evaluates no expression", - "BreakStmt": "evaluates no expression", - "ContinueStmt": "evaluates no expression", + omitted: map[string]classification{ + "BlockStmt": {ignore, decomposedByCFGReason}, + "BadStmt": {ignore, "recovery node evaluates no expression"}, + "BreakStmt": {ignore, "evaluates no expression"}, + "ContinueStmt": {ignore, "evaluates no expression"}, }, }, { file: "semantics/definiteinit/initialization.go", fn: "checkReads", inertDeclarations: true, - omitted: map[string]string{ - "BlockStmt": "blocks are decomposed by CFG construction and are never a site statement", - "BadStmt": "recovery node reads nothing", - "IfStmt": "condition arrives separately through the CFG site condition", - "ForStmt": "condition arrives separately through the CFG site condition", - "MatchStmt": "subject arrives separately through the CFG site condition", - "BreakStmt": "reads nothing", - "ContinueStmt": "reads nothing", + omitted: map[string]classification{ + "BlockStmt": {ignore, decomposedByCFGReason}, + "BadStmt": {ignore, "recovery node reads nothing"}, + "IfStmt": {ignore, "condition arrives separately through the CFG site condition"}, + "ForStmt": {ignore, "condition arrives separately through the CFG site condition"}, + "MatchStmt": {ignore, "subject arrives separately through the CFG site condition"}, + "BreakStmt": {ignore, "reads nothing"}, + "ContinueStmt": {ignore, "reads nothing"}, }, }, { file: "semantics/typechecker/flow.go", fn: "applyConditionEdge", inertDeclarations: true, - omitted: map[string]string{ - "BlockStmt": "carries no branch condition", - "ExprStmt": "carries no branch condition", - "AssignStmt": "carries no branch condition", - "ReturnStmt": "carries no branch condition", - "BadStmt": "carries no branch condition", - "BreakStmt": "carries no branch condition", - "ContinueStmt": "carries no branch condition", - "MatchStmt": "match narrowing uses case tests, not a true/false condition edge", - "LetDecl": "carries no branch condition", - "ConstDecl": "carries no branch condition", + omitted: map[string]classification{ + "BlockStmt": {ignore, noConditionReason}, + "ExprStmt": {ignore, noConditionReason}, + "AssignStmt": {ignore, noConditionReason}, + "ReturnStmt": {ignore, noConditionReason}, + "BadStmt": {ignore, noConditionReason}, + "BreakStmt": {ignore, noConditionReason}, + "ContinueStmt": {ignore, noConditionReason}, + "MatchStmt": {ignore, "match narrowing uses case tests, not a true/false condition edge"}, + "LetDecl": {ignore, noConditionReason}, + "ConstDecl": {ignore, noConditionReason}, }, }, } @@ -180,11 +222,6 @@ func declaredKinds(t *testing.T, marker string) []string { return kinds } -func declaredStatementKinds(t *testing.T) []string { - t.Helper() - return declaredKinds(t, "stmtNode") -} - // handledKinds returns the ast.X kinds named by type-switch cases in fn. func handledKinds(t *testing.T, file, fn string) []string { t.Helper() @@ -241,17 +278,12 @@ var expressionSites = []dispatchSite{ { file: "ir/hir/lower/module_lower.go", fn: "lowerASTExpr", - omitted: map[string]string{ - "RangeExpr": "a range is only a loop iterable or a slice index, lowered by its parent, never a standalone value", + omitted: map[string]classification{ + "RangeExpr": {traverse, "a range is only a loop iterable or a slice index, lowered by its parent, never a standalone value"}, }, }, } -func declaredExpressionKinds(t *testing.T) []string { - t.Helper() - return declaredKinds(t, "exprNode") -} - func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { t.Helper() for _, site := range sites { @@ -260,9 +292,9 @@ func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { omitted := site.omissions() for _, kind := range kinds { if slices.Contains(handled, kind) { - if reason, declared := omitted[kind]; declared { - t.Errorf("%s handles %s but still declares it omitted (%q); delete the entry", - site.fn, kind, reason) + if entry, declared := omitted[kind]; declared { + t.Errorf("%s handles %s but still classifies it %s (%q); delete the entry", + site.fn, kind, entry.decision, entry.reason) } continue } @@ -276,24 +308,27 @@ func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { } func TestEveryStatementKindHasAPhaseDecision(t *testing.T) { - assertSitesDecide(t, statementSites, declaredStatementKinds(t)) + assertSitesDecide(t, statementSites, declaredKinds(t, "stmtNode")) } func TestEveryExpressionKindHasAPhaseDecision(t *testing.T) { - assertSitesDecide(t, expressionSites, declaredExpressionKinds(t)) + assertSitesDecide(t, expressionSites, declaredKinds(t, "exprNode")) } // A reason that no longer names a real statement kind is stale and must not // silently excuse a future kind of the same name. func TestOmissionReasonsNameRealNodeKinds(t *testing.T) { - kinds := append(declaredStatementKinds(t), declaredExpressionKinds(t)...) + kinds := append(declaredKinds(t, "stmtNode"), declaredKinds(t, "exprNode")...) for _, site := range append(slices.Clone(statementSites), expressionSites...) { - for kind, reason := range site.omissions() { + for kind, entry := range site.omissions() { if !slices.Contains(kinds, kind) { - t.Errorf("%s declares omitted kind %s that no longer exists", site.fn, kind) + t.Errorf("%s classifies kind %s that no longer exists", site.fn, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) } - if strings.TrimSpace(reason) == "" { - t.Errorf("%s omits %s without a reason", site.fn, kind) + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) } } } From 18b4800389bd269dd05cb491341645cadbf55606 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:45:02 +0600 Subject: [PATCH 11/80] Require every node-bearing AST field to be traversed Child traversal is handwritten, and interface satisfaction cannot see inside a valid method. Adding a node-bearing field and forgetting to visit it compiled, passed the node-dispatch contract, and silently hid the field from every ast.Inspect consumer. This was the remaining gap the framework direction names explicitly. Two checks read the AST package. The first requires each forEachChild to mention every node-bearing field of its own type. The second covers sub-structures such as Param and StructLitField, which carry nodes but have no traversal of their own because a helper or inline loop expands them; every node-bearing field of those must appear in some traversal context. Node-bearing is transitive, so a field typed []Param counts even though Param is not itself a node. A composite field the classifier cannot judge is reported rather than assumed inert, so an unfamiliar shape fails loudly instead of being skipped. Sub-structures held by no node, such as the Module root aggregate, are excluded because consumers walk them directly rather than through ast.Inspect. Expansion evidence is matched by field name across traversal contexts, so a field sharing a name with a traversed field of another type can read as covered. Removing that needs go/types resolution. The check still catches a newly added field whose name appears in no traversal at all. Verified against both shapes: adding ForStmt.Else without updating forEachChild, and adding Param.Constraint with no expansion. Each is reported with the tree restored afterward. --- internal/contracts/child_traversal_test.go | 318 +++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 internal/contracts/child_traversal_test.go diff --git a/internal/contracts/child_traversal_test.go b/internal/contracts/child_traversal_test.go new file mode 100644 index 00000000..dcfe5445 --- /dev/null +++ b/internal/contracts/child_traversal_test.go @@ -0,0 +1,318 @@ +package contracts + +// Child traversal is handwritten. Interface satisfaction cannot see inside a +// valid method, so adding a node-bearing field and forgetting to visit it +// compiles, passes the node-dispatch contract, and silently hides the field +// from every ast.Inspect consumer. +// +// This reads the AST package and requires each forEachChild implementation to +// mention every node-bearing field of its own type. Fields whose type carries +// no AST node need no visit. A composite field the classifier cannot judge is +// reported rather than assumed inert, so an unfamiliar shape fails loudly. + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// nonNodeComposites are composite field types that carry no AST node. Each entry +// is a deliberate classification, not a default. +var nonNodeComposites = map[string]string{ + "CommentGroup": "documentation text, not part of the syntax tree", + "Comment": "documentation text, not part of the syntax tree", + "NodeIDHolder": "embedded node identity", + "NodeMeta": "embedded identity and position metadata", + "Documented": "doc comment and declaration surface text", + "Attributed": "attribute metadata", + "Attribute": "attribute metadata", + "Location": "source position", + "Position": "source position", + "Token": "lexical token", +} + +type astPackage struct { + structs map[string][]*ast.Field // type name -> fields + traversals map[string][]string // type name -> field names visited + nodeTypes map[string]bool // type names that are AST nodes + // visitedAnywhere holds every field name named inside a traversal context: + // a forEachChild body, or a helper that expands a sub-structure by calling + // visit. Sub-structures such as Param are not nodes and have no traversal of + // their own, so this is where their expansion is observed. + visitedAnywhere map[string]bool +} + +func loadASTPackage(t *testing.T) *astPackage { + t.Helper() + dir := filepath.Join(internalDir(t), "frontend", "ast") + pkgs, err := parser.ParseDir(token.NewFileSet(), dir, func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse %s: %v", dir, err) + } + pkg := &astPackage{ + structs: map[string][]*ast.Field{}, + traversals: map[string][]string{}, + nodeTypes: map[string]bool{}, + visitedAnywhere: map[string]bool{}, + } + // Node interfaces are node-bearing by definition; a field typed as one of + // them holds a child. + for _, name := range []string{"Node", "Decl", "TypeDecl", "Stmt", "Expr", "TypeExpr"} { + pkg.nodeTypes[name] = true + } + for _, parsed := range pkgs { + for _, file := range parsed.Files { + for _, decl := range file.Decls { + switch typed := decl.(type) { + case *ast.GenDecl: + for _, spec := range typed.Specs { + spec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if structType, ok := spec.Type.(*ast.StructType); ok && structType.Fields != nil { + pkg.structs[spec.Name.Name] = structType.Fields.List + } + } + case *ast.FuncDecl: + if callsVisit(typed) { + for _, name := range allSelectorNames(typed) { + pkg.visitedAnywhere[name] = true + } + } + owner, ok := receiverTypeName(typed) + if !ok { + continue + } + if typed.Name.Name == "forEachChild" { + pkg.nodeTypes[owner] = true + pkg.traversals[owner] = visitedFields(typed) + } + } + } + } + } + if len(pkg.traversals) == 0 { + t.Fatalf("no forEachChild implementations found in %s", dir) + } + return pkg +} + +// callsVisit reports whether fn performs traversal, either a forEachChild body +// or a helper that expands a sub-structure. +func callsVisit(fn *ast.FuncDecl) bool { + found := false + ast.Inspect(fn, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if name, ok := call.Fun.(*ast.Ident); ok && strings.HasPrefix(name.Name, "visit") { + found = true + } + return true + }) + return found || fn.Name.Name == "forEachChild" +} + +func allSelectorNames(fn *ast.FuncDecl) []string { + names := make([]string, 0) + ast.Inspect(fn, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + names = append(names, selector.Sel.Name) + } + return true + }) + return names +} + +func receiverTypeName(fn *ast.FuncDecl) (string, bool) { + if fn.Recv == nil || len(fn.Recv.List) != 1 { + return "", false + } + expr := fn.Recv.List[0].Type + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + name, ok := expr.(*ast.Ident) + if !ok { + return "", false + } + return name.Name, true +} + +// visitedFields returns every field of the receiver named anywhere in fn, which +// covers direct visits, range loops, and conditional visits alike. +func visitedFields(fn *ast.FuncDecl) []string { + receiver := "" + if len(fn.Recv.List[0].Names) == 1 { + receiver = fn.Recv.List[0].Names[0].Name + } + fields := make([]string, 0) + ast.Inspect(fn, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + if base, ok := selector.X.(*ast.Ident); ok && base.Name == receiver { + fields = append(fields, selector.Sel.Name) + } + return true + }) + return fields +} + +// typeNames returns every identifier naming a type inside a field type. +func typeNames(expr ast.Expr) []string { + names := make([]string, 0) + ast.Inspect(expr, func(node ast.Node) bool { + if ident, ok := node.(*ast.Ident); ok { + names = append(names, ident.Name) + } + if selector, ok := node.(*ast.SelectorExpr); ok { + names = append(names, selector.Sel.Name) + return false + } + return true + }) + return names +} + +// bearing reports whether a type name transitively carries an AST node, so a +// field typed []Param counts even though Param is not itself a node. +func (p *astPackage) bearing(name string, seen map[string]bool) bool { + if p.nodeTypes[name] { + return true + } + if _, inert := nonNodeComposites[name]; inert { + return false + } + fields, ok := p.structs[name] + if !ok || seen[name] { + return false + } + seen[name] = true + for _, field := range fields { + for _, inner := range typeNames(field.Type) { + if inner != name && p.bearing(inner, seen) { + return true + } + } + } + return false +} + +// typesHeldByNodes returns every type named by a field of a traversable node. +func (p *astPackage) typesHeldByNodes() map[string]bool { + held := map[string]bool{} + var walk func(string) + walk = func(name string) { + for _, field := range p.structs[name] { + for _, inner := range typeNames(field.Type) { + if _, ok := p.structs[inner]; !ok || held[inner] { + continue + } + held[inner] = true + walk(inner) + } + } + } + for name := range p.traversals { + walk(name) + } + return held +} + +// Sub-structures such as Param carry nodes but have no traversal of their own; +// their fields are expanded by a helper or an inline loop. Every node-bearing +// field must be named in some traversal context, or it is unreachable. +// +// Evidence is matched by field name across all traversal contexts, so a field +// sharing a name with a traversed field of another type can read as covered. +// Removing that needs go/types resolution; the check still catches a newly +// added field whose name appears in no traversal at all. +func TestEverySubStructureFieldIsExpanded(t *testing.T) { + pkg := loadASTPackage(t) + held := pkg.typesHeldByNodes() + for name, fields := range pkg.structs { + if _, isNode := pkg.traversals[name]; isNode { + continue + } + // A sub-structure only needs expansion if some node actually holds it. + // Root aggregates such as Module are held by nobody and are walked by + // their consumers directly, not through ast.Inspect. + if !held[name] || !pkg.bearing(name, map[string]bool{}) { + continue + } + t.Run(name, func(t *testing.T) { + for _, field := range fields { + if len(field.Names) == 0 { + continue + } + carries := false + for _, inner := range typeNames(field.Type) { + if pkg.bearing(inner, map[string]bool{}) { + carries = true + break + } + } + if !carries { + continue + } + if !pkg.visitedAnywhere[field.Names[0].Name] { + t.Errorf("%s.%s carries an AST node but is never expanded in any traversal; ast.Inspect cannot reach it", + name, field.Names[0].Name) + } + } + }) + } +} + +func TestEveryNodeBearingFieldIsTraversed(t *testing.T) { + pkg := loadASTPackage(t) + for owner, visited := range pkg.traversals { + t.Run(owner, func(t *testing.T) { + for _, field := range pkg.structs[owner] { + names := typeNames(field.Type) + bearing := false + unknown := "" + for _, name := range names { + if pkg.bearing(name, map[string]bool{}) { + bearing = true + break + } + // Composite types must be classified rather than assumed inert. + if _, known := nonNodeComposites[name]; known { + continue + } + if _, isStruct := pkg.structs[name]; isStruct { + unknown = name + } + } + label := "embedded " + strings.Join(names, ".") + if len(field.Names) > 0 { + label = field.Names[0].Name + } + if unknown != "" && !bearing { + t.Errorf("%s.%s has unclassified composite type %s; classify it in nonNodeComposites or give it a traversal", + owner, label, unknown) + continue + } + if !bearing || len(field.Names) == 0 { + continue + } + if !slices.Contains(visited, field.Names[0].Name) { + t.Errorf("%s.forEachChild never visits node-bearing field %s; add it or the field is invisible to ast.Inspect", + owner, field.Names[0].Name) + } + } + }) + } +} From 17c00255d35366e5d9e9b6bf9f82eedae7dec7d6 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 16:46:57 +0600 Subject: [PATCH 12/80] Assemble canonical module identity in one place Identity was assembled separately in module construction, import resolution and the prelude, each pairing an origin and namespace with a derived import path. The parts stayed similar by convention only, which is how the prelude came to register under an import path no import could reproduce. CompilerContext.IdentityForFile now owns that assembly and every producer calls it. It is not a forwarding wrapper: it couples the origin and namespace a caller chose with the import path derived from the file, and returns an error when no identity exists. Exactly one moduleid.ID literal remains in production code, inside that function. --- internal/prelude/prelude.go | 8 ++------ internal/project/imports.go | 11 ++--------- internal/project/modules.go | 24 ++++++++++++++++++------ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index aed60fc7..10590d64 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -25,15 +25,11 @@ func ModuleID(ctx *project.CompilerContext) moduleid.ID { if !ok { return moduleid.ID{} } - importPath, err := ctx.ImportPathForFile(project.ModuleOriginStdlib, preludeNamespace, project.CanonicalPath(preludePath)) + id, err := ctx.IdentityForFile(project.ModuleOriginStdlib, preludeNamespace, project.CanonicalPath(preludePath)) if err != nil { return moduleid.ID{} } - return moduleid.ID{ - Origin: string(project.ModuleOriginStdlib), - Namespace: preludeNamespace, - ImportPath: importPath, - } + return id } func globalPreludePath(ctx *project.CompilerContext) (string, bool) { diff --git a/internal/project/imports.go b/internal/project/imports.go index 346030b1..8ec076d9 100644 --- a/internal/project/imports.go +++ b/internal/project/imports.go @@ -309,19 +309,12 @@ func (ctx *CompilerContext) ResolveImportPath(rawPath string) (*ResolvedImport, } absPath = CanonicalPath(absPath) - resolvedImportPath, err := ctx.ImportPathForFile(origin, namespace, absPath) + id, err := ctx.IdentityForFile(origin, namespace, absPath) if err != nil { return nil, &ImportError{Code: diagnostics.ErrInvalidImportPath, Msg: err.Error()} } - return &ResolvedImport{ - ID: moduleid.ID{ - Origin: string(origin), - Namespace: namespace, - ImportPath: resolvedImportPath, - }, - FilePath: absPath, - }, nil + return &ResolvedImport{ID: id, FilePath: absPath}, nil } func splitNamespacedImportPath(importPath string) (string, string, bool) { diff --git a/internal/project/modules.go b/internal/project/modules.go index 60bb8122..d39fa1c7 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -222,22 +222,34 @@ func PathWithinRoot(rootPath, path string) bool { return strings.HasPrefix(path, rootPath+"/") } +// IdentityForFile assembles canonical module identity for a file. Every producer +// of a moduleid.ID goes through here so origin, namespace and derived import path +// cannot drift apart. Assembling identity separately is what let the prelude +// register under an import path no import could reproduce. +func (ctx *CompilerContext) IdentityForFile(origin ModuleOrigin, namespace, filePath string) (moduleid.ID, error) { + importPath, err := ctx.ImportPathForFile(origin, namespace, filePath) + if err != nil { + return moduleid.ID{}, err + } + return moduleid.ID{ + Origin: string(origin), + Namespace: namespace, + ImportPath: importPath, + }, nil +} + // NewModuleForFile builds one file-backed module with canonical identity derived from compiler config. func (ctx *CompilerContext) NewModuleForFile(filePath, content string) *Module { if ctx == nil || filePath == "" { return nil } origin, namespace := ctx.ModuleOriginForFile(filePath) - importPath, err := ctx.ImportPathForFile(origin, namespace, filePath) + id, err := ctx.IdentityForFile(origin, namespace, filePath) if err != nil { return nil } return &Module{ - ID: moduleid.ID{ - Origin: string(origin), - Namespace: namespace, - ImportPath: importPath, - }, + ID: id, FilePath: filePath, Content: content, ContentProvided: true, From 26600b0bd39eeedb3012a154ba01d7ae3789a7f5 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:42:29 +0600 Subject: [PATCH 13/80] Traverse attribute arguments and harden AST contracts Attribute arguments are real AST expressions but were classified as inert metadata, so no forEachChild reached them and the contract could not see the gap. Attribute and Attributed now own traversals, and the five declarations embedding Attributed chain into them, making attribute args reachable through ast.Inspect and ast.Index. The child-traversal contract is rewritten with owner-qualified coverage: sub-structure fields must be expanded by a traversal context that handles that specific sub-structure (helpers found structurally by their visit parameter, liveness required), and embedded node-bearing composites must own a traversal and be chained by type name. The dispatch contract now only counts type switches whose operand is the function's statement or expression input, so unrelated nested switches cannot fake coverage; the ast package alias is resolved from file imports; ImportDecl/BadDecl reasons state their real reachability; RangeExpr gains a contextual decision; and both contracts share one AST package loader. Proven by mutation: adding an unvisited ForStmt field fails the traversal contract, and removing a dispatch case fails the phase-decision contract. --- internal/contracts/child_traversal_test.go | 347 ++++++++++++++++----- internal/contracts/node_dispatch_test.go | 247 ++++++++++----- internal/frontend/ast/decl.go | 5 + internal/frontend/ast/meta.go | 18 ++ 4 files changed, 469 insertions(+), 148 deletions(-) diff --git a/internal/contracts/child_traversal_test.go b/internal/contracts/child_traversal_test.go index dcfe5445..68dee4d8 100644 --- a/internal/contracts/child_traversal_test.go +++ b/internal/contracts/child_traversal_test.go @@ -5,10 +5,16 @@ package contracts // compiles, passes the node-dispatch contract, and silently hides the field // from every ast.Inspect consumer. // -// This reads the AST package and requires each forEachChild implementation to -// mention every node-bearing field of its own type. Fields whose type carries -// no AST node need no visit. A composite field the classifier cannot judge is -// reported rather than assumed inert, so an unfamiliar shape fails loudly. +// This reads the AST package and enforces two completeness rules: +// +// - every node-bearing field of a traversable node is visited by that node's +// own forEachChild. An embedded composite that carries nodes must own a +// traversal, and the embedding node must chain into it by type name. +// - every node-bearing field of a non-node sub-structure (Param, +// StructLitField, ...) is expanded by some traversal context that actually +// handles that sub-structure. Evidence is matched by owner, not by bare +// field name, so a field name visited in an unrelated context cannot +// cover a forgotten field. import ( "go/ast" @@ -29,8 +35,6 @@ var nonNodeComposites = map[string]string{ "NodeIDHolder": "embedded node identity", "NodeMeta": "embedded identity and position metadata", "Documented": "doc comment and declaration surface text", - "Attributed": "attribute metadata", - "Attribute": "attribute metadata", "Location": "source position", "Position": "source position", "Token": "lexical token", @@ -38,13 +42,28 @@ var nonNodeComposites = map[string]string{ type astPackage struct { structs map[string][]*ast.Field // type name -> fields - traversals map[string][]string // type name -> field names visited + traversals map[string][]string // type name -> receiver field names visited nodeTypes map[string]bool // type names that are AST nodes - // visitedAnywhere holds every field name named inside a traversal context: - // a forEachChild body, or a helper that expands a sub-structure by calling - // visit. Sub-structures such as Param are not nodes and have no traversal of - // their own, so this is where their expansion is observed. - visitedAnywhere map[string]bool + markers map[string][]string // marker method -> implementing type names + // expansions holds every function participating in traversal: forEachChild + // methods and helpers accepting a visit func(Node) parameter. Coverage and + // fields are transitive over package-level helper calls, so a chain such as + // FnDecl.forEachChild -> inspectParams -> inspectParam attributes Param + // fields to every owner that expands params. + expansions map[string]*expansion +} + +// expansion is one traversal context. owner is the receiver type for methods +// and empty for package-level helpers. mentions lists struct type names written +// anywhere in the function (signature included), so inspectParam(param Param, +// ...) mentions Param. fields lists every selector field name reachable through +// the helper-call closure. live records whether visit is actually invoked, +// directly or through helpers; a helper that never calls visit proves nothing. +type expansion struct { + owner string + mentions map[string]bool + fields map[string]bool + live bool } func loadASTPackage(t *testing.T) *astPackage { @@ -57,16 +76,18 @@ func loadASTPackage(t *testing.T) *astPackage { t.Fatalf("parse %s: %v", dir, err) } pkg := &astPackage{ - structs: map[string][]*ast.Field{}, - traversals: map[string][]string{}, - nodeTypes: map[string]bool{}, - visitedAnywhere: map[string]bool{}, + structs: map[string][]*ast.Field{}, + traversals: map[string][]string{}, + nodeTypes: map[string]bool{}, + markers: map[string][]string{}, + expansions: map[string]*expansion{}, } // Node interfaces are node-bearing by definition; a field typed as one of // them holds a child. for _, name := range []string{"Node", "Decl", "TypeDecl", "Stmt", "Expr", "TypeExpr"} { pkg.nodeTypes[name] = true } + packageFuncs := map[string]*ast.FuncDecl{} for _, parsed := range pkgs { for _, file := range parsed.Files { for _, decl := range file.Decls { @@ -82,55 +103,191 @@ func loadASTPackage(t *testing.T) *astPackage { } } case *ast.FuncDecl: - if callsVisit(typed) { - for _, name := range allSelectorNames(typed) { - pkg.visitedAnywhere[name] = true + if marker := markerName(typed); marker != "" { + if owner, ok := receiverTypeName(typed); ok { + pkg.markers[marker] = append(pkg.markers[marker], owner) } - } - owner, ok := receiverTypeName(typed) - if !ok { continue } - if typed.Name.Name == "forEachChild" { + owner, isMethod := receiverTypeName(typed) + if isMethod && typed.Name.Name == "forEachChild" { pkg.nodeTypes[owner] = true pkg.traversals[owner] = visitedFields(typed) } + if isMethod || hasVisitParam(typed) { + key := expansionKey(owner, typed.Name.Name) + visitName, _ := visitParamName(typed) + pkg.expansions[key] = &expansion{ + owner: owner, + mentions: mentionedStructs(pkg, typed), + fields: allSelectorNames(typed), + live: callsIdent(typed, visitName), + } + if !isMethod { + packageFuncs[typed.Name.Name] = typed + } + } } } } } + pkg.closeOverHelpers(packageFuncs) if len(pkg.traversals) == 0 { t.Fatalf("no forEachChild implementations found in %s", dir) } return pkg } -// callsVisit reports whether fn performs traversal, either a forEachChild body -// or a helper that expands a sub-structure. -func callsVisit(fn *ast.FuncDecl) bool { +// closeOverHelpers merges fields, mentions, and liveness transitively through +// package-level helper calls so inspectParam evidence reaches FnDecl.forEachChild. +func (p *astPackage) closeOverHelpers(packageFuncs map[string]*ast.FuncDecl) { + callees := map[string][]string{} + for key, fn := range packageFuncs { + for _, name := range calledPackageFuncs(fn, packageFuncs) { + callees[key] = append(callees[key], name) + } + } + for changed := true; changed; { + changed = false + for key, targets := range callees { + target := p.expansions[key] + if target == nil { + continue + } + for _, callee := range targets { + source := p.expansions[callee] + if source == nil { + continue + } + for field := range source.fields { + if !target.fields[field] { + target.fields[field] = true + changed = true + } + } + for mention := range source.mentions { + if !target.mentions[mention] { + target.mentions[mention] = true + changed = true + } + } + if source.live && !target.live { + target.live = true + changed = true + } + } + } + } +} + +func expansionKey(owner, name string) string { + if owner == "" { + return name + } + return owner + "." + name +} + +// markerName returns the marker method name (stmtNode, exprNode, ...) or "". +func markerName(fn *ast.FuncDecl) string { + switch fn.Name.Name { + case "stmtNode", "exprNode", "declNode", "typeNode": + if fn.Recv != nil { + return fn.Name.Name + } + } + return "" +} + +// hasVisitParam reports whether the function accepts a visit func(Node) +// parameter, making it a traversal helper regardless of its name. +func hasVisitParam(fn *ast.FuncDecl) bool { + _, ok := visitParamName(fn) + return ok +} + +// visitParamName returns the name of the func(Node) parameter, which is the +// identifier the body must call to expand children. +func visitParamName(fn *ast.FuncDecl) (string, bool) { + if fn == nil || fn.Type.Params == nil { + return "", false + } + for _, field := range fn.Type.Params.List { + funcType, ok := field.Type.(*ast.FuncType) + if !ok || funcType.Params == nil || len(funcType.Params.List) != 1 { + continue + } + nodeType, ok := funcType.Params.List[0].Type.(*ast.Ident) + if !ok || nodeType.Name != "Node" || len(field.Names) == 0 { + continue + } + return field.Names[0].Name, true + } + return "", false +} + +// callsIdent reports whether the function body invokes the given identifier. +func callsIdent(fn *ast.FuncDecl, name string) bool { + if name == "" { + return false + } found := false - ast.Inspect(fn, func(node ast.Node) bool { + ast.Inspect(fn.Body, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { return true } - if name, ok := call.Fun.(*ast.Ident); ok && strings.HasPrefix(name.Name, "visit") { + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == name { found = true } return true }) - return found || fn.Name.Name == "forEachChild" + return found } -func allSelectorNames(fn *ast.FuncDecl) []string { +// calledPackageFuncs lists package-level helper functions the body calls. +func calledPackageFuncs(fn *ast.FuncDecl, packageFuncs map[string]*ast.FuncDecl) []string { names := make([]string, 0) + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + ident, ok := call.Fun.(*ast.Ident) + if !ok { + return true + } + if _, known := packageFuncs[ident.Name]; known { + names = append(names, ident.Name) + } + return true + }) + return names +} + +func mentionedStructs(pkg *astPackage, fn *ast.FuncDecl) map[string]bool { + mentions := map[string]bool{} + ast.Inspect(fn, func(node ast.Node) bool { + ident, ok := node.(*ast.Ident) + if !ok { + return true + } + if _, isStruct := pkg.structs[ident.Name]; isStruct { + mentions[ident.Name] = true + } + return true + }) + return mentions +} + +func allSelectorNames(fn *ast.FuncDecl) map[string]bool { + fields := map[string]bool{} ast.Inspect(fn, func(node ast.Node) bool { if selector, ok := node.(*ast.SelectorExpr); ok { - names = append(names, selector.Sel.Name) + fields[selector.Sel.Name] = true } return true }) - return names + return fields } func receiverTypeName(fn *ast.FuncDecl) (string, bool) { @@ -186,24 +343,56 @@ func typeNames(expr ast.Expr) []string { } // bearing reports whether a type name transitively carries an AST node, so a -// field typed []Param counts even though Param is not itself a node. -func (p *astPackage) bearing(name string, seen map[string]bool) bool { - if p.nodeTypes[name] { - return true - } - if _, inert := nonNodeComposites[name]; inert { +// field typed []Param counts even though Param is not itself a node. The +// visited set is recursion-internal cycle protection, not caller state. +func (p *astPackage) bearing(name string) bool { + seen := map[string]bool{} + var walk func(string) bool + walk = func(current string) bool { + if p.nodeTypes[current] { + return true + } + if _, inert := nonNodeComposites[current]; inert { + return false + } + fields, ok := p.structs[current] + if !ok || seen[current] { + return false + } + seen[current] = true + for _, field := range fields { + for _, inner := range typeNames(field.Type) { + if inner != current && walk(inner) { + return true + } + } + } return false } - fields, ok := p.structs[name] - if !ok || seen[name] { + return walk(name) +} + +// covers reports whether the expansion context handles the sub-structure: it +// either mentions the type (a helper parameter typed Param) or its owner struct +// holds it directly (StructLit holds []StructLitField). +func (p *astPackage) covers(e *expansion, sub string) bool { + if e.mentions[sub] { + return true + } + if e.owner == "" { return false } - seen[name] = true - for _, field := range fields { - for _, inner := range typeNames(field.Type) { - if inner != name && p.bearing(inner, seen) { - return true - } + return slices.ContainsFunc(p.structs[e.owner], func(field *ast.Field) bool { + return slices.Contains(typeNames(field.Type), sub) + }) +} + +// expandsField reports whether some live traversal context that handles sub +// touches the given field name. +func (p *astPackage) expandsField(sub, field string) bool { + for _, e := range p.expansions { + if e.live && p.covers(e, sub) && e.fields[field] { + return true } } return false @@ -232,12 +421,9 @@ func (p *astPackage) typesHeldByNodes() map[string]bool { // Sub-structures such as Param carry nodes but have no traversal of their own; // their fields are expanded by a helper or an inline loop. Every node-bearing -// field must be named in some traversal context, or it is unreachable. -// -// Evidence is matched by field name across all traversal contexts, so a field -// sharing a name with a traversed field of another type can read as covered. -// Removing that needs go/types resolution; the check still catches a newly -// added field whose name appears in no traversal at all. +// field must be expanded by a traversal context that handles that specific +// sub-structure, so a same-named field visited in an unrelated context cannot +// cover a forgotten one. func TestEverySubStructureFieldIsExpanded(t *testing.T) { pkg := loadASTPackage(t) held := pkg.typesHeldByNodes() @@ -248,7 +434,7 @@ func TestEverySubStructureFieldIsExpanded(t *testing.T) { // A sub-structure only needs expansion if some node actually holds it. // Root aggregates such as Module are held by nobody and are walked by // their consumers directly, not through ast.Inspect. - if !held[name] || !pkg.bearing(name, map[string]bool{}) { + if !held[name] || !pkg.bearing(name) { continue } t.Run(name, func(t *testing.T) { @@ -256,19 +442,13 @@ func TestEverySubStructureFieldIsExpanded(t *testing.T) { if len(field.Names) == 0 { continue } - carries := false - for _, inner := range typeNames(field.Type) { - if pkg.bearing(inner, map[string]bool{}) { - carries = true - break - } - } - if !carries { + if !slices.ContainsFunc(typeNames(field.Type), pkg.bearing) { continue } - if !pkg.visitedAnywhere[field.Names[0].Name] { - t.Errorf("%s.%s carries an AST node but is never expanded in any traversal; ast.Inspect cannot reach it", - name, field.Names[0].Name) + fieldName := field.Names[0].Name + if !pkg.expandsField(name, fieldName) { + t.Errorf("%s.%s carries an AST node but no traversal context handling %s expands it; ast.Inspect cannot reach it", + name, fieldName, name) } } }) @@ -281,19 +461,18 @@ func TestEveryNodeBearingFieldIsTraversed(t *testing.T) { t.Run(owner, func(t *testing.T) { for _, field := range pkg.structs[owner] { names := typeNames(field.Type) - bearing := false + bearing := slices.ContainsFunc(names, pkg.bearing) unknown := "" - for _, name := range names { - if pkg.bearing(name, map[string]bool{}) { - bearing = true - break - } + if !bearing { // Composite types must be classified rather than assumed inert. - if _, known := nonNodeComposites[name]; known { - continue - } - if _, isStruct := pkg.structs[name]; isStruct { - unknown = name + for _, name := range names { + if _, known := nonNodeComposites[name]; known { + continue + } + if _, isStruct := pkg.structs[name]; isStruct { + unknown = name + break + } } } label := "embedded " + strings.Join(names, ".") @@ -305,7 +484,23 @@ func TestEveryNodeBearingFieldIsTraversed(t *testing.T) { owner, label, unknown) continue } - if !bearing || len(field.Names) == 0 { + if !bearing { + continue + } + if len(field.Names) == 0 { + // Embedded node-bearing composite: it must own a traversal and + // the owner must chain into it, otherwise its children are only + // reachable if every consumer knows to expand it manually. + embedded := names[0] + if _, hasTraversal := pkg.traversals[embedded]; !hasTraversal { + t.Errorf("%s embeds node-bearing %s without a traversal; give it a forEachChild", + owner, embedded) + continue + } + if !slices.Contains(visited, embedded) { + t.Errorf("%s.forEachChild never chains into embedded %s; call its forEachChild or the subtree is invisible to ast.Inspect", + owner, embedded) + } continue } if !slices.Contains(visited, field.Names[0].Name) { diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index 2f03fcdf..da28728f 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -1,18 +1,16 @@ // Package contracts owns the phase-coverage contract for AST node handling. // -// Structural traversal is already centralized in ast.Inspect and friends, which -// prevents a forgotten child walk. It cannot prevent a forgotten *semantic* -// decision: adding a statement kind and never teaching a phase about it. These -// tests read the real sources and compare every declared statement kind against -// every phase that dispatches on statements, so an omission fails immediately -// instead of surfacing as wrong output much later. +// Child-field completeness is enforced separately in child_traversal_test.go. +// These tests cover the semantic layer: they read the real sources and compare +// every declared statement and expression kind against every phase that +// dispatches on them, so an omission fails immediately instead of surfacing as +// wrong output much later. package contracts import ( "go/ast" "go/parser" "go/token" - "os" "path/filepath" "runtime" "slices" @@ -33,6 +31,9 @@ const ( ignore // reject: node is invalid here and another phase reports it. reject + // contextual: the parent construct owns the node's handling; reaching this + // dispatcher directly is an internal invariant violation. + contextual ) func (d decision) String() string { @@ -43,6 +44,8 @@ func (d decision) String() string { return "ignore" case reject: return "reject" + case contextual: + return "contextual" } return "unknown" } @@ -59,7 +62,7 @@ type dispatchSite struct { // omitted classifies kinds this site implements no case for. An empty map // means the site is fully exhaustive. omitted map[string]classification - // inertDeclarations adds the shared declaration-statement classification. + // inertDeclarations adds the shared declaration-statement classifications. // Set it on CFG-site consumers rather than repeating one rule at each. inertDeclarations bool } @@ -70,20 +73,24 @@ type dispatchSite struct { // hir.Invalid, but CFG through ownership are not error-gated, so the nodes do // reach those sites. They are skipped because a declaration evaluates no // expression and performs no assignment at a CFG site, not because they are -// absent. -var declarationStatements = []string{ - "ImportDecl", "FnDecl", "TypeAliasDecl", "StructDecl", - "InterfaceDecl", "EnumDecl", "BadDecl", +// absent. ImportDecl is only produced at module level and BadDecl is never +// produced by the parser; both are tolerated for synthetic trees. +var declarationStatements = map[string]classification{ + "FnDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "TypeAliasDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "StructDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "InterfaceDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "EnumDecl": {ignore, "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here"}, + "ImportDecl": {ignore, "the parser only produces import declarations at module level, never as a block statement"}, + "BadDecl": {ignore, "the parser never produces BadDecl; it is tolerated for synthetic trees"}, } const ( decomposedByCFGReason = "blocks are decomposed by CFG construction and are never a site statement" - elsePositionReason = "parseIfStmt produces only a block or else-if in else position; the default rejects anything else" + elsePositionReason = "parseIfStmt produces only a block or else-if in else position; anything else is an internal invariant violation (the default panics)" noConditionReason = "carries no branch condition" ) -const declarationStatementReason = "reaches this site after the resolver reports an unsupported statement, and evaluates no expression here" - // omissions returns the site's declared classifications including the shared // declaration-statement rule. func (d dispatchSite) omissions() map[string]classification { @@ -92,9 +99,9 @@ func (d dispatchSite) omissions() map[string]classification { merged[kind] = entry } if d.inertDeclarations { - for _, kind := range declarationStatements { + for kind, entry := range declarationStatements { if _, declared := merged[kind]; !declared { - merged[kind] = classification{decision: ignore, reason: declarationStatementReason} + merged[kind] = entry } } } @@ -184,45 +191,26 @@ func internalDir(t *testing.T) string { return filepath.Dir(filepath.Dir(thisFile)) } -// declaredKinds scans the whole AST package for types implementing marker, so -// the contract tracks the real node set. Scanning the package rather than one -// file matters: every declaration also implements stmtNode, so statement kinds -// are split across stmt.go and decl.go. +// declaredKinds returns every AST type implementing the marker method, tracked +// through the shared AST package loader so both contracts see one node set. func declaredKinds(t *testing.T, marker string) []string { t.Helper() - dir := filepath.Join(internalDir(t), "frontend", "ast") - pkgs, err := parser.ParseDir(token.NewFileSet(), dir, func(info os.FileInfo) bool { - return !strings.HasSuffix(info.Name(), "_test.go") - }, 0) - if err != nil { - t.Fatalf("parse %s: %v", dir, err) - } - kinds := make([]string, 0) - for _, pkg := range pkgs { - for _, file := range pkg.Files { - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Name.Name != marker || fn.Recv == nil || len(fn.Recv.List) != 1 { - continue - } - star, ok := fn.Recv.List[0].Type.(*ast.StarExpr) - if !ok { - continue - } - if name, ok := star.X.(*ast.Ident); ok { - kinds = append(kinds, name.Name) - } - } - } - } + pkg := loadASTPackage(t) + kinds := slices.Clone(pkg.markers[marker]) if len(kinds) == 0 { - t.Fatalf("no types implement %s in %s", marker, dir) + t.Fatalf("no types implement %s in the ast package", marker) } slices.Sort(kinds) return kinds } -// handledKinds returns the ast.X kinds named by type-switch cases in fn. +// handledKinds returns the ast.X kinds named by type-switch cases whose operand +// is the function's statement/expression input. That input is either a direct +// parameter, a CFG-site payload selected from a parameter (node.stmt), or a +// local extracted from a site through a comma-ok assertion to ast.Stmt. +// Switches on other derived values (a callee extracted from an expression, a +// loop variable) are a different decision and are excluded, so an unrelated +// nested switch cannot fake coverage. func handledKinds(t *testing.T, file, fn string) []string { t.Helper() path := filepath.Join(internalDir(t), filepath.FromSlash(file)) @@ -230,42 +218,157 @@ func handledKinds(t *testing.T, file, fn string) []string { if err != nil { t.Fatalf("parse %s: %v", path, err) } + decl := findFuncDecl(parsed, fn) + if decl == nil { + t.Fatalf("function %s not found in %s", fn, file) + } + params := paramNames(decl) + stmtLocals := stmtAssertionLocals(decl) + astLocal := importLocalName(parsed, "compiler/internal/frontend/ast") handled := make([]string, 0) - found := false - ast.Inspect(parsed, func(node ast.Node) bool { - decl, ok := node.(*ast.FuncDecl) - if !ok || decl.Name.Name != fn { + ast.Inspect(decl.Body, func(node ast.Node) bool { + switchStmt, ok := node.(*ast.TypeSwitchStmt) + if !ok { return true } - found = true - ast.Inspect(decl, func(inner ast.Node) bool { - clause, ok := inner.(*ast.CaseClause) - if !ok { - return true - } - for _, expr := range clause.List { - star, ok := expr.(*ast.StarExpr) - if !ok { - continue - } - selector, ok := star.X.(*ast.SelectorExpr) + // Do not descend: switches on derived values inside this switch are not + // part of this function's dispatch contract. + if isDispatchOperand(switchOperandExpr(switchStmt), params, stmtLocals) { + for _, stmt := range switchStmt.Body.List { + clause, ok := stmt.(*ast.CaseClause) if !ok { continue } - if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == "ast" { - handled = append(handled, selector.Sel.Name) + for _, expr := range clause.List { + star, ok := expr.(*ast.StarExpr) + if !ok { + continue + } + selector, ok := star.X.(*ast.SelectorExpr) + if !ok { + continue + } + if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == astLocal { + handled = append(handled, selector.Sel.Name) + } } } - return true - }) + } return false }) - if !found { - t.Fatalf("function %s not found in %s", fn, file) - } return handled } +// isDispatchOperand reports whether a type-switch operand is the function's +// statement/expression input under the rule documented on handledKinds. +func isDispatchOperand(operand ast.Expr, params, stmtLocals map[string]bool) bool { + if operand == nil { + return false + } + switch typed := operand.(type) { + case *ast.Ident: + return params[typed.Name] || stmtLocals[typed.Name] + case *ast.SelectorExpr: + base, ok := typed.X.(*ast.Ident) + return ok && params[base.Name] && typed.Sel.Name == "stmt" + } + return false +} + +// stmtAssertionLocals returns locals assigned through a comma-ok assertion to +// ast.Stmt, the canonical CFG-site statement extraction. +func stmtAssertionLocals(fn *ast.FuncDecl) map[string]bool { + locals := map[string]bool{} + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 { + return true + } + assert, ok := assign.Rhs[0].(*ast.TypeAssertExpr) + if !ok { + return true + } + selector, ok := assert.Type.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := selector.X.(*ast.Ident) + if !ok || pkg.Name != "ast" || selector.Sel.Name != "Stmt" { + return true + } + if ident, ok := assign.Lhs[0].(*ast.Ident); ok { + locals[ident.Name] = true + } + return true + }) + return locals +} + +func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +func paramNames(fn *ast.FuncDecl) map[string]bool { + params := map[string]bool{} + if fn.Type.Params == nil { + return params + } + for _, field := range fn.Type.Params.List { + for _, name := range field.Names { + params[name.Name] = true + } + } + return params +} + +// switchOperandExpr returns the expression a type switch asserts on, for both +// `switch x := y.(type)` and `switch y.(type)` forms. +func switchOperandExpr(switchStmt *ast.TypeSwitchStmt) ast.Expr { + switch assign := switchStmt.Assign.(type) { + case *ast.AssignStmt: + if len(assign.Rhs) != 1 { + return nil + } + assert, ok := assign.Rhs[0].(*ast.TypeAssertExpr) + if !ok { + return nil + } + return assert.X + case *ast.ExprStmt: + assert, ok := assign.X.(*ast.TypeAssertExpr) + if !ok { + return nil + } + return assert.X + } + return nil +} + +// importLocalName resolves the local name the file uses for the package at the +// given import path, so the case-type filter does not hardcode an alias. +func importLocalName(file *ast.File, importPath string) string { + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) != importPath { + continue + } + if imp.Name != nil { + return imp.Name.Name + } + break + } + // Unaliased imports bind the package name, which is the path's last segment. + if index := strings.LastIndex(importPath, "/"); index >= 0 { + return importPath[index+1:] + } + return importPath +} + // Expression dispatch is split between sites that must be total and narrow // queries such as "which expressions can hold a reference". Only the total sites // belong in this contract: demanding a declared reason from every predicate for @@ -279,7 +382,7 @@ var expressionSites = []dispatchSite{ file: "ir/hir/lower/module_lower.go", fn: "lowerASTExpr", omitted: map[string]classification{ - "RangeExpr": {traverse, "a range is only a loop iterable or a slice index, lowered by its parent, never a standalone value"}, + "RangeExpr": {contextual, "a range is lowered by its parent construct (loop iterable or slice index); this dispatcher must never receive one directly"}, }, }, } diff --git a/internal/frontend/ast/decl.go b/internal/frontend/ast/decl.go index bce8d3d2..7cd95052 100644 --- a/internal/frontend/ast/decl.go +++ b/internal/frontend/ast/decl.go @@ -467,6 +467,7 @@ type FnDecl struct { func (*FnDecl) declNode() {} func (*FnDecl) stmtNode() {} func (d *FnDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) if d.Receiver != nil { inspectParam(*d.Receiver, visit) @@ -503,6 +504,7 @@ type TypeAliasDecl struct { func (*TypeAliasDecl) declNode() {} func (*TypeAliasDecl) stmtNode() {} func (d *TypeAliasDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -532,6 +534,7 @@ type StructDecl struct { func (*StructDecl) declNode() {} func (*StructDecl) stmtNode() {} func (d *StructDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -560,6 +563,7 @@ type InterfaceDecl struct { func (*InterfaceDecl) declNode() {} func (*InterfaceDecl) stmtNode() {} func (d *InterfaceDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) @@ -588,6 +592,7 @@ type EnumDecl struct { func (*EnumDecl) declNode() {} func (*EnumDecl) stmtNode() {} func (d *EnumDecl) forEachChild(visit func(Node)) { + d.Attributed.forEachChild(visit) visit(d.Name) inspectTypeParams(d.TypeParams, visit) visit(d.Type) diff --git a/internal/frontend/ast/meta.go b/internal/frontend/ast/meta.go index a75fe664..5d7fb1b3 100644 --- a/internal/frontend/ast/meta.go +++ b/internal/frontend/ast/meta.go @@ -46,6 +46,15 @@ type Attribute struct { Location *source.Location } +func (a *Attribute) forEachChild(visit func(Node)) { + if a == nil { + return + } + for _, arg := range a.Args { + visit(arg) + } +} + const ( AttributeExtern = "extern" AttributeTest = "test" @@ -122,6 +131,15 @@ type Attributed struct { Attributes []Attribute } +func (a *Attributed) forEachChild(visit func(Node)) { + if a == nil { + return + } + for _, attr := range a.Attributes { + attr.forEachChild(visit) + } +} + func (a *Attributed) SetAttributes(attrs []Attribute) { if a == nil { return From 164baea1571defde64d2bf34ba611e8c60e2a1d1 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:42:56 +0600 Subject: [PATCH 14/80] Diagnose module identity conflicts before they load Two same-identity modules loading from different files could race past the registry conflict check: the loader deduplicated by ID before either reached AddModule, so the losing file was dropped without a diagnostic and the winner depended on scheduling. The scheduled map now records the queued file path; a dedupe hit with a different non-empty path routes through AddModule so the registry emits its ambiguous-import diagnostic, and resolveImports does the same for an already-registered module whose file differs. Conflict policy stays centralized in the registry. AddModule also clears the stale file index when a same-ID registration replaces a file-backed module with a pathless one, keeping the ID and file indexes consistent in both directions. --- internal/pipeline/loader.go | 22 +++++++----- internal/pipeline/pipeline.go | 4 +-- internal/pipeline/pipeline_test.go | 55 ++++++++++++++++++++++++++++-- internal/project/modules.go | 5 +++ internal/project/modules_test.go | 16 +++++++++ 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index 1c1c6116..d13d2113 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -20,7 +20,7 @@ import ( type moduleLoader struct { ctx *project.CompilerContext mu sync.Mutex - scheduled map[moduleid.ID]struct{} + scheduled map[moduleid.ID]string wg sync.WaitGroup } @@ -45,11 +45,18 @@ func (l *moduleLoader) enqueue(module *project.Module) { } l.mu.Lock() - if _, ok := l.scheduled[module.ID]; ok { + scheduledPath, ok := l.scheduled[module.ID] + if ok { l.mu.Unlock() + // A same-identity enqueue from a different file must reach the registry + // so its conflict policy emits ErrAmbiguousImport instead of a silent + // dedupe hiding the identity conflict. + if module.FilePath != "" && scheduledPath != "" && scheduledPath != module.FilePath { + l.ctx.AddModule(module) + } return } - l.scheduled[module.ID] = struct{}{} + l.scheduled[module.ID] = module.FilePath l.mu.Unlock() if existing, ok := l.ctx.ModuleByID(module.ID); ok { @@ -152,11 +159,10 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. // Two distinct files deriving one identity must not silently resolve // to whichever loaded first. Extensions compare case-insensitively // while the import path keeps the file's own case, so foo.peep and - // foo.PEEP can both reduce to the same logical identity. - if existing.FilePath != "" && resolved.FilePath != "" && - existing.FilePath != project.CanonicalPath(resolved.FilePath) { - l.addImportError(diag, imp, diagnostics.ErrAmbiguousImport, - "import resolves to "+resolved.FilePath+" but identity is already registered for "+existing.FilePath) + // foo.PEEP can both reduce to the same logical identity. The registry + // owns the conflict policy and emits the diagnostic. + if existing.FilePath != "" && existing.FilePath != project.CanonicalPath(resolved.FilePath) { + l.ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}) continue } l.enqueue(existing) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 4c8bd32c..8a58fe19 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -48,7 +48,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { loader := &moduleLoader{ ctx: ctx, - scheduled: make(map[moduleid.ID]struct{}), + scheduled: make(map[moduleid.ID]string), } preludeID := moduleid.ID{} if preludeMod, ok := ctx.ModuleByID(preludepkg.ModuleID(ctx)); ok { @@ -263,7 +263,7 @@ func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, // requireScheduledModulesAtLeast reports scheduled modules that stalled before // a required project-wide phase barrier without user diagnostics. -func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[moduleid.ID]struct{}, phase phase.Phase) error { +func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[moduleid.ID]string, phase phase.Phase) error { for _, module := range modules { if module == nil || module.Phase >= phase { continue diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index ec17d437..9e4ca1c0 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -1153,16 +1153,16 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[moduleid.ID]struct{}{test.module.ID: {}}, phase.Backend) + err := requireScheduledModulesAtLeast([]*project.Module{test.module}, map[moduleid.ID]string{test.module.ID: ""}, phase.Backend) if err == nil || !strings.Contains(err.Error(), "local:main") || !strings.Contains(err.Error(), test.want) { t.Fatalf("terminal error = %v, want module and %q", err, test.want) } }) } - if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Backend}}, map[moduleid.ID]struct{}{moduleid.ID{ImportPath: "local:main"}: {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Backend}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { t.Fatalf("completed module rejected: %v", err) } - if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "overlay:stub"}, Phase: phase.None}}, map[moduleid.ID]struct{}{moduleid.ID{ImportPath: "local:main"}: {}}, phase.Backend); err != nil { + if err := requireScheduledModulesAtLeast([]*project.Module{{ID: moduleid.ID{ImportPath: "overlay:stub"}, Phase: phase.None}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { t.Fatalf("unscheduled overlay rejected: %v", err) } } @@ -3187,3 +3187,52 @@ fn invalid(holder: &mut Holder) -> i32 { }) } } + +func TestModuleLoaderReportsSameIdentityFromDifferentFiles(t *testing.T) { + root := t.TempDir() + firstPath := filepath.Join(root, "first"+peeper.SourceExt) + secondPath := filepath.Join(root, "second"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(root, peeper.SourceExt, diag) + loader := &moduleLoader{ + ctx: ctx, + scheduled: make(map[moduleid.ID]string), + } + id := moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/shared"} + + loader.enqueue(&project.Module{ID: id, FilePath: firstPath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + loader.enqueue(&project.Module{ID: id, FilePath: secondPath, Content: "fn helper() {}\n", ContentProvided: true}) + loader.wg.Wait() + + ambiguous := 0 + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrAmbiguousImport { + ambiguous++ + } + } + if ambiguous != 1 { + t.Fatalf("ambiguous-import diagnostics = %d, want 1:\n%s", ambiguous, diag.EmitAllToString()) + } +} + +func TestModuleLoaderSamePathDoubleEnqueueIsQuietDedupe(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "shared"+peeper.SourceExt) + diag := diagnostics.NewDiagnosticBag() + ctx := project.New(root, peeper.SourceExt, diag) + loader := &moduleLoader{ + ctx: ctx, + scheduled: make(map[moduleid.ID]string), + } + id := moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/shared"} + + loader.enqueue(&project.Module{ID: id, FilePath: filePath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + loader.enqueue(&project.Module{ID: id, FilePath: filePath, Content: "fn main() {}\n", ContentProvided: true}) + loader.wg.Wait() + + if diag.HasErrors() { + t.Fatalf("same-path dedupe produced diagnostics:\n%s", diag.EmitAllToString()) + } +} diff --git a/internal/project/modules.go b/internal/project/modules.go index d39fa1c7..ebda4c70 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -284,9 +284,14 @@ func (ctx *CompilerContext) AddModule(module *Module) { } return } + previous := ctx.modules[module.ID] ctx.modules[module.ID] = module if module.FilePath != "" { ctx.fileIndex[module.FilePath] = module.ID + } else if previous != nil && previous.FilePath != "" && ctx.fileIndex[previous.FilePath] == module.ID { + // A pathless replacement must not leave the old file pointing at the + // identity it no longer names. + delete(ctx.fileIndex, previous.FilePath) } if module.Phase >= phase.Collected { for identity := range module.namedTypeDeclarations { diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index 8157e648..e7850909 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -350,3 +350,19 @@ func TestCompilerContextReindexesCollectedTypeDeclarations(t *testing.T) { t.Fatal("reset below collection retained context declaration index") } } + +func TestCompilerContextPathlessReplacementClearsFileIndex(t *testing.T) { + ctx := New(".", ".peep", nil) + id := moduleid.ID{Origin: string(ModuleOriginLocal), ImportPath: "x"} + + ctx.AddModule(&Module{ID: id, FilePath: "x.peep"}) + ctx.AddModule(&Module{ID: id}) + + if _, found := ctx.ModuleByFile("x.peep"); found { + t.Fatal("stale file index survived pathless replacement") + } + module, found := ctx.ModuleByID(id) + if !found || module == nil || module.FilePath != "" { + t.Fatalf("ModuleByID = %#v, want pathless replacement module", module) + } +} From f63b87c03746575d03d3eb77b33c11934ca84821 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:42:56 +0600 Subject: [PATCH 15/80] Encode dump artifact paths injectively The dump path encoding was lossy: empty and "_" namespaces collided, invalid components silently became "_", and ":" collapsed to "/" so import paths a:b and a/b wrote the same artifacts. Components are now encoded with one injective, path-safe scheme: a safe byte set passes through, everything else is percent-encoded, leading dots are escaped so no dot-segment survives, and a literal "_" is escaped to reserve the empty-component marker. Distinct canonical identities can no longer share an artifact path and no component can escape the stage directory. --- cmd/dump.go | 41 +++++++++++++------ cmd/dump_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/cmd/dump.go b/cmd/dump.go index b15ef8c8..6c3b0cf2 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "compiler/internal/project" @@ -71,33 +72,47 @@ func moduleArtifactBase(stage string, module *project.Module) (string, error) { if origin == "" { origin = string(project.ModuleOriginLocal) } - identity := strings.TrimSpace(module.ID.ImportPath) + identity := module.ID.ImportPath if identity == "" { return "", fmt.Errorf("module %q has no import identity", module.FilePath) } - identity = filepath.Clean(filepath.FromSlash(strings.ReplaceAll(identity, ":", "/"))) - if identity == "." || filepath.IsAbs(identity) || identity == ".." || strings.HasPrefix(identity, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("invalid module import identity %q", module.ID.ImportPath) + segments := strings.Split(identity, "/") + if slices.Contains(segments, "") { + return "", fmt.Errorf("invalid module import identity %q", identity) } // Every canonical identity component participates, so two identities that // differ only by namespace or dependency cannot write the same artifacts. - return filepath.Join(stage, origin, + parts := make([]string, 0, len(segments)+4) + parts = append(parts, stage, + identityComponent(origin), identityComponent(module.ID.Namespace), - identityComponent(module.ID.Dependency), - identity), nil + identityComponent(module.ID.Dependency)) + for _, segment := range segments { + parts = append(parts, identityComponent(segment)) + } + return filepath.Join(parts...), nil } func identityComponent(value string) string { - value = strings.TrimSpace(value) if value == "" { return emptyIdentityComponent } - cleaned := filepath.Clean(filepath.FromSlash(value)) - if cleaned == "." || cleaned == ".." || filepath.IsAbs(cleaned) || - strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - return emptyIdentityComponent + var encoded strings.Builder + for i := 0; i < len(value); i++ { + c := value[i] + // Escape a leading '.' so "." and ".." cannot form dot segments, and + // escape a lone "_" so it cannot collide with the empty marker. + if i == 0 && (c == '.' || (c == '_' && len(value) == 1)) { + fmt.Fprintf(&encoded, "%%%02X", c) + continue + } + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' { + encoded.WriteByte(c) + continue + } + fmt.Fprintf(&encoded, "%%%02X", c) } - return cleaned + return encoded.String() } func replacePath(stage, target string) error { diff --git a/cmd/dump_test.go b/cmd/dump_test.go index d800d20d..fdef9715 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" "compiler/internal/moduleid" @@ -65,6 +66,106 @@ func TestSaveIRsSeparatesIdentitiesDifferingOnlyByNamespace(t *testing.T) { } } +func TestModuleArtifactBaseEncodesIdentityComponentsInjectively(t *testing.T) { + stage := t.TempDir() + cases := []struct { + name string + id moduleid.ID + want string + within bool + }{ + { + name: "empty and underscore namespace are distinct", + id: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "app"}, + want: filepath.Join(stage, "local", "%5F", "_", "app"), + }, + { + name: "dotdot dependency is confined", + id: moduleid.ID{Origin: string(project.ModuleOriginLocal), Dependency: "..", ImportPath: "app"}, + want: filepath.Join(stage, "local", "_", "%2E.", "app"), + within: true, + }, + { + name: "dotdot origin is confined", + id: moduleid.ID{Origin: "..", ImportPath: "app"}, + want: filepath.Join(stage, "%2E.", "_", "_", "app"), + within: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := moduleArtifactBase(stage, &project.Module{ID: tc.id}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if got != tc.want { + t.Fatalf("moduleArtifactBase = %q, want %q", got, tc.want) + } + if tc.within { + rel, err := filepath.Rel(stage, got) + if err != nil || strings.HasPrefix(rel, "..") { + t.Fatalf("artifact %q escapes stage %q", got, stage) + } + } + }) + } + + empty, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + underscore, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if empty == underscore { + t.Fatalf("empty and underscore namespaces share artifact path %q", empty) + } + dotdot, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Dependency: "..", ImportPath: "app"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if dotdot == empty { + t.Fatalf("dotdot and empty dependencies share artifact path %q", dotdot) + } + + colon, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a:b"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + slash, err := moduleArtifactBase(stage, &project.Module{ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a/b"}}) + if err != nil { + t.Fatalf("moduleArtifactBase: %v", err) + } + if colon == slash { + t.Fatalf("import paths a:b and a/b share artifact path %q", colon) + } +} + +func TestSaveIRsWritesEncodedIdentityArtifacts(t *testing.T) { + ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), Namespace: "_", ImportPath: "a:b"}, + FilePath: "/colon.peep", LLVMIR: "colon", + }) + ctx.AddModule(&project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "a/b"}, + FilePath: "/slash.peep", LLVMIR: "slash", + }) + target := filepath.Join(t.TempDir(), "_gen") + if err := saveIRs(ctx, target); err != nil { + t.Fatalf("saveIRs: %v", err) + } + for _, artifact := range []string{ + filepath.Join("local", "%5F", "_", "a%3Ab.ll"), + filepath.Join("local", "_", "_", "a", "b.ll"), + } { + if _, err := os.Stat(filepath.Join(target, artifact)); err != nil { + t.Fatalf("missing encoded artifact %s: %v", artifact, err) + } + } +} + func TestSaveIRsSeparatesIdentitiesDifferingOnlyByDependency(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: t.TempDir()}, nil) ctx.AddModule(&project.Module{ From 29dc746434be8174f2a96cb918d2755f2f0472c1 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:42:56 +0600 Subject: [PATCH 16/80] Test LLVM emission rejects unknown MIR nodes Unknown MIR instructions and terminators previously reached LLVM emission, where unrecognized instruction kinds were silently skipped and terminator kinds fell through without emitting a terminator, producing malformed IR instead of failing. The emitter now panics on unclassified nodes; regression tests pin both failure paths with a test-only instruction/terminator implementation. --- internal/backend/llvm/emitter_test.go | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 2621dbe1..54b2d020 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -284,6 +284,16 @@ func requireLLVMInvariant(t *testing.T, emit func()) { emit() } +type unknownMIRNode struct{} + +func (*unknownMIRNode) Text() string { return "unknown" } +func (*unknownMIRNode) SourceLocation() *source.Location { return nil } + +var ( + _ mir.Instr = (*unknownMIRNode)(nil) + _ mir.Terminator = (*unknownMIRNode)(nil) +) + func TestLLVMLayoutsNameBuiltInCarrierFields(t *testing.T) { interfaceType := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInterface}) ownedInterface := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeOwnedPtr, Elem: interfaceType}) @@ -348,6 +358,42 @@ func TestTypedLLVMBuilderRejectsOperandMismatches(t *testing.T) { } } +func TestGenerateLLVMIRPanicsForUnknownMIRNodes(t *testing.T) { + for _, tt := range []struct { + name string + block *mir.Block + want string + }{ + { + name: "instruction", + block: &mir.Block{Instrs: []mir.Instr{&unknownMIRNode{}}}, + want: "LLVM emission: unhandled MIR instruction *llvm.unknownMIRNode", + }, + { + name: "terminator", + block: &mir.Block{Term: &unknownMIRNode{}}, + want: "LLVM emission: unhandled MIR terminator *llvm.unknownMIRNode", + }, + } { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if recovered := recover(); recovered != tt.want { + t.Fatalf("GenerateLLVMIR panic = %#v; want %q", recovered, tt.want) + } + }() + GenerateLLVMIR(&mir.Module{ + Name: "unknown-node", + Types: llvmTypes.table, + Funcs: []*mir.Function{{ + Name: "test", + ReturnType: llvmTypes.void, + Blocks: []*mir.Block{tt.block}, + }}, + }, diagnostics.NewDiagnosticBag(), testLinuxAMD64, false) + }) + } +} + func TestLLVMEmitterRejectsUnknownMIROperators(t *testing.T) { emitter := &llvmEmitter{mod: &mir.Module{Types: llvmTypes.table}} operand := &mir.RefConst{Value: "1", Type: llvmTypes.i32} From 4994e0d076ff57a91ce7b902ec4258076e74fc44 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:42:57 +0600 Subject: [PATCH 17/80] Drop vacuous identity determinism assertion The determinism check compared first.String() with itself on the same receiver, which can never fail for a pure function and tested nothing. The collision check stays as the real property. --- internal/moduleid/identity_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/moduleid/identity_test.go b/internal/moduleid/identity_test.go index 03d9179b..f1801e3f 100644 --- a/internal/moduleid/identity_test.go +++ b/internal/moduleid/identity_test.go @@ -8,9 +8,6 @@ func TestIDStringFramesComponentsWithoutCollisions(t *testing.T) { if first.String() == second.String() { t.Fatalf("length-ambiguous module identities collide: %q", first.String()) } - if first.String() != first.String() { - t.Fatal("module identity encoding is not deterministic") - } } func TestIDDependsOnLogicalIdentityOnly(t *testing.T) { From 8ad69ae5acb98851eb41990e38d965c6325c6920 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:43:20 +0600 Subject: [PATCH 18/80] Classify ownership through one capability query Ownership capability was scattered across three predicates with no single consume point, and several types had undeclared character: none was move-on-use, FuncType and TypeParameterType were ambiguous, and the backend re-derived drop obligations from the IR table. Add OwnershipCapabilityOf as the classification new code consumes: a copy class (implicit / explicit / never) plus a drop flag, composed from the established predicates so existing behavior is exact. NoneType joins the implicit-copy set per the structural rule (it contains nothing). IsNoCopyType is unexported as the CopyNever half of the derivation, and its cycle guard now pops on exit like the other walkers. The backend drop walker stays for now: it drives structural per-field drop decomposition over the IR table, which is a lowering concern rather than a policy re-derivation; its drift risk is covered by the end-to-end drop fixtures. The design record and maintainer decisions D1-D7 live in docs/compiler-framework/ownership-vocabulary.md. --- .../ownership-vocabulary.md | 245 ++++++++++++++++++ .../semantics/typechecker/typechecker_test.go | 8 +- internal/semantics/typeinfo/capabilities.go | 55 +++- internal/semantics/typeinfo/types_test.go | 23 +- 4 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 docs/compiler-framework/ownership-vocabulary.md diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md new file mode 100644 index 00000000..76d84faf --- /dev/null +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -0,0 +1,245 @@ +# Ownership Vocabulary Design + +Status: **proposal — needs maintainer approval on vocabulary before any code**. + +This document defines the ownership decision vocabulary that makes lifetime +handling automatic for future features. Design goal, stated by the maintainer: + +> A new construct (e.g. a Zig-style `catch` block) should auto-handle its +> payload's lifetime — consumed, copied, dropped — without writing ownership +> code, unless it invents a genuinely new lifetime relationship. + +This is the design record for framework workstream 6 and the ownership slice +of `COMPILER_FRAMEWORK_REPORT.md`. Facts below come from inspected code +(paths relative to `compiler/`); baseline findings are recorded for +traceability. + +## 1. Current state + +### What exists and works + +The type level already has a three-axis capability model in +`internal/semantics/typeinfo/capabilities.go`: + +| Query | Meaning | +| --- | --- | +| `IsImplicitCopyType` | value copies implicitly on read use | +| `IsNoCopyType` | contains ownership; copy forbidden | +| `NeedsDrop` | scope cleanup must destroy runtime state | + +Ownership is the enforcement authority: every consumption flows through +`useKind` (`useRead`/`useCopy`/`useConsume`, `ownership/expr.go:17`) and +cleanup lands in `ownershipresult.CleanupPlan` keyed to exact CFG sites. +MIR consumes the plan; the backend emits drops. For-in is the model citizen: +the typechecker publishes `ForIteration.Carrier` and ownership consumes it +without re-deriving. + +### What is broken: decisions are scattered, not published + +The typechecker publishes *shape* evidence; **all use-kind decisions are +re-derived in ownership from AST shapes plus hardcoded per-node rules**: + +| Decision | Where it actually lives today | +| --- | --- | +| Ordinary call argument consume-vs-copy | re-derived in `ownership/expr.go:344-351` from `fn.Params` + `IsImplicitCopyType`; typechecker already matched params in `checkCall` | +| Binding/assignment/return consumption | hardcoded `useConsume` (`ownership.go:501,530,688`); typechecker publishes nothing | +| String-concat operand roles | shape published (`StringConcatenations`), use kinds re-derived (`ownership/expr.go:111-115`) | +| Match-arm carrier move | re-derived from binding types (`ownership.go:664-671`); typechecker had all inputs | +| `alloc` argument consumption | hardcoded (`ownership/expr.go:266-274`), absent from `CompilerCalls` evidence | +| Variant/array-literal/as-cast consumption | hardcoded per-node-type (`ownership/expr.go:96-122`) | +| Drop obligations | `NeedsDrop` checked at six separate ownership sites | + +Capability model gaps (each is a place where "auto" silently breaks): + +| # | Gap | +| --- | --- | +| G1 | `IsNoCopyType` is dead in production; move-only is re-derived as `!IsImplicitCopyType` everywhere — but structs with only scalar fields are neither implicit-copy nor no-copy (explicit-copy middle class), so the negation is not equivalent | +| G2 | `NeedsDrop` implemented twice: `typeinfo/capabilities.go:356` and backend `drop_emit.go:303-373` over the IR type table — they agree today, drift tomorrow | +| G3 | Owned interface values: source says no-drop, backend raw-frees through a `TypeOwnedPtr`-to-interface special case — drop policy lives only in the backend | +| G4 | `FuncType` has no ownership character (closure move-only? nothing answers) | +| G5 | `TypeParameterType` treated move-only even when instantiated with a copyable argument — generics over `T` cannot copy | +| G6 | `NoneType` not implicit-copyable — `none` is move-on-use | +| G7 | Enum-payload copyability is non-compositional: same struct copyable as variant payload, move-only standalone (intentional, but must be stated as vocabulary, not accident) | +| G8 | Cycle-guard inconsistency in `IsNoCopyType` (`seen` never deleted on exit) | +| G9 | "Dynamic array owns" encoded three times (implicit-copy, no-copy, needs-drop, plus backend `Length == ""`) | + +Cleanup machinery fragilities found along the way: + +- **Three drop channels**: planned drops (`CleanupPlan`), embedded flags + (`DropTarget`/`DropRoot`/`DropBase`), and `temporaryDrops` flushes. +- **Return asymmetry**: returns emit no CFG scope-exit sites + (`cfg/build.go:133-139`); `cleanupBeforeReturn` re-walks the scope chain, + duplicating `applyBlockExit` logic. +- **`hir.Return.Cleanup` is vestigial** — never populated by lowering. +- **`MatchCarrierMoves` is published but never consumed.** + +## 2. Proposed vocabulary + +Two closed sets. Everything else is derived. + +### 2.1 Type capability (per `typeinfo.Type`, total — every type answers) + +```go +type OwnershipCapability uint8 + +const ( + CapabilityTrivial OwnershipCapability = iota // copy free, no drop + CapabilityCopy // implicit copy, no drop + CapabilityExplicitCopy // copy via explicit op, no drop + CapabilityMove // move-on-use, needs drop +) + +// One canonical walker; every other query derives from it. +func OwnershipCapabilityOf(typ Type) OwnershipCapability +``` + +Mapping from today's model: + +| Type | Capability | +| --- | --- | +| scalars, `RawPtr`, `CStr`, `Allocator` | `Copy` | +| immutable `Ref` | `Copy` (borrow is a use-kind concern, not type concern) | +| mutable `Ref` | `ExplicitCopy`? — **decision D1** | +| `String`, `OwnedPtr`, owner arrays | `Move` | +| `Optional`, `Array`, `Struct`, `Enum` | compose from inner/fields/cases | +| `Interface` | **decision D2** (source-level `Move` + drop, or backend-owned) | +| `FuncType` | **decision D3** (proposed: `Move`, closures own captures) | +| `TypeParameter` | **decision D4** (proposed: conservative `Move` + copy when instantiated copyable — requires instantiation-aware query) | +| `None` | `Copy` (**decision D5**, fixes G6) | + +Rules: + +- `IsImplicitCopyType`, `IsNoCopyType`, `NeedsDrop` become **derivations** of + `OwnershipCapabilityOf` (or are deleted — **decision D6** on `IsNoCopyType`, + which is dead today). +- The backend's `typeNeedsDrop` is **deleted**; MIR carries drop obligations + published by ownership, and the backend trusts them. One implementation of + "what needs drop" (fixes G2/G3/G9). +- Cycle guards are uniform (fixes G8). + +### 2.2 Use kind (per value use, published once by the typechecker) + +```go +type UseKind uint8 + +const ( + UseRead UseKind = iota // borrow-ish observation, value unchanged + UseCopy // value copied; source unchanged + UseMove // value consumed; source dead +) +``` + +Published in `typecheckresult.Result`: + +```go +// ValueUses classifies every ownership-relevant value use for one generation. +// Ownership consumes this; it never re-derives from AST shape. +ValueUses map[ast.NodeID]UseKind +``` + +Plus two published facts that remove hardcoded per-node rules: + +```go +// consumption of binding/assign/return initializers is derivable from +// UseKind on the initializer expression — no separate map needed. +// Match-arm carrier moves: +MatchArmMoves map[ast.NodeID]UseKind // per arm body: UseMove or UseCopy +// String-concat operand roles: +// left UseMove, right UseRead — publish alongside StringConcatenations +``` + +What becomes deletable in ownership: `checkCallArgument`'s param re-derivation +(typechecker publishes argument `UseKind` during `checkCall`), hardcoded +`useConsume` at binding/assign/return, concat operand re-derivation, +`matchArmMovesCarrier`, `alloc` special case, and the per-node consumption +switch in `ownership/expr.go:96-122`. + +What ownership **keeps**: flow-sensitive enforcement — use-after-move, use +while borrowed, loan conflicts, destroy-while-borrowed. Those are *state +machines over published classifications*, which is ownership's real job. + +### 2.3 The rule new features follow + +```text +new syntax node + → children use standard field types (Expr, *BlockStmt, …) → traversal free + → typechecker classifies each value use with UseKind during normal checking + → ownership consumes ValueUses + type capabilities → cleanup plan automatic + → MIR lowers plan; backend trusts it + → zero ownership code, unless the construct invents a new lifetime relation +``` + +For `catch err { body }`: payload binding is an ordinary `UseMove` (or +`UseCopy` per capability) into the arm scope; cleanup at scope exit is already +universal. Only the variant construction is catch-specific. + +## 3. Validator contract + +One canonical validator at the ownership boundary: + +```go +// ownershipresult.Validate checks published evidence and plan shape only. +func (r Result) Validate(types *typecheckresult.Result, cfg *cfg.Module) error +``` + +Invariants: + +1. Every ownership-relevant expression (capability ≠ Trivial) has a `UseKind` + entry — missing entry is an internal error, not silent fallback. +2. Every `UseKind` is legal for the expression type's capability (no `UseCopy` + of a `Move` type without explicit-copy context). +3. Every drop-needing symbol appears in exactly one cleanup site per path; + no symbol in both `AfterScope` and `BeforeReturn` for the same path. +4. Every cleanup site key references an existing CFG site / node. +5. Plan maps contain no stale entries after regeneration (existing `delete` + convention becomes validator-checked). + +Invalid source remains diagnostics; validator failure is a compiler bug. + +## 4. Migration slices + +Each slice keeps the full suite green and is independently reviewable. + +1. **Capability consolidation** — `OwnershipCapabilityOf` as single walker; + re-point `IsImplicitCopyType`/`NeedsDrop` at it; resolve D1–D5; delete + backend `typeNeedsDrop` (MIR already carries obligations); fix G6/G8. + No behavior change intended; existing suite + fixtures prove it. +2. **Publish use kinds** — typechecker publishes `ValueUses` for call + arguments, bindings, assignments, returns, concat operands, match arms. + Ownership consumes; delete the re-derivations listed in §2.2. Largest + slice; behavior-preserving with focused regressions per decision site. +3. **Cleanup unification** — fold return-cleanup into CFG scope-exit sites + (removing the asymmetry) or explicitly document the split; delete + vestigial `hir.Return.Cleanup`; resolve the three-drop-channels question + (**decision D7**: keep embedded flags as MIR-lowering detail but single + source in plan). +4. **Validator** — `ownershipresult.Validate` at the phase boundary + tests. + +## 5. Decisions (resolved by maintainer) + +| # | Decision | Resolution | +| --- | --- | --- | +| D1 | Mutable ref | **`ExplicitCopy`, with the single-active-`&mut` invariant**: conceptually only one active `&mut` may exist. A use of a mutable ref either moves it, or is an error if it would create a second active `&mut`. Reservations (temporary, scope-bound reborrows) remain the only sanctioned exception, enforced by the loan machinery. "Copy a `&mut`" is not a second owner — it is an error. | +| D2 | Owned interface drop | **Source-level `Move` + needs-drop.** Structural rule: owned interface allocates → move. Backend raw-free special case becomes the lowering of a source-published drop obligation, not an independent policy. | +| D3 | Function values / closures | **Structural rule applied to the capture set**: closure capability composes from captured state like a struct — captures only Copy state → Copy; captures owned/borrowed state → Move. No special policy. | +| D4 | Generic `T` | **Instantiation-aware.** The structural rule needs a concrete type; at declaration `T` is conservative `Move`, at instantiation the query checks the bound type and applies the rule. | +| D5 | `NoneType` | **`Copy`** — contains nothing; structural rule says trivially copyable. Fixes the current move-on-use oddity. | +| D6 | `IsNoCopyType` | **Delete.** One capability walker; every query derives from it. | +| D7 | Drop channels | **Single source: `CleanupPlan` is authoritative.** Embedded flags become MIR-lowering details filled from the plan. | + +The governing rule stated by the maintainer: + +> Ownership capability is baked into the type itself. Scalar → copyable → +> copy. Contains a reference, pointer, or allocation inside → move. Check +> the type, apply the rule. No per-type policy tables. + +Consequence: capability is a **pure structural function of the type** — +compositional over fields/cases/inner, instantiation-aware for generics, +with exactly one implementation that the whole compiler consumes. + +## 6. Non-goals + +- No borrow-checker redesign; loan machinery stays. +- No change to `place` semantics or `Local` provenance rules. +- No new language surface; this is evidence plumbing with behavior preserved. +- Validators never re-derive semantic decisions; they check published shape. diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index c216929d..ef7923e7 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -900,8 +900,8 @@ func TestMutablePointerFieldDefaultsTypeToNoCopy(t *testing.T) { if !ok || typ == nil { t.Fatalf("missing Buffer type") } - if !typeinfo.IsNoCopyType(typ) { - t.Fatalf("Buffer should default to no-copy") + if got := typeinfo.OwnershipCapabilityOf(typ); got.Copy != typeinfo.CopyNever { + t.Fatalf("Buffer should default to no-copy, got %v", got.Copy) } } @@ -964,8 +964,8 @@ func TestRawPointerFieldStructSupportsExplicitCopy(t *testing.T) { if !ok || typ == nil { t.Fatalf("missing View type") } - if typeinfo.IsImplicitCopyType(typ) || typeinfo.IsNoCopyType(typ) { - t.Fatalf("View should implicitly and support explicit copy") + if got := typeinfo.OwnershipCapabilityOf(typ); got.Copy != typeinfo.CopyExplicit || got.Drop { + t.Fatalf("View should support explicit copy without drop, got %v", got) } } diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index adb0d16f..9130bf28 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -101,7 +101,7 @@ func IsImplicitCopyType(t Type) bool { return check(defined.Underlying, enumPayload) } switch typ := Underlying(current).(type) { - case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, *CStrType, *RawPtrType, *AllocatorType: + case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, *CStrType, *RawPtrType, *AllocatorType, *NoneType: return true case *RefType: return typ != nil && !typ.Mutable @@ -207,7 +207,11 @@ func IsSizedType(t Type) bool { return check(t) } -func IsNoCopyType(t Type) bool { +// noCopyType reports whether the type contains owned runtime state, so copies +// are forbidden outright. It is the CopyNever half of the ownership capability +// and is intentionally unexported: consumers classify through +// OwnershipCapabilityOf instead of re-deriving. +func noCopyType(t Type) bool { seen := make(map[*DefinedType]bool) var check func(Type) bool check = func(current Type) bool { @@ -217,6 +221,7 @@ func IsNoCopyType(t Type) bool { return false } seen[typ] = true + defer delete(seen, typ) return check(typ.Underlying) } switch typ := Underlying(current).(type) { @@ -352,7 +357,7 @@ func IsLowerableType(t Type) bool { // NeedsDrop reports whether normal scope cleanup must destroy runtime-owned // state reachable through a value. Move-only borrows and plain composites do -// not need destruction; this is intentionally narrower than IsNoCopyType. +// not need destruction; this is intentionally narrower than noCopyType. func NeedsDrop(t Type) bool { seen := make(map[*DefinedType]bool) var check func(Type) bool @@ -396,3 +401,47 @@ func NeedsDrop(t Type) bool { } return check(t) } + +// CopyClass classifies how a value of a type may be duplicated. +type CopyClass uint8 + +const ( + // CopyImplicit: a read use copies the value; it is never moved. + CopyImplicit CopyClass = iota + // CopyExplicit: a plain use moves the value; an explicit copy + // operation exists for types that want one. + CopyExplicit + // CopyNever: a plain use moves the value; no copy operation exists. + CopyNever +) + +// OwnershipCapability is the single classification new code should consume: +// how a type duplicates (Copy) and whether scope cleanup must destroy it +// (Drop). It composes the established predicates, which remain the exact +// behavioral source; this type exists so callers stop re-deriving decisions +// from their negations. +// +// Deliberate asymmetries preserved from the current language semantics: +// - top-level structs and arrays never copy implicitly (bulk storage), +// while enum payloads of copyable fields do; +// - Interface values never copy implicitly but do not yet require source- +// level drop (owned-interface drop activation is tracked separately); +// - TypeParameterType is conservatively move-on-use until instantiation- +// aware capability queries arrive with generic support. +type OwnershipCapability struct { + Copy CopyClass + Drop bool +} + +// OwnershipCapabilityOf classifies a type's ownership behavior. +func OwnershipCapabilityOf(t Type) OwnershipCapability { + drop := NeedsDrop(t) + switch { + case IsImplicitCopyType(t): + return OwnershipCapability{Copy: CopyImplicit, Drop: drop} + case noCopyType(t): + return OwnershipCapability{Copy: CopyNever, Drop: drop} + default: + return OwnershipCapability{Copy: CopyExplicit, Drop: drop} + } +} diff --git a/internal/semantics/typeinfo/types_test.go b/internal/semantics/typeinfo/types_test.go index 9112d0e0..7cad2a9f 100644 --- a/internal/semantics/typeinfo/types_test.go +++ b/internal/semantics/typeinfo/types_test.go @@ -83,14 +83,17 @@ func TestCopyCapabilitiesFollowStructuralModel(t *testing.T) { if IsImplicitCopyType(&StructType{Fields: []Field{{Name: "value", Type: i32}}}) { t.Fatalf("struct should not copy implicitly") } - if IsNoCopyType(&StructType{Fields: []Field{{Name: "value", Type: i32}}}) { - t.Fatalf("scalar-only struct should support structural copy") + if got := OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "value", Type: i32}}}); got.Copy != CopyExplicit { + t.Fatalf("scalar-only struct should support structural copy, got %v", got.Copy) } - if !IsNoCopyType(&StructType{Fields: []Field{{Name: "owner", Type: &OwnedPtrType{Target: i32}}}}) { - t.Fatalf("owned pointer should propagate nocopy through struct") + if got := OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "owner", Type: &OwnedPtrType{Target: i32}}}}); got.Copy != CopyNever || !got.Drop { + t.Fatalf("owned pointer should propagate nocopy and drop through struct, got %v", got) } - if !IsNoCopyType(&ArrayType{Shape: ArrayOwner, Elem: i32}) { - t.Fatalf("dynamic array should be intrinsically nocopy") + if got := OwnershipCapabilityOf(&ArrayType{Shape: ArrayOwner, Elem: i32}); got.Copy != CopyNever || !got.Drop { + t.Fatalf("dynamic array should be intrinsically nocopy, got %v", got) + } + if got := OwnershipCapabilityOf(&NoneType{}); got.Copy != CopyImplicit { + t.Fatalf("none should copy implicitly, got %v", got.Copy) } } @@ -492,11 +495,11 @@ func TestNamedEnumPayloadCapabilitiesFollowEveryCaseField(t *testing.T) { }}}, }}} - if !IsImplicitCopyType(copyable) || IsNoCopyType(copyable) || NeedsDrop(copyable) { - t.Fatal("scalar enum payload should remain copyable and require no drop") + if got := OwnershipCapabilityOf(copyable); got.Copy != CopyImplicit || got.Drop { + t.Fatalf("scalar enum payload should remain copyable and require no drop, got %v", got) } - if IsImplicitCopyType(owned) || !IsNoCopyType(owned) || !NeedsDrop(owned) { - t.Fatal("owned enum payload should be move-only and require drop") + if got := OwnershipCapabilityOf(owned); got.Copy != CopyNever || !got.Drop { + t.Fatalf("owned enum payload should be move-only and require drop, got %v", got) } if IsSizedType(unsized) || IsLowerableType(unsized) { t.Fatal("enum payload capabilities must reject unsized cases") From f3347bf3f03a6d4536f606b123ce75570014ee9e Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 21:43:20 +0600 Subject: [PATCH 19/80] Record compiler framework direction report Captures why the framework work started (the for-loop exposing that adding one construct required coordinating scattered phase logic), the executable-safety goal (adding a node or field should mechanically expose every phase that must change), the workstreams, before/after examples, and the anti-goals that keep the framework from becoming abstraction tax. --- COMPILER_FRAMEWORK_REPORT.md | 741 +++++++++++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 COMPILER_FRAMEWORK_REPORT.md diff --git a/COMPILER_FRAMEWORK_REPORT.md b/COMPILER_FRAMEWORK_REPORT.md new file mode 100644 index 00000000..77cf7608 --- /dev/null +++ b/COMPILER_FRAMEWORK_REPORT.md @@ -0,0 +1,741 @@ +# Compiler Framework Direction + +## Why this work was requested + +Framework discussion started during `for` loop implementation. Loop itself was not only concern. Work exposed broader development risk: adding one language construct required finding and coordinating many scattered types, functions, maps, switches, and phase-specific assumptions. + +A feature could appear complete in parser or typechecker while one downstream phase was silently missed. Missing work might only surface later as: + +- incorrect control flow; +- missing ownership cleanup; +- malformed HIR or MIR; +- backend failure; +- editor/LSP crash on incomplete source; +- stale incremental state; +- production-only behavior discovered long after implementation. + +Peeper already had one strong framework-like pattern: structural traversal through APIs such as `Inspect` and node-owned child walking. Central traversal reduced repeated recursion and made child handling easier to audit. It did not yet make a newly added child field impossible to omit from manual `forEachChild` code; framework must close that remaining gap through generated traversal or an equivalent mechanical check. + +Request was to apply same principle to rest of compiler: + +> Make required work mechanically visible. When syntax or semantics changes, compiler should immediately reveal every phase, invariant, artifact, and test that must also change. + +Primary goal is **code as executable safety guard**. Documentation explains architecture, but codebase must enforce it. Adding a node should break compilation until participating phases make explicit decisions. Adding a child field should fail generated-code or static consistency checks until traversal includes it. Publishing incomplete ownership/type/CFG evidence should fail at phase boundary before HIR, MIR, backend, or production use. + +Goal was not a generic language-generator product. Goal was a clean, logically safe compiler architecture that teaches its own flow through package ownership, function signatures, interface requirements, generated traversal, typed results, and validators. Someone should be able to replace syntax or selected language rules while retaining these safety checks. + +## Problems that motivated proposal + +### 1. Semantic state was scattered + +Related facts could live in broad project-level structures, while producers and consumers were spread across collector, binder, resolver, typechecker, CFG, ownership, HIR, MIR, backend, and LSP code. + +This obscured basic questions: + +- Which phase owns this fact? +- When is it complete? +- Which later phases may consume it? +- When must incremental compilation discard it? +- Is missing evidence valid recovery behavior or compiler bug? + +### 2. Structural traversal was safer than semantic dispatch + +`Inspect`/walk functions helped every consumer reach nested nodes. They did not guarantee that every semantic phase had consciously handled a newly introduced node kind. + +A new statement could be traversed structurally while resolver, typechecker, CFG, ownership, or lowering silently lacked required semantics. + +### 3. Invalid artifacts failed too late + +Many cross-phase invariants existed only as assumptions. Bad semantic evidence could survive until HIR, MIR, or backend code, where resulting failure was far from actual producer. + +Desired behavior: + +```text +producer creates invalid result + ↓ +producer boundary rejects it immediately +``` + +Not: + +```text +producer creates invalid result + ↓ +several phases accept it + ↓ +backend crashes on unrelated-looking operation +``` + +### 4. Naming and generated artifact construction were mixed with orchestration + +Functions such as generated binding/identifier construction looked small enough to resemble wrappers, while module lowering also owned source lowering, hidden loop state, symbol naming, callable mangling, and generated assignments. + +Concern was valid: small functions are useful only when they own a real invariant. A helper that merely forwards data is noise. A constructor that consistently couples symbol ID, lowered type, name, and source location protects a real lowering invariant—but it should live in a purposeful subsystem and be named accordingly. + +### 5. Loop metadata exposed unclear ownership + +Types such as iteration evidence and loop target stacks raised two different questions: + +- Is this temporary builder state, semantic evidence, CFG topology, or project-wide state? +- Does its file/package location match its owner? + +Proposal was to place each type beside phase that produces and validates it, instead of using broad files such as `project/modules.go` as a general storage location. + +### 6. Multiple identities and rediscovery increased drift risk + +Compiler concepts could be identified differently by filesystem path, import key, symbol owner, graph node, or mangled name. Multiple representations invite scans, conversion helpers, stale aliases, collisions, and mismatched incremental invalidation. + +Framework direction therefore included one canonical typed identity per concept, with string serialization only at boundaries that require strings. + +## Proposed framework + +Framework is a set of explicit subsystem contracts, not one giant abstraction. + +```text +source + ↓ +parser-owned AST + ↓ validated phase boundary +binding/resolution result + ↓ validated phase boundary +typechecker result + ↓ validated phase boundary +CFG + flow result + ↓ validated phase boundary +ownership result + ↓ validated phase boundary +HIR + ↓ validated phase boundary +MIR + ↓ validated phase boundary +backend IR +``` + +Each phase should answer: + +| Contract | Required answer | +| --- | --- | +| Owner | Which package owns decision and result? | +| Inputs | Which exact earlier artifacts are valid inputs? | +| Output | Which explicit result or artifact is published? | +| Invariants | What is guaranteed after successful completion? | +| Diagnostics | Which invalid source conditions are reported here? | +| Consumers | Which later phases may read result? | +| Invalidation | Which edit/reset discards result? | +| Mutation | Which shared state may phase mutate? | +| Concurrency | Can modules execute phase in parallel? | +| Failure policy | User diagnostic, recoverable invalid artifact, or compiler bug? | +| Verification | Which validator and tests enforce contract? | + +## Primary guarantee: codebase guides and rejects incomplete work + +Framework succeeds only when compiler source itself leads developer through required integration points. + +Desired failure chain for new syntax: + +```text +add node kind + ↓ +Go interface satisfaction identifies phases missing node decision + ↓ + generated traversal/static check identifies unclassified child fields + ↓ +typed phase APIs show required inputs and result owner + ↓ +artifact validator rejects incomplete or inconsistent evidence + ↓ +source/property tests reject logically wrong semantics +``` + +This distinguishes omissions from semantic mistakes: + +| Developer mistake | Earliest intended guard | +| --- | --- | +| New node omitted by resolver/typechecker/ownership/HIR | Go compile error through exhaustive phase interface | +| New child field omitted from structural walk | generated traversal consistency or custom static-analysis failure | +| Node intentionally irrelevant to phase | explicit `ignore` implementation with reviewed reason | +| Required ownership/type evidence not published | phase-result validator failure | +| Evidence points to wrong CFG site, symbol, type, or scope | cross-artifact validator failure | +| Implementation compiles but language semantics are wrong | invariant, property, and source-fixture failure | + +Go compiler can prove interface satisfaction and type compatibility. It cannot prove arbitrary ownership semantics or detect a field omitted inside an otherwise valid handwritten method. Framework therefore combines Go interfaces with generated code/static checks and executable validators. Calling all of this “compile-time safety” would be imprecise; target is earliest mechanical failure available for each mistake class. + +### Exhaustive phase interfaces + +Canonical AST family defines complete phase-facing visitor contract: + +```go +type StmtVisitor interface { + VisitBlock(*BlockStmt) + VisitIf(*IfStmt) + VisitWhile(*WhileStmt) + VisitFor(*ForStmt) + VisitReturn(*ReturnStmt) +} +``` + +Every participating phase proves satisfaction: + +```go +var _ ast.StmtVisitor = (*ownershipChecker)(nil) +var _ ast.StmtVisitor = (*hirLowerer)(nil) +``` + +Adding `VisitDefer(*DeferStmt)` then produces direct Go errors in every incomplete phase. No default/no-op visitor implementation may hide missing methods. + +Separate interfaces should cover real AST families—statements, expressions, declarations, and type syntax—rather than one universal visitor that forces meaningless methods on every phase. + +### Generated child traversal + +Interface satisfaction cannot catch this handwritten omission: + +```go +type ForStmt struct { + Iterable Expr + Body *BlockStmt + Else *BlockStmt // new field +} + +func (s *ForStmt) forEachChild(yield func(Node) bool) { + yield(s.Iterable) + yield(s.Body) + // Else forgotten; Go still compiles. +} +``` + +Preferred framework derives traversal from AST struct definitions: + +```go +func (s *ForStmt) forEachChild(yield func(Node) bool) bool { + return yield(s.Iterable) && + yield(s.Body) && + yield(s.Else) +} +``` + +Generator classifies node-compatible fields and lists. Metadata fields such as IDs, tokens, positions, and primitive values are known non-children. Any unknown composite field must require explicit classification instead of silently defaulting to ignored. + +Normal repository validation should run generator in check mode: + +```bash +go run ./scripts/generate-ast --check +``` + +Adding or changing AST field then fails until generated traversal and node-kind contracts are current. Equivalent custom `go vet` analyzer is acceptable if it provides same deterministic guarantee with less complexity. + +### Ownership completeness accounting + +Ownership phase should not merely expose `VisitFor`; it should prove every ownership-relevant typed expression received decision. + +Conceptual result: + +```go +type ValueOwnership uint8 + +const ( + OwnershipInvalid ValueOwnership = iota + OwnershipCopy + OwnershipMove + OwnershipBorrow + OwnershipOwnedTemporary +) + +type Result struct { + Values map[ast.NodeID]ValueOwnership +} +``` + +Boundary validator walks typed expressions and requires non-invalid ownership classification whenever expression type needs ownership handling: + +```go +if typeinfo.RequiresOwnershipDecision(typ) && result.Values[expr.ID()] == OwnershipInvalid { + return fmt.Errorf("expression %d has no ownership decision", expr.ID()) +} +``` + +Exact representation should follow existing ownership model rather than this conceptual map. Required invariant remains: compiler can mechanically account for every ownership-relevant element, and missing analysis cannot silently reach lowering. + +### Types and signatures explain compiler flow + +Phase dependencies should be visible from result ownership and APIs. Functions that accept broad mutable `Module` access should be narrowed where practical, without creating decorative parameter structs. + +Conceptual ownership boundary: + +```go +type ownership.Input struct { + AST *ast.Module + Bindings *bindingresult.Result + Types *typecheckresult.Result + CFG *cfg.Module +} + +func Analyze(input Input) ownershipresult.Result +``` + +This input type earns its place only if it is actual ownership phase contract. Reading signature should tell developer what ownership consumes, what it publishes, and which earlier phase must change when evidence changes. + +## Main workstreams + +### 1. Phase-owned semantic results + +Every meaningful phase output should have one owner and one storage location. + +Examples: + +```text +bindingresult.Result +constantresult.Result +typecheckresult.Result +flowresult.Result +ownershipresult.Result +``` + +These are justified boundaries because they represent real compiler phases or distinct lifetimes. They are not decorative wrappers. + +Rules: + +- one fact has one producer; +- one fact has one canonical storage location; +- later phases consume published evidence instead of rediscovering it; +- result lifetime matches incremental reset boundary; +- no compatibility maps or forwarding accessors remain after migration; +- result packages contain phase data, not scheduler orchestration. + +### 2. Exhaustive node-handling contracts + +Structural walking solves recursion. Separate phase contracts should solve omitted semantics. + +For every relevant node kind, each participating phase must explicitly choose one: + +```text +handle — phase owns distinct semantics +traverse — canonical child walk is sufficient +ignore — intentionally irrelevant, with reason +reject — invalid at this phase boundary +``` + +Important constraint: no visitor base type with default no-op methods. Defaults would recreate omission bug by silently accepting new nodes. + +Preferred implementation starts with compile-time visitor interfaces requiring every node-kind method and compile-time satisfaction assertions for participating phases. Generated node-kind registries and completeness tests may supplement interfaces where Go cannot express closed sets directly. + +Child-field completeness is a separate problem. Generate `forEachChild` implementations from AST structs or enforce them with a custom analyzer; do not assume visitor interfaces can detect a forgotten field inside a valid method. + +Choose least boilerplate mechanism that makes omissions fail Go compilation, generated-code checks, static analysis, or normal tests—before feature can reach production. + +### 3. Canonical artifact validators + +Each phase result should have one validator at real boundary. + +Validators check artifact shape and published invariants. They do not repeat semantic analysis. + +Examples: + +- AST recovery invariants; +- symbol/type identity validity; +- required typechecker evidence; +- CFG edge/predecessor symmetry and terminators; +- ownership cleanup-site validity; +- HIR symbol/type/location consistency; +- MIR operand and block validity; +- backend physical type compatibility. + +Invalid source remains source diagnostics. Validator failure indicates compiler implementation bug. + +### 4. Canonical CFG queries and descriptors + +CFG remains owner of control-flow topology. Consumers should not infer loops or structured control flow from incidental block IDs and shapes. + +Before adding metadata, inspect consumers and prove existing typed blocks/sites/edges are insufficient. If shared construct metadata is needed, publish validated descriptors once from CFG instead of rediscovering loops in ownership or MIR. + +Example conceptual result: + +```go +type LoopDescriptor struct { + Header BlockID + Body BlockID + Latch BlockID + Exit BlockID +} +``` + +Builder-only state such as active `break` target, `continue` target, and lexical scope depth should remain private CFG construction context. It is not semantic evidence and should not live in project-wide module state. + +### 5. Separate mangling from artifact construction + +Two different responsibilities must not share misleading names. + +**Mangler owns names:** + +- callable/linkage names; +- module identity components; +- receiver identity; +- generic/symbol instance suffixes; +- collision-safe framing. + +**Phase-local artifact construction owns generated nodes:** + +- hidden symbol name and ID; +- lowered type ID; +- source location; +- generated binding/identifier/place/expression shape. + +Do not create compiler-wide generic builder. AST, HIR, MIR, and backend artifacts have different invariants and lifetimes. Builder earns its place only inside phase that owns generated representation. + +### 6. Validated semantic variants + +Semantic plans should make invalid combinations impossible or immediately rejectable. + +A tag plus many nullable fields is difficult to audit: + +```go +type ForIteration struct { + Kind ForIterationKind + Carrier *Symbol + Cursor *Symbol + End *Symbol + Ordinal *Symbol +} +``` + +Range and sequence iteration do not require same hidden state. Cleaner design gives each variant its required fields and validates it before publication. + +```go +type RangeIteration struct { + Cursor *Symbol + End *Symbol + Ordinal *Symbol +} + +type SequenceIteration struct { + Carrier *Symbol + Cursor *Symbol +} +``` + +Exact Go representation can vary. Core requirement is stable: downstream phases should not repeatedly reconstruct which nullable combinations are legal. + +### 7. Canonical identities + +Use one comparable typed identity for module registry, imports, symbol ownership, semantic fingerprints, invalidation, and graph membership. + +```go +type ID struct { + Origin string + Namespace string + Dependency string + ImportPath string +} +``` + +Filesystem path remains secondary lookup because logical module identity should survive relocation. String encoding exists only for string-only boundaries such as generic graph or diagnostic grouping APIs, and must be collision-safe. + +No duplicate `Module.Key`, symbol-owner key type, scan-based owner lookup, or compatibility accessor should survive migration. + +### 8. Recovered-AST and editor safety contracts + +Interactive compiler receives incomplete source continuously. Parser recovery output therefore needs deliberate invariants. + +Preferred model: + +- parser defines which fields may be absent; +- required recovered fields use explicit missing/synthetic nodes where practical; +- downstream phases consume documented recovered shape; +- LSP/compiler boundary contains ordinary frontend panics; +- failed analysis snapshots are discarded, never published; +- stale document revisions cannot overwrite newer results. + +`recover()` is panic containment, not process isolation. It protects server from ordinary compiler panics but not OOM, deadlock, `os.Exit`, or corrupted shared state. Subprocess isolation remains possible future escalation, not present requirement. + +### 9. Workflow enforcement + +Architecture only helps when normal workflow enforces it. + +For every new language construct, contributor should be able to mechanically check: + +```text +parser +AST node + child traversal +binding/resolution +base typechecking evidence +constant evaluation, when relevant +CFG +flow typing +definite initialization +ownership +HIR +MIR +backend +LSP/editor recovery +artifact validators +positive and negative source fixtures +``` + +CI should include: + +- phase coverage/completeness tests; +- artifact validator tests; +- source fixtures; +- malformed-source regression tests; +- progressive typing/prefix tests; +- frontend fuzzing with invariant “arbitrary editor source must not panic.” + +## Before and after examples + +Examples are intentionally short and conceptual. Exact production names may differ. + +### Before: broad shared semantic storage + +```go +type Module struct { + ExprTypes map[NodeID]Type + CaseTests map[NodeID]CaseTest + MatchInfo map[NodeID]Match + ConstValues map[SymbolID]Value +} +``` + +### After: explicit phase ownership + +```go +type Module struct { + Bindings *bindingresult.Result + Constants *constantresult.Result + Typechecking *typecheckresult.Result + Flow *flowresult.Result + Ownership ownershipresult.Result +} +``` + +Result tells reader who produced data and when it is valid. + +--- + +### Before: later phase rediscovers semantic meaning + +```go +func lowerCall(call *ast.CallExpr) hir.Expr { + sym := lookup(call.Callee) + args := expandDefaults(sym, call.Args) + return lowerResolvedCall(sym, args) +} +``` + +### After: consume typechecker evidence + +```go +func lowerCall(call *ast.CallExpr) hir.Expr { + plan := module.Typechecking.Calls[call.ID()] + return lowerResolvedCall(plan.Symbol, plan.Arguments) +} +``` + +HIR lowers a decision; it does not repeat typechecking. + +--- + +### Before: new node silently falls through + +```go +switch stmt := stmt.(type) { +case *ast.IfStmt: + checkIf(stmt) +case *ast.WhileStmt: + checkWhile(stmt) +} +``` + +### After: phase contract must classify every kind + +```go +var statementContract = map[ast.StmtKind]Decision{ + ast.StmtIf: Handle, + ast.StmtWhile: Handle, + ast.StmtFor: Handle, + ast.StmtBad: Ignore, +} +``` + +Completeness test compares this table with canonical statement-kind registry. Adding node without decision fails immediately. + +--- + +### Before: nullable evidence requires scattered checks + +```go +if plan.Kind == Range && plan.End != nil { + // lower range +} +if plan.Kind == Sequence && plan.Carrier != nil { + // lower sequence +} +``` + +### After: explicit validated variants + +```go +switch plan := plan.(type) { +case RangeIteration: + lowerRange(plan) +case SequenceIteration: + lowerSequence(plan) +default: + panic("invalid iteration plan") +} +``` + +Required state travels with variant that needs it. + +--- + +### Before: multiple module identities and scan lookup + +```go +type Module struct { + Key string + ImportPath string +} + +for _, module := range ctx.modules { + if module.DefiningModuleKey() == owner { + return module + } +} +``` + +### After: one typed identity and direct lookup + +```go +type Module struct { + ID moduleid.ID + FilePath string +} + +module := ctx.modules[symbol.DefiningModule] +``` + +Logical lookup becomes direct and identity conversion disappears. + +--- + +### Before: generated node helpers look like wrappers + +```go +func generatedIdent(ctx *Context, mod *Module, sym *Symbol, loc *Location) *ir.Ident { + return &ir.Ident{ + Name: symbolName(mod, sym), + Type: loweredTypeID(ctx, mod, sym.Type), + } +} +``` + +### After: phase-local artifact constructor owns invariant + +```go +type artifactBuilder struct { + ctx *Context + module *Module +} + +func (b artifactBuilder) ident(sym *Symbol, loc *Location) *ir.Ident { + return &ir.Ident{ + Name: b.mangle(sym), + Type: b.lowerType(sym.Type), + SymbolID: sym.ID, + SourceInfo: ir.SourceInfo{Location: loc}, + } +} +``` + +This boundary is justified only if several generated artifacts must preserve same name/type/symbol/location invariant. If used once or only forwarding, inline it instead. + +--- + +### Before: invalid result fails downstream + +```go +hir := Lower(module) +mir := LowerMIR(hir) // panic here +``` + +### After: fail at producing boundary + +```go +result := typechecker.Check(module) +if err := result.Validate(); err != nil { + return internalError("typecheck result", err) +} +module.Typechecking = result +``` + +Failure points at producer that violated contract. + +## What “cleaner” means + +Framework does not promise fewer named types in every package. Some types are necessary because phases represent genuinely different facts. Cleanliness means fewer ambiguous and duplicated concepts. + +Desired reduction: + +- fewer broad “miscellaneous semantic info” structs; +- fewer compatibility accessors; +- fewer pass-through wrappers; +- fewer repeated lookups and semantic rediscovery; +- fewer nullable combinations; +- fewer identity conversions; +- fewer files that act as unrelated storage bins; +- fewer production bugs discovered only after downstream failure. + +Useful types remain when they make ownership and invariants explicit. Decorative types disappear. + +A clean compiler should let contributor answer quickly: + +```text +Where is this decision made? +Where is its result stored? +Who may consume it? +How is it validated? +When is it invalidated? +Which test fails if I forget a phase? +``` + +## Anti-goals and guardrails + +Do not turn framework into abstraction tax. + +Avoid: + +- generic pass manager hiding real scheduler barriers; +- one universal visitor with no-op defaults; +- one universal artifact builder; +- wrappers added only to rename existing calls; +- old and new semantic maps kept together; +- validators that rerun compiler semantics; +- project package becoming dumping ground for phase-owned types; +- backend naming moved into source semantics when physical ABI layout matters; +- subprocess compiler split before actual isolation need exists. + +Every new boundary must own at least one real phase, lifetime, invariant, policy, or independently reused operation. + +## Expected development experience + +Ideal feature workflow: + +1. Add syntax node. +2. Canonical child traversal test identifies missing structural registration. +3. Phase contract tests list every semantic phase needing explicit decision. +4. Typechecker publishes validated evidence. +5. CFG, ownership, and lowering consume evidence directly. +6. Artifact validators catch malformed handoff at producer boundary. +7. Source fixtures prove accepted and rejected behavior end to end. +8. Prefix/fuzz tests prove incomplete editor source does not crash frontend. + +Result should be compiler that is not only correct today, but difficult to extend incorrectly tomorrow. + +## Final objective + +Peeper framework goal is executable omission safety through explicit ownership: + +> One phase owns each decision. One artifact carries each result. One validator protects each boundary. One canonical identity names each concept. Go interfaces expose missing node handling. Generated traversal exposes missing child fields. Normal tests expose invalid semantics. + +Compiler codebase—not contributor memory—should be primary implementation guide. Adding syntax or semantic behavior should cause compiler, generator, analyzer, validators, and fixtures to enumerate unfinished work immediately. + +That architecture keeps codebase readable, makes compiler journey safer, and gives future contributors a clear map for extending language without relying on accidental production discovery. From c8eca73e5e261ceb0aeba9685c155f5f1e87642d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 22:47:30 +0600 Subject: [PATCH 20/80] Publish value use kinds from typechecking Ownership re-derived every consumption decision from AST shapes and hardcoded per-node rules: call arguments were matched against parameter types again, binding and return initializers were hardcoded consumes, match arms re-derived carrier moves from binding types, and alloc special-cased its first argument. The typechecker had already made each of those decisions while checking the call, then threw the answer away. The typechecker now publishes the classification: UseKind (read, copy, move) lives in typeinfo beside OwnershipCapability as the per-use counterpart of the per-type capability, and typecheckresult carries ValueUses keyed by the used expression plus CarrierUse on each match arm. Ownership consumes the published kinds through publishedUse, which falls back to the capability derivation for diagnostics-continued paths where publication is incomplete; the ownership validator will enforce presence for error-free programs. The deleted re-derivations: per-argument capability matching, matchArmMovesCarrier (both call sites), and the alloc argument hardcode. Behavior is preserved end to end; new publication regressions cover copyable, owned, and reference parameters, alloc operands, and match carrier use, and the use-after-move fixture still fails through the published evidence. --- .../ownership-vocabulary.md | 11 +- internal/semantics/ownership/expr.go | 107 +++++++-------- internal/semantics/ownership/ownership.go | 33 ++--- internal/semantics/typechecker/check_call.go | 30 +++++ internal/semantics/typechecker/check_stmt.go | 8 ++ .../semantics/typechecker/typechecker_test.go | 123 ++++++++++++++++++ internal/semantics/typecheckresult/result.go | 9 ++ internal/semantics/typeinfo/capabilities.go | 15 +++ 8 files changed, 256 insertions(+), 80 deletions(-) diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md index 76d84faf..e57363e5 100644 --- a/docs/compiler-framework/ownership-vocabulary.md +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -119,6 +119,10 @@ Rules: ### 2.2 Use kind (per value use, published once by the typechecker) +The `UseKind` vocabulary lives in `typeinfo` beside `OwnershipCapability` +(the per-use counterpart of the per-type capability); the published map +lives in the typecheck result: + ```go type UseKind uint8 @@ -127,13 +131,8 @@ const ( UseCopy // value copied; source unchanged UseMove // value consumed; source dead ) -``` -Published in `typecheckresult.Result`: - -```go -// ValueUses classifies every ownership-relevant value use for one generation. -// Ownership consumes this; it never re-derives from AST shape. +// Published in typecheckresult.Result: ValueUses map[ast.NodeID]UseKind ``` diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 8c3a1216..3ab6682a 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -12,19 +12,11 @@ import ( "compiler/internal/semantics/typeinfo" ) -type useKind uint8 - -const ( - useRead useKind = iota - useCopy - useConsume -) - func (a *analyzer) checkExpr( scope *symbols.Scope, expr ast.Expr, st state, - use useKind, + use typeinfo.UseKind, loans *loanContext, projectionBase bool, ) { @@ -61,8 +53,8 @@ func (a *analyzer) checkExpr( return } _, slicing := e.Index.(*ast.RangeExpr) - a.checkExpr(scope, e.Expr, st, useRead, loans, true) - a.checkExpr(scope, e.Index, st, useRead, loans, false) + a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, true) + a.checkExpr(scope, e.Index, st, typeinfo.UseRead, loans, false) if !projectionBase { access := storageAccessForUse(a.exprType(e), use) if slicing { @@ -79,7 +71,7 @@ func (a *analyzer) checkExpr( if a.planProjectionBaseDrop(e, e.Expr) { return } - if use != useRead && ownershipTrackedType(a.exprType(e)) { + if use != typeinfo.UseRead && ownershipTrackedType(a.exprType(e)) { if a.partialVariantPayloadMove(e) { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(e), "") @@ -89,36 +81,36 @@ func (a *analyzer) checkExpr( "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", ast.LocOf(e), "") } case *ast.RangeExpr: - a.checkExpr(scope, e.Start, st, useRead, loans, false) - a.checkExpr(scope, e.End, st, useRead, loans, false) + a.checkExpr(scope, e.Start, st, typeinfo.UseRead, loans, false) + a.checkExpr(scope, e.End, st, typeinfo.UseRead, loans, false) case *ast.StructLit: a.checkLiteralFields(scope, e.Fields, st, loans) case *ast.VariantLit: - a.checkExpr(scope, e.Payload, st, useConsume, loans, false) + a.checkExpr(scope, e.Payload, st, typeinfo.UseMove, loans, false) case *ast.ArrayLit: for _, value := range e.Values { - a.checkExpr(scope, value, st, useConsume, loans, false) + a.checkExpr(scope, value, st, typeinfo.UseMove, loans, false) } case *ast.CallExpr: a.checkCall(scope, e, st, loans) case *ast.FreeExpr: - a.checkExpr(scope, e.Expr, st, useConsume, loans, false) + a.checkExpr(scope, e.Expr, st, typeinfo.UseMove, loans, false) case *ast.PrintExpr: - a.checkExpr(scope, e.Expr, st, useRead, loans, false) + a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, false) case *ast.UnaryExpr: - a.checkExpr(scope, e.Expr, st, useRead, loans, false) + a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, false) case *ast.BinaryExpr: if _, concat := a.module.Typechecking.StringConcatenations[e.ID()]; concat { - a.checkExpr(scope, e.Left, st, useConsume, loans, false) - a.checkExpr(scope, e.Right, st, useRead, loans, false) + a.checkExpr(scope, e.Left, st, typeinfo.UseMove, loans, false) + a.checkExpr(scope, e.Right, st, typeinfo.UseRead, loans, false) return } - a.checkExpr(scope, e.Left, st, useRead, loans, false) - a.checkExpr(scope, e.Right, st, useRead, loans, false) + a.checkExpr(scope, e.Left, st, typeinfo.UseRead, loans, false) + a.checkExpr(scope, e.Right, st, typeinfo.UseRead, loans, false) case *ast.IsExpr: - a.checkExpr(scope, e.Value, st, useRead, loans, false) + a.checkExpr(scope, e.Value, st, typeinfo.UseRead, loans, false) case *ast.AsExpr: - a.checkExpr(scope, e.Expr, st, useConsume, loans, false) + a.checkExpr(scope, e.Expr, st, typeinfo.UseMove, loans, false) case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: return default: @@ -128,7 +120,7 @@ func (a *analyzer) checkExpr( func (a *analyzer) checkLiteralFields(scope *symbols.Scope, fields []ast.StructLitField, st state, loans *loanContext) { for _, field := range fields { - a.checkExpr(scope, field.Value, st, useConsume, loans, false) + a.checkExpr(scope, field.Value, st, typeinfo.UseMove, loans, false) } } @@ -142,20 +134,20 @@ func (a *analyzer) checkAddressExpr( if expr == nil { return } - a.checkExpr(scope, expr.Expr, st, useRead, loans, true) + a.checkExpr(scope, expr.Expr, st, typeinfo.UseRead, loans, true) if expr.Mode != ast.AddressRaw { a.checkStorageAccess(expr.Expr, loans, access) } } -func storageAccessForUse(typ typeinfo.Type, use useKind) storageAccess { - if use == useConsume && ownershipTrackedType(typ) { +func storageAccessForUse(typ typeinfo.Type, use typeinfo.UseKind) storageAccess { + if use == typeinfo.UseMove && ownershipTrackedType(typ) { return storageConsume } return storageRead } -func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, use useKind) { +func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, use typeinfo.UseKind) { if scope == nil || ident == nil { return } @@ -183,7 +175,7 @@ func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, return } switch use { - case useCopy: + case typeinfo.UseCopy: if symType, ok := symbols.GetSymbolType(sym); ok { if _, mutable, ok := typeinfo.ReferenceTarget(typeinfo.Underlying(symType)); ok && mutable { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, @@ -193,7 +185,7 @@ func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, } a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "copy of move-only value requires a consuming context", ast.LocOf(ident), "") - case useConsume: + case typeinfo.UseMove: st.moved[sym] = ident delete(st.live, sym) } @@ -203,17 +195,17 @@ func (a *analyzer) checkSelector( scope *symbols.Scope, selector *ast.SelectorExpr, st state, - use useKind, + use typeinfo.UseKind, loans *loanContext, ) { if selector == nil { return } - a.checkExpr(scope, selector.Expr, st, useRead, loans, true) + a.checkExpr(scope, selector.Expr, st, typeinfo.UseRead, loans, true) if a.planProjectionBaseDrop(selector, selector.Expr) { return } - if use == useRead { + if use == typeinfo.UseRead { return } if ownershipTrackedType(a.exprType(selector)) { @@ -260,16 +252,12 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, } return } - a.checkExpr(scope, call.Callee, st, useRead, loans, false) + a.checkExpr(scope, call.Callee, st, typeinfo.UseRead, loans, false) if ident, ok := call.Callee.(*ast.Ident); ok && ident != nil { sym := a.module.Bindings.NodeSymbols[ident.ID()] if sym != nil && sym.CompilerOp == symbols.CompilerOpAlloc { - for i, arg := range args { - use := useRead - if i == 0 { - use = useConsume - } - a.checkExpr(scope, arg, st, use, loans, false) + for _, arg := range args { + a.checkExpr(scope, arg, st, a.publishedUse(arg, nil), loans, false) } return } @@ -281,7 +269,7 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, fn := definition.Signature(nil, a.ctx.Target) for i, arg := range args { if i >= len(fn.Params) { - a.checkExpr(scope, arg, st, useRead, loans, false) + a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) continue } a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) @@ -292,7 +280,7 @@ func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, fn, ok := a.exprType(call.Callee).(*typeinfo.FuncType) if !ok || fn == nil || len(args) != len(fn.Params) { for _, arg := range args { - a.checkExpr(scope, arg, st, useRead, loans, false) + a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) } return } @@ -313,17 +301,17 @@ func (a *analyzer) checkMethodCall( fn, ok := a.exprType(selector).(*typeinfo.FuncType) if !ok || fn == nil || selector == nil || call == nil { if selector != nil { - a.checkExpr(scope, selector.Expr, st, useRead, loans, false) + a.checkExpr(scope, selector.Expr, st, typeinfo.UseRead, loans, false) } for _, arg := range args { - a.checkExpr(scope, arg, st, useRead, loans, false) + a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) } return false } a.checkCallArgument(scope, selector.Expr, fn.Params[0], call, st, loans) if len(args)+1 != len(fn.Params) { for _, arg := range args { - a.checkExpr(scope, arg, st, useRead, loans, false) + a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) } return false } @@ -343,11 +331,7 @@ func (a *analyzer) checkCallArgument( ) { _, mutable, reference := typeinfo.ReferenceValueTarget(paramType) if !reference { - use := useConsume - if typeinfo.IsImplicitCopyType(paramType) { - use = useRead - } - a.checkExpr(scope, arg, st, use, loans, false) + a.checkExpr(scope, arg, st, a.publishedUse(arg, paramType), loans, false) return } access := storageSharedBorrow @@ -357,7 +341,7 @@ func (a *analyzer) checkCallArgument( if explicitBorrow, explicit := arg.(*ast.AddressExpr); explicit { a.checkAddressExpr(scope, explicitBorrow, st, loans, access) } else { - a.checkExpr(scope, arg, st, useRead, loans, true) + a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, true) a.checkStorageAccess(arg, loans, access) } origins := a.originsForExpr(arg) @@ -381,6 +365,23 @@ func (a *analyzer) checkCallArgument( loans.addTemporary([]referenceLoan{loan}, call) } +// publishedUse maps the typechecker's published use kind for this argument +// publishedUse resolves the ownership use kind for one value use: the +// typechecker's published classification when present, otherwise the +// capability fallback for diagnostics-continued paths. The ownership +// validator will enforce presence for error-free programs. +func (a *analyzer) publishedUse(arg ast.Expr, paramType typeinfo.Type) typeinfo.UseKind { + if a.module != nil && a.module.Typechecking != nil { + if kind, ok := a.module.Typechecking.ValueUses[arg.ID()]; ok { + return kind + } + } + if paramType == nil || typeinfo.IsImplicitCopyType(paramType) { + return typeinfo.UseRead + } + return typeinfo.UseMove +} + func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { if a == nil || a.module == nil || expr == nil { return nil diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 30f0b842..21419499 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -277,7 +277,7 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { armsByJoin[join] = make(map[ast.NodeID]struct{}) } armsByJoin[join][arm.BodyID] = struct{}{} - movesByJoin[join] = movesByJoin[join] || matchArmMovesCarrier(arm) + movesByJoin[join] = movesByJoin[join] || arm.CarrierUse == typeinfo.UseMove break } if len(joinNode.cfgSite.Successors) != 1 { @@ -498,9 +498,9 @@ func (a *analyzer) applyStmt(node *site, st state) { case *ast.AssignStmt: reference, hasReference := a.referenceValueForExpr(s.Value, st) delete(a.cleanup.BeforeAssign, ir.NodeID(s.ID())) - a.checkExpr(scope, s.Value, st, useConsume, loans, false) + a.checkExpr(scope, s.Value, st, typeinfo.UseMove, loans, false) if _, ok := s.Target.(*ast.Ident); !ok { - a.checkExpr(scope, s.Target, st, useRead, loans, true) + a.checkExpr(scope, s.Target, st, typeinfo.UseRead, loans, true) a.checkStorageAccess(s.Target, loans, storageMutate) if typeinfo.NeedsDrop(a.exprType(s.Target)) { a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} @@ -527,27 +527,27 @@ func (a *analyzer) applyStmt(node *site, st state) { case *ast.ReturnStmt: a.checkPointerEscape(scope, s.Value, st) a.validateReferenceReturn(scope, s, st) - a.checkExpr(scope, s.Value, st, useConsume, loans, false) + a.checkExpr(scope, s.Value, st, typeinfo.UseMove, loans, false) releaseIterationLoans(st, loans, 0) a.cleanupBeforeReturn(scope, s, st, loans) case *ast.ExprStmt: - a.checkExpr(scope, s.Expr, st, useRead, loans, false) + a.checkExpr(scope, s.Expr, st, typeinfo.UseRead, loans, false) if s.Expr != nil && !place.IsPlaceExpr(s.Expr) && typeinfo.NeedsDrop(a.exprType(s.Expr)) { a.cleanup.DiscardedValue[ir.NodeID(s.Expr.ID())] = struct{}{} } case *ast.IfStmt: - a.checkExpr(scope, s.Cond, st, useRead, loans, false) + a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) case *ast.ForStmt: if s.Iterable == nil { - a.checkExpr(scope, s.Cond, st, useRead, loans, false) + a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) break } evidence, found := a.module.Typechecking.ForIterations[s.ID()] if !found || evidence.Kind != typecheckresult.ForIterationSequence || evidence.Carrier == nil { - a.checkExpr(scope, s.Iterable, st, useRead, loans, false) + a.checkExpr(scope, s.Iterable, st, typeinfo.UseRead, loans, false) break } - a.checkExpr(scope, s.Iterable, st, useRead, loans, true) + a.checkExpr(scope, s.Iterable, st, typeinfo.UseRead, loans, true) a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) origins := a.originsForExpr(s.Iterable) if ident, ok := s.Iterable.(*ast.Ident); ok { @@ -564,7 +564,7 @@ func (a *analyzer) applyStmt(node *site, st state) { }} } case *ast.MatchStmt: - a.checkExpr(scope, s.Subject, st, useRead, loans, false) + a.checkExpr(scope, s.Subject, st, typeinfo.UseRead, loans, false) } } @@ -584,7 +584,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { if subject == nil { return } - movesCarrier := matchArmMovesCarrier(arm) + movesCarrier := arm.CarrierUse == typeinfo.UseMove listed := make(map[int]bool, len(arm.Bindings)) for _, field := range arm.Bindings { switch field.Projection { @@ -661,15 +661,6 @@ func (a *analyzer) matchSubjectCarrier(match typecheckresult.Match) (ast.Expr, * return subject, carrier } -func matchArmMovesCarrier(arm typecheckresult.MatchArm) bool { - for _, field := range arm.Bindings { - if !typeinfo.IsImplicitCopyType(field.Type) { - return true - } - } - return false -} - func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { ids := make([]symbols.SymbolID, 0, len(values)) for _, sym := range values { @@ -685,7 +676,7 @@ func (a *analyzer) applyBinding(scope *symbols.Scope, stmt ast.Stmt, value ast.E return } reference, hasReference := a.referenceValueForExpr(value, st) - a.checkExpr(scope, value, st, useConsume, loans, false) + a.checkExpr(scope, value, st, typeinfo.UseMove, loans, false) sym, found := scope.LookupNode(stmt) if !found || sym == nil { return diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index c727fea9..79e50ae9 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -123,6 +123,11 @@ func (c *checker) typeFromBytesCall(scope *symbols.Scope, node *ast.CallExpr, de panic("missing from_bytes signature") } c.module.Typechecking.ExprTypes[node.Callee.ID()] = fnType + for i, arg := range node.Args { + if i < len(fnType.Params) { + c.publishValueUse(arg, fnType.Params[i]) + } + } bytesType := c.typeExpr(scope, node.Args[0], fnType.Params[0]) if !typeinfo.IsInvalidOrUnknown(bytesType) && !typeinfo.SameType(bytesType, fnType.Params[0]) { c.ctx.Diagnostics.Add(invalidTypeError(node.Args[0], @@ -251,10 +256,31 @@ func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typein c.ctx.Diagnostics.Add(d) } } + if c.module != nil && c.module.Typechecking != nil { + c.module.Typechecking.ValueUses[node.Args[0].ID()] = typeinfo.UseMove + if len(node.Args) > 1 { + c.module.Typechecking.ValueUses[node.Args[1].ID()] = typeinfo.UseRead + } + } return &typeinfo.OwnedPtrType{Target: valueType} } +// publishValueUse records the ownership use kind a parameter position imposes +// on its argument, keyed by the argument expression the ownership analyzer +// walks. Reference parameters publish UseRead: the kind is recorded for +// completeness, while the borrow machinery in ownership still governs them. +func (c *checker) publishValueUse(arg ast.Expr, paramType typeinfo.Type) { + if c == nil || arg == nil || paramType == nil || c.module == nil || c.module.Typechecking == nil { + return + } + use := typeinfo.UseMove + if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference || typeinfo.IsImplicitCopyType(paramType) { + use = typeinfo.UseRead + } + c.module.Typechecking.ValueUses[arg.ID()] = use +} + func (c *checker) checkOptionalAllocatorArity(scope *symbols.Scope, node *ast.CallExpr) bool { const minArgs, maxArgs = 1, 2 argCount := len(node.Args) @@ -393,6 +419,10 @@ func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExp if paramType == nil { continue } + c.publishValueUse(implicitExpr, paramType) + if argIndex >= 0 { + c.publishValueUse(argExpr, paramType) + } if implicitExpr != nil && c.acceptImplicitCallArgument(scope, implicitExpr, argType, paramType) { c.module.Typechecking.ImplicitCallArguments[implicitExpr.ID()] = paramType continue diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 61dc6c87..a637f784 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -250,6 +250,14 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu } } } + carrierUse := typeinfo.UseRead + for _, field := range armEvidence.Bindings { + if !typeinfo.IsImplicitCopyType(field.Type) { + carrierUse = typeinfo.UseMove + break + } + } + armEvidence.CarrierUse = carrierUse evidence.Arms = append(evidence.Arms, armEvidence) c.checkBlock(scope, arm.Body, returnType) } diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index ef7923e7..8f05ce5d 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -22,6 +22,129 @@ import ( "compiler/pkg/peeper" ) +func TestCheckCallPublishesValueUses(t *testing.T) { + module, diag := checkTypeModule(t, `struct Box { value: i32 } +fn Take(box: Box) -> i32 { return box.value; } +fn Read(v: i32) -> i32 { return v; } +fn Borrow(b: &Box) -> i32 { return b.value; } +fn main() -> i32 { + let stack = .Box { value = 1 }; + let copied = Read(5); + let borrowed = Borrow(&stack); + let moved = Take(stack); + return copied + borrowed + moved; +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + type callUse struct { + param int + use typeinfo.UseKind + found bool + } + walk := func(visit func(ast.Node) bool) { + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, visit) + } + } + uses := make(map[string][]callUse) + walk(func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + callee, ok := call.Callee.(*ast.Ident) + if !ok { + return true + } + for _, arg := range call.Args { + kind, found := module.Typechecking.ValueUses[arg.ID()] + uses[callee.Name] = append(uses[callee.Name], callUse{use: kind, found: found}) + } + return true + }) + for _, arg := range uses["Read"] { + if !arg.found || arg.use != typeinfo.UseRead { + t.Fatalf("copyable parameter argument = %+v, want published UseRead", arg) + } + } + for _, arg := range uses["Take"] { + if !arg.found || arg.use != typeinfo.UseMove { + t.Fatalf("owned parameter argument = %+v, want published UseMove", arg) + } + } + for _, arg := range uses["Borrow"] { + if !arg.found || arg.use != typeinfo.UseRead { + t.Fatalf("reference parameter argument = %+v, want published UseRead", arg) + } + } + if len(uses["Take"]) != 1 || len(uses["Read"]) != 1 || len(uses["Borrow"]) != 1 { + t.Fatalf("unexpected call counts: %#v", uses) + } +} + +func TestCheckAllocPublishesConsumingUse(t *testing.T) { + module, diag := checkTypeModule(t, `fn main() -> i32 { + let heap = alloc(7); + return 0; +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + published := 0 + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + lit, ok := node.(*ast.NumberLit) + if !ok { + return true + } + kind, found := module.Typechecking.ValueUses[lit.ID()] + if !found { + return true + } + published++ + if kind != typeinfo.UseMove { + t.Fatalf("alloc operand = %v, want UseMove", kind) + } + return true + }) + } + if published != 1 { + t.Fatalf("alloc operand published uses = %d, want 1", published) + } +} + +func TestCheckMatchPublishesCarrierUse(t *testing.T) { + module, diag := checkTypeModule(t, `struct Box { value: i32 } +enum Resource { + Owned: { box: Box }, + Free, +} +fn main() -> i32 { + let resource = Resource::Owned with .{ box = .Box { value = 1 } }; + match resource { + Resource::Owned with { box = b } => { return b.value; } + Resource::Free => { return 0; } + } +}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + if len(module.Typechecking.Matches) != 1 { + t.Fatalf("published matches = %d, want 1", len(module.Typechecking.Matches)) + } + for _, match := range module.Typechecking.Matches { + for _, arm := range match.Arms { + if arm.Case == 0 && arm.CarrierUse != typeinfo.UseMove { + t.Fatalf("owned-payload arm carrier use = %v, want UseMove", arm.CarrierUse) + } + if arm.Case == 1 && arm.CarrierUse != typeinfo.UseRead { + t.Fatalf("payloadless arm carrier use = %v, want UseRead", arm.CarrierUse) + } + } + } +} + func checkTypeSource(t *testing.T, src string) *diagnostics.DiagnosticBag { t.Helper() const filePath = "typechecker_test" + peeper.SourceExt diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 67b39f45..cfb6d3de 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -45,6 +45,10 @@ type MatchArm struct { Case int Payload typeinfo.Type Bindings []MatchBinding + // CarrierUse is the published use kind applied to the match subject + // carrier when this arm is selected: UseMove when the arm binds any + // move-only payload part, UseRead when everything binds by copy. + CarrierUse typeinfo.UseKind } type MatchBinding struct { @@ -115,6 +119,10 @@ type Result struct { Matches map[ast.NodeID]Match ForIterations map[ast.NodeID]ForIteration ExprTypes map[ast.NodeID]typeinfo.Type + // ValueUses classifies every ownership-relevant value use, keyed by the + // used expression's node ID. Reference parameters publish UseRead; the + // borrow machinery in ownership still governs them. + ValueUses map[ast.NodeID]typeinfo.UseKind } func New() *Result { @@ -131,6 +139,7 @@ func New() *Result { Matches: make(map[ast.NodeID]Match), ForIterations: make(map[ast.NodeID]ForIteration), ExprTypes: make(map[ast.NodeID]typeinfo.Type), + ValueUses: make(map[ast.NodeID]typeinfo.UseKind), } } diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index 9130bf28..f5127c30 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -445,3 +445,18 @@ func OwnershipCapabilityOf(t Type) OwnershipCapability { return OwnershipCapability{Copy: CopyExplicit, Drop: drop} } } + +// UseKind is the ownership classification of one value use: what happens to +// the value at a specific expression, as decided by the typechecker and +// consumed by ownership and lowering. It is the per-use counterpart of +// OwnershipCapability: the capability constrains which use kinds are legal. +type UseKind uint8 + +const ( + // UseRead: the value is observed; its owner keeps it. + UseRead UseKind = iota + // UseCopy: the value is duplicated; the source keeps it. + UseCopy + // UseMove: the value is consumed; the source is dead afterwards. + UseMove +) From d4545ba0721c458ec33e5ee8c0c347798e262c60 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:29:02 +0600 Subject: [PATCH 21/80] Locate module identity conflicts at the offending import The registry detected identity conflicts but reported them with no location, so the reader got a message with nothing to click and editors had no line to attach it to. Import resolution previously labelled the offending import. AddModule now returns the diagnostic it recorded, following the AddError chaining pattern, and import resolution labels it with the import site. The registry keeps sole ownership of the conflict policy; only the caller knows which source site triggered the registration. --- internal/pipeline/loader.go | 7 ++-- internal/pipeline/pipeline_test.go | 52 ++++++++++++++++++++++++++++++ internal/project/modules.go | 19 +++++++---- internal/project/modules_test.go | 13 ++++++-- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index d13d2113..750e5509 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -160,9 +160,12 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. // to whichever loaded first. Extensions compare case-insensitively // while the import path keeps the file's own case, so foo.peep and // foo.PEEP can both reduce to the same logical identity. The registry - // owns the conflict policy and emits the diagnostic. + // owns the conflict policy; only this site knows which import caused + // it, so it labels the diagnostic the registry recorded. if existing.FilePath != "" && existing.FilePath != project.CanonicalPath(resolved.FilePath) { - l.ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}) + if conflict := l.ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: resolved.FilePath}); conflict != nil { + conflict.WithPrimaryLabel(ast.LocOf(imp), "conflicting import") + } continue } l.enqueue(existing) diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 9e4ca1c0..37c61a16 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -24,6 +24,7 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" "compiler/internal/target" + "compiler/pkg/manifest" "compiler/pkg/peeper" ) @@ -3216,6 +3217,57 @@ func TestModuleLoaderReportsSameIdentityFromDifferentFiles(t *testing.T) { } } +// An identity conflict reached through an import must name the import that +// caused it, so the reader has somewhere to go. The registry detects the +// conflict; the loader owns the source site. +func TestModuleLoaderLabelsImportSiteOnIdentityConflict(t *testing.T) { + root := t.TempDir() + srcDir := manifest.SourceDir(root) + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatalf("create source dir: %v", err) + } + sharedPath := filepath.Join(srcDir, "shared"+peeper.SourceExt) + if err := os.WriteFile(sharedPath, []byte("fn Helper() {}\n"), 0o600); err != nil { + t.Fatalf("write shared module: %v", err) + } + + diag := diagnostics.NewDiagnosticBag() + ctx := project.NewWithConfig(project.Config{ + RootDir: root, + ProjectName: "app", + Extension: peeper.SourceExt, + }, diag) + + resolved, err := ctx.ResolveImportPath("app/shared") + if err != nil { + t.Fatalf("resolve import: %v", err) + } + // Claim the identity for a different file so the import below conflicts. + ctx.AddModule(&project.Module{ID: resolved.ID, FilePath: filepath.Join(srcDir, "other"+peeper.SourceExt)}) + + entryPath := filepath.Join(srcDir, "entry"+peeper.SourceExt) + entrySrc := "import \"app/shared\";\n" + diag.AddSourceContent(entryPath, entrySrc) + importer := parseModuleSource(entryPath, entrySrc, diag) + loader := &moduleLoader{ctx: ctx, scheduled: make(map[moduleid.ID]string)} + loader.resolveImports(importer, diag) + loader.wg.Wait() + + for _, item := range diag.Diagnostics() { + if item == nil || item.Code != diagnostics.ErrAmbiguousImport { + continue + } + if len(item.Labels) == 0 || item.Labels[0].Location == nil { + t.Fatalf("ambiguous-import diagnostic carries no primary label:\n%s", diag.EmitAllToString()) + } + if item.FilePath != project.CanonicalPath(entryPath) { + t.Fatalf("diagnostic file = %q, want %q", item.FilePath, project.CanonicalPath(entryPath)) + } + return + } + t.Fatalf("no ambiguous-import diagnostic:\n%s", diag.EmitAllToString()) +} + func TestModuleLoaderSamePathDoubleEnqueueIsQuietDedupe(t *testing.T) { root := t.TempDir() filePath := filepath.Join(root, "shared"+peeper.SourceExt) diff --git a/internal/project/modules.go b/internal/project/modules.go index ebda4c70..8ef4592b 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -256,10 +256,13 @@ func (ctx *CompilerContext) NewModuleForFile(filePath, content string) *Module { } } -// Register a module in shared compiler state. -func (ctx *CompilerContext) AddModule(module *Module) { +// Register a module in shared compiler state. The identity conflict is detected +// and reported here so registration policy stays in one place; the recorded +// diagnostic is returned so a caller holding the offending source site can label +// it. Returns nil when the module registers cleanly. +func (ctx *CompilerContext) AddModule(module *Module) *diagnostics.Diagnostic { if ctx == nil || module == nil || !module.ID.Valid() { - return + return nil } module.FilePath = CanonicalPath(module.FilePath) ctx.mu.Lock() @@ -279,10 +282,13 @@ func (ctx *CompilerContext) AddModule(module *Module) { } if conflict != "" { ctx.mu.Unlock() - if ctx.Diagnostics != nil { - ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, conflict, nil, "") + if ctx.Diagnostics == nil { + return nil } - return + // Reported without a location: the registry knows the identities in + // conflict, not which source site caused the registration. A caller + // holding that site labels the returned diagnostic. + return ctx.Diagnostics.AddError(diagnostics.ErrAmbiguousImport, conflict, nil, "") } previous := ctx.modules[module.ID] ctx.modules[module.ID] = module @@ -299,6 +305,7 @@ func (ctx *CompilerContext) AddModule(module *Module) { } } ctx.mu.Unlock() + return nil } // PublishedConstant returns the authoritative value of a constant symbol, diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index e7850909..b9b237b4 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -62,8 +62,13 @@ func TestCompilerContextReportsConflictingFileIdentity(t *testing.T) { secondID := moduleid.ID{Origin: "stdlib", Namespace: "core", ImportPath: "prelude/global"} first := &Module{ID: firstID, FilePath: shared} - ctx.AddModule(first) - ctx.AddModule(&Module{ID: secondID, FilePath: shared}) + if reported := ctx.AddModule(first); reported != nil { + t.Fatalf("clean registration returned a conflict: %#v", reported) + } + conflict := ctx.AddModule(&Module{ID: secondID, FilePath: shared}) + if conflict == nil { + t.Fatal("conflicting registration returned no diagnostic for the caller to label") + } // Two identities for one file is reachable from imports and library-root // configuration, so it must diagnose rather than abort the compiler. @@ -96,7 +101,9 @@ func TestCompilerContextRejectsIdentityRelocationWithoutCorruptingIndexes(t *tes // Moving A onto B's file must be rejected, and rejection must not disturb // the indexes A already owns. - ctx.AddModule(&Module{ID: idA, FilePath: "b.peep"}) + if conflict := ctx.AddModule(&Module{ID: idA, FilePath: "b.peep"}); conflict == nil { + t.Fatal("rejected relocation returned no diagnostic for the caller to label") + } if got, ok := ctx.ModuleByID(idA); !ok || got != first { t.Fatal("rejected relocation lost the original module registration") From 540596b222e9130fc1ba6a8970a3998cf185748a Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:29:15 +0600 Subject: [PATCH 22/80] Reject prelude modules without derivable identity ModuleForFile returned success with a zero module identity when identity derivation failed. Every downstream path discards a module with an invalid identity, so callers ran the pipeline on a module that could never register and produced no diagnostic. NewModuleForFile already fails this case by returning nil. ModuleForFile now reports failure, and Load surfaces it as an error instead of dropping the prelude silently. --- internal/prelude/prelude.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index 10590d64..b3550699 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -52,8 +52,14 @@ func ModuleForFile(ctx *project.CompilerContext, filePath, content string) (*pro if !ok || project.CanonicalPath(preludePath) != project.CanonicalPath(filePath) { return nil, false } + id := ModuleID(ctx) + if !id.Valid() { + // Without derivable identity the module can never register, so reporting + // success here would hand callers a module every path silently drops. + return nil, false + } return &project.Module{ - ID: ModuleID(ctx), + ID: id, FilePath: preludePath, Content: content, ContentProvided: true, @@ -76,7 +82,10 @@ func Load(ctx *project.CompilerContext) error { } return fmt.Errorf("load prelude %s: %w", preludePath, err) } - module, _ := ModuleForFile(ctx, preludePath, string(content)) + module, ok := ModuleForFile(ctx, preludePath, string(content)) + if !ok { + return fmt.Errorf("load prelude %s: no derivable module identity", preludePath) + } ctx.AddModule(module) return nil } From 3e5de2e661f0ea92df782d62b1d78c377e4e3022 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:29:15 +0600 Subject: [PATCH 23/80] Fail LLVM emission on blocks without a terminator The unclassified-terminator panic sat inside a nil guard, so it caught unknown terminator kinds only. A block reaching emission with no terminator skipped the switch entirely and emitted an unterminated basic block with no compiler-side signal, which is what the guard's own comment claimed to prevent. An unterminated block is an impossible MIR state: lowerCFGTerminator is exhaustive and panics on unhandled CFG terminators, so it never publishes one. Emission now panics on it, naming the block. --- internal/backend/llvm/emitter.go | 51 ++++++++++++++------------- internal/backend/llvm/emitter_test.go | 7 ++++ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/internal/backend/llvm/emitter.go b/internal/backend/llvm/emitter.go index e5207c64..3b24fbb2 100644 --- a/internal/backend/llvm/emitter.go +++ b/internal/backend/llvm/emitter.go @@ -317,32 +317,35 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo panic(fmt.Sprintf("LLVM emission: unhandled MIR instruction %T", instr)) } } - if block.Term != nil { - returnLayout := emitter.layout(llvmFunctionReturnType(mod.Types, fn)) - lb.setLocation(block.Term.SourceLocation()) - switch term := block.Term.(type) { - case *mir.Jump: - lb.branch(fmt.Sprintf("b%d", term.TargetID)) - case *mir.Branch: - cond := emitCondRef(lb, term.Cond) - lb.condBranch(cond, fmt.Sprintf("b%d", term.ThenID), fmt.Sprintf("b%d", term.ElseID)) - case *mir.SwitchVariant: - emitVariantSwitch(lb, term) - case *mir.Ret: - if term.Value == nil || isVoidType(mod.Types, fn.ReturnType) { - if returnLayout.Kind != llvmLayoutVoid { - lb.ret(lb.value("0", returnLayout), returnLayout) - } else { - lb.retVoid(returnLayout) - } - continue + // Both terminator invariants are compiler bugs, not invalid source: + // every block carries a terminator, and every terminator kind emits + // one. Skipping either silently produces unterminated LLVM IR. + if block.Term == nil { + panic(fmt.Sprintf("LLVM emission: block b%d has no terminator", block.ID)) + } + returnLayout := emitter.layout(llvmFunctionReturnType(mod.Types, fn)) + lb.setLocation(block.Term.SourceLocation()) + switch term := block.Term.(type) { + case *mir.Jump: + lb.branch(fmt.Sprintf("b%d", term.TargetID)) + case *mir.Branch: + cond := emitCondRef(lb, term.Cond) + lb.condBranch(cond, fmt.Sprintf("b%d", term.ThenID), fmt.Sprintf("b%d", term.ElseID)) + case *mir.SwitchVariant: + emitVariantSwitch(lb, term) + case *mir.Ret: + if term.Value == nil || isVoidType(mod.Types, fn.ReturnType) { + if returnLayout.Kind != llvmLayoutVoid { + lb.ret(lb.value("0", returnLayout), returnLayout) + } else { + lb.retVoid(returnLayout) } - val := emitRef(lb, term.Value) - lb.ret(val, returnLayout) - default: - // A block without an emitted terminator is malformed LLVM IR. - panic(fmt.Sprintf("LLVM emission: unhandled MIR terminator %T", block.Term)) + continue } + val := emitRef(lb, term.Value) + lb.ret(val, returnLayout) + default: + panic(fmt.Sprintf("LLVM emission: unhandled MIR terminator %T", block.Term)) } lb.setLocation(nil) } diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 54b2d020..60b4b375 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -374,6 +374,13 @@ func TestGenerateLLVMIRPanicsForUnknownMIRNodes(t *testing.T) { block: &mir.Block{Term: &unknownMIRNode{}}, want: "LLVM emission: unhandled MIR terminator *llvm.unknownMIRNode", }, + { + // A block that reaches emission with no terminator would otherwise + // produce an unterminated basic block and no compiler-side signal. + name: "missing terminator", + block: &mir.Block{ID: 7}, + want: "LLVM emission: block b7 has no terminator", + }, } { t.Run(tt.name, func(t *testing.T) { defer func() { From c173dceae5ed4b6fc890fc99a2f95ddc92ead9ce Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:29:26 +0600 Subject: [PATCH 24/80] Drop stale publishedUse comment line The doc comment kept its opening line from the version that translated between two use-kind vocabularies. That translation is gone, so the line described behavior the function no longer has. --- internal/semantics/ownership/expr.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 3ab6682a..b4519ed1 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -365,7 +365,6 @@ func (a *analyzer) checkCallArgument( loans.addTemporary([]referenceLoan{loan}, call) } -// publishedUse maps the typechecker's published use kind for this argument // publishedUse resolves the ownership use kind for one value use: the // typechecker's published classification when present, otherwise the // capability fallback for diagnostics-continued paths. The ownership From 5bdc22e49be388ff270648645b0aa3c98151a14a Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:29:27 +0600 Subject: [PATCH 25/80] Publish alloc operand use kind before operand typing Ownership reads the published use kind for alloc arguments instead of deriving them, and its fallback for a missing entry is a read. Publication happened after the operand type was resolved, so an operand producing no type exited first and the consuming use was lost: the operand stayed live where it used to be moved. The use kinds come from the intrinsic's own semantics and need no types, so they are published right after the arity gate, which already guarantees the argument count they index. --- internal/semantics/typechecker/check_call.go | 16 +++++---- .../semantics/typechecker/typechecker_test.go | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index 79e50ae9..bd86bdd6 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -230,6 +230,16 @@ func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typein return &typeinfo.InvalidType{} } + // The intrinsic has no parameter types to classify against, so the use kinds + // are published from its own semantics, before any type-dependent exit: the + // analyzer consumes them on diagnostics-continued paths too. + if c.module != nil && c.module.Typechecking != nil { + c.module.Typechecking.ValueUses[node.Args[0].ID()] = typeinfo.UseMove + if len(node.Args) > 1 { + c.module.Typechecking.ValueUses[node.Args[1].ID()] = typeinfo.UseRead + } + } + valueType := c.typeExpr(scope, node.Args[0], nil) if valueType == nil { return &typeinfo.InvalidType{} @@ -256,12 +266,6 @@ func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typein c.ctx.Diagnostics.Add(d) } } - if c.module != nil && c.module.Typechecking != nil { - c.module.Typechecking.ValueUses[node.Args[0].ID()] = typeinfo.UseMove - if len(node.Args) > 1 { - c.module.Typechecking.ValueUses[node.Args[1].ID()] = typeinfo.UseRead - } - } return &typeinfo.OwnedPtrType{Target: valueType} } diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 8f05ce5d..a4f66b6a 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -114,6 +114,40 @@ func TestCheckAllocPublishesConsumingUse(t *testing.T) { } } +// Ownership reads the published kind for alloc rather than deriving it, and its +// fallback for a missing entry is a read. An operand that produces no value type +// exits typing early, so publication must already have happened by then or the +// move is silently lost. +func TestCheckAllocPublishesConsumingUseForUntypedOperand(t *testing.T) { + module, _ := checkTypeModule(t, `fn Nothing() {} + +fn main() -> i32 { + let heap = alloc(Nothing()); + return 0; +}`) + published := 0 + for _, stmt := range module.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + kind, found := module.Typechecking.ValueUses[call.ID()] + if !found { + return true + } + published++ + if kind != typeinfo.UseMove { + t.Fatalf("alloc operand = %v, want UseMove", kind) + } + return true + }) + } + if published != 1 { + t.Fatalf("alloc operand published uses = %d, want 1", published) + } +} + func TestCheckMatchPublishesCarrierUse(t *testing.T) { module, diag := checkTypeModule(t, `struct Box { value: i32 } enum Resource { From c687b50e830851597b78de8c4e0bf0a45b6f0044 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Wed, 2 Sep 2026 23:51:39 +0600 Subject: [PATCH 26/80] Make the cleanup plan the only source of planned drops hir.Return.Cleanup and hir.Assign.DropTarget were drop channels nothing produced. No production code ever wrote either one, so folding and MIR lowering carried them, MIR ORed the dead assign flag with the plan, and two tests asserted only that the empty channels survived a fold. Both are deleted, and MIR reads CleanupPlan.BeforeAssign alone. The drops that remain are not competing policy: a source-level free is the programmer's own drop, and MIR temporary drops destroy temporaries MIR materializes, which have no source symbol to plan against. Return and scope exit stay separate channels, now documented with the reason they cannot merge as lowering stands: site drops emit before the terminator runs, while a return must compute its value before unwinding the scopes it leaves. A new regression pins that ordering through the plan, replacing the test that only proved the dead channel was carried. --- internal/ir/hir/fold/fold.go | 17 +++---- internal/ir/hir/fold/fold_test.go | 32 ++----------- internal/ir/hir/model.go | 18 ++----- internal/ir/mir/module_lower.go | 11 ++--- internal/ir/mir/module_lower_test.go | 50 +++++++++++++++----- internal/semantics/ownershipresult/result.go | 19 +++++++- 6 files changed, 74 insertions(+), 73 deletions(-) diff --git a/internal/ir/hir/fold/fold.go b/internal/ir/hir/fold/fold.go index 8cb234ca..807382c8 100644 --- a/internal/ir/hir/fold/fold.go +++ b/internal/ir/hir/fold/fold.go @@ -61,23 +61,18 @@ func foldStmt(types *ir.TypeTable, stmt hir.Stmt, env map[string]constvalue.Valu return []hir.Stmt{&hir.ExprStmt{Value: ir.FoldExpr(types, node.Value, env), NodeID: node.NodeID, ValueNodeID: node.ValueNodeID, Location: node.Location}} case *hir.Assign: return []hir.Stmt{&hir.Assign{ - Target: ir.FoldPlace(types, node.Target, env), - Value: ir.FoldExpr(types, node.Value, env), - DropTarget: node.DropTarget, - NodeID: node.NodeID, - Location: node.Location, + Target: ir.FoldPlace(types, node.Target, env), + Value: ir.FoldExpr(types, node.Value, env), + NodeID: node.NodeID, + Location: node.Location, }} case *hir.Invalid: return []hir.Stmt{node} case *hir.Return: - cleanup := make([]ir.Expr, 0, len(node.Cleanup)) - for _, expr := range node.Cleanup { - cleanup = append(cleanup, ir.FoldExpr(types, expr, env)) - } if node.Value == nil { - return []hir.Stmt{&hir.Return{Cleanup: cleanup, NodeID: node.NodeID, Location: node.Location}} + return []hir.Stmt{&hir.Return{NodeID: node.NodeID, Location: node.Location}} } - return []hir.Stmt{&hir.Return{Value: ir.FoldExpr(types, node.Value, env), Cleanup: cleanup, NodeID: node.NodeID, Location: node.Location}} + return []hir.Stmt{&hir.Return{Value: ir.FoldExpr(types, node.Value, env), NodeID: node.NodeID, Location: node.Location}} case *hir.If: thenBlock := foldBlock(types, node.Then, env) var elseStmt hir.Stmt diff --git a/internal/ir/hir/fold/fold_test.go b/internal/ir/hir/fold/fold_test.go index cfbadc36..5c966700 100644 --- a/internal/ir/hir/fold/fold_test.go +++ b/internal/ir/hir/fold/fold_test.go @@ -86,31 +86,6 @@ func TestApplyTypedExpressionFoldingPreservesStatementsAfterReturn(t *testing.T) } } -func TestApplyTypedExpressionFoldingPreservesReturnCleanup(t *testing.T) { - types := ir.NewTypeTable() - i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) - ownedI32 := types.Intern(ir.Type{Kind: ir.TypeOwnedPtr, Elem: i32}) - mod := &hir.Module{Funcs: []*hir.Function{{ - Name: "main", - Body: &hir.Block{Stmts: []hir.Stmt{&hir.Return{ - Value: &ir.IntLit{Value: "0", Type: i32}, - Cleanup: []ir.Expr{&ir.Drop{Value: &ir.Ident{ - Name: "owner", - Type: ownedI32, - }}}, - }}}, - }}, Types: types} - - out := ApplyTypedExpressionFolding(mod) - ret, ok := out.Funcs[0].Body.Stmts[0].(*hir.Return) - if !ok || len(ret.Cleanup) != 1 { - t.Fatalf("folded return cleanup = %#v, want one expression", ret) - } - if _, ok := ret.Cleanup[0].(*ir.Drop); !ok { - t.Fatalf("folded cleanup = %#v, want drop", ret.Cleanup[0]) - } -} - func TestApplyTypedExpressionFoldingPreservesPlaceRootAndFoldsIndexes(t *testing.T) { types := ir.NewTypeTable() i32 := types.Intern(ir.Type{Kind: ir.TypeInteger, Signed: true, Bits: 32}) @@ -164,9 +139,8 @@ func TestApplyTypedExpressionFoldingFoldsAssignments(t *testing.T) { }}, Type: i32, }, - Value: &ir.Binary{Op: "+", Left: &ir.IntLit{Value: "20", Type: i32}, Right: &ir.IntLit{Value: "22", Type: i32}, Type: i32}, - DropTarget: true, - NodeID: 31, + Value: &ir.Binary{Op: "+", Left: &ir.IntLit{Value: "20", Type: i32}, Right: &ir.IntLit{Value: "22", Type: i32}, Type: i32}, + NodeID: 31, }, }}, }}, Types: types} @@ -177,7 +151,7 @@ func TestApplyTypedExpressionFoldingFoldsAssignments(t *testing.T) { index, indexOK := assignment.Target.Projections[0].Index.(*ir.IntLit) value, valueOK := assignment.Value.(*ir.IntLit) if !rootOK || root.Name != "items" || !indexOK || index.Value != "1" || !valueOK || value.Value != "42" || - !assignment.DropTarget || assignment.NodeID != 31 { + assignment.NodeID != 31 { t.Fatalf("folded assignment = %#v, want preserved target with folded index and value", assignment) } } diff --git a/internal/ir/hir/model.go b/internal/ir/hir/model.go index 24c668e7..0a3f2a8e 100644 --- a/internal/ir/hir/model.go +++ b/internal/ir/hir/model.go @@ -86,11 +86,10 @@ type ExprStmt struct { } type Assign struct { - Target *ir.Place - Value ir.Expr - DropTarget bool - NodeID NodeID - Location *source.Location + Target *ir.Place + Value ir.Expr + NodeID NodeID + Location *source.Location } type Invalid struct { @@ -101,7 +100,6 @@ type Invalid struct { type Return struct { Value ir.Expr - Cleanup []ir.Expr NodeID NodeID Location *source.Location } @@ -300,9 +298,6 @@ func (s *ExprStmt) appendText(b *strings.Builder, indent int) { func (s *Assign) appendText(b *strings.Builder, indent int) { writeIndent(b, indent) - if s.DropTarget { - b.WriteString("drop-before ") - } b.WriteString(s.Target.String()) b.WriteString(" = ") b.WriteString(s.Value.String()) @@ -320,11 +315,6 @@ func (s *Invalid) appendText(b *strings.Builder, indent int) { } func (s *Return) appendText(b *strings.Builder, indent int) { - for _, cleanup := range s.Cleanup { - writeIndent(b, indent) - b.WriteString(cleanup.String()) - b.WriteString("\n") - } writeIndent(b, indent) b.WriteString("return") if s != nil && s.Value != nil { diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index bfa2f6db..f594f488 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -424,11 +424,11 @@ func (l *lowerer) lowerCFGStmt(stmt hir.Stmt) bool { if target == nil || target.Root == nil { return false } - dropTarget := node.DropTarget + // The ownership cleanup plan is the only source of drop obligations over + // source values; lowering never decides one for itself. + dropTarget := false if l.cleanup != nil { - if _, planned := l.cleanup.BeforeAssign[node.NodeID]; planned { - dropTarget = true - } + _, dropTarget = l.cleanup.BeforeAssign[node.NodeID] } if ident, direct := target.Root.(*ir.Ident); direct && len(target.Projections) == 0 { if dropTarget { @@ -544,9 +544,6 @@ func (l *lowerer) lowerCFGTerminator(source, exit *cfg.Block, blocks map[*cfg.Bl temporaryMark := len(l.temporaryDrops) value := l.lowerExpr(ret.Value, &l.current.Instrs) l.flushTemporaryDrops(&l.current.Instrs, temporaryMark) - for _, cleanup := range ret.Cleanup { - l.lowerExpr(cleanup, &l.current.Instrs) - } if l.cleanup != nil { l.appendPlannedDrops(l.cleanup.BeforeReturn[term.NodeID], &l.current.Instrs) } diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 0d34ebf0..5e0b5b71 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -243,29 +243,57 @@ func TestGenerateMIRDoesNotAssignUninitializedBinding(t *testing.T) { } } -func TestGenerateMIRLowersReturnCleanupBeforeTerminator(t *testing.T) { +// A return computes its value before unwinding the scopes it exits, which is +// why return cleanup is planned separately from CFG scope-exit sites: a +// scope-exit site emits its drops before the terminator runs, so a returned +// value read from a dropped local would be freed first. +func TestGenerateMIRComputesReturnValueBeforePlannedCleanup(t *testing.T) { mod := &hir.Module{ Name: "test", Types: mirTypes.table, Funcs: []*hir.Function{{ Name: "release", + Params: []ir.Param{{Name: "owner", Type: mirTypes.ownedI32, SymbolID: 1}}, ReturnType: mirTypes.i32, Body: &hir.Block{Stmts: []hir.Stmt{&hir.Return{ - Value: &ir.IntLit{Value: "7", Type: mirTypes.i32}, - Cleanup: []ir.Expr{&ir.Drop{Value: &ir.Ident{Name: "owner", Type: mirTypes.ownedI32}}}, + NodeID: 20, + Value: &ir.Binary{ + Op: "+", + Left: &ir.Ident{Name: "left", Type: mirTypes.i32}, + Right: &ir.IntLit{Value: "7", Type: mirTypes.i32}, + Type: mirTypes.i32, + }, }}}, }}, } - out := GenerateMIR(mod, cfgForHIR(mod), nil, nil, nil) + // CFG construction assigns function identity, so the plan is keyed after it. + graphs := cfgForHIR(mod) + plans := ownershipresult.Result{mod.Funcs[0].NodeID: &ownershipresult.CleanupPlan{ + BeforeReturn: map[ir.NodeID][]symbols.SymbolID{20: {1}}, + }} + + out := GenerateMIR(mod, graphs, plans, nil, nil) block := out.Funcs[0].Blocks[0] - if len(block.Instrs) != 1 { - t.Fatalf("expected one cleanup instruction, got %#v", block.Instrs) + value, drop := -1, -1 + for index, instr := range block.Instrs { + switch instr.(type) { + case *Assign: + if value < 0 { + value = index + } + case *Drop: + if drop < 0 { + drop = index + } + } + } + if value < 0 || drop < 0 { + t.Fatalf("instructions = %#v, want the return value and its planned drop", block.Instrs) } - if _, ok := block.Instrs[0].(*Drop); !ok { - t.Fatalf("expected MIR drop, got %#v", block.Instrs[0]) + if value > drop { + t.Fatalf("planned drop at %d precedes return value at %d: %#v", drop, value, block.Instrs) } - ret, ok := block.Term.(*Ret) - if !ok || ret.Value.Text() != "7" { - t.Fatalf("expected preserved return value, got %#v", block.Term) + if _, ok := block.Term.(*Ret); !ok { + t.Fatalf("terminator = %#v, want return", block.Term) } } diff --git a/internal/semantics/ownershipresult/result.go b/internal/semantics/ownershipresult/result.go index 63589b5f..6bf25c14 100644 --- a/internal/semantics/ownershipresult/result.go +++ b/internal/semantics/ownershipresult/result.go @@ -7,8 +7,25 @@ import ( ) // CleanupPlan records ownership effects at CFG and stable HIR source sites. +// +// It is the only source of drop obligations over source values: lowering reads +// the plan and never decides a drop for itself. The two other drops in the +// pipeline are not competing policy — a source-level `free` is the programmer's +// own drop, and MIR's temporary drops destroy temporaries MIR itself +// materializes, which have no source symbol to plan against. +// +// Scope exit and return stay separate channels because the events differ. A +// scope-exit site leaves exactly one scope, and its drops emit while the block's +// sites are processed. A return leaves every enclosing scope at once, and its +// drops must emit after the returned value is computed — a value read from a +// local being unwound would otherwise be freed before it is read. Folding return +// into scope-exit sites therefore requires MIR to defer trailing site drops +// until after the terminator's value expression. type CleanupPlan struct { - AfterScope map[cfg.SiteID][]symbols.SymbolID + // AfterScope drops the symbols owned by the one scope a site exits. + AfterScope map[cfg.SiteID][]symbols.SymbolID + // BeforeReturn drops every scope a return unwinds, after its value is + // computed. Keyed by the return statement, which is the event, not a site. BeforeReturn map[ir.NodeID][]symbols.SymbolID BeforeAssign map[ir.NodeID]struct{} DiscardedValue map[ir.NodeID]struct{} From 52ff00717a8c67fe694c8435c60ff918b92d5c1e Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 00:07:29 +0600 Subject: [PATCH 27/80] Validate published ownership evidence at the phase boundary Ownership published a cleanup plan and consumed use kinds with nothing checking that the two agreed with the artifacts they describe. A stale plan key, a use kind on an untyped node, or a call argument the typechecker never classified all stayed silent, and the ownership analyzer's capability fallback quietly absorbed the last of those. ownershipresult.Validate now checks plan and evidence shape: every plan key names a real CFG site, typed expression, or block scope; every published use kind belongs to a typed expression and is legal for that type's capability; and every argument of a resolved call carries a classification. The pipeline runs it after each ownership pass and reports failure as ICE0002, skipped when the module already has errors so broken source never reports a compiler bug. Reports are sorted and truncated, because plans are maps and an unsorted report would name different problems on different runs. The analyzer's fallback stays: it is reachable only where the typechecker exited before publishing, which means the source is already diagnosed. Per-path single-drop checking stays out, because that is the dataflow ownership already performs and a validator must not re-derive it. Both choices are recorded in the design doc. --- .../ownership-vocabulary.md | 26 +++ internal/diagnostics/codes.go | 4 + internal/pipeline/pipeline.go | 9 + internal/semantics/ownership/expr.go | 12 +- .../semantics/ownershipresult/validate.go | 185 ++++++++++++++++++ .../ownershipresult/validate_test.go | 180 +++++++++++++++++ 6 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 internal/semantics/ownershipresult/validate.go create mode 100644 internal/semantics/ownershipresult/validate_test.go diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md index e57363e5..5fe93fff 100644 --- a/docs/compiler-framework/ownership-vocabulary.md +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -195,6 +195,32 @@ Invariants: Invalid source remains diagnostics; validator failure is a compiler bug. +**As implemented (slice 4).** `Validate(types, bindings, graphs)` runs in the +pipeline after every ownership pass, skipped when the module already has errors, +and reports failure as `ICE0002` rather than a source diagnostic. Invariants 2, +4 and 5 are implemented as written; 5 collapses into 4, since a stale entry is +exactly a key that no longer names a program point. + +Invariant 1 is implemented over the sites that actually need publication: a use +kind is published where the decision requires type information the typechecker +holds — call arguments, intrinsic operands, match carriers — and the validator +requires an entry for every argument in `EffectiveCallArguments`. The remaining +uses are structural: a binding moves, a condition reads, an index reads. Those +follow from syntactic position alone, so publishing them would add ceremony +without adding knowledge, and the validator does not demand entries for them. + +Invariant 3 is **deferred**. Proving that a symbol is dropped exactly once per +path is a CFG dataflow walk — the analysis ownership already performs. Repeating +it inside the validator would make the validator a second implementation of the +thing it checks, which §6 rules out. Double-drop stays covered behaviorally by +the ownership suite and the `x_test` drop fixtures. + +`publishedUse`'s capability fallback is retained, not removed. It is reachable +only where the typechecker exited before publishing, which means the program +already has diagnostics; raising a compiler-bug error there would blame the +compiler for source the user was already told is invalid. The validator is the +single place that treats a missing classification as a bug. + ## 4. Migration slices Each slice keeps the full suite green and is independently reviewable. diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index c7330f38..8b9b80f0 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -84,6 +84,10 @@ const ( ErrAmbiguousImport = "M0005" ErrInvalidEntrypoint = "M0006" + // Internal compiler errors (ICE prefix). ICE0001 is the generic marker in + // diagnostic.go; codes here name a specific broken compiler invariant. + ErrInvalidEvidence = "ICE0002" + // Style/Info codes (S prefix) InfoTrailingComma = "S0001" InfoUnnecessarySemicolon = "S0002" diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 8a58fe19..2662d7fe 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -477,6 +477,15 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di } if module.Phase < phase.Ownership { module.Ownership = ownership.Check(phaseCtx, module) + // Published evidence is only checkable once the module is otherwise + // error-free: broken source legitimately leaves evidence incomplete, + // and reporting that as a compiler bug would bury the real diagnostic. + if !phaseDiag.HasErrors() { + if err := module.Ownership.Validate(module.Typechecking, module.Bindings, module.CFG); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "ownership evidence is inconsistent: "+err.Error(), nil, "") + } + } module.Phase = phase.Ownership ctx.Metrics.AddPhaseAdvance() return true diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index b4519ed1..13477fae 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -366,9 +366,15 @@ func (a *analyzer) checkCallArgument( } // publishedUse resolves the ownership use kind for one value use: the -// typechecker's published classification when present, otherwise the -// capability fallback for diagnostics-continued paths. The ownership -// validator will enforce presence for error-free programs. +// typechecker's published classification when present, otherwise the capability +// fallback. +// +// The fallback is reachable only on diagnostics-continued paths, where the +// typechecker exited before publishing. It stays deliberately: ownership still +// runs on broken source, and turning an absent classification into an internal +// error here would report a compiler bug for a program the user has already been +// told is invalid. For error-free programs the absence is a compiler bug, and +// ownershipresult.Validate is the one place that says so. func (a *analyzer) publishedUse(arg ast.Expr, paramType typeinfo.Type) typeinfo.UseKind { if a.module != nil && a.module.Typechecking != nil { if kind, ok := a.module.Typechecking.ValueUses[arg.ID()]; ok { diff --git a/internal/semantics/ownershipresult/validate.go b/internal/semantics/ownershipresult/validate.go new file mode 100644 index 00000000..2ddf5aad --- /dev/null +++ b/internal/semantics/ownershipresult/validate.go @@ -0,0 +1,185 @@ +package ownershipresult + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" + "compiler/internal/semantics/typeinfo" +) + +// maxReportedProblems bounds one internal error so a systematic evidence break +// reports a readable sample instead of one line per node in the module. +const maxReportedProblems = 10 + +// Validate checks published ownership evidence against the artifacts it +// describes: every plan key must name a real program point, every published use +// kind must belong to a typed expression and be legal for that type's +// capability, and every call argument the typechecker resolved must carry a +// classification. A failure means the compiler published inconsistent evidence, +// so callers report it as an internal error, never as a source diagnostic. +// +// The validator never re-derives an ownership decision. In particular, proving +// that a symbol is dropped exactly once per path is deliberately out of scope: +// that is the analysis ownership already performs, and repeating it here would +// make the validator a second implementation of the thing it checks rather than +// a check on published shape. +func (r Result) Validate(types *typecheckresult.Result, bindings *bindingresult.Result, graphs *cfg.Module) error { + if len(r) == 0 { + return nil + } + if types == nil || bindings == nil || graphs == nil { + return errors.New("ownership published a cleanup plan without typechecking, binding, or CFG evidence") + } + + problems := validateValueUses(types) + for fnID, plan := range r { + problems = append(problems, validatePlan(fnID, plan, types, bindings, graphs)...) + } + if len(problems) == 0 { + return nil + } + // Plans and evidence are maps, so a stable report needs an explicit order. + sort.Strings(problems) + total := len(problems) + if len(problems) > maxReportedProblems { + problems = problems[:maxReportedProblems] + return fmt.Errorf("%s (%d more)", strings.Join(problems, "; "), total-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +// validateValueUses checks the published use kinds against the expressions they +// classify: a kind for an untyped node is stale evidence, a kind the type's +// capability forbids is an illegal classification, and a resolved call argument +// with no kind is the gap the ownership fallback used to hide. +func validateValueUses(types *typecheckresult.Result) []string { + problems := make([]string, 0) + for id, use := range types.ValueUses { + valueType, typed := types.ExprTypes[id] + if !typed { + problems = append(problems, fmt.Sprintf("use kind published for node %d with no expression type", id)) + continue + } + if use == typeinfo.UseCopy && typeinfo.OwnershipCapabilityOf(valueType).Copy == typeinfo.CopyNever { + problems = append(problems, fmt.Sprintf("node %d copies %s, which has no copy operation", id, typeinfo.TypeText(valueType))) + } + } + for callID, args := range types.EffectiveCallArguments { + for index, arg := range args { + if arg == nil { + continue + } + if _, published := types.ValueUses[arg.ID()]; !published { + problems = append(problems, fmt.Sprintf("call %d argument %d has no published use kind", callID, index)) + } + } + } + return problems +} + +// validatePlan checks one function's cleanup plan against its CFG and the +// program points each map is keyed by. +func validatePlan(fnID ir.NodeID, plan *CleanupPlan, types *typecheckresult.Result, bindings *bindingresult.Result, graphs *cfg.Module) []string { + if plan == nil { + return []string{fmt.Sprintf("function %d has a nil cleanup plan", fnID)} + } + graph := graphs.Function(fnID) + if graph == nil { + return []string{fmt.Sprintf("function %d has a cleanup plan but no CFG", fnID)} + } + + scopeExits := make(map[cfg.SiteID]struct{}) + siteNodes := make(map[ir.NodeID]struct{}) + for _, block := range graph.Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + siteNodes[site.NodeID] = struct{}{} + if site.Kind == cfg.SiteScopeExit { + scopeExits[site.ID] = struct{}{} + } + } + } + + problems := make([]string, 0) + for siteID, ids := range plan.AfterScope { + if _, exists := scopeExits[siteID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops at site %v, which is not a scope exit in its CFG", fnID, siteID)) + } + problems = append(problems, validateSymbols(fnID, "scope exit", ids)...) + } + for nodeID, ids := range plan.BeforeReturn { + if _, exists := siteNodes[nodeID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops before return %d, which is not a site in its CFG", fnID, nodeID)) + } + problems = append(problems, validateSymbols(fnID, "return", ids)...) + } + for nodeID := range plan.BeforeAssign { + if _, exists := siteNodes[nodeID]; !exists { + problems = append(problems, fmt.Sprintf("function %d drops before assignment %d, which is not a site in its CFG", fnID, nodeID)) + } + } + for nodeID := range plan.DiscardedValue { + problems = append(problems, validateTypedNode(types, fnID, "discarded value", nodeID)...) + } + for nodeID := range plan.ProjectionBase { + problems = append(problems, validateTypedNode(types, fnID, "projection base", nodeID)...) + } + for nodeID, symbolID := range plan.MatchCarrierMoves { + problems = append(problems, validateArmBody(bindings, fnID, "match carrier move", nodeID)...) + if symbolID == 0 { + problems = append(problems, fmt.Sprintf("function %d moves an unidentified match carrier at %d", fnID, nodeID)) + } + } + for nodeID := range plan.MatchWholePayloadDrops { + problems = append(problems, validateArmBody(bindings, fnID, "match payload drop", nodeID)...) + } + for nodeID, fields := range plan.MatchFieldDrops { + problems = append(problems, validateArmBody(bindings, fnID, "match field drop", nodeID)...) + for _, field := range fields { + if field < 0 { + problems = append(problems, fmt.Sprintf("function %d drops match field %d at %d", fnID, field, nodeID)) + } + } + } + return problems +} + +// validateSymbols rejects unidentified cleanup targets. Full symbol-identity +// checking waits for a canonical symbol registry; a zero id is already proof the +// plan lost the symbol it meant to drop. +func validateSymbols(fnID ir.NodeID, where string, ids []symbols.SymbolID) []string { + problems := make([]string, 0) + for _, id := range ids { + if id == 0 { + problems = append(problems, fmt.Sprintf("function %d plans an unidentified %s drop", fnID, where)) + } + } + return problems +} + +func validateTypedNode(types *typecheckresult.Result, fnID ir.NodeID, where string, nodeID ir.NodeID) []string { + if _, typed := types.ExprTypes[ast.NodeID(nodeID)]; typed { + return nil + } + return []string{fmt.Sprintf("function %d plans a %s at node %d with no expression type", fnID, where, nodeID)} +} + +func validateArmBody(bindings *bindingresult.Result, fnID ir.NodeID, where string, nodeID ir.NodeID) []string { + if _, scoped := bindings.BlockScopes[ast.NodeID(nodeID)]; scoped { + return nil + } + return []string{fmt.Sprintf("function %d plans a %s at node %d, which is not a block", fnID, where, nodeID)} +} diff --git a/internal/semantics/ownershipresult/validate_test.go b/internal/semantics/ownershipresult/validate_test.go new file mode 100644 index 00000000..b57d84d0 --- /dev/null +++ b/internal/semantics/ownershipresult/validate_test.go @@ -0,0 +1,180 @@ +package ownershipresult + +import ( + "strings" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/frontend/lexer" + "compiler/internal/frontend/parser" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/bindingresult" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typecheckresult" + "compiler/internal/semantics/typeinfo" +) + +// buildGraph produces real CFG topology so the validator is checked against the +// artifact it actually receives, not a hand-shaped stand-in. +func buildGraph(t *testing.T) (*cfg.Module, ir.NodeID) { + t.Helper() + const file = "validate_test" + ".peep" + diag := diagnostics.NewDiagnosticBag() + source := parser.New(file, lexer.New(file, "fn main() -> i32 {\n\treturn 0;\n}\n", diag).Tokenize(), diag).ParseModule() + graphs := cfg.BuildModule(source, cfg.BuildQueries{}) + if graphs == nil || len(graphs.Functions) == 0 { + t.Fatalf("no CFG built: %s", diag.EmitAllToString()) + } + return graphs, graphs.Functions[0].NodeID +} + +func emptyPlan() *CleanupPlan { + return &CleanupPlan{ + AfterScope: make(map[cfg.SiteID][]symbols.SymbolID), + BeforeReturn: make(map[ir.NodeID][]symbols.SymbolID), + BeforeAssign: make(map[ir.NodeID]struct{}), + DiscardedValue: make(map[ir.NodeID]struct{}), + ProjectionBase: make(map[ir.NodeID]struct{}), + MatchCarrierMoves: make(map[ir.NodeID]symbols.SymbolID), + MatchFieldDrops: make(map[ir.NodeID][]int), + MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), + } +} + +func TestValidateAcceptsConsistentEvidence(t *testing.T) { + graphs, fnID := buildGraph(t) + result := Result{fnID: emptyPlan()} + if err := result.Validate(typecheckresult.New(), bindingresult.New(), graphs); err != nil { + t.Fatalf("consistent evidence rejected: %v", err) + } +} + +func TestValidateRejectsEvidenceGaps(t *testing.T) { + graphs, fnID := buildGraph(t) + argument := &ast.Ident{Name: "value"} + argument.SetID(41) + + for _, tt := range []struct { + name string + want string + build func(*typecheckresult.Result, *CleanupPlan) + }{ + { + name: "use kind without a type", + want: "no expression type", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.ValueUses[7] = typeinfo.UseMove + }, + }, + { + name: "copy of a type with no copy operation", + want: "no copy operation", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.ExprTypes[7] = &typeinfo.StringType{} + types.ValueUses[7] = typeinfo.UseCopy + }, + }, + { + name: "call argument with no use kind", + want: "no published use kind", + build: func(types *typecheckresult.Result, _ *CleanupPlan) { + types.EffectiveCallArguments[5] = []ast.Expr{argument} + }, + }, + { + name: "drop at a site that is not a scope exit", + want: "not a scope exit", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.AfterScope[cfg.SiteID{}] = []symbols.SymbolID{1} + }, + }, + { + name: "return drop at an unknown node", + want: "not a site in its CFG", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.BeforeReturn[9999] = []symbols.SymbolID{1} + }, + }, + { + name: "unidentified drop target", + want: "unidentified", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.BeforeReturn[9999] = []symbols.SymbolID{0} + }, + }, + { + name: "projection base with no type", + want: "no expression type", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.ProjectionBase[8888] = struct{}{} + }, + }, + { + name: "match drop outside a block", + want: "not a block", + build: func(_ *typecheckresult.Result, plan *CleanupPlan) { + plan.MatchWholePayloadDrops[7777] = struct{}{} + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + types := typecheckresult.New() + plan := emptyPlan() + tt.build(types, plan) + err := Result{fnID: plan}.Validate(types, bindingresult.New(), graphs) + if err == nil { + t.Fatal("inconsistent evidence accepted") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want it to mention %q", err, tt.want) + } + }) + } +} + +func TestValidateRejectsPlanWithoutCFG(t *testing.T) { + graphs, fnID := buildGraph(t) + err := Result{fnID + 1000: emptyPlan()}.Validate(typecheckresult.New(), bindingresult.New(), graphs) + if err == nil || !strings.Contains(err.Error(), "no CFG") { + t.Fatalf("error = %v, want a missing-CFG report", err) + } +} + +func TestValidateRejectsNilPlan(t *testing.T) { + graphs, fnID := buildGraph(t) + err := Result{fnID: nil}.Validate(typecheckresult.New(), bindingresult.New(), graphs) + if err == nil || !strings.Contains(err.Error(), "nil cleanup plan") { + t.Fatalf("error = %v, want a nil-plan report", err) + } +} + +// Plans and evidence are maps, so an unsorted report would name different +// problems on different runs for one broken module. +func TestValidateReportsProblemsDeterministically(t *testing.T) { + graphs, fnID := buildGraph(t) + first := "" + for attempt := 0; attempt < 8; attempt++ { + types := typecheckresult.New() + plan := emptyPlan() + for id := ast.NodeID(1); id <= 40; id++ { + types.ValueUses[id] = typeinfo.UseMove + plan.ProjectionBase[ir.NodeID(id)] = struct{}{} + } + err := Result{fnID: plan}.Validate(types, bindingresult.New(), graphs) + if err == nil { + t.Fatal("inconsistent evidence accepted") + } + if attempt == 0 { + first = err.Error() + continue + } + if err.Error() != first { + t.Fatalf("report changed between runs:\n%s\n%s", first, err.Error()) + } + } + if !strings.Contains(first, "more)") { + t.Fatalf("report = %q, want a truncated sample", first) + } +} From 7ec06e9c049641b9580b2e9fa32472f11e0b2647 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:05:58 +0600 Subject: [PATCH 28/80] Delete the unconsumed match carrier move channel CleanupPlan.MatchCarrierMoves was write-only. Ownership recorded a moved match carrier there, but MIR lowering reads the other seven plan fields and never this one, so no drop or suppression depended on it. The effect it appeared to describe is already produced by removing the carrier from the live set two lines above the write. It was the third dead drop channel, after hir.Return.Cleanup and hir.Assign.DropTarget. Keeping it was worse than inert: the phase-boundary validator checked it, which presented dead evidence to the next reader as though lowering depended on it. The two ownership tests that read the map as a proxy now assert the observable instead: leaving the arm that consumes the carrier must not drop it, and leaving the arm that does not must. Removing the live-set deletion fails those assertions, which the map assertion did not require. --- internal/semantics/ownership/ownership.go | 2 -- internal/semantics/ownership/ownership_test.go | 17 +++++------------ internal/semantics/ownershipresult/result.go | 1 - internal/semantics/ownershipresult/validate.go | 6 ------ .../semantics/ownershipresult/validate_test.go | 1 - 5 files changed, 5 insertions(+), 22 deletions(-) diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 21419499..d8d338f7 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -70,7 +70,6 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult BeforeAssign: make(map[ir.NodeID]struct{}), DiscardedValue: make(map[ir.NodeID]struct{}), ProjectionBase: make(map[ir.NodeID]struct{}), - MatchCarrierMoves: make(map[ir.NodeID]symbols.SymbolID), MatchFieldDrops: make(map[ir.NodeID][]int), MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), } @@ -608,7 +607,6 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { st.moved[carrier] = moveSite delete(st.live, carrier) delete(st.references, carrier) - a.cleanup.MatchCarrierMoves[ir.NodeID(arm.BodyID)] = carrier.ID if len(arm.Bindings) == 1 && arm.Bindings[0].Projection == typecheckresult.MatchWholePayload { if arm.Bindings[0].Discard && typeinfo.NeedsDrop(arm.Bindings[0].Type) { a.cleanup.MatchWholePayloadDrops[ir.NodeID(arm.BodyID)] = struct{}{} diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 28242d4b..fefb078e 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -248,14 +248,13 @@ func TestOwnershipCheckClearsAllDerivedPlans(t *testing.T) { plan.BeforeAssign[staleID] = struct{}{} plan.DiscardedValue[staleID] = struct{}{} plan.ProjectionBase[staleID] = struct{}{} - plan.MatchCarrierMoves[staleID] = 999999 plan.MatchFieldDrops[staleID] = []int{0} plan.MatchWholePayloadDrops[staleID] = struct{}{} result.module.Ownership = Check(result.ctx, result.module) plan = cleanupPlanForFunction(t, result, fn) if len(plan.AfterScope) != 0 || len(plan.BeforeReturn) != 0 || len(plan.BeforeAssign) != 0 || - len(plan.DiscardedValue) != 0 || len(plan.ProjectionBase) != 0 || len(plan.MatchCarrierMoves) != 0 || + len(plan.DiscardedValue) != 0 || len(plan.ProjectionBase) != 0 || len(plan.MatchFieldDrops) != 0 || len(plan.MatchWholePayloadDrops) != 0 { t.Fatalf("stale ownership plans survived rerun: %#v", plan) } @@ -772,12 +771,11 @@ fn valid(resource: Resource) { fn := result.module.AST.Stmts[1].(*ast.FnDecl) match := fn.Body.Stmts[0].(*ast.MatchStmt) plan := cleanupPlanForFunction(t, result, fn) - function, _ := result.module.ModuleScope.Lookup("valid") - resource, _ := function.Scope.Lookup("resource") - ownedBodyID := ir.NodeID(match.Arms[0].Body.ID()) graph := result.module.CFG.Function(ir.NodeID(fn.ID())) - if got := plan.MatchCarrierMoves[ownedBodyID]; got != resource.ID { - t.Fatalf("owned arm carrier move = %d, want %d", got, resource.ID) + // The owned arm consumes the carrier, so leaving it must not drop the + // carrier again; the pending arm never consumes it, so leaving there must. + if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); slices.Contains(got, "resource") { + t.Fatalf("consumed carrier dropped on the owned arm: %v", got) } if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[1].Body.ID())]); !slices.Equal(got, []string{"resource"}) { t.Fatalf("pending arm cleanup = %v, want [resource]", got) @@ -1240,11 +1238,6 @@ fn consume(resource: Resource) { if got := plan.MatchFieldDrops[bodyID]; !slices.Equal(got, []int{2, 1}) { t.Fatalf("match field drops = %v, want [2 1]", got) } - function, _ := result.module.ModuleScope.Lookup("consume") - resource, _ := function.Scope.Lookup("resource") - if got := plan.MatchCarrierMoves[bodyID]; got != resource.ID { - t.Fatalf("match carrier move = %d, want %d", got, resource.ID) - } graph := result.module.CFG.Function(ir.NodeID(fn.ID())) if got := cleanupSymbolNames(result.module, plan.AfterScope[scopeExitSiteID(t, graph, match.Arms[0].Body.ID())]); !slices.Equal(got, []string{"selected"}) { t.Fatalf("arm binding cleanup = %v, want [selected]", got) diff --git a/internal/semantics/ownershipresult/result.go b/internal/semantics/ownershipresult/result.go index 6bf25c14..11ae8371 100644 --- a/internal/semantics/ownershipresult/result.go +++ b/internal/semantics/ownershipresult/result.go @@ -30,7 +30,6 @@ type CleanupPlan struct { BeforeAssign map[ir.NodeID]struct{} DiscardedValue map[ir.NodeID]struct{} ProjectionBase map[ir.NodeID]struct{} - MatchCarrierMoves map[ir.NodeID]symbols.SymbolID MatchFieldDrops map[ir.NodeID][]int MatchWholePayloadDrops map[ir.NodeID]struct{} } diff --git a/internal/semantics/ownershipresult/validate.go b/internal/semantics/ownershipresult/validate.go index 2ddf5aad..844168f9 100644 --- a/internal/semantics/ownershipresult/validate.go +++ b/internal/semantics/ownershipresult/validate.go @@ -137,12 +137,6 @@ func validatePlan(fnID ir.NodeID, plan *CleanupPlan, types *typecheckresult.Resu for nodeID := range plan.ProjectionBase { problems = append(problems, validateTypedNode(types, fnID, "projection base", nodeID)...) } - for nodeID, symbolID := range plan.MatchCarrierMoves { - problems = append(problems, validateArmBody(bindings, fnID, "match carrier move", nodeID)...) - if symbolID == 0 { - problems = append(problems, fmt.Sprintf("function %d moves an unidentified match carrier at %d", fnID, nodeID)) - } - } for nodeID := range plan.MatchWholePayloadDrops { problems = append(problems, validateArmBody(bindings, fnID, "match payload drop", nodeID)...) } diff --git a/internal/semantics/ownershipresult/validate_test.go b/internal/semantics/ownershipresult/validate_test.go index b57d84d0..04885709 100644 --- a/internal/semantics/ownershipresult/validate_test.go +++ b/internal/semantics/ownershipresult/validate_test.go @@ -37,7 +37,6 @@ func emptyPlan() *CleanupPlan { BeforeAssign: make(map[ir.NodeID]struct{}), DiscardedValue: make(map[ir.NodeID]struct{}), ProjectionBase: make(map[ir.NodeID]struct{}), - MatchCarrierMoves: make(map[ir.NodeID]symbols.SymbolID), MatchFieldDrops: make(map[ir.NodeID][]int), MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), } From 866d498f4f8c1878c6f1476877ab105bea06ee79 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:07:13 +0600 Subject: [PATCH 29/80] Correct the ownership slice completion claims Section 4 listed "delete backend typeNeedsDrop" as part of slice 1. The walker is still at backend/llvm/drop_emit.go:303 with ten call sites, so G2, G3 and decision D2 remain open, and the doc read as though owned interface drop policy had already moved into the source language. Section 2.1 claimed every capability query derives from OwnershipCapabilityOf. The dependency runs the other way: the capability composes NeedsDrop, IsImplicitCopyType and noCopyType, which keeps the consolidation behavior-preserving but leaves the single-walker half of D6 outstanding. Both sections now carry an "As implemented" note, matching the one section 3 already carries for slice 4, and the status line no longer calls a document whose decisions are settled a proposal. --- .../ownership-vocabulary.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md index 5fe93fff..e8138a7e 100644 --- a/docs/compiler-framework/ownership-vocabulary.md +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -1,6 +1,9 @@ # Ownership Vocabulary Design -Status: **proposal — needs maintainer approval on vocabulary before any code**. +Status: **approved; slices 1-4 implemented**. The vocabulary and decisions +D1-D7 in section 5 are settled. Sections 2 and 4 state the design; each carries +an *As implemented* note recording what actually landed and what was deferred. +Trust the notes over the design text where they differ. This document defines the ownership decision vocabulary that makes lifetime handling automatic for future features. Design goal, stated by the maintainer: @@ -117,6 +120,22 @@ Rules: "what needs drop" (fixes G2/G3/G9). - Cycle guards are uniform (fixes G8). +**As implemented (slice 1).** The dependency runs the other way round from the +design text. `OwnershipCapabilityOf` (`typeinfo/capabilities.go:437`) *composes* +`NeedsDrop`, `IsImplicitCopyType` and `noCopyType` rather than being the walker +they derive from. That was deliberate — it makes the consolidation behavior- +preserving by construction, so the existing suite proves it — but it means the +single-walker half of the design is still outstanding. New code consumes +`OwnershipCapabilityOf`; the three predicates remain the behavioral source. + +`IsNoCopyType` is unexported to `noCopyType`, not deleted, so D6 is half done: +the capability struct is the only public spelling, but a second implementation +of the copy question still exists behind it. + +Landed as written: G6 (`NoneType` joins the implicit-copy set, +`capabilities.go:104`) and G8 (the cycle guard releases with `defer delete`, +`capabilities.go:100`). + ### 2.2 Use kind (per value use, published once by the typechecker) The `UseKind` vocabulary lives in `typeinfo` beside `OwnershipCapability` @@ -229,6 +248,19 @@ Each slice keeps the full suite green and is independently reviewable. re-point `IsImplicitCopyType`/`NeedsDrop` at it; resolve D1–D5; delete backend `typeNeedsDrop` (MIR already carries obligations); fix G6/G8. No behavior change intended; existing suite + fixtures prove it. + + **Deferred out of slice 1: the backend `typeNeedsDrop` deletion.** It still + exists at `backend/llvm/drop_emit.go:303` with ten call sites across + `drop_emit.go` and `emitter.go`. So **G2** (two implementations of "what + needs drop") and **G3** (owned-interface drop policy living only in the + backend) are both still open, and **D2** is unimplemented — an owned + interface is still raw-freed by a backend special case rather than by a + source-published obligation. + + Deleting the walker is not a plumbing change: it moves drop policy for + owned interfaces into the source language, which is observable behavior and + needs `x_test` fixtures of its own. It is scheduled as separate work, not as + a leftover of this slice. 2. **Publish use kinds** — typechecker publishes `ValueUses` for call arguments, bindings, assignments, returns, concat operands, match arms. Ownership consumes; delete the re-derivations listed in §2.2. Largest From 202304ee43caf9b4018d0b25d2baf3f2bbcfb506 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:08:10 +0600 Subject: [PATCH 30/80] Name the diagnostics module scope for what it is The module-key concept was deleted when moduleid.ID became the canonical identity, but internal/diagnostics kept naming its grouping parameter and field moduleKey. It now receives moduleid.ID.String(), so the old name pointed at a type that no longer exists. Rename to moduleScope throughout the bag and its one LSP caller, and record on the field why the package takes an opaque string: diagnostics must not depend on how module identity is spelled, and only ever compares and sorts the value. Pure rename; no behavior change. --- internal/diagnostics/bag.go | 50 ++++++++++++++++++++----------------- internal/lsp/state.go | 4 +-- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/internal/diagnostics/bag.go b/internal/diagnostics/bag.go index 00a1ec62..9a001d7e 100644 --- a/internal/diagnostics/bag.go +++ b/internal/diagnostics/bag.go @@ -25,7 +25,11 @@ type DiagnosticBag struct { mu *sync.Mutex sourceCache *SourceCache phase phase.Phase - moduleKey string + // moduleScope groups diagnostics by the module that produced them. It is an + // opaque string on purpose: this package must not depend on how module + // identity is spelled, so callers pass moduleid.ID.String() and the bag + // only ever compares and sorts it. + moduleScope string } type diagnosticGroup struct { @@ -43,37 +47,37 @@ func NewDiagnosticBag() *DiagnosticBag { } // BeginPhase replaces one producing phase/module group and returns its writer. -func (db *DiagnosticBag) BeginPhase(producingPhase phase.Phase, moduleKey string) *DiagnosticBag { - scoped := db.AppendPhase(producingPhase, moduleKey) +func (db *DiagnosticBag) BeginPhase(producingPhase phase.Phase, moduleScope string) *DiagnosticBag { + scoped := db.AppendPhase(producingPhase, moduleScope) db.mu.Lock() if db.groups[producingPhase] == nil { db.groups[producingPhase] = make(map[string]diagnosticGroup) } - db.groups[producingPhase][moduleKey] = diagnosticGroup{active: true} + db.groups[producingPhase][moduleScope] = diagnosticGroup{active: true} db.mu.Unlock() return scoped } // AppendPhase returns a writer that continues one producing phase/module group. -func (db *DiagnosticBag) AppendPhase(producingPhase phase.Phase, moduleKey string) *DiagnosticBag { +func (db *DiagnosticBag) AppendPhase(producingPhase phase.Phase, moduleScope string) *DiagnosticBag { return &DiagnosticBag{ groups: db.groups, mu: db.mu, sourceCache: db.sourceCache, phase: producingPhase, - moduleKey: moduleKey, + moduleScope: moduleScope, } } // DiscardModuleAfter removes diagnostics invalidated by a module phase reset. -func (db *DiagnosticBag) DiscardModuleAfter(moduleKey string, retained phase.Phase) { +func (db *DiagnosticBag) DiscardModuleAfter(moduleScope string, retained phase.Phase) { db.mu.Lock() defer db.mu.Unlock() for producingPhase, modules := range db.groups { if producingPhase <= retained { continue } - delete(modules, moduleKey) + delete(modules, moduleScope) if len(modules) == 0 { delete(db.groups, producingPhase) } @@ -82,7 +86,7 @@ func (db *DiagnosticBag) DiscardModuleAfter(moduleKey string, retained phase.Pha // CopyModuleRange replaces one module's destination groups inside an inclusive // phase range. Inactive groups remain reusable but do not affect compilation or output. -func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string, first, last phase.Phase, active bool) { +func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleScope string, first, last phase.Phase, active bool) { if source == nil || db.mu == source.mu || first > last { return } @@ -92,7 +96,7 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if producingPhase < first || producingPhase > last { continue } - if group, ok := modules[moduleKey]; ok { + if group, ok := modules[moduleScope]; ok { copiedGroups[producingPhase] = diagnosticGroup{ diagnostics: append([]*Diagnostic(nil), group.diagnostics...), active: active, @@ -107,7 +111,7 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if producingPhase < first || producingPhase > last { continue } - delete(modules, moduleKey) + delete(modules, moduleScope) if len(modules) == 0 { delete(db.groups, producingPhase) } @@ -116,12 +120,12 @@ func (db *DiagnosticBag) CopyModuleRange(source *DiagnosticBag, moduleKey string if db.groups[producingPhase] == nil { db.groups[producingPhase] = make(map[string]diagnosticGroup) } - db.groups[producingPhase][moduleKey] = group + db.groups[producingPhase][moduleScope] = group } } // ActivateModuleRange publishes retained groups after their project barrier succeeds. -func (db *DiagnosticBag) ActivateModuleRange(moduleKey string, first, last phase.Phase) { +func (db *DiagnosticBag) ActivateModuleRange(moduleScope string, first, last phase.Phase) { if first > last { return } @@ -131,12 +135,12 @@ func (db *DiagnosticBag) ActivateModuleRange(moduleKey string, first, last phase if producingPhase < first || producingPhase > last { continue } - group, ok := modules[moduleKey] + group, ok := modules[moduleScope] if !ok { continue } group.active = true - modules[moduleKey] = group + modules[moduleScope] = group } } @@ -186,10 +190,10 @@ func (db *DiagnosticBag) Add(diag *Diagnostic) { if db.groups[db.phase] == nil { db.groups[db.phase] = make(map[string]diagnosticGroup) } - group := db.groups[db.phase][db.moduleKey] + group := db.groups[db.phase][db.moduleScope] group.diagnostics = append(group.diagnostics, diag) group.active = true - db.groups[db.phase][db.moduleKey] = group + db.groups[db.phase][db.moduleScope] = group } // AddError adds an error diagnostic to the bag and returns it for chaining/customization. @@ -260,13 +264,13 @@ func (db *DiagnosticBag) Diagnostics() []*Diagnostic { result := make([]*Diagnostic, 0) for _, producingPhase := range phases { modules := db.groups[producingPhase] - moduleKeys := make([]string, 0, len(modules)) - for moduleKey := range modules { - moduleKeys = append(moduleKeys, moduleKey) + moduleScopes := make([]string, 0, len(modules)) + for moduleScope := range modules { + moduleScopes = append(moduleScopes, moduleScope) } - slices.Sort(moduleKeys) - for _, moduleKey := range moduleKeys { - group := modules[moduleKey] + slices.Sort(moduleScopes) + for _, moduleScope := range moduleScopes { + group := modules[moduleScope] if group.active { result = append(result, group.diagnostics...) } diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 44f43cf0..89880c00 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -335,8 +335,8 @@ func activateReusableDiagnostics(ctx *project.CompilerContext, retainedPhases ma if ctx == nil || ctx.Diagnostics == nil || ctx.CompletedProjectPhase < phase.Usage { return } - for moduleKey, retainedPhase := range retainedPhases { - ctx.Diagnostics.ActivateModuleRange(moduleKey, phase.Usage, retainedPhase) + for moduleScope, retainedPhase := range retainedPhases { + ctx.Diagnostics.ActivateModuleRange(moduleScope, phase.Usage, retainedPhase) } } From a6aaaab9b567f08bea0fd9b84119e27aaf4a0f23 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:09:15 +0600 Subject: [PATCH 31/80] Drop unreachable dependency-alias completion guards Two completion paths skipped imports whose module identity carried a dependency component. IdentityForFile is the only production producer of a moduleid.ID and never sets Dependency, so both guards were always false; ModuleOriginDependency is likewise declared but never assigned. The branches were carried over from the deleted ResolvedImport.Dependency Alias field, which was equally never written. Keeping them implied that dependency imports reach these paths, which they cannot. Whoever lands dependency resolution will populate Dependency in IdentityForFile and can decide then whether dependency modules offer completions, with the surrounding context rather than an inherited guess. --- internal/lsp/completion.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 52c06836..6f572187 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -444,7 +444,7 @@ func qualifiedCompletionItems(ctx *project.CompilerContext, module *project.Modu return sortCompletionItems(items) } resolved, ok := module.Imports[qualifier] - if !ok || resolved.ID.Dependency != "" { + if !ok { return []CompletionItem{} } imported, ok := ctx.ModuleByID(resolved.ID) @@ -713,9 +713,6 @@ func operationCompletionItems(ctx *project.CompilerContext, module *project.Modu } } for alias, resolved := range module.Imports { - if resolved.ID.Dependency != "" { - continue - } imported, found := ctx.ModuleByID(resolved.ID) if !found || imported == nil || imported.Bindings == nil { continue From 5de78e669117b377fbe9b163d4f651e518d850f0 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:15:52 +0600 Subject: [PATCH 32/80] Document the change paths through the compiler The framework roadmap's matrix states which question each area asks. It does not say where to go, so a contributor still had to read the pipeline to find out that the CFG decision lives in buildStmt or that the backend needs nothing at all for a construct lowering to existing MIR shapes. Add change-paths.md: three walks, for a new syntax construct, an internal lowering change, and a new type. Each is traced from a change already in git, so every stop is a file some commit actually touched. Walk 1 follows 2302c08, the for-loop implementation, through nineteen production files and fourteen fixtures. Every stop names what catches you if you skip it, ranked automatic, visible, loud, or nothing. The nothing rows are the point: type kinds have no dispatch contract, HIR and MIR have neither contract nor validator, and no fixture is ever required. A gap list that a contributor can see beats one they discover in review. Link it from CONTRIBUTING.md and from the roadmap matrix it complements. --- CONTRIBUTING.md | 5 + docs/compiler-framework/README.md | 5 + docs/compiler-framework/change-paths.md | 314 ++++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 docs/compiler-framework/change-paths.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f941b907..06348b96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,11 @@ These files are canonical; do not copy their rules into new documents: - [`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md): compiler phase, representation, traversal, and incremental-analysis guidance. +For a change that touches the pipeline, read +[`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) +first. It walks the files a new syntax construct, an internal lowering change, or a +new type must visit, and names the test that fails at each stop you skip. + `AGENTS.md` contains automation workflow, not additional human-facing code policy. diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index 8997be16..4cbb839c 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -540,6 +540,11 @@ validation, and fixture checks until contributor updates all required owners. Use this matrix before implementation. Mark each row **changed**, **verified unchanged**, or **not applicable with reason**. +The matrix states the question each area asks. [`change-paths.md`](change-paths.md) +answers where to go: it walks the real file sequence for three change shapes, traced +from commits already in the repository, and names what catches you when a stop is +missed — including the stops where nothing does. + | Area | Required question | | --- | --- | | Lexer/token model | Does syntax require token or lexical-state change? | diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md new file mode 100644 index 00000000..962aee04 --- /dev/null +++ b/docs/compiler-framework/change-paths.md @@ -0,0 +1,314 @@ +# Change paths through the compiler + +Mandatory policy is [`RULES.md`](../../RULES.md). Durable principles are +[`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). The framework roadmap is +[`README.md`](README.md), whose *Adding or changing a language construct* matrix +lists the questions you must answer. + +**This document answers a different question: where do I actually go?** The matrix +tells you that CFG needs a decision. It does not tell you that the decision lives in +`buildStmt`, that a missing one fails +`TestEveryStatementKindHasAPhaseDecision/buildStmt`, or that you can skip the backend +entirely. That is what follows. + +Each walk is traced from a change already in git, so every stop is a real file that a +real commit touched — not a plausible guess. Line numbers drift; function names and +test names are the durable part. + +## How to read a walk + +Every stop names the **owner** (file and function), the **decision** made there, and +**what catches you** if you skip it. That last column ranks the same three ways the +framework does: + +| Rank | Meaning | +| --- | --- | +| **Automatic** | The compiler will not build. You cannot forget. | +| **Visible** | A named test fails under plain `go test ./...`. | +| **Loud** | A validator or panic fires at runtime, naming the phase. | +| **— nothing** | Nothing catches you. Read this as a warning, not as permission. | + +The `— nothing` rows are deliberate. A map with the gaps drawn in is more useful than +one that pretends the coast is clear. + +--- + +## Walk 1 — adding a syntax construct + +**Traced from `2302c08` "Implement and harden for loops"**: 19 production files, 11 +test files, 14 `x_test` fixtures. `git show --stat 2302c08` is the ground truth for this walk. + +`for` was a good stress test because it is not one construct but two — `for i in 0..n` +(range) and `for v in array` (sequence) — plus `break`/`continue`, which are transfers +rather than statements with an effect. + +### The stops, in pipeline order + +**1. Token** — `frontend/token/kinds.go`, `frontend/token/keywords.go` +Does the syntax need a new keyword or token kind? `for` already existed; this change +added only `in` — one entry in `keywords.go`, one `Kind` in `kinds.go`, one help line. +*Catches you:* — nothing. A missing token surfaces as a parse error in your own test. + +**2. AST node** — `frontend/ast/stmt.go` +Declare the node, give it `NodeIDHolder` and a `Location`, and implement the family +marker (`stmtNode()`, `exprNode()`, or `typeNode()`). The marker is what enrolls your +node in every contract below — there is no registry to update, and no list to forget. +`ForStmt` holds `Index`, `Value`, `Iterable`, `Cond`, `Body`. + +**3. AST traversal** — same file, `forEachChild` +Every field that holds a node must be visited. `ForStmt.forEachChild` visits all five. +*Catches you:* **Visible** — `contracts.TestEveryNodeBearingFieldIsTraversed` parses +this package with `go/ast` and fails naming the field you left out. +`TestEverySubStructureFieldIsExpanded` covers fields that hold sub-structures rather +than nodes directly. + +**4. Parser** — `frontend/parser/parse_stmt.go` +Produce the node for valid syntax *and* decide what a malformed header produces. The +for-loop change added recovery paths deliberately; `x_test/negative_for_malformed_header` +pins the result. +*Catches you:* — nothing structural. Your own parser tests are the only guard. + +**5. Resolver** — `semantics/resolver/resolver.go`, `resolveStmt` +Which names does the construct introduce, and in which scope? For `for`, the loop +variables get a body scope. + +**6. Typechecker** — `semantics/typechecker/check_stmt.go`, `checkStmt` +The semantic rule, and — the important part — **the evidence you publish**. `checkStmt` +delegates to `checkForInStmt`, which publishes +`typecheckresult.Result.ForIterations[node.ID()]`: the iteration kind, element type, +generated cursor/end/carrier symbols, and guaranteed-entry proof. + +> **This is the load-bearing step.** Everything downstream consumes this evidence +> rather than re-reading the source. If you publish nothing here, every later phase +> either re-derives the fact — which the framework forbids — or silently does nothing. + +**7. CFG** — `ir/cfg/build.go`, `buildStmt` +Which blocks, edges, and sites does the construct create? `ForStmt` builds +`BlockLoopInit`, `BlockLoop`, `BlockLoopBody`, `BlockLoopLatch` and wires +`break`/`continue` as edges. +If CFG construction needs a semantic fact, it takes it as a **query**, not by importing +the typechecker: `cfg.BuildQueries{MatchCases, LoopGuaranteedEntry}`. +`LoopGuaranteedEntry` exists exactly because a loop with a proven-nonempty range must +not report its body as conditionally skipped. + +**8. Flow typing** — `semantics/typechecker/flow.go`, `applyConditionEdge` +Which facts differ on the true and false edges? A `for` header carries no narrowing +condition, so it is classified `ignore` with that reason — an explicit decision, not +an omission. + +**9. Definite initialization** — `semantics/definiteinit/initialization.go`, `checkReads` +Which storage does the construct read? Classified `ignore` for `ForStmt`: the condition +arrives separately through the CFG site condition, so reading it here would double-count. + +**10. Ownership** — `semantics/ownership/ownership.go` (`applyStmt`) and +`semantics/ownership/reference.go` (`symbolUseSequence`) +Which loans, moves, and drops occur? `applyStmt` reads the published `ForIterations` +evidence to establish the sequence carrier's borrow. Cleanup lands in +`ownershipresult.CleanupPlan`, keyed to exact CFG sites. + +**11. HIR lowering** — `ir/hir/lower/module_lower.go`, `appendStmt` → `lowerForStmt` +Represent the established evidence without rediscovering it. `hir.For` has explicit +`Init`, `Cond`, `Bindings`, `Body`, `Next` blocks; `lowerForStmt` fills them from the +published symbols. Note what it does **not** do: it never re-inspects the iterable's +type to decide range-vs-sequence — it switches on the published kind. +If you add a HIR node, it needs `forEachChild` and `appendText` there too. + +**12. HIR folding** — `ir/hir/fold/fold.go`, `foldStmt` +Constant folding over typed HIR. `hir.For` is handled so folding descends into all +five blocks. +*Catches you:* — nothing. HIR has no dispatch contract and no validator. + +**13. MIR lowering** — `ir/mir/module_lower.go` +Lower normalized control flow and consume the cleanup plan. `hir.For` is read in +`lowerCFGFunction` (to find the loop a CFG block belongs to) and in +`lowerCFGTerminator` (to emit the header and latch). +*Catches you:* — nothing. MIR has no dispatch contract; `mir.Instr` and +`mir.Terminator` are unsealed. + +**14. Backend** — `backend/llvm/` +**The for-loop change touched no backend file at all.** This is the single most +useful fact in this walk: a construct that lowers to existing MIR shapes needs zero +backend work, because MIR is the backend's only input. You owe the backend a change +only when you introduce a new MIR instruction or terminator. +*Catches you if you do:* **Loud** — `GenerateLLVMIR` panics with +`LLVM emission: unhandled MIR instruction …` / `… unhandled MIR terminator …`, pinned +by `TestGenerateLLVMIRPanicsForUnknownMIRNodes`. A block with no terminator panics too. + +**15. LSP** — `internal/lsp/` +The for-loop change touched no LSP file either. Revisit only if the construct +introduces a new completion or hover surface. +*Catches you:* — nothing. + +**16. Fixtures** — `x_test/` +The for-loop change added 14: four runtime (`for_range_loop`, `for_array_loop`, +`for_nested_loops`, `for_break_continue`) and ten negative. A fixture is a directory +with `peeper.toml` plus `src/`, discovered automatically. +*Catches you:* — nothing forces you to add one; the suite passes without. RULES §14 +requires end-to-end regressions for behavior changes, but that requirement is enforced +by review, not by a test. This is the largest honest gap in the pipeline. + +### What Walk 1 forced automatically + +Adding one `stmtNode` implementation enrolls the kind in +`TestEveryStatementKindHasAPhaseDecision`, which then fails at **all nine** statement +dispatch sites until each one either handles the kind or declares why it is inert: + +``` +resolveStmt · checkStmt · buildStmt · appendStmt · lowerElse +applyStmt · symbolUseSequence · checkReads · applyConditionEdge +``` + +An `exprNode` enrolls in `TestEveryExpressionKindHasAPhaseDecision` across four sites: +`resolveExpr`, `typeExprBase`, `checkExpr`, `lowerASTExpr`. + +The families as they stand: **19** statement kinds, **23** expression kinds, **12** +type kinds. Type kinds have no dispatch contract yet. + +"Declare why it is inert" means an entry in `internal/contracts/node_dispatch_test.go` +with one of four decisions — `traverse`, `ignore`, `reject`, `contextual` — and a +**reason string**. Reasons are checked: `TestOmissionReasonsNameRealNodeKinds` fails on +an empty reason, an invalid decision, or a reason naming a kind that no longer exists. +Claiming a kind is inert while also handling it fails too, so the classification cannot +rot in either direction. + +--- + +## Walk 2 — an internal lowering or optimization change + +This is the short path, and it is short because of a rule rather than a coincidence. + +**Traced from the shape of `ir/hir/fold/fold.go`.** + +| Stop | Owner | Decision | +| --- | --- | --- | +| HIR folding | `ir/hir/fold/fold.go`, `ApplyTypedExpressionFolding` → `foldStmt`, `foldBlock` | Constant propagation over typed HIR; must descend into every block a statement owns | +| MIR lowering | `ir/mir/module_lower.go` | Normalized control flow, temporaries, cleanup emission | +| Backend | `backend/llvm/` | Physical layout, instruction selection, ABI | + +### The rule that makes this path short + +> **Below HIR, no phase may re-derive a source-level fact.** Consume published +> evidence or fail. + +Concretely: MIR lowering does not decide whether an assignment drops its target. It +reads `CleanupPlan.BeforeAssign`. It does not decide which match fields to destroy. It +reads `MatchFieldDrops`. When you find yourself reaching back toward the AST from MIR, +that is the signal you are in the wrong phase — the fact belongs in the typechecker's +or ownership's published result, and the change belongs in Walk 1. + +Three drop channels have been deleted for breaking this rule: `hir.Return.Cleanup`, +`hir.Assign.DropTarget`, and `CleanupPlan.MatchCarrierMoves`. The first two let +lowering carry an opinion of its own; the third recorded an opinion nothing consumed. +`ownershipresult.CleanupPlan` is now the single source of planned drops. + +### What catches you + +| Concern | Guard | +| --- | --- | +| New MIR instruction or terminator | **Loud** — `GenerateLLVMIR` panics; `TestGenerateLLVMIRPanicsForUnknownMIRNodes` | +| Block emitted with no terminator | **Loud** — panics: `LLVM emission: block bN has no terminator` | +| Operand/type mismatch in emission | **Visible** — `TestTypedLLVMBuilderRejectsOperandMismatches` | +| Ownership evidence inconsistent with CFG or types | **Loud** — `ICE0002` from `ownershipresult.Validate` at the phase boundary | +| Folding that drops a block | — nothing | +| Wrong MIR lowering that still type-checks | — nothing but fixtures | + +--- + +## Walk 3 — adding a type + +A new `typeinfo.Type` is the change shape with the **weakest** automatic coverage, +because the type family has no dispatch contract. Read this walk as a checklist you +must run manually. + +**1. Declare it** — `semantics/typeinfo/types.go` +Implement `Type`: `TypeNode()` and `Text() string`. +*Catches you:* **Automatic** — the interface will not be satisfied otherwise. This is +the only automatic guard in the entire walk. + +**2. Ownership capability** — `semantics/typeinfo/capabilities.go` +This is the step that decides whether your type is safe by default. The governing rule: + +> Ownership capability is baked into the type itself. Scalar → copyable → copy. +> Contains a reference, pointer, or allocation inside → move. Check the type, apply +> the rule. No per-type policy tables. + +Answer, in this file: `IsImplicitCopyType`, `noCopyType`, `NeedsDrop`, and the +composite `OwnershipCapabilityOf`. Also consider `IsSizedType`, `IsLowerableType`, +`IsEquatable`, `IsOrderable`, `IsArithmetic`, `IsIntegral`, `IsCondition`. +Get this right and ownership, cleanup, and drop emission follow with no further work — +that is the whole point of the capability model. +*Catches you:* — nothing. A `default:` branch will quietly classify your type as +non-copyable, which is safe but may be wrong. + +**3. HIR type lowering** — `ir/hir/lower/lower_types.go` +The largest type switch in the compiler (~31 cases). Map your type to an `ir.TypeID`. +*Catches you:* — nothing; unmapped types fall to `ir.InvalidType`. + +**4. Export fingerprint** — `project/export_fingerprint.go`, `semanticTypeKey` +Incremental correctness. If your type is not keyed distinctly, a dependent module can +fail to rebuild when your type changes. +*Catches you:* — nothing, and the failure is a stale-build bug that looks like +something else entirely. Treat this stop as high-risk. + +**5. Backend layout and ABI** — `backend/llvm/` +Physical size, alignment, pointee, calling convention. Backend-owned by design; do not +push layout decisions into `typeinfo`. + +**6. Hover and completion** — `lsp/hover.go` (~7 type cases) +*Catches you:* — nothing; the type renders with a fallback. + +**7. Fixtures** — `x_test/` +Positive runtime plus negative semantics. For anything with a target-sized +representation, cover both 32- and 64-bit. + +--- + +## Consolidated: what the compiler actually enforces + +| Contract | Guards | Location | +| --- | --- | --- | +| `TestEveryNodeBearingFieldIsTraversed` | A new child field is traversed | `internal/contracts` | +| `TestEverySubStructureFieldIsExpanded` | Sub-structure fields are expanded | `internal/contracts` | +| `TestEveryStatementKindHasAPhaseDecision` | 19 statement kinds × 9 phase sites | `internal/contracts` | +| `TestEveryExpressionKindHasAPhaseDecision` | 23 expression kinds × 4 phase sites | `internal/contracts` | +| `TestOmissionReasonsNameRealNodeKinds` | Inert-kind reasons stay true | `internal/contracts` | +| `ownershipresult.Validate` → `ICE0002` | Published ownership evidence matches CFG and types | pipeline, after ownership | +| `llvm.ValidateRuntimeSymbols` | Reserved runtime symbols, extern ownership | pipeline, after backend emission | +| `GenerateLLVMIR` panics | Unhandled MIR node; block with no terminator | backend emission | +| `cfg.Analyze` | Unreachable code, constant conditions, missing return — **user diagnostics, not structure** | after CFG | + +## Where nothing catches you + +Stated plainly, because a contributor deserves to know which parts of the walk are on +the honor system: + +- **Type kinds have no dispatch contract.** Adding a `typeinfo.Type` and forgetting + capability, lowering, or fingerprinting compiles and passes. +- **HIR and MIR have no dispatch contract**, and `mir.Instr`/`mir.Terminator` are + unsealed, so exhaustiveness is a runtime panic rather than a compile error. +- **No structural validator exists for CFG, HIR, or MIR.** `cfg.Analyze` reports user + problems; it checks no invariant of the graph itself. Ownership is the only artifact + with a boundary validator. +- **Nothing requires a fixture.** A construct can reach the backend with no end-to-end + coverage at all. +- **Nothing requires an LSP update**, so a new construct can be invisible to hover and + completion without any signal. + +These are tracked as framework workstreams 3, 4, and 7 in [`README.md`](README.md). If +you close one, delete its bullet here — a stale gap list is worse than none. + +--- + +## Before you call it done + +1. Walk the matrix in [`README.md`](README.md) and mark every row **changed**, + **verified unchanged**, or **not applicable with reason**. +2. Run the RULES §14 minimum validation, plus what this repo has settled into: + `gofmt`, `go test -count=1 ./...`, race on touched packages, + `go run ./scripts/bundle.go`, `PEEPER_BIN="$PWD/build/bin/peeper" go test ./x_test`, + `git diff --check`. §14 also requires every supported target width when the change + touches target-sized integers, lengths, indexes, pointers, or ABI carriers. +3. **Prove each new test is not vacuous.** Revert your fix, confirm the test fails, and + confirm the failure message is the one you expect. A test that passes for a reason + other than its own check is a false guarantee — which is precisely what this + framework exists to eliminate. From 6fa713a00218396f127f653c7e4e800a4bc2ee65 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:23:07 +0600 Subject: [PATCH 33/80] Replace nullable iteration evidence with a closed plan ForIteration carried a kind tag beside eight optional fields, five of which were meaningful for one kind only. Nothing tied the tag to the state, so a record claiming range while holding a carrier was representable, and consumers defended against it: HIR lowering rechecked Cursor, Value, End and Carrier for nil across three sites, and ownership rechecked the carrier at two more. Those checks were already unnecessary. The typechecker publishes only when the loop typed cleanly, so a published record has always been complete. The type simply did not say so. Replace the tag and the kind-specific fields with one IterationPlan, closed by an unexported method, holding either a RangeIteration or a SequenceIteration. Exactly one kind, carrying exactly its own state, enforced by the type rather than by a validator. Consumers switch on the plan and read it; every defensive nil check is deleted. The one malformed shape still admitted is a published record with no plan, which HIR reports as invalid; a new lowering test covers it and fails if the default arm is dropped. Behavior is otherwise unchanged, as the for-loop fixtures confirm. --- internal/ir/hir/lower/module_lower.go | 41 +++++++------- internal/ir/hir/lower/module_lower_test.go | 16 ++++++ internal/semantics/ownership/ownership.go | 13 +++-- internal/semantics/typechecker/check_stmt.go | 28 +++++----- internal/semantics/typechecker/for_in_test.go | 22 +++++--- internal/semantics/typecheckresult/result.go | 54 ++++++++++++++----- 6 files changed, 117 insertions(+), 57 deletions(-) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 172b7bb3..ddde3e35 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -333,8 +333,10 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s return loop } + // Published evidence is complete by construction: the typechecker publishes + // only for a loop that typed cleanly. Absence is the one case to handle. evidence, found := module.Typechecking.ForIterations[node.ID()] - if !found || evidence.Cursor == nil || evidence.Value == nil { + if !found { return &hir.Invalid{Message: "for-in statement missing semantic evidence", NodeID: hir.NodeID(node.ID()), Location: location} } loop.Init = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} @@ -342,46 +344,45 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s loop.Next = &hir.Block{Stmts: make([]hir.Stmt, 0), Location: location} boolType := loweredTypeID(ctx, module, &typeinfo.BoolType{}) - switch evidence.Kind { - case typecheckresult.ForIterationRange: + switch plan := evidence.Plan.(type) { + case *typecheckresult.RangeIteration: + // Range evidence is published only for a RangeExpr iterable with both + // bounds present, so the assertion cannot fail for a published loop. rangeExpr, ok := node.Iterable.(*ast.RangeExpr) - if !ok || rangeExpr.Start == nil || rangeExpr.End == nil || evidence.End == nil { + if !ok { return &hir.Invalid{Message: "range iteration evidence does not match syntax", NodeID: hir.NodeID(node.ID()), Location: location} } loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, evidence.Cursor, lowerASTExpr(ctx, module, scope, rangeExpr.Start, evidence.ElementType), location), - generatedBinding(ctx, module, evidence.End, lowerASTExpr(ctx, module, scope, rangeExpr.End, evidence.ElementType), location), + generatedBinding(ctx, module, plan.Limit, lowerASTExpr(ctx, module, scope, rangeExpr.End, evidence.ElementType), location), ) - if evidence.Ordinal != nil { - ordinalType := loweredTypeID(ctx, module, evidence.Ordinal.Type) - loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, evidence.Ordinal, + if plan.Ordinal != nil { + ordinalType := loweredTypeID(ctx, module, plan.Ordinal.Type) + loop.Init.Stmts = append(loop.Init.Stmts, generatedBinding(ctx, module, plan.Ordinal, &ir.IntLit{Value: "0", Type: ordinalType, SourceInfo: ir.SourceInfo{Location: location}}, location)) } loop.Cond = &ir.Binary{ - Op: "<", Left: generatedIdent(ctx, module, evidence.Cursor, location), Right: generatedIdent(ctx, module, evidence.End, location), Type: boolType, + Op: "<", Left: generatedIdent(ctx, module, evidence.Cursor, location), Right: generatedIdent(ctx, module, plan.Limit, location), Type: boolType, SourceInfo: ir.SourceInfo{Location: location}, } if evidence.Index != nil { loop.Bindings.Stmts = append(loop.Bindings.Stmts, - generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, evidence.Ordinal, location), location)) + generatedBinding(ctx, module, evidence.Index, generatedIdent(ctx, module, plan.Ordinal, location), location)) } loop.Bindings.Stmts = append(loop.Bindings.Stmts, generatedBinding(ctx, module, evidence.Value, generatedIdent(ctx, module, evidence.Cursor, location), location)) loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Cursor, location)) - if evidence.Ordinal != nil { - loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, evidence.Ordinal, location)) - } - case typecheckresult.ForIterationSequence: - if evidence.Carrier == nil { - return &hir.Invalid{Message: "sequence iteration missing carrier evidence", NodeID: hir.NodeID(node.ID()), Location: location} + if plan.Ordinal != nil { + loop.Next.Stmts = append(loop.Next.Stmts, incrementSymbol(ctx, module, plan.Ordinal, location)) } - carrier := generatedIdent(ctx, module, evidence.Carrier, location) + case *typecheckresult.SequenceIteration: + carrier := generatedIdent(ctx, module, plan.Carrier, location) cursor := generatedIdent(ctx, module, evidence.Cursor, location) cursorType := loweredTypeID(ctx, module, evidence.Cursor.Type) elementType := loweredTypeID(ctx, module, evidence.ElementType) loop.Init.Stmts = append(loop.Init.Stmts, - generatedBinding(ctx, module, evidence.Carrier, - lowerImplicitReferenceValue(ctx, module, scope, node.Iterable, evidence.CarrierType), location), + generatedBinding(ctx, module, plan.Carrier, + lowerImplicitReferenceValue(ctx, module, scope, node.Iterable, plan.CarrierType), location), generatedBinding(ctx, module, evidence.Cursor, &ir.IntLit{Value: "0", Type: cursorType, SourceInfo: ir.SourceInfo{Location: location}}, location), ) @@ -396,7 +397,7 @@ func lowerForStmt(ctx *project.CompilerContext, module *project.Module, scope *s } loop.Bindings.Stmts = append(loop.Bindings.Stmts, generatedBinding(ctx, module, evidence.Value, &ir.Load{Place: &ir.Place{ - Root: generatedIdent(ctx, module, evidence.Carrier, location), + Root: generatedIdent(ctx, module, plan.Carrier, location), Projections: []ir.PlaceProjection{{ Kind: ir.PlaceProjectionIndex, Index: generatedIdent(ctx, module, evidence.Cursor, location), Type: elementType, Location: location, }}, diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index 86ff5440..8b190c92 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -150,6 +150,22 @@ func TestGenerateHIRRejectsForInWithoutSemanticEvidence(t *testing.T) { } } +// A published record with no plan is the one malformed shape the type still +// admits: IterationPlan is closed, so a consumer's switch over the two plans is +// exhaustive, but a zero Plan is reachable if a producer ever publishes early. +func TestGenerateHIRRejectsForInWithoutAnIterationPlan(t *testing.T) { + out := generateTestHIR(t, "hir_for_plan_test"+peeper.SourceExt, "hir_for_plan_test", `fn main() { for value in 0..2 {} }`, func(module *project.Module) { + for id, evidence := range module.Typechecking.ForIterations { + evidence.Plan = nil + module.Typechecking.ForIterations[id] = evidence + } + }) + invalid, ok := out.Funcs[0].Body.Stmts[0].(*hir.Invalid) + if !ok || !strings.Contains(invalid.Message, "unknown for-in iteration evidence") { + t.Fatalf("for-in without an iteration plan = %#v", out.Funcs[0].Body.Stmts[0]) + } +} + func TestGenerateHIRCallableNamesAreStableAndModuleAware(t *testing.T) { const src = `struct Counter { value: i32 } fn Value() -> i32 { return 1; } diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index d8d338f7..210a4c75 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -195,8 +195,8 @@ func (a *analyzer) run() { next := copyState(a.inStates[id]) if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockNormal { loopID := ast.NodeID(node.cfgBlock.NodeID) - if evidence, found := a.module.Typechecking.ForIterations[loopID]; found && - evidence.Kind == typecheckresult.ForIterationSequence && evidence.Carrier != nil { + evidence, found := a.module.Typechecking.ForIterations[loopID] + if _, sequence := evidence.Plan.(*typecheckresult.SequenceIteration); found && sequence { releaseIterationLoans(next, nil, loopID) } } @@ -541,8 +541,11 @@ func (a *analyzer) applyStmt(node *site, st state) { a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) break } - evidence, found := a.module.Typechecking.ForIterations[s.ID()] - if !found || evidence.Kind != typecheckresult.ForIterationSequence || evidence.Carrier == nil { + // A range loop borrows nothing; only a sequence loop holds the iterated + // storage for the loop's lifetime through its published carrier. + evidence := a.module.Typechecking.ForIterations[s.ID()] + sequence, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) + if !isSequence { a.checkExpr(scope, s.Iterable, st, typeinfo.UseRead, loans, false) break } @@ -558,7 +561,7 @@ func (a *analyzer) applyStmt(node *site, st state) { origins = referenceOrigins(value) } if len(origins) > 0 { - st.references[evidence.Carrier] = []referenceLoan{{ + st.references[sequence.Carrier] = []referenceLoan{{ id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), }} } diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index a637f784..209bd54c 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -526,8 +526,9 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } var elemType typeinfo.Type - if rangeExpr, ok := node.Iterable.(*ast.RangeExpr); ok { - evidence.Kind = typecheckresult.ForIterationRange + var carrierType typeinfo.Type + rangeExpr, isRange := node.Iterable.(*ast.RangeExpr) + if isRange { if !rangeExpr.EndExclusive { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(rangeExpr, "for range requires an exclusive end; use `..` instead of `..=`")) @@ -586,7 +587,6 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return } evidence.ElementType = elemType } else { - evidence.Kind = typecheckresult.ForIterationSequence var ok bool indexType, ok = typeinfo.NumericTypeFromName("usize", c.ctx.Target) if !ok { @@ -619,9 +619,9 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return "for-in requires copyable sequence elements; iterate indexes and borrow move-only elements explicitly")) } if _, array := typeinfo.Underlying(iterableType).(*typeinfo.ArrayType); array { - evidence.CarrierType = &typeinfo.RefType{Target: iterableType} + carrierType = &typeinfo.RefType{Target: iterableType} } else { - evidence.CarrierType = iterableType + carrierType = iterableType } evidence.ElementType = elem } else { @@ -643,18 +643,22 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return if valid && elemType != nil && !typeinfo.IsInvalidOrUnknown(elemType) { location := ast.LocOf(node) evidence.Cursor = symbols.New("$for.cursor", symbols.SymbolVar, nil, location) - if evidence.Kind == typecheckresult.ForIterationRange { + if isRange { evidence.Cursor.BindType(elemType) - evidence.End = symbols.New("$for.end", symbols.SymbolVar, nil, location) - evidence.End.BindType(elemType) + plan := &typecheckresult.RangeIteration{ + Limit: symbols.New("$for.end", symbols.SymbolVar, nil, location), + } + plan.Limit.BindType(elemType) if node.Index != nil { - evidence.Ordinal = symbols.New("$for.ordinal", symbols.SymbolVar, nil, location) - evidence.Ordinal.BindType(indexType) + plan.Ordinal = symbols.New("$for.ordinal", symbols.SymbolVar, nil, location) + plan.Ordinal.BindType(indexType) } + evidence.Plan = plan } else { evidence.Cursor.BindType(indexType) - evidence.Carrier = symbols.New("$for.carrier", symbols.SymbolVar, nil, location) - evidence.Carrier.BindType(evidence.CarrierType) + carrier := symbols.New("$for.carrier", symbols.SymbolVar, nil, location) + carrier.BindType(carrierType) + evidence.Plan = &typecheckresult.SequenceIteration{Carrier: carrier, CarrierType: carrierType} } c.module.Typechecking.ForIterations[node.ID()] = evidence } diff --git a/internal/semantics/typechecker/for_in_test.go b/internal/semantics/typechecker/for_in_test.go index 95616fb7..ab0dfa74 100644 --- a/internal/semantics/typechecker/for_in_test.go +++ b/internal/semantics/typechecker/for_in_test.go @@ -64,16 +64,20 @@ return total; if !ok { t.Fatal("missing range iteration evidence") } - if evidence.Kind != typecheckresult.ForIterationRange || evidence.Cursor == nil || evidence.End == nil || evidence.Ordinal == nil { + plan, isRange := evidence.Plan.(*typecheckresult.RangeIteration) + if !isRange { t.Fatalf("range iteration evidence = %#v", evidence) } + if evidence.Cursor == nil || plan.Limit == nil || plan.Ordinal == nil { + t.Fatalf("range iteration evidence is incomplete = %#v", evidence) + } if evidence.Index != module.Bindings.NodeSymbols[loop.Index.ID()] || evidence.Value != module.Bindings.NodeSymbols[loop.Value.ID()] { t.Fatal("range evidence does not preserve source binding symbols") } for name, symbol := range map[string]string{ "cursor": typeinfo.TypeText(evidence.Cursor.Type), - "end": typeinfo.TypeText(evidence.End.Type), - "ordinal": typeinfo.TypeText(evidence.Ordinal.Type), + "end": typeinfo.TypeText(plan.Limit.Type), + "ordinal": typeinfo.TypeText(plan.Ordinal.Type), "index": typeinfo.TypeText(evidence.Index.Type), "value": typeinfo.TypeText(evidence.Value.Type), } { @@ -105,7 +109,7 @@ func TestCheckForInRangeTypeIsBoundOrderIndependent(t *testing.T) { for name, typ := range map[string]typeinfo.Type{ "element": evidence.ElementType, "cursor": evidence.Cursor.Type, - "end": evidence.End.Type, + "end": evidence.Plan.(*typecheckresult.RangeIteration).Limit.Type, "value": evidence.Value.Type, } { if got := typeinfo.TypeText(typ); got != "i64" { @@ -136,7 +140,7 @@ return 0i64; for name, typ := range map[string]typeinfo.Type{ "element": evidence.ElementType, "cursor": evidence.Cursor.Type, - "end": evidence.End.Type, + "end": evidence.Plan.(*typecheckresult.RangeIteration).Limit.Type, "value": evidence.Value.Type, } { if got := typeinfo.TypeText(typ); got != "i64" { @@ -193,10 +197,14 @@ return total; if !ok { t.Fatal("missing sequence iteration evidence") } - if evidence.Kind != typecheckresult.ForIterationSequence || evidence.Carrier == nil || evidence.Cursor == nil { + plan, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) + if !isSequence { t.Fatalf("sequence iteration evidence = %#v", evidence) } - if got := typeinfo.TypeText(evidence.Carrier.Type); got != "&[3]i32" { + if evidence.Cursor == nil || plan.Carrier == nil { + t.Fatalf("sequence iteration evidence is incomplete = %#v", evidence) + } + if got := typeinfo.TypeText(plan.Carrier.Type); got != "&[3]i32" { t.Fatalf("carrier type = %s, want &[3]i32", got) } wantCursor, ok := typeinfo.NumericTypeFromName("usize", target.Host()) diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index cfb6d3de..59d069a6 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -68,29 +68,57 @@ func (m Match) Arm(caseIndex int) (MatchArm, bool) { return MatchArm{}, false } -type ForIterationKind uint8 - -const ( - ForIterationRange ForIterationKind = iota - ForIterationSequence -) - // ForIteration records typechecker-owned loop lowering and CFG evidence. // Generated symbols carry hidden loop state; source bindings remain body-scoped. +// +// Evidence is published only for a loop that typed cleanly, so a record that +// exists is complete: Cursor, Value and ElementType are always set, and Plan +// carries exactly one iteration kind. Consumers switch on the plan and read its +// fields; they must not re-check them, because a nil there would be a compiler +// bug rather than a shape the source can produce. type ForIteration struct { - Kind ForIterationKind GuaranteedEntry bool ElementType typeinfo.Type - CarrierType typeinfo.Type - Carrier *symbols.Symbol Cursor *symbols.Symbol - End *symbols.Symbol - Ordinal *symbols.Symbol - Index *symbols.Symbol Value *symbols.Symbol + // Index is nil unless the source binds an index name. + Index *symbols.Symbol + + // Plan carries the iteration kind and that kind's state as one value. + // There is no separate kind tag to disagree with the state, and no way to + // hold both kinds at once. + Plan IterationPlan +} + +// IterationPlan is the state one iteration kind needs. The interface is closed +// by its unexported method: only the two plans below implement it, so a +// consumer's type switch over both is exhaustive and a third kind cannot be +// introduced outside this package. +type IterationPlan interface { + iterationPlan() } +// RangeIteration is the state of a `for i in a..b` loop. Limit holds the +// evaluated exclusive upper bound; Ordinal counts iterations and is non-nil +// exactly when ForIteration.Index is. +type RangeIteration struct { + Limit *symbols.Symbol + Ordinal *symbols.Symbol +} + +func (*RangeIteration) iterationPlan() {} + +// SequenceIteration is the state of a `for v in seq` loop. Carrier holds the +// iterated storage for the loop's lifetime, borrowed when CarrierType is a +// reference; the cursor indexes through it. +type SequenceIteration struct { + Carrier *symbols.Symbol + CarrierType typeinfo.Type +} + +func (*SequenceIteration) iterationPlan() {} + // VariantConstruction records resolved enum construction without later path or field resolution. type VariantConstruction struct { EnumType typeinfo.Type From 62fa4e7f7618b1402047093bc019b25985c89807 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:23:39 +0600 Subject: [PATCH 34/80] Describe iteration evidence as the closed plan it now is --- docs/compiler-framework/change-paths.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 962aee04..86d2ba4f 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -75,8 +75,14 @@ variables get a body scope. **6. Typechecker** — `semantics/typechecker/check_stmt.go`, `checkStmt` The semantic rule, and — the important part — **the evidence you publish**. `checkStmt` delegates to `checkForInStmt`, which publishes -`typecheckresult.Result.ForIterations[node.ID()]`: the iteration kind, element type, -generated cursor/end/carrier symbols, and guaranteed-entry proof. +`typecheckresult.Result.ForIterations[node.ID()]`: element type, the generated cursor +and value symbols, guaranteed-entry proof, and an `IterationPlan` holding the kind +together with that kind's own state. + +> Publish evidence that cannot be malformed. `IterationPlan` is a closed interface, so +> a loop cannot claim one iteration kind while carrying another's state, and consumers +> need no defensive checks. Prefer this over a kind tag beside optional fields. + > **This is the load-bearing step.** Everything downstream consumes this evidence > rather than re-reading the source. If you publish nothing here, every later phase From 7b11a33d4728c386b57e02de13354996fec37ef4 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:29:50 +0600 Subject: [PATCH 35/80] Verify control-flow topology at the phase boundary CFG had no structural verifier. Analyze reports problems in the user's program against a graph it assumes well formed, so a construction defect reached flow typing, definite initialization, ownership and MIR as whatever those phases inferred from it. Add cfg.Module.Validate, reported as ICE0003 from the pipeline right after construction: block IDs index their own slice, entry and exit belong to the graph, every reachable block terminates and the exit does not, transfers stay inside the graph, a variant switch claims each case once, block adjacency agrees in both directions, sites carry the identity their position implies, site edges resolve and match the terminator that produced them, and the reachable flag matches an entry walk. It runs unconditionally, unlike the ownership validator: construction promises the same topology for a program that will not compile, and a malformed graph misleads Analyze immediately below it. Validate checks the graph and says nothing about the program; the split with Analyze is the boundary between a compiler bug and a user error. Evidence that it is not decorative. Forcing it to fail produced ICE0003 on 225 fixture compilations, so it does run end to end; with the probe removed the corpus passes, so it produces no false positives. Disabling each check group in turn fails exactly its own subtests and no others. --- internal/diagnostics/codes.go | 1 + internal/ir/cfg/validate.go | 354 +++++++++++++++++++++++++++++++ internal/ir/cfg/validate_test.go | 205 ++++++++++++++++++ internal/pipeline/pipeline.go | 8 + 4 files changed, 568 insertions(+) create mode 100644 internal/ir/cfg/validate.go create mode 100644 internal/ir/cfg/validate_test.go diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index 8b9b80f0..2bf5df35 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -87,6 +87,7 @@ const ( // Internal compiler errors (ICE prefix). ICE0001 is the generic marker in // diagnostic.go; codes here name a specific broken compiler invariant. ErrInvalidEvidence = "ICE0002" + ErrInvalidTopology = "ICE0003" // Style/Info codes (S prefix) InfoTrailingComma = "S0001" diff --git a/internal/ir/cfg/validate.go b/internal/ir/cfg/validate.go new file mode 100644 index 00000000..87f080eb --- /dev/null +++ b/internal/ir/cfg/validate.go @@ -0,0 +1,354 @@ +package cfg + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" +) + +// maxReportedProblems bounds one internal error so a systematic construction +// break reports a readable sample instead of one line per block in the module. +const maxReportedProblems = 10 + +// Validate checks that finalized control-flow topology is internally +// consistent: blocks and sites are identified as construction promises, every +// reachable block terminates, edges agree with the terminator that produced +// them, and every adjacency is recorded from both ends. A failure means CFG +// construction produced a malformed graph, so callers report it as an internal +// error, never as a source diagnostic. +// +// This is deliberately disjoint from Analyze. Analyze reports problems in the +// user's program — unreachable code, constant conditions, a missing return — +// against a graph it assumes well-formed. Validate checks the graph itself and +// says nothing about the program. Neither re-derives a semantic decision. +func (m *Module) Validate() error { + if m == nil { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Functions { + if fn == nil { + problems = append(problems, "module holds a nil function graph") + continue + } + problems = append(problems, validateGraph(fn)...) + } + if len(problems) == 0 { + return nil + } + // Reported in a stable order: callers compare internal errors across runs, + // and block iteration is the only ordered part of the walk. + sort.Strings(problems) + total := len(problems) + if total > maxReportedProblems { + problems = problems[:maxReportedProblems] + return fmt.Errorf("%s (%d more)", strings.Join(problems, "; "), total-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateGraph(fn *Graph) []string { + problems := validateBlockIdentity(fn) + // Every later check indexes blocks by ID, so a broken index makes their + // output noise rather than evidence. + if len(problems) > 0 { + return problems + } + problems = append(problems, validateTermination(fn)...) + problems = append(problems, validateBlockAdjacency(fn)...) + problems = append(problems, validateSites(fn)...) + problems = append(problems, validateReachability(fn)...) + return problems +} + +// validateBlockIdentity checks the promise every other check depends on: block +// IDs are dense indexes into Blocks, and entry and exit are blocks of this +// graph rather than of another one. +func validateBlockIdentity(fn *Graph) []string { + problems := make([]string, 0) + for index, block := range fn.Blocks { + if block == nil { + problems = append(problems, fmt.Sprintf("function %d holds a nil block at index %d", fn.NodeID, index)) + continue + } + if block.ID != index { + problems = append(problems, fmt.Sprintf("function %d block at index %d identifies as b%d", fn.NodeID, index, block.ID)) + } + } + if len(problems) > 0 { + return problems + } + if fn.Entry == nil { + problems = append(problems, fmt.Sprintf("function %d has no entry block", fn.NodeID)) + } else if !ownsBlock(fn, fn.Entry) { + problems = append(problems, fmt.Sprintf("function %d entry b%d is not one of its blocks", fn.NodeID, fn.Entry.ID)) + } + if fn.Exit == nil { + problems = append(problems, fmt.Sprintf("function %d has no exit block", fn.NodeID)) + } else if !ownsBlock(fn, fn.Exit) { + problems = append(problems, fmt.Sprintf("function %d exit b%d is not one of its blocks", fn.NodeID, fn.Exit.ID)) + } + return problems +} + +// validateTermination checks that control leaves every reachable block. The +// exit block is the one exception: it is where control stops. +func validateTermination(fn *Graph) []string { + problems := make([]string, 0) + for _, block := range fn.Blocks { + if block == fn.Exit { + if block.Terminator != nil { + problems = append(problems, fmt.Sprintf("function %d exit b%d carries a terminator", fn.NodeID, block.ID)) + } + continue + } + if block.Terminator == nil { + // An unreachable block may legitimately have been abandoned mid + // construction; a reachable one leaves control nowhere. + if block.Reachable { + problems = append(problems, fmt.Sprintf("function %d reachable block b%d has no terminator", fn.NodeID, block.ID)) + } + continue + } + for _, successor := range block.Terminator.Successors() { + if successor == nil { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to a nil block", fn.NodeID, block.ID)) + } else if !ownsBlock(fn, successor) { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which is not one of its blocks", fn.NodeID, block.ID, successor.ID)) + } + } + if variant, ok := block.Terminator.(*SwitchVariant); ok { + problems = append(problems, validateVariantCases(fn, block, variant)...) + } + } + return problems +} + +// validateVariantCases checks that a variant switch selects each case once. +// Which cases a switch should carry is a typechecking decision; that two +// targets claim the same one is a topology defect, because the second is +// unreachable through the edge that names it. +func validateVariantCases(fn *Graph, block *Block, term *SwitchVariant) []string { + problems := make([]string, 0) + seen := make(map[int]bool, len(term.Targets)) + for _, target := range term.Targets { + if target.Case < 0 { + problems = append(problems, fmt.Sprintf("function %d block b%d switches on negative case %d", fn.NodeID, block.ID, target.Case)) + } + if seen[target.Case] { + problems = append(problems, fmt.Sprintf("function %d block b%d switches twice on case %d", fn.NodeID, block.ID, target.Case)) + } + seen[target.Case] = true + } + return problems +} + +// validateBlockAdjacency checks that block-level predecessors record exactly +// the transfers terminators make. A consumer walking backwards must see the +// same graph as one walking forwards. +func validateBlockAdjacency(fn *Graph) []string { + problems := make([]string, 0) + forward := make(map[[2]int]bool) + for _, block := range fn.Blocks { + if block.Terminator == nil { + continue + } + for _, successor := range block.Terminator.Successors() { + if successor != nil && ownsBlock(fn, successor) { + forward[[2]int{block.ID, successor.ID}] = true + } + } + } + recorded := make(map[[2]int]bool) + for _, block := range fn.Blocks { + for _, predecessor := range block.Predecessors { + if predecessor == nil { + problems = append(problems, fmt.Sprintf("function %d block b%d lists a nil predecessor", fn.NodeID, block.ID)) + continue + } + pair := [2]int{predecessor.ID, block.ID} + if !forward[pair] { + problems = append(problems, fmt.Sprintf("function %d block b%d lists b%d as a predecessor, which does not transfer to it", fn.NodeID, block.ID, predecessor.ID)) + } + recorded[pair] = true + } + } + for pair := range forward { + if !recorded[pair] { + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which does not list it as a predecessor", fn.NodeID, pair[0], pair[1])) + } + } + return problems +} + +// validateSites checks the program points consumers key evidence against: every +// block owns at least one, each carries the identity its position implies, and +// every site edge resolves, agrees with the terminator that produced it, and is +// recorded from both ends. +func validateSites(fn *Graph) []string { + problems := make([]string, 0) + for _, block := range fn.Blocks { + if len(block.Sites) == 0 { + problems = append(problems, fmt.Sprintf("function %d block b%d owns no site", fn.NodeID, block.ID)) + continue + } + for index, site := range block.Sites { + if site == nil { + problems = append(problems, fmt.Sprintf("function %d block b%d holds a nil site at index %d", fn.NodeID, block.ID, index)) + continue + } + want := SiteID{Block: block.ID, Index: index} + if site.ID != want { + problems = append(problems, fmt.Sprintf("function %d site at b%d[%d] identifies as b%d[%d]", fn.NodeID, block.ID, index, site.ID.Block, site.ID.Index)) + } + if site.Kind == SiteScopeExit && site.ScopeID == 0 { + problems = append(problems, fmt.Sprintf("function %d scope exit at b%d[%d] names no scope", fn.NodeID, block.ID, index)) + } + } + } + if len(problems) > 0 { + return problems + } + return validateSiteEdges(fn) +} + +func validateSiteEdges(fn *Graph) []string { + problems := make([]string, 0) + recorded := make(map[Edge]bool) + for _, block := range fn.Blocks { + last := len(block.Sites) - 1 + for index, site := range block.Sites { + for _, edge := range site.Successors { + if edge.From != site.ID { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] owns an edge leaving %v", fn.NodeID, block.ID, index, edge.From)) + } + if siteAt(fn, edge.To) == nil { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] transfers to %v, which is not a site", fn.NodeID, block.ID, index, edge.To)) + continue + } + if kind, ok := expectedEdgeKind(block, index == last, edge.Kind); !ok { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] leaves on a %s edge, but %s", fn.NodeID, block.ID, index, edgeKindName(edge.Kind), kind)) + } + recorded[edge] = true + } + for _, edge := range site.Predecessors { + if edge.To != site.ID { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] records an edge arriving at %v", fn.NodeID, block.ID, index, edge.To)) + } + if siteAt(fn, edge.From) == nil { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] arrives from %v, which is not a site", fn.NodeID, block.ID, index, edge.From)) + } + } + } + } + if len(problems) > 0 { + return problems + } + // Both directions must describe the same edge set, so a consumer walking + // predecessors sees every transfer a successor walk would. + for _, block := range fn.Blocks { + for index, site := range block.Sites { + for _, edge := range site.Predecessors { + if !recorded[edge] { + problems = append(problems, fmt.Sprintf("function %d site b%d[%d] arrives on an edge %v does not send", fn.NodeID, block.ID, index, edge.From)) + } + } + } + } + for edge := range recorded { + target := siteAt(fn, edge.To) + found := slices.Contains(target.Predecessors, edge) + if !found { + problems = append(problems, fmt.Sprintf("function %d site %v sends an edge %v does not record", fn.NodeID, edge.From, edge.To)) + } + } + return problems +} + +// expectedEdgeKind reports whether one outgoing edge kind is legal at a site. +// Edges between sites within a block are plain sequence; only the block's last +// site leaves on the terminator, and then the kind must name that terminator's +// meaning. It returns the expectation to quote when the kind is wrong. +func expectedEdgeKind(block *Block, last bool, kind EdgeKind) (string, bool) { + if !last { + if kind == EdgeNormal { + return "", true + } + return "a site inside a block leaves only on a normal edge", false + } + switch block.Terminator.(type) { + case *Jump: + return "a jump leaves only on a normal edge", kind == EdgeNormal + case *Branch: + return "a branch leaves only on a true or false edge", kind == EdgeTrue || kind == EdgeFalse + case *Return: + return "a return leaves only on a return edge", kind == EdgeReturn + case *SwitchVariant: + return "a variant switch leaves only on a variant-case edge", kind == EdgeVariantCase + case nil: + return "a block with no terminator leaves on no edge", false + } + return "", true +} + +// validateReachability checks the flag consumers trust against the traversal it +// claims to summarize. Analyze reports unreachable user code from this flag, so +// a stale flag turns a construction defect into a wrong diagnostic. +func validateReachability(fn *Graph) []string { + seen := make(map[int]bool, len(fn.Blocks)) + var walk func(block *Block) + walk = func(block *Block) { + if block == nil || seen[block.ID] { + return + } + seen[block.ID] = true + if block.Terminator == nil { + return + } + for _, successor := range block.Terminator.Successors() { + walk(successor) + } + } + walk(fn.Entry) + + problems := make([]string, 0) + for _, block := range fn.Blocks { + if block.Reachable != seen[block.ID] { + problems = append(problems, fmt.Sprintf("function %d block b%d is marked reachable=%t but entry traversal says %t", fn.NodeID, block.ID, block.Reachable, seen[block.ID])) + } + } + return problems +} + +func ownsBlock(fn *Graph, block *Block) bool { + return block.ID >= 0 && block.ID < len(fn.Blocks) && fn.Blocks[block.ID] == block +} + +func siteAt(fn *Graph, id SiteID) *Site { + if id.Block < 0 || id.Block >= len(fn.Blocks) { + return nil + } + block := fn.Blocks[id.Block] + if id.Index < 0 || id.Index >= len(block.Sites) { + return nil + } + return block.Sites[id.Index] +} + +func edgeKindName(kind EdgeKind) string { + switch kind { + case EdgeNormal: + return "normal" + case EdgeTrue: + return "true" + case EdgeFalse: + return "false" + case EdgeReturn: + return "return" + case EdgeVariantCase: + return "variant-case" + } + return fmt.Sprintf("unknown(%d)", kind) +} diff --git a/internal/ir/cfg/validate_test.go b/internal/ir/cfg/validate_test.go new file mode 100644 index 00000000..391c0036 --- /dev/null +++ b/internal/ir/cfg/validate_test.go @@ -0,0 +1,205 @@ +package cfg + +import ( + "strings" + "testing" + + "compiler/internal/frontend/ast" + "compiler/internal/source" +) + +// branchingModule builds a graph with every terminator kind reachable from one +// function, so a break in any check group has something real to break. +func branchingModule(t *testing.T) *Module { + t.Helper() + location := source.NewLocation("validate_test.peep", source.Position{Line: 1, Column: 1}, source.Position{Line: 1, Column: 10}) + branch := &ast.IfStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 30}, + Cond: &ast.BoolLit{NodeIDHolder: ast.NodeIDHolder{NodeID: 31}, Value: true, Location: location}, + Then: &ast.BlockStmt{ + NodeIDHolder: ast.NodeIDHolder{NodeID: 32}, + Stmts: []ast.Stmt{&ast.ReturnStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 33}, Location: location}}, + }, + Else: &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 34}}, + Location: location, + } + body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{branch}} + module := BuildModule(testModule(body, nil), BuildQueries{}) + if module == nil || len(module.Functions) != 1 { + t.Fatalf("test fixture built %#v, want one function graph", module) + } + if module.Functions[0] == nil { + t.Fatal("test fixture built a nil graph") + } + return module +} + +// A graph the builder produced must validate. Without this the negative tests +// below could all pass against a fixture that was malformed to begin with. +func TestValidateAcceptsConstructedTopology(t *testing.T) { + if err := branchingModule(t).Validate(); err != nil { + t.Fatalf("constructed topology rejected: %v", err) + } +} + +func TestValidateRejectsTopologyDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Graph) + want string + }{ + { + name: "block identity does not match its index", + damage: func(fn *Graph) { fn.Blocks[2].ID = 99 }, + want: "identifies as b99", + }, + { + name: "entry belongs to another graph", + damage: func(fn *Graph) { fn.Entry = &Block{ID: 0} }, + want: "entry b0 is not one of its blocks", + }, + { + name: "exit is missing", + damage: func(fn *Graph) { fn.Exit = nil }, + want: "has no exit block", + }, + { + name: "a reachable block leaves control nowhere", + damage: func(fn *Graph) { + fn.Entry.Terminator = nil + }, + want: "reachable block b0 has no terminator", + }, + { + name: "the exit block terminates", + damage: func(fn *Graph) { + fn.Exit.Terminator = &Jump{Target: fn.Entry} + }, + want: "carries a terminator", + }, + { + name: "a transfer leaves the graph", + damage: func(fn *Graph) { + fn.Entry.Terminator = &Jump{Target: &Block{ID: 7}} + }, + want: "is not one of its blocks", + }, + { + name: "a variant switch claims one case twice", + damage: func(fn *Graph) { + fn.Entry.Terminator = &SwitchVariant{Targets: []VariantTarget{ + {Case: 0, Target: fn.Exit}, {Case: 0, Target: fn.Exit}, + }} + }, + want: "switches twice on case 0", + }, + { + name: "a predecessor records a transfer that does not exist", + damage: func(fn *Graph) { + fn.Exit.Predecessors = append(fn.Exit.Predecessors, fn.Blocks[2]) + }, + want: "as a predecessor, which does not transfer to it", + }, + { + name: "a transfer goes unrecorded by its target", + damage: func(fn *Graph) { + fn.Exit.Predecessors = nil + }, + want: "which does not list it as a predecessor", + }, + { + name: "a site carries the wrong identity", + damage: func(fn *Graph) { + fn.Entry.Sites[0].ID = SiteID{Block: 4, Index: 6} + }, + want: "identifies as b4[6]", + }, + { + name: "a scope exit names no scope", + damage: func(fn *Graph) { + for _, block := range fn.Blocks { + for _, site := range block.Sites { + if site.Kind == SiteScopeExit { + site.ScopeID = 0 + return + } + } + } + t.Fatal("fixture has no scope exit site to damage") + }, + want: "names no scope", + }, + { + name: "a site edge points at no site", + damage: func(fn *Graph) { + last := fn.Entry.Sites[len(fn.Entry.Sites)-1] + last.Successors[0].To = SiteID{Block: 42, Index: 0} + }, + want: "which is not a site", + }, + { + name: "a branch leaves on a plain sequence edge", + damage: func(fn *Graph) { + last := fn.Entry.Sites[len(fn.Entry.Sites)-1] + last.Successors[0].Kind = EdgeNormal + }, + want: "a branch leaves only on a true or false edge", + }, + { + name: "reachability disagrees with entry traversal", + damage: func(fn *Graph) { + fn.Blocks[len(fn.Blocks)-1].Reachable = !fn.Blocks[len(fn.Blocks)-1].Reachable + }, + want: "entry traversal says", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := branchingModule(t) + test.damage(module.Functions[0]) + err := module.Validate() + if err == nil { + t.Fatalf("damaged topology accepted: %s", test.name) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %q, want it to mention %q", err.Error(), test.want) + } + }) + } +} + +// Problems are collected by walking maps, so an unsorted report would name +// different defects on different runs and make an internal error unreproducible. +func TestValidateReportsDefectsDeterministically(t *testing.T) { + first := "" + for attempt := 0; attempt < 8; attempt++ { + module := branchingModule(t) + fn := module.Functions[0] + fn.Exit.Predecessors = nil + for _, block := range fn.Blocks { + block.Reachable = !block.Reachable + } + err := module.Validate() + if err == nil { + t.Fatal("damaged topology accepted") + } + if attempt == 0 { + first = err.Error() + continue + } + if err.Error() != first { + t.Fatalf("report %d = %q, want %q", attempt, err.Error(), first) + } + } +} + +func TestValidateAcceptsAnEmptyModule(t *testing.T) { + var missing *Module + if err := missing.Validate(); err != nil { + t.Fatalf("nil module rejected: %v", err) + } + if err := (&Module{}).Validate(); err != nil { + t.Fatalf("empty module rejected: %v", err) + } +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 2662d7fe..14a04bc2 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -434,6 +434,14 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di MatchCases: module.Typechecking.MatchCases, LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) + // Structure is checkable regardless of source validity: CFG construction + // promises the same topology for a program that will not compile, and a + // malformed graph misleads every phase that reads it, including Analyze + // just below. + if err := module.CFG.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidTopology, + "control-flow topology is malformed: "+err.Error(), nil, "") + } cfg.Analyze(module.CFG, phaseDiag, func(conditionID, scopeID ir.NodeID) (bool, bool) { node := module.TypedASTNodes[ast.NodeID(conditionID)] expr, ok := node.(ast.Expr) From 3afde1313e5e1d87b329e93de24c43fc2e21ea6f Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:30:08 +0600 Subject: [PATCH 36/80] Record the CFG verifier in the change-path gap list --- docs/compiler-framework/change-paths.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 86d2ba4f..1b5de295 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -96,6 +96,9 @@ If CFG construction needs a semantic fact, it takes it as a **query**, not by im the typechecker: `cfg.BuildQueries{MatchCases, LoopGuaranteedEntry}`. `LoopGuaranteedEntry` exists exactly because a loop with a proven-nonempty range must not report its body as conditionally skipped. +*Catches you:* **Loud** — `cfg.Module.Validate` reports `ICE0003` when the topology you +build is malformed: an unterminated reachable block, an edge kind disagreeing with its +terminator, an adjacency recorded from only one side, or a stale reachable flag. **8. Flow typing** — `semantics/typechecker/flow.go`, `applyConditionEdge` Which facts differ on the true and false edges? A `for` header carries no narrowing @@ -281,6 +284,7 @@ representation, cover both 32- and 64-bit. | `ownershipresult.Validate` → `ICE0002` | Published ownership evidence matches CFG and types | pipeline, after ownership | | `llvm.ValidateRuntimeSymbols` | Reserved runtime symbols, extern ownership | pipeline, after backend emission | | `GenerateLLVMIR` panics | Unhandled MIR node; block with no terminator | backend emission | +| `cfg.Module.Validate` → `ICE0003` | Block and site identity, termination, edge kind vs terminator, adjacency in both directions, reachability | pipeline, after CFG construction | | `cfg.Analyze` | Unreachable code, constant conditions, missing return — **user diagnostics, not structure** | after CFG | ## Where nothing catches you @@ -292,9 +296,9 @@ the honor system: capability, lowering, or fingerprinting compiles and passes. - **HIR and MIR have no dispatch contract**, and `mir.Instr`/`mir.Terminator` are unsealed, so exhaustiveness is a runtime panic rather than a compile error. -- **No structural validator exists for CFG, HIR, or MIR.** `cfg.Analyze` reports user - problems; it checks no invariant of the graph itself. Ownership is the only artifact - with a boundary validator. +- **No structural validator exists for HIR or MIR.** CFG topology and ownership + evidence have boundary validators; the two lowered representations do not, so a + malformed HIR or MIR artifact is caught only when the backend trips over it. - **Nothing requires a fixture.** A construct can reach the backend with no end-to-end coverage at all. - **Nothing requires an LSP update**, so a new construct can be invisible to hover and From 557e8b57c8e4514b6a3330880ccc3ad2aa487c70 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:34:36 +0600 Subject: [PATCH 37/80] Require a phase decision for every type syntax kind Statement and expression kinds were enrolled in the dispatch contract; the twelve type kinds were not, so adding one compiled and passed while two phases silently ignored it. Both failures are quiet. A kind TypeFromSyntax does not resolve becomes an invalid type rather than a diagnostic, and a kind missing from declaration-cycle detection hides a recursive type until layout recurses forever. Add typeSites covering typeinfo.TypeFromSyntax and the binder's addTypeDeclEdges. Both are exhaustive today, so the contract locks that in rather than reporting a backlog. Stale-reason checking now spans all three families. Verified by mutation at both sites: dropping the raw-pointer case from addTypeDeclEdges and renaming the reference case in TypeFromSyntax each fail with the omitted kind named. --- internal/contracts/node_dispatch_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index da28728f..090284a6 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -387,6 +387,16 @@ var expressionSites = []dispatchSite{ }, } +// Type syntax reaches two phases that must decide per kind, and both failures +// are silent: an unresolved kind becomes an invalid type rather than an error, +// and a kind missing from declaration-cycle detection hides a recursive type +// until layout recurses forever. Parsing and printing walk type syntax too, but +// they consume the node's own TypeText rather than dispatching on its kind. +var typeSites = []dispatchSite{ + {file: "semantics/typeinfo/syntax.go", fn: "TypeFromSyntax"}, + {file: "semantics/binder/type_decl_cycles.go", fn: "addTypeDeclEdges"}, +} + func assertSitesDecide(t *testing.T, sites []dispatchSite, kinds []string) { t.Helper() for _, site := range sites { @@ -418,11 +428,17 @@ func TestEveryExpressionKindHasAPhaseDecision(t *testing.T) { assertSitesDecide(t, expressionSites, declaredKinds(t, "exprNode")) } +func TestEveryTypeKindHasAPhaseDecision(t *testing.T) { + assertSitesDecide(t, typeSites, declaredKinds(t, "typeNode")) +} + // A reason that no longer names a real statement kind is stale and must not // silently excuse a future kind of the same name. func TestOmissionReasonsNameRealNodeKinds(t *testing.T) { kinds := append(declaredKinds(t, "stmtNode"), declaredKinds(t, "exprNode")...) - for _, site := range append(slices.Clone(statementSites), expressionSites...) { + kinds = append(kinds, declaredKinds(t, "typeNode")...) + sites := slices.Concat(statementSites, expressionSites, typeSites) + for _, site := range sites { for kind, entry := range site.omissions() { if !slices.Contains(kinds, kind) { t.Errorf("%s classifies kind %s that no longer exists", site.fn, kind) From 2a5fcf046ba6bb64114db27e2fd66b1bd4bc0c8f Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:41:07 +0600 Subject: [PATCH 38/80] Seal the MIR instruction and terminator node sets Instr and Terminator declared identical method sets, so every instruction satisfied Terminator and every terminator satisfied Instr. Lowering could put a Drop in a block's terminator position, or a Ret among its instructions, and nothing objected until the backend panicked or emitted an unterminated block. Give each interface an unexported marker. The two sets are now distinct and closed to the mir package, and a compile-time membership assertion records which node belongs to which, so moving one between the sets stops the build. This removes the fabricated node the backend's unknown-node test relied on: it can no longer implement either interface, which is the point. The two subtests it fed are gone and the reachable case, a block carrying no terminator, is kept. The panicking defaults stay for a node added inside the mir package without a backend case, and their comments now say that is the only way to reach them. What is still missing is a contract proving the backend classifies every declared node. The change-path gap list records that rather than implying the seal closed it. --- docs/compiler-framework/change-paths.md | 30 ++++++++++++++++-------- internal/backend/llvm/emitter.go | 6 ++++- internal/backend/llvm/emitter_test.go | 28 ++++------------------ internal/ir/mir/model.go | 21 +++++++++++++++++ internal/ir/mir/model_membership_test.go | 23 ++++++++++++++++++ 5 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 internal/ir/mir/model_membership_test.go diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 1b5de295..735353cb 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -131,8 +131,10 @@ five blocks. Lower normalized control flow and consume the cleanup plan. `hir.For` is read in `lowerCFGFunction` (to find the loop a CFG block belongs to) and in `lowerCFGTerminator` (to emit the header and latch). -*Catches you:* — nothing. MIR has no dispatch contract; `mir.Instr` and -`mir.Terminator` are unsealed. +*Catches you:* **Automatic**, partly — `mir.Instr` and `mir.Terminator` are sealed by +unexported markers, so the set is closed to the `mir` package and an instruction can no +longer be used where a terminator belongs. What still catches nothing is forgetting to +classify a new node in the backend: MIR has no dispatch contract. **14. Backend** — `backend/llvm/` **The for-loop change touched no backend file at all.** This is the single most @@ -214,7 +216,7 @@ lowering carry an opinion of its own; the third recorded an opinion nothing cons | Concern | Guard | | --- | --- | -| New MIR instruction or terminator | **Loud** — `GenerateLLVMIR` panics; `TestGenerateLLVMIRPanicsForUnknownMIRNodes` | +| New MIR instruction or terminator | **Loud** — `GenerateLLVMIR` panics on an unclassified node | | Block emitted with no terminator | **Loud** — panics: `LLVM emission: block bN has no terminator` | | Operand/type mismatch in emission | **Visible** — `TestTypedLLVMBuilderRejectsOperandMismatches` | | Ownership evidence inconsistent with CFG or types | **Loud** — `ICE0002` from `ownershipresult.Validate` at the phase boundary | @@ -225,9 +227,14 @@ lowering carry an opinion of its own; the third recorded an opinion nothing cons ## Walk 3 — adding a type -A new `typeinfo.Type` is the change shape with the **weakest** automatic coverage, -because the type family has no dispatch contract. Read this walk as a checklist you -must run manually. +A new `typeinfo.Type` is the change shape with the **weakest** automatic coverage. The +contract added for AST type *syntax* does not help here: `typeinfo.Type` is the semantic +type model, a different family with no contract of its own. Read this walk as a +checklist you must run manually. + +If your type also needs new syntax to write it, that syntax node joins the `typeNode` +family and `TestEveryTypeKindHasAPhaseDecision` will hold you to `TypeFromSyntax` and +the binder's `addTypeDeclEdges`. **1. Declare it** — `semantics/typeinfo/types.go` Implement `Type`: `TypeNode()` and `Text() string`. @@ -280,6 +287,7 @@ representation, cover both 32- and 64-bit. | `TestEverySubStructureFieldIsExpanded` | Sub-structure fields are expanded | `internal/contracts` | | `TestEveryStatementKindHasAPhaseDecision` | 19 statement kinds × 9 phase sites | `internal/contracts` | | `TestEveryExpressionKindHasAPhaseDecision` | 23 expression kinds × 4 phase sites | `internal/contracts` | +| `TestEveryTypeKindHasAPhaseDecision` | 12 type-syntax kinds × 2 phase sites | `internal/contracts` | | `TestOmissionReasonsNameRealNodeKinds` | Inert-kind reasons stay true | `internal/contracts` | | `ownershipresult.Validate` → `ICE0002` | Published ownership evidence matches CFG and types | pipeline, after ownership | | `llvm.ValidateRuntimeSymbols` | Reserved runtime symbols, extern ownership | pipeline, after backend emission | @@ -292,10 +300,12 @@ representation, cover both 32- and 64-bit. Stated plainly, because a contributor deserves to know which parts of the walk are on the honor system: -- **Type kinds have no dispatch contract.** Adding a `typeinfo.Type` and forgetting - capability, lowering, or fingerprinting compiles and passes. -- **HIR and MIR have no dispatch contract**, and `mir.Instr`/`mir.Terminator` are - unsealed, so exhaustiveness is a runtime panic rather than a compile error. +- **Semantic type kinds have no dispatch contract.** AST type *syntax* is covered by + `TestEveryTypeKindHasAPhaseDecision`, but adding a `typeinfo.Type` and forgetting + capability, lowering, or fingerprinting still compiles and passes. +- **HIR and MIR have no dispatch contract.** `mir.Instr`/`mir.Terminator` are sealed, + so the node set is closed and the two cannot be confused, but nothing proves the + backend classifies every member; that is still a runtime panic. - **No structural validator exists for HIR or MIR.** CFG topology and ownership evidence have boundary validators; the two lowered representations do not, so a malformed HIR or MIR artifact is caught only when the backend trips over it. diff --git a/internal/backend/llvm/emitter.go b/internal/backend/llvm/emitter.go index 3b24fbb2..ec97318e 100644 --- a/internal/backend/llvm/emitter.go +++ b/internal/backend/llvm/emitter.go @@ -292,7 +292,9 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo lb.setLocation(instr.SourceLocation()) // Emitting nothing for an unrecognized instruction silently drops // program behavior, so every MIR instruction must be classified - // here. A missing case is a compiler bug, not invalid source. + // here. mir.Instr is sealed, so the only way to reach the + // default is to add an instruction inside the mir package and + // not classify it here: a compiler bug, not invalid source. switch typed := instr.(type) { case *mir.Assign: val := emitValueExpr(lb, typed.Value) @@ -320,6 +322,8 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo // Both terminator invariants are compiler bugs, not invalid source: // every block carries a terminator, and every terminator kind emits // one. Skipping either silently produces unterminated LLVM IR. + // mir.Terminator is sealed, so the unhandled-kind default below can + // only be reached from inside the mir package. if block.Term == nil { panic(fmt.Sprintf("LLVM emission: block b%d has no terminator", block.ID)) } diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 60b4b375..81b12abd 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -284,16 +284,6 @@ func requireLLVMInvariant(t *testing.T, emit func()) { emit() } -type unknownMIRNode struct{} - -func (*unknownMIRNode) Text() string { return "unknown" } -func (*unknownMIRNode) SourceLocation() *source.Location { return nil } - -var ( - _ mir.Instr = (*unknownMIRNode)(nil) - _ mir.Terminator = (*unknownMIRNode)(nil) -) - func TestLLVMLayoutsNameBuiltInCarrierFields(t *testing.T) { interfaceType := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInterface}) ownedInterface := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeOwnedPtr, Elem: interfaceType}) @@ -358,25 +348,17 @@ func TestTypedLLVMBuilderRejectsOperandMismatches(t *testing.T) { } } -func TestGenerateLLVMIRPanicsForUnknownMIRNodes(t *testing.T) { +// mir.Instr and mir.Terminator are sealed, so a node this package could pass to +// emission no longer exists: the earlier fabricated one cannot implement either +// interface. What remains reachable is a block that carries no terminator at +// all, which would otherwise emit an unterminated basic block with no signal. +func TestGenerateLLVMIRPanicsForMalformedMIRBlocks(t *testing.T) { for _, tt := range []struct { name string block *mir.Block want string }{ { - name: "instruction", - block: &mir.Block{Instrs: []mir.Instr{&unknownMIRNode{}}}, - want: "LLVM emission: unhandled MIR instruction *llvm.unknownMIRNode", - }, - { - name: "terminator", - block: &mir.Block{Term: &unknownMIRNode{}}, - want: "LLVM emission: unhandled MIR terminator *llvm.unknownMIRNode", - }, - { - // A block that reaches emission with no terminator would otherwise - // produce an unterminated basic block and no compiler-side signal. name: "missing terminator", block: &mir.Block{ID: 7}, want: "LLVM emission: block b7 has no terminator", diff --git a/internal/ir/mir/model.go b/internal/ir/mir/model.go index 1c645916..007a0c93 100644 --- a/internal/ir/mir/model.go +++ b/internal/ir/mir/model.go @@ -67,12 +67,20 @@ type Block struct { Term Terminator } +// Instr and Terminator are sealed by their marker methods. Without them the two +// interfaces are structurally identical, so a terminator satisfies Instr and an +// instruction satisfies Terminator, and lowering can put either in the other's +// position with nothing to catch it until the backend panics. The markers also +// keep the set closed to this package, so the backend's exhaustive switches +// cannot be outflanked by a node declared elsewhere. type Instr interface { + instrNode() Text() string SourceLocation() *source.Location } type Terminator interface { + termNode() Text() string SourceLocation() *source.Location } @@ -377,6 +385,19 @@ func (i *Ret) Text() string { return "ret " + i.Value.Text() } +func (*Assign) instrNode() {} +func (*Store) instrNode() {} +func (*Print) instrNode() {} +func (*Drop) instrNode() {} +func (*DynamicArrayOp) instrNode() {} +func (*Call) instrNode() {} +func (*InterfaceCall) instrNode() {} + +func (*Jump) termNode() {} +func (*Branch) termNode() {} +func (*SwitchVariant) termNode() {} +func (*Ret) termNode() {} + func (*Unary) valueExprNode() {} func (*Binary) valueExprNode() {} func (*StringConcat) valueExprNode() {} diff --git a/internal/ir/mir/model_membership_test.go b/internal/ir/mir/model_membership_test.go new file mode 100644 index 00000000..3db478ee --- /dev/null +++ b/internal/ir/mir/model_membership_test.go @@ -0,0 +1,23 @@ +package mir + +// The two node sets, asserted at compile time. Instr and Terminator are +// structurally identical apart from their markers, so these declarations are +// what keep a node in the set its position requires: drop a marker, or move a +// node between the sets, and this file stops compiling. +// +// This is membership only. That the backend classifies every member is a +// separate contract, and one the repository does not yet have for MIR. +var ( + _ Instr = (*Assign)(nil) + _ Instr = (*Store)(nil) + _ Instr = (*Print)(nil) + _ Instr = (*Drop)(nil) + _ Instr = (*DynamicArrayOp)(nil) + _ Instr = (*Call)(nil) + _ Instr = (*InterfaceCall)(nil) + + _ Terminator = (*Jump)(nil) + _ Terminator = (*Branch)(nil) + _ Terminator = (*SwitchVariant)(nil) + _ Terminator = (*Ret)(nil) +) From b9dc98254540fd72e7e52667e6337174a57df315 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:47:59 +0600 Subject: [PATCH 39/80] Name the block a loop exits to Auditing every topology query outside the CFG package found two readers of BlockOrigin. MIR asks which role a block plays inside its loop and gets a direct answer. Ownership asked whether it was leaving a sequence loop, and got it by inference: a block carrying no loop role whose NodeID happens to name a loop is that loop's exit. That held only because the exit was the one of a loop's five blocks left as BlockNormal. Giving it a role later, or reusing BlockNormal elsewhere with a loop NodeID, would have silently stopped releasing iteration loans, which is a borrow error the CFG tests could not see. Add BlockLoopExit and label the block. Ownership now asks which loop it is leaving instead of deducing it, and three CFG tests that had encoded the inference now name the role. Missing-return reporting distinguished structured control from plain continuations by comparing against BlockNormal, so a new origin would have changed which span a user sees. Move that to structuredControl, where a loop exit stays a continuation, and pin every origin's classification in a test. Diagnostics for loop, nested-loop and conditional fall-through are byte-identical before and after. This is the smallest thing that removes the inference. No loop descriptor is warranted: MIR's query is already served, and one consumer needing one fact does not meet the bar in the framework roadmap. --- internal/ir/cfg/analyze.go | 19 ++++++++++++-- internal/ir/cfg/build.go | 2 +- internal/ir/cfg/cfg_test.go | 31 ++++++++++++++++++++--- internal/ir/cfg/model.go | 6 +++++ internal/semantics/ownership/ownership.go | 5 +++- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/internal/ir/cfg/analyze.go b/internal/ir/cfg/analyze.go index 5f797dee..c977698c 100644 --- a/internal/ir/cfg/analyze.go +++ b/internal/ir/cfg/analyze.go @@ -124,7 +124,7 @@ func findMissingReturnBranches(fn *Graph) []*Block { found := make([]*Block, 0) seen := make(map[*Block]bool) for block := range reachesExit { - if block.Origin != BlockNormal { + if structuredControl(block.Origin) { if !seen[block] { found = append(found, block) seen[block] = true @@ -140,7 +140,7 @@ func findMissingReturnBranches(fn *Graph) []*Block { continue } traceSeen[current] = true - if current.Origin != BlockNormal { + if structuredControl(current.Origin) { if !seen[current] { found = append(found, current) seen[current] = true @@ -154,6 +154,21 @@ func findMissingReturnBranches(fn *Graph) []*Block { return found } +// structuredControl reports whether a block is part of a structured construct +// rather than a plain continuation. Missing-return reporting walks back to the +// nearest such block to name the branch that falls through. A loop exit is a +// continuation despite carrying a loop role: the code after the loop lives +// there, and reporting it would name the wrong branch. +func structuredControl(origin BlockOrigin) bool { + switch origin { + case BlockNormal, BlockLoopExit: + return false + case BlockThen, BlockElse, BlockLoopInit, BlockLoop, BlockLoopBody, BlockLoopLatch: + return true + } + return true +} + func filterMostSpecificBranches(blocks []*Block) []*Block { if len(blocks) <= 1 { return blocks diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index 6fe39d67..ce8009f0 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -187,7 +187,7 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B init := b.newBlock(BlockLoopInit, ast.LocOf(node)) bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node)) latch := b.newBlock(BlockLoopLatch, ast.LocOf(node)) - exit := b.newBlock(BlockNormal, ast.LocOf(node)) + exit := b.newBlock(BlockLoopExit, ast.LocOf(node)) init.NodeID = loopID bodyBlock.NodeID = loopID latch.NodeID = loopID diff --git a/internal/ir/cfg/cfg_test.go b/internal/ir/cfg/cfg_test.go index 79620db1..ead9bceb 100644 --- a/internal/ir/cfg/cfg_test.go +++ b/internal/ir/cfg/cfg_test.go @@ -203,7 +203,7 @@ func TestBuildModuleCreatesForInLoopBlocksWithSynthesizedCondition(t *testing.T) header := loopBlock(t, graph, 30, BlockLoop) loopBody := loopBlock(t, graph, 30, BlockLoopBody) latch := loopBlock(t, graph, 30, BlockLoopLatch) - exit := loopBlock(t, graph, 30, BlockNormal) + exit := loopBlock(t, graph, 30, BlockLoopExit) entryJump, ok := graph.Entry.Terminator.(*Jump) if !ok || entryJump.Target != init { @@ -329,7 +329,7 @@ func TestBuildModuleNestedLoopJumpsUseInnermostTargets(t *testing.T) { } body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}, Stmts: []ast.Stmt{outer}} graph := BuildModule(testModule(body, nil), BuildQueries{}).Functions[0] - innerExit := loopBlock(t, graph, 40, BlockNormal) + innerExit := loopBlock(t, graph, 40, BlockLoopExit) innerLatch := loopBlock(t, graph, 40, BlockLoopLatch) outerLatch := loopBlock(t, graph, 30, BlockLoopLatch) foundBreak := false @@ -418,7 +418,7 @@ func TestBuildModuleInfiniteLoopBreakMakesExitReachable(t *testing.T) { init := loopBlock(t, graph, 30, BlockLoopInit) loopBody := loopBlock(t, graph, 30, BlockLoopBody) latch := loopBlock(t, graph, 30, BlockLoopLatch) - exit := loopBlock(t, graph, 30, BlockNormal) + exit := loopBlock(t, graph, 30, BlockLoopExit) initJump, initOK := init.Terminator.(*Jump) latchJump, latchOK := latch.Terminator.(*Jump) if !initOK || initJump.Target != loopBody || !latchOK || latchJump.Target != loopBody { @@ -525,3 +525,28 @@ func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { } return false } + +// Missing-return reporting walks back to the nearest structured-control block +// to name the branch that falls through, so this classification decides which +// span a user sees. A loop exit carries a loop role but is a continuation: the +// code after the loop lives there. +func TestStructuredControlClassifiesEveryBlockOrigin(t *testing.T) { + for _, test := range []struct { + origin BlockOrigin + name string + want bool + }{ + {BlockNormal, "normal", false}, + {BlockLoopExit, "loop exit", false}, + {BlockThen, "then", true}, + {BlockElse, "else", true}, + {BlockLoopInit, "loop init", true}, + {BlockLoop, "loop header", true}, + {BlockLoopBody, "loop body", true}, + {BlockLoopLatch, "loop latch", true}, + } { + if got := structuredControl(test.origin); got != test.want { + t.Errorf("structuredControl(%s) = %t, want %t", test.name, got, test.want) + } + } +} diff --git a/internal/ir/cfg/model.go b/internal/ir/cfg/model.go index 06bdea86..8f84c541 100644 --- a/internal/ir/cfg/model.go +++ b/internal/ir/cfg/model.go @@ -84,6 +84,12 @@ const ( BlockLoop BlockLoopBody BlockLoopLatch + // BlockLoopExit is the continuation a loop leaves to. It carries the loop's + // NodeID like the roles above, so a consumer can ask which loop it exits, + // but it is not part of the loop's structure: the code after the loop lives + // here. Before it existed, exiting a loop was identified by the absence of a + // role plus a NodeID that happened to name one. + BlockLoopExit ) type Block struct { diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 210a4c75..a0470303 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -193,7 +193,10 @@ func (a *analyzer) run() { queued[id] = false node := a.sites[id] next := copyState(a.inStates[id]) - if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockNormal { + // Leaving a sequence loop releases the loans its carrier held. The CFG + // names the exit block's role, so this asks which loop is being left + // rather than inferring it from a block that carries no other role. + if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockLoopExit { loopID := ast.NodeID(node.cfgBlock.NodeID) evidence, found := a.module.Typechecking.ForIterations[loopID] if _, sequence := evidence.Plan.(*typecheckresult.SequenceIteration); found && sequence { From cd4e2a4e0c2ac100627de47b506894ef34f280c5 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 14:48:50 +0600 Subject: [PATCH 40/80] Record the CFG topology audit outcome Workstream 4 said to inspect every topology query before adding construct metadata. The audit is done and the answer is that no loop descriptor is warranted: MIR's role query is served directly by BlockOrigin, and terminator kinds, edge meanings and site adjacency are all direct uses of typed APIs. Two inferences were found. The loop-exit one is fixed. The match-join walk is left in place with the reason: ownership needs the join site, not the block, so a label would not remove the walk, and one consumer does not justify a descriptor. --- docs/compiler-framework/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index 4cbb839c..f470f95d 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -432,6 +432,33 @@ Acceptance criteria: duplicated logic; - no descriptor is added when existing typed edges and sites already suffice. +### As investigated + +Every topology query outside `internal/ir/cfg` was inspected. **No loop descriptor +is warranted.** What the audit found: + +| Query | Consumer | Verdict | +| --- | --- | --- | +| Block role within a loop | `mir/module_lower.go` maps `BlockLoopInit`/`Body`/`Latch` to the matching `hir.For` segment | Direct. `BlockOrigin` answers exactly the question asked; keep. | +| Terminator kind | MIR lowering, definite-init | Direct typed switch on `cfg.Jump`/`Branch`/`Return`/`SwitchVariant`. No gap. | +| Edge meaning | flow typing, ownership, definite-init | Direct. `EdgeTrue`/`EdgeFalse`/`EdgeVariantCase` carry branch meaning independently of adjacency order. No gap. | +| Site adjacency | flow, definite-init, ownership | Ordinary dataflow over `Site.Successors`/`Predecessors`. Legitimate use, not inference. | +| "Am I leaving a sequence loop?" | ownership | **Was inferred** from `BlockNormal` plus a `NodeID` naming a loop — true only because the exit was the one loop block left unlabelled. Fixed by adding `BlockLoopExit`, the smallest change that removes the inference. | +| "Where do these match arms converge?" | ownership | **Still inferred**, by walking single successors past scope-exit sites until a non-scope-exit site is reached. Recorded, not fixed — see below. | + +The match-join walk in `semantics/ownership/ownership.go` is a real inference: CFG +construction creates the join block and then discards that knowledge. It is left in +place deliberately. Ownership needs the join *site* at which carrier liveness is +checked, not the block, so a block-origin label would not remove the walk; the fix +would be a published join site, which is a descriptor rather than a label. With one +consumer and no second phase needing the same fact, it does not meet the bar above. +Revisit if a second consumer appears. + +`BlockLoopExit` also made explicit something previously implicit: missing-return +reporting distinguished structured control from plain continuations by comparing +against `BlockNormal`. That comparison is now `structuredControl`, so adding an origin +requires classifying it rather than silently changing which span a user sees. + ## Workstream 5: Naming and generated artifact subsystems **Target.** Separate naming policy from typed artifact construction. From f3d72a2cf95d210bbe0d5279e9466f1a6a3d9faa Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 15:05:19 +0600 Subject: [PATCH 41/80] Explain the type declaration reindex in module registration Every other branch in AddModule justifies itself; this one did not, and reaching into a phase artifact from registration reads as misplaced without the reason. Record it: the context index is derived state over per-module declarations, the LSP rebuilds a context per request while retained modules keep declarations collection will not produce again, and a pointer-valued index also has to follow a replaced module. Note that the phase check states the rule rather than changing the outcome, and name its mirror in ResetModule. --- internal/project/modules.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/project/modules.go b/internal/project/modules.go index 8ef4592b..4fd5b370 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -299,6 +299,23 @@ func (ctx *CompilerContext) AddModule(module *Module) *diagnostics.Diagnostic { // identity it no longer names. delete(ctx.fileIndex, previous.FilePath) } + // ctx.typeDeclarations is derived state: the authoritative copy of a generic + // declaration lives on the module that collection registered it from. A + // context therefore has to rebuild the index for every module it takes in, + // and registration is the only place that happens. + // + // The LSP is what makes this load-bearing. Each request builds a fresh + // context and re-registers modules retained from the previous run, so the + // index starts empty while the retained modules still carry declarations + // collection will not produce again. Without this, instantiateType finds no + // owner and tells the user to recompile a module that is perfectly fine. + // Replacing a module for an identity needs it too, since the index holds + // pointers and would otherwise keep naming the superseded object. + // + // Below Collected the map is nil, so the phase check states the rule rather + // than changing the outcome: only a collected module owns declarations. Its + // mirror is in ResetModule, which drops these entries when a module resets + // below Collected. if module.Phase >= phase.Collected { for identity := range module.namedTypeDeclarations { ctx.typeDeclarations[identity] = module From fee77910e694f454fe927b7cbf5413aefdff22e0 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:17:06 +0600 Subject: [PATCH 42/80] Document the effect stream migration plan Four of the change-path stops rediscover read and write meaning the typechecker already decided, and the consumers that do it fail silently: definiteinit switches over AST kinds in three places with no default, while cfg.buildStmt panics on the same omission. Ownership walks the AST twice for the same facts and the two walks already disagree about ForStmt.Iterable. Record the plan to publish those effects once, keyed by CFG site, with the vocabulary deliberately held to three operations and a trigger written down for each one left out. Track it in the repository so the work survives a lost session, and link it from the framework README. --- docs/compiler-framework/README.md | 5 + .../effect-stream-migration.md | 209 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 docs/compiler-framework/effect-stream-migration.md diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index f470f95d..45a33f4e 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -572,6 +572,11 @@ answers where to go: it walks the real file sequence for three change shapes, tr from commits already in the repository, and names what catches you when a stop is missed — including the stops where nothing does. +Four of those stops are pure mechanism: they rediscover read and write meaning the +typechecker already decided. +[`effect-stream-migration.md`](effect-stream-migration.md) is the in-progress plan to +publish that meaning once and let the analyses consume it instead. + | Area | Required question | | --- | --- | | Lexer/token model | Does syntax require token or lexical-state change? | diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md new file mode 100644 index 00000000..88cac00e --- /dev/null +++ b/docs/compiler-framework/effect-stream-migration.md @@ -0,0 +1,209 @@ +# Effect stream migration + +Status: **in progress**. Milestone 1 of an unknown number. + +This document is the executable plan for publishing semantic effects once and migrating +dataflow consumers onto them. It is tracked so that anyone — human or agent — picking the +work up cold has the whole context in the repository. + +Read [`change-paths.md`](change-paths.md) first for how a change travels through the +compiler today, and [`README.md`](README.md) for the framework workstreams this extends. + +## Why + +Adding a language feature should need parser, scope, and type rules, and nothing else. +Today every later phase re-derives the same meaning from AST shape. `change-paths.md` +records nine statement dispatch sites and four expression sites for one new node kind. + +Four of the nine are pure mechanism — they rediscover read/write meaning the typechecker +already decided: + +``` +ownership.applyStmt · ownership.symbolUseSequence +definiteinit.checkReads · typechecker.applyConditionEdge +``` + +Two concrete failures this causes today: + +- **Silent omission.** `internal/semantics/definiteinit/initialization.go` switches over AST + kinds in three places and none has a `default:`. A new statement kind is dropped without + error or panic; the result is a false "used before initialized" or a missed one, depending + on which switch missed it. Contrast `cfg.buildStmt`, which panics on an unhandled + statement. The producer is total; the consumers silently are not. +- **Two walks that must agree, and don't.** `ownership.checkExpr` and + `ownership.symbolUseSequence` both enumerate value uses. `applyStmt` handles + `ForStmt.Iterable`; `symbolUseSequence` never visits it. Liveness and borrow-ending see a + different program than the effect analysis does. + +## The shape + +One producer translates AST into ordered semantic effects, keyed by CFG site. Consumers read +effects, never syntax. A construct that maps onto existing effects needs no case in any +consumer. + +This shares **evidence**, not a solver. `COMPILER_GUIDELINES.md` §6 forbids extracting a +generic dataflow framework from similar-looking worklists. Each analysis keeps its own +lattice, join, direction, and diagnostics. Only the facts are shared. + +### Package + +`internal/semantics/effect/` — `model.go`, `build.go`, `validate.go`, plus tests. + +Model and builder live together, following `internal/ir/cfg`, not the +`typechecker`→`typecheckresult` split, because this producer is a normalization pass rather +than a phase with its own analysis. + +### Vocabulary + +Three operations. `Define` brings a binding into existence and records whether it is also +initialized; `Write` stores to a binding that already exists; `Use` reads one. Each carries +the `*symbols.Symbol` and the `ast.NodeID` that anchors a diagnostic. + +`Op` is sealed by an unexported marker method, the same idiom as `cfg.Terminator` and +`typecheckresult.IterationPlan`. Go cannot make a consumer's type switch exhaustive, so +`internal/contracts` carries that half. + +### Deliberately absent + +Adding a channel before a consumer needs it is what commit `7ec06e9` had to delete. Each of +these has a recorded trigger instead: + +| Absent | Add when | +| --- | --- | +| `Borrow` | ownership migrates and needs shared/mutable borrow distinct from read | +| `Discard` | ownership migrates and needs the `DiscardedValue` cleanup channel | +| `Use.Kind` (read/copy/move) | ownership migrates **and** `typecheckresult.ValueUses` covers more than call arguments. Today it covers only those, so a `Kind` field now would carry false data for roughly twenty constructs | +| `Place` with projections | field- or index-level initialization tracking is wanted. Definite initialization tracks whole symbols only | +| `Region` (deferred/repeated body) | a construct exists whose body does not execute at its syntactic position — a lambda. CFG back-edges already give "runs 0..N times" for loops, so a loop does not justify it | + +### Result and phase + +`Result.Ops` is `map[ir.NodeID]map[cfg.SiteID][]Op` — function identity outer, site inner, +copying `flowresult.Result.SiteFacts`. A `cfg.SiteID` is `{Block, Index}` and is only +meaningful relative to one graph, so the outer key is required. + +New phase `Effects`, placed after `FlowTyped` and before `DefiniteInit`: + +``` +CFG → FlowTyped → Effects → DefiniteInit → Ownership → Usage +``` + +After `FlowTyped` so the producer may use `module.EffectiveExprType`, as ownership already +does. Any reset clearing `CFG` also clears everything later, so no stale `SiteID` can +outlive its graph. + +## Milestone 1 — pilot on definite initialization + +Ownership is roughly 1900 lines with loans, NLL borrow-ending, and fifteen diagnostics. +The vocabulary is proved against one small consumer first. + +`usage` is **not** part of this and needs no migration: it has no AST switch and no state, +it scans `sym.Used` flags. + +Each step ends with its gate green and one commit. Do not start a step before the previous +gate passes. Prefix every command with +`GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1`. + +### Step 1 — vocabulary and artifact slot + +`effect/model.go` with `Op`, `Define`, `Write`, `Use`, `Result`, `New`, `At`. `New` +allocates every map so consumers never nil-check. Add `phase.Effects` with its `String` +case; add `Module.Effects` with an ownership comment; add the `resetToPhase` clause in phase +order; add the `nextModulePhase` and `importPrerequisitePhase` cases. + +Gate: `go build ./...`, `go vet ./...`, tests for `internal/project`, `internal/pipeline`, +`internal/phase`. +Commit: `Add semantic effect artifact and phase slot` + +### Step 2 — producer + +`effect/build.go` with `Build(module) *Result`. Walk reachable blocks and their sites, +mirroring `definiteinit.indexSites` for site, scope, and statement rehydration. + +Emit in evaluation order: parameters as initialized defines at the entry site; `LetDecl` and +`ConstDecl` as reads of the value then a define; `AssignStmt` as reads of the value then a +write for an ident target, or reads of both for a projection target; `ExprStmt` and +`ReturnStmt` as reads; a `*cfg.Branch` terminator site as reads of its condition; match arm +bindings as initialized defines at the arm body's entry site. + +Resolve every identifier through `module.Bindings.NodeSymbols`, never `Scope.Lookup` by +name — name lookup walks parent scopes and can bind the wrong symbol under shadowing. +Intercept `*ast.CallExpr` and walk `Typechecking.CallArgumentsOrSource(call)` so +default-expanded arguments are covered. + +The producer is now the single place that inspects syntax for this meaning, so it takes +`cfg.buildStmt`'s policy: `default: panic`. + +Gate: full `go test -count=1 ./...`. Nothing consumes the artifact yet, so nothing may change. +Commit: `Publish semantic effects for every CFG site` + +### Step 3 — migrate definite initialization + +`Check(graphs *cfg.Module, effects *effect.Result, diag *diagnostics.DiagnosticBag)`. +Delete `indexSites`, `transfer`, and `checkReads`. + +Preserve exactly, all pinned by existing tests: the set lattice with **intersection** join +(a must-analysis); optimistic first arrival, intersect afterwards, requeue only on change; +the FIFO worklist; reads checked against `In` state, not `Out`; the report loop iterating +site order rather than worklist order; reachability by both `block.Reachable` and +non-arrival; `tracked` as the diagnosable universe; and the single `T0039` diagnostic +verbatim, anchored at the reading identifier, with no deduplication. + +Gate: focused tests, then full suite, then `go run ./scripts/bundle.go` and the `x_test` +fixtures. **Zero diagnostic changes.** +Commit: `Consume published effects in definite initialization` + +### Step 4 — contract and validator + +Add the producer to `statementSites` in `internal/contracts/node_dispatch_test.go` with +`inertDeclarations: true`; remove the `checkReads` entry, whose function no longer exists. +Add `effect/validate.go` following `cfg/validate.go` literally — accumulate, sort, truncate +at ten — and state in its doc comment what it does not re-derive. + +Mutation-prove both: delete a producer case and confirm the named contract failure; corrupt +an op and confirm the validator message. Restore both, and record the outputs in the commit +body. Update the counts in `change-paths.md`. + +Gate: full suite plus race on `internal/project`, `internal/pipeline`, `internal/lsp`. +Commit: `Require a phase decision for every published effect` + +## Behavior changes found, not fixed here + +`RULES.md` §10 forbids mixing a behavior change into a refactor. Both of these are recorded +for separate approval and must **not** be corrected inside this migration. + +- **Match subjects are never read-checked.** `definiteinit` attaches a site condition only + for `*cfg.Branch`; `*cfg.SwitchVariant` is not handled, so a match on an uninitialized + value is not diagnosed. The effect stream makes emitting that read natural, which would + start rejecting code that compiles today. Step 2 must reproduce the gap. Closing it needs + its own approval and an `x_test` fixture. +- **`ForStmt.Iterable` is invisible to `symbolUseSequence`** while `applyStmt` handles it. + Ownership only; out of scope. + +## Success test + +`definiteinit` contains no `switch` over AST types and imports `ast` only for `ast.NodeID`. +Diagnostics are byte-identical to before. + +## Picking this up cold + +1. `git status --short --branch`, and record HEAD. +2. `git log --oneline -5`. Steps commit in order with the subjects above, so the last + subject tells you which step finished. +3. Read `AGENTS.md`, `RULES.md`, and `go-style.md` before editing. Re-run the `AGENTS.md` §2 + pre-patch gate before every patch and the §7 post-patch audit before every stop. +4. Re-verify this document against live source. It was accurate when written; line numbers + and counts rot. +5. Never start a step before the current gate is green. +6. Never push, merge, open a pull request, or rewrite history without explicit approval. +7. If a gate fails for a reason this document does not predict, stop and report it. Do not + improvise around a failing gate. + +## After milestone 1 + +Ownership is the next consumer. It needs `Borrow`, `Discard`, and `Use.Kind`; `Use.Kind` +first needs `ValueUses` extended past call arguments, with a matching extension to +`ownershipresult.validateValueUses`, which today enforces only the call-argument case. +`UseCopy` is never published, so the two `UseCopy` diagnostics in `ownership/expr.go` are +presently dead and would activate for the first time — they need tests before that. +Ownership's loans, liveness, and borrow-ending stay local to ownership. From 8302559769e36beb070dd3eeb68dfb528883da02 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:19:42 +0600 Subject: [PATCH 43/80] Add semantic effect artifact and phase slot Definite initialization and ownership each rediscover what a statement does to a binding by switching on AST shape, and the definite-init switches have no default, so a new statement kind is dropped without a failure. Declare the vocabulary those analyses will consume instead: Define, Write and Use, sealed by an unexported marker so the set cannot grow outside this package. Hold it to three operations. Borrow, Discard, a use kind and projected places are all left out with a recorded trigger, because a channel no consumer reads is the thing commit 7ec06e9 had to delete. Result is a bare map keyed by function then CFG site, following ownershipresult.Result rather than wrapping one field in a struct. A SiteID is only meaningful relative to one graph, so function identity is the outer key. The phase constant sits after FlowTyped so the producer can read flow-refined types. Phase transitions are deliberately not wired yet: without a production block a module would advance through Effects while running definite init, and its diagnostics would be tagged with the wrong phase. Step 2 wires both together. --- internal/phase/phase.go | 5 +++ internal/project/modules.go | 13 ++++-- internal/project/modules_test.go | 16 +++++--- internal/semantics/effect/model.go | 66 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 internal/semantics/effect/model.go diff --git a/internal/phase/phase.go b/internal/phase/phase.go index 89ba27da..c3e1e441 100644 --- a/internal/phase/phase.go +++ b/internal/phase/phase.go @@ -23,6 +23,9 @@ const ( CFG // FlowTyped includes CFG-refined expression types and place origins. FlowTyped + // Effects publishes the ordered semantic meaning of each CFG site, which + // the dataflow analyses consume instead of re-reading syntax. + Effects // DefiniteInit records completion of diagnostic-only initialization checks. DefiniteInit // Ownership includes ownership cleanup results. @@ -60,6 +63,8 @@ func (phase Phase) String() string { return "CFG" case FlowTyped: return "flow-typed" + case Effects: + return "effects" case DefiniteInit: return "definite-init" case Ownership: diff --git a/internal/project/modules.go b/internal/project/modules.go index 4fd5b370..276e5adb 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -16,6 +16,7 @@ import ( "compiler/internal/phase" "compiler/internal/semantics/bindingresult" "compiler/internal/semantics/constantresult" + "compiler/internal/semantics/effect" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" @@ -66,9 +67,12 @@ type Module struct { // TypedASTNodes indexes source and typechecker-generated expressions. TypedASTNodes map[ast.NodeID]ast.Node // Canonical IR slots. - HIR *hir.Module - CFG *cfg.Module - Flow *flowresult.Result + HIR *hir.Module + CFG *cfg.Module + Flow *flowresult.Result + // Effects is the published semantic meaning of each CFG site, produced once + // and consumed by the dataflow analyses. + Effects effect.Result Ownership ownershipresult.Result MIR *mir.Module LLVMIR string @@ -184,6 +188,9 @@ func (m *Module) resetToPhase(retained phase.Phase) { if retained < phase.FlowTyped { m.Flow = nil } + if retained < phase.Effects { + m.Effects = nil + } if retained < phase.Ownership { m.Ownership = nil } diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index b9b237b4..9b93accb 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -11,6 +11,7 @@ import ( "compiler/internal/ir/mir" "compiler/internal/moduleid" "compiler/internal/phase" + "compiler/internal/semantics/effect" "compiler/internal/semantics/flowresult" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" @@ -171,6 +172,7 @@ func moduleWithArtifacts() *Module { HIR: &hir.Module{}, CFG: &cfg.Module{Functions: []*cfg.Graph{{}}}, Flow: &flowresult.Result{ExprTypes: map[ast.NodeID]typeinfo.Type{1: typeinfo.DefaultIntegerType()}}, + Effects: effect.Result{1: {cfg.SiteID{}: {effect.Use{}}}}, Ownership: ownershipresult.Result{1: &ownershipresult.CleanupPlan{}}, MIR: &mir.Module{}, LLVMIR: "stale IR", @@ -193,6 +195,7 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { hir bool cfg bool flow bool + effects bool ownership bool mir bool llvm bool @@ -201,12 +204,12 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { {phase: phase.Typechecked, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true}, {phase: phase.CFG, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true}, {phase: phase.FlowTyped, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.DefiniteInit, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true}, - {phase: phase.Ownership, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.Usage, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, ownership: true}, - {phase: phase.HIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true}, - {phase: phase.MIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true}, - {phase: phase.Backend, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, ownership: true, mir: true, llvm: true}, + {phase: phase.DefiniteInit, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true}, + {phase: phase.Ownership, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.Usage, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.HIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true}, + {phase: phase.MIR, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true, mir: true}, + {phase: phase.Backend, scope: true, bindings: true, constants: true, typechecking: true, exportAPI: true, astNodes: true, hir: true, cfg: true, flow: true, effects: true, ownership: true, mir: true, llvm: true}, } for _, test := range tests { module := moduleWithArtifacts() @@ -219,6 +222,7 @@ func TestModuleResetToPhaseClearsOnlyDownstreamArtifacts(t *testing.T) { (module.SemanticExportFingerprint != "") != test.exportAPI || (module.CFG != nil) != test.cfg || (module.Flow != nil) != test.flow || + (module.Effects != nil) != test.effects || (module.Ownership != nil) != test.ownership || (module.MIR != nil) != test.mir || (module.LLVMIR != "") != test.llvm { diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go new file mode 100644 index 00000000..88ad8c1c --- /dev/null +++ b/internal/semantics/effect/model.go @@ -0,0 +1,66 @@ +// Package effect defines the ordered semantic meaning of source constructs. +// +// The producer in this package is the only code that inspects syntax to decide +// what a construct does to a binding. Definite initialization consumes the +// published stream and never switches on an AST kind, so a construct that maps +// onto these operations needs no case in any consumer. +// +// This package shares evidence, not a solver. Each analysis keeps its own +// lattice, join, direction, and diagnostics, per COMPILER_GUIDELINES.md section 6. +package effect + +import ( + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/symbols" +) + +// Op is one semantic effect on one binding. +// +// The set is closed by the unexported marker method, so no kind can be +// introduced outside this package. Go cannot make a consumer's type switch +// exhaustive; internal/contracts carries that half. +type Op interface { + effectOp() +} + +// Define brings a binding into existence. Initialized separates `let x = e`, +// which also stores a value, from a declaration that leaves storage empty. +type Define struct { + Symbol *symbols.Symbol + // Node is the declaration, which is where a diagnostic about the binding + // itself belongs. + Node ast.NodeID + Initialized bool +} + +// Write stores to a binding that already exists. +type Write struct { + Symbol *symbols.Symbol + // Node is the assignment target. + Node ast.NodeID +} + +// Use reads a binding's value. Node is the reading identifier rather than the +// enclosing statement, so a diagnostic anchors on the read itself. +type Use struct { + Symbol *symbols.Symbol + Node ast.NodeID +} + +func (Define) effectOp() {} +func (Write) effectOp() {} +func (Use) effectOp() {} + +// Result holds published effects for one semantic generation. +// +// A cfg.SiteID is only meaningful relative to one graph, so function identity +// is the outer key. Slice order is evaluation order; consumers must not reorder +// it. +type Result map[ir.NodeID]map[cfg.SiteID][]Op + +// At returns the effects published for one site, in evaluation order. +func (r Result) At(fn ir.NodeID, site cfg.SiteID) []Op { + return r[fn][site] +} From d77fb9e1e49ce2fba0841f8fb9370b3f6e22fc22 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:26:48 +0600 Subject: [PATCH 44/80] Publish semantic effects for every CFG site Build translates each reachable CFG site into ordered Define, Write and Use operations, so the meaning of a statement is decided in one place instead of being rediscovered by every analysis that needs it. Nothing consumes the result yet; the full suite is unchanged, which is the point. Build takes narrow queries rather than a Module. Passing the Module would make project and this package import each other, and cfg.BuildQueries already establishes the alternative: declare the accessors you need and let the owning artifact supply matching methods. ArmBindings joins MatchCases and ForLoopGuaranteedEntry on typecheckresult.Result for that reason. Definitions resolve through the site's scope, not through NodeSymbols. The plan assumed one index answered both, but the resolver indexes references only, so a declaration name and an assignment target are absent from it. Definite initialization already resolves definitions by scope and references by node, and reproducing that split exactly is what keeps this step free of behavior change. Unifying it can only follow a separate approval, because scope lookup by name walks parents and can bind a shadowed symbol. The phase transitions land here rather than in the previous commit: without a production block a module would advance through Effects while actually running definite init, tagging its diagnostics with the wrong phase. --- internal/pipeline/pipeline.go | 18 +- internal/pipeline/pipeline_test.go | 4 + internal/semantics/effect/build.go | 242 +++++++++++++++++++ internal/semantics/effect/build_test.go | 198 +++++++++++++++ internal/semantics/typecheckresult/result.go | 24 ++ 5 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 internal/semantics/effect/build.go create mode 100644 internal/semantics/effect/build_test.go diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 14a04bc2..3f14dba7 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -24,6 +24,7 @@ import ( "compiler/internal/semantics/collector" "compiler/internal/semantics/consteval" "compiler/internal/semantics/definiteinit" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownership" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" @@ -335,6 +336,8 @@ func nextModulePhase(current phase.Phase) phase.Phase { case phase.CFG: return phase.FlowTyped case phase.FlowTyped: + return phase.Effects + case phase.Effects: return phase.DefiniteInit case phase.DefiniteInit: return phase.Ownership @@ -367,8 +370,10 @@ func importPrerequisitePhase(next phase.Phase) phase.Phase { return phase.Typechecked case phase.FlowTyped: return phase.CFG - case phase.DefiniteInit: + case phase.Effects: return phase.FlowTyped + case phase.DefiniteInit: + return phase.Effects case phase.Ownership: return phase.DefiniteInit case phase.Usage: @@ -470,6 +475,17 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di ctx.Metrics.AddPhaseAdvance() return true } + if module.Phase < phase.Effects { + module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + }) + module.Phase = phase.Effects + ctx.Metrics.AddPhaseAdvance() + return true + } if module.Phase < phase.DefiniteInit { definiteinit.Check( module.CFG, diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 37c61a16..d838312f 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -885,6 +885,7 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { phase.Typechecked, phase.CFG, phase.FlowTyped, + phase.Effects, phase.DefiniteInit, phase.Ownership, } @@ -901,6 +902,9 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { if wantPhase == phase.FlowTyped && entry.Flow == nil { t.Fatal("flow-typed phase must retain canonical result") } + if wantPhase == phase.Effects && entry.Effects == nil { + t.Fatal("effects phase must retain published site effects") + } if wantPhase < phase.HIR && entry.HIR != nil { t.Fatalf("phase %v produced HIR before mandatory semantics completed", wantPhase) } diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go new file mode 100644 index 00000000..9efee2bb --- /dev/null +++ b/internal/semantics/effect/build.go @@ -0,0 +1,242 @@ +package effect + +import ( + "fmt" + + "compiler/internal/frontend/ast" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/symbols" +) + +// BuildQueries supplies the published facts this producer needs. Like +// cfg.BuildQueries, it declares narrow accessors so this package does not +// import the artifacts that own them. +type BuildQueries struct { + // Symbols resolves a referenced identifier to its binding. The resolver + // indexes references only, so a declaration name and an assignment target + // are absent here and resolve through Scopes instead. + Symbols map[ast.NodeID]*symbols.Symbol + // Scopes resolves a CFG site's lexical scope, which is how a definition + // reaches its symbol. Definitions and references genuinely use two different + // mechanisms today; this producer reproduces that rather than changing it. + Scopes map[ast.NodeID]*symbols.Scope + // CallArguments returns a call's effective arguments, including any the + // typechecker expanded from a default. + CallArguments func(*ast.CallExpr) []ast.Expr + // ArmBindings returns the payload symbols one match arm binds. + ArmBindings func(match ast.NodeID, caseIndex int) []*symbols.Symbol +} + +// Build publishes the semantic effects of every reachable CFG site. +// +// This is the only place that inspects syntax to decide what a construct does +// to a binding. It runs after flow typing so published evidence is final. +func Build(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node, queries BuildQueries) Result { + if graphs == nil || queries.Symbols == nil || queries.Scopes == nil { + return nil + } + result := make(Result, len(graphs.Functions)) + for _, graph := range graphs.Functions { + if graph == nil { + continue + } + fn, _ := nodes[ast.NodeID(graph.NodeID)].(*ast.FnDecl) + if fn == nil { + continue + } + b := &builder{nodes: nodes, queries: queries, graph: graph, ops: make(map[cfg.SiteID][]Op)} + b.buildFunction(fn) + result[graph.NodeID] = b.ops + } + return result +} + +type builder struct { + nodes map[ast.NodeID]ast.Node + queries BuildQueries + graph *cfg.Graph + ops map[cfg.SiteID][]Op +} + +func (b *builder) emit(site cfg.SiteID, op Op) { + b.ops[site] = append(b.ops[site], op) +} + +func (b *builder) symbolOf(id ast.NodeID) *symbols.Symbol { + return b.queries.Symbols[id] +} + +func (b *builder) buildFunction(fn *ast.FnDecl) { + if b.graph.Entry == nil || len(b.graph.Entry.Sites) == 0 { + return + } + entry := b.graph.Entry.Sites[0].ID + if fn.Body != nil { + functionScope := b.queries.Scopes[fn.Body.ID()] + for _, param := range fn.ParamsWithReceiver() { + if param.Name == nil { + continue + } + sym, found := functionScope.Lookup(param.Name.Name) + if !found || sym == nil { + continue + } + b.emit(entry, Define{Symbol: sym, Node: param.Name.ID(), Initialized: true}) + } + } + for _, block := range b.graph.Blocks { + if block == nil || !block.Reachable { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + b.buildSite(block, site) + } + } +} + +func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { + stmt, _ := b.nodes[ast.NodeID(site.NodeID)].(ast.Stmt) + if stmt != nil { + b.buildStmt(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], stmt) + } + if site.Kind != cfg.SiteTerminator { + return + } + switch terminator := block.Terminator.(type) { + case *cfg.Branch: + if condition, ok := b.nodes[ast.NodeID(terminator.ConditionID)].(ast.Expr); ok { + b.reads(site.ID, condition) + } + case *cfg.SwitchVariant: + b.buildMatchArms(site, terminator) + case *cfg.Jump, *cfg.Return: + // A jump carries no condition, and a return's value is read at the + // return statement's own site. + default: + panic(fmt.Sprintf("effect: unhandled CFG terminator %T", block.Terminator)) + } +} + +// buildMatchArms publishes each arm's payload bindings at the first site of +// that arm's body. CFG construction gives every arm a fresh block reached only +// by its own case edge, so a site-keyed define is equivalent to attaching it to +// the edge. +func (b *builder) buildMatchArms(site *cfg.Site, terminator *cfg.SwitchVariant) { + if b.queries.ArmBindings == nil { + return + } + for _, edge := range site.Successors { + if edge.Kind != cfg.EdgeVariantCase { + continue + } + for _, sym := range b.queries.ArmBindings(ast.NodeID(terminator.NodeID), edge.Case) { + if sym == nil { + continue + } + b.emit(edge.To, Define{Symbol: sym, Node: ast.NodeID(terminator.NodeID), Initialized: true}) + } + } +} + +// buildStmt publishes one statement's effects in evaluation order. +// +// CFG construction panics on a statement it does not place, and this is now the +// single producer of statement meaning, so it takes the same policy: a new kind +// must be handled here or declared inert in internal/contracts. +func (b *builder) buildStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.Stmt) { + switch node := stmt.(type) { + case *ast.LetDecl: + b.buildBinding(site, scope, node, node.Value) + case *ast.ConstDecl: + b.buildBinding(site, scope, node, node.Value) + case *ast.AssignStmt: + b.reads(site, node.Value) + ident, direct := node.Target.(*ast.Ident) + if !direct || ident == nil { + b.reads(site, node.Target) + return + } + sym, found := scope.Lookup(ident.Name) + if found && sym != nil { + b.emit(site, Write{Symbol: sym, Node: ident.ID()}) + } + case *ast.ExprStmt: + b.reads(site, node.Expr) + case *ast.ReturnStmt: + b.reads(site, node.Value) + case *ast.IfStmt, *ast.ForStmt, *ast.MatchStmt: + // Control-flow statements reach this producer at their terminator site. + // The condition or subject is published there, from the terminator, so + // that a site carries exactly the reads that happen at it. + case *ast.BlockStmt: + // Blocks are decomposed by CFG construction; a scope-exit site names one + // but evaluates nothing. + case *ast.BreakStmt, *ast.ContinueStmt: + // A transfer is a CFG edge and evaluates no expression. + case *ast.BadStmt, *ast.BadDecl: + // Recovery nodes carry no evidence. + case *ast.ImportDecl, *ast.FnDecl, *ast.TypeAliasDecl, + *ast.StructDecl, *ast.InterfaceDecl, *ast.EnumDecl: + // Declarations reach a CFG site after the resolver reports them as + // unsupported statements. They evaluate nothing here. + default: + panic(fmt.Sprintf("effect: unhandled AST statement %T", stmt)) + } +} + +// buildBinding publishes a declaration's initializer reads before the define +// they initialize, so `let x = x` reads an outer binding rather than itself. +func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.Stmt, value ast.Expr) { + b.reads(site, value) + if scope == nil { + return + } + sym, found := scope.LookupNode(decl) + if !found || sym == nil { + return + } + b.emit(site, Define{Symbol: sym, Node: decl.ID(), Initialized: value != nil}) +} + +// reads publishes one Use per identifier the expression evaluates, in traversal +// order. Call arguments come from published call evidence so that arguments the +// typechecker expanded from defaults are covered. +func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { + if expr == nil { + return + } + var walk func(ast.Expr) + walk = func(current ast.Expr) { + if current == nil { + return + } + ast.Inspect(current, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok && call != nil { + walk(call.Callee) + for _, arg := range b.callArguments(call) { + walk(arg) + } + return false + } + ident, ok := node.(*ast.Ident) + if !ok || ident == nil { + return true + } + if sym := b.symbolOf(ident.ID()); sym != nil { + b.emit(site, Use{Symbol: sym, Node: ident.ID()}) + } + return true + }) + } + walk(expr) +} + +func (b *builder) callArguments(call *ast.CallExpr) []ast.Expr { + if b.queries.CallArguments == nil { + return call.Args + } + return b.queries.CallArguments(call) +} diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go new file mode 100644 index 00000000..c1df2651 --- /dev/null +++ b/internal/semantics/effect/build_test.go @@ -0,0 +1,198 @@ +package effect_test + +import ( + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/frontend/lexer" + "compiler/internal/frontend/parser" + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/moduleid" + "compiler/internal/project" + "compiler/internal/semantics/binder" + "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/resolver" + "compiler/internal/semantics/typechecker" + "compiler/pkg/peeper" +) + +func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) { + t.Helper() + const filePath = "effect_test" + peeper.SourceExt + diag := diagnostics.NewDiagnosticBag() + diag.AddSourceContent(filePath, source) + ctx := project.New(".", peeper.SourceExt, diag) + module := &project.Module{ + ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "effect_test"}, + FilePath: filePath, + Content: source, + AST: parser.New(filePath, lexer.New(filePath, source, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + ctx.AddModule(module) + collector.Collect(ctx, module) + binder.Bind(ctx, module) + resolver.Resolve(ctx, module) + typechecker.Check(ctx, module) + module.RebuildTypedASTIndex() + module.CFG = cfg.BuildModule(module.AST, cfg.BuildQueries{ + MatchCases: module.Typechecking.MatchCases, + LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + result := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + }) + if result == nil { + t.Fatal("Build published no result") + } + return result, module +} + +// publishedOps flattens one function's effects in site order so a test can +// state the published sequence without naming SiteIDs. +func publishedOps(t *testing.T, result effect.Result, module *project.Module, name string) []string { + t.Helper() + symbol, found := module.ModuleScope.Lookup(name) + if !found || symbol == nil { + t.Fatalf("function %q missing", name) + } + fn, ok := symbol.ASTNode.(*ast.FnDecl) + if !ok || fn == nil { + t.Fatalf("function %q has no declaration", name) + } + graph := module.CFG.Function(ir.NodeID(fn.ID())) + if graph == nil { + t.Fatalf("function %q has no CFG", name) + } + published := make([]string, 0) + for _, block := range graph.Blocks { + if block == nil || !block.Reachable { + continue + } + for _, site := range block.Sites { + if site == nil { + continue + } + for _, op := range result.At(graph.NodeID, site.ID) { + published = append(published, describe(op)) + } + } + } + return published +} + +func describe(op effect.Op) string { + switch op := op.(type) { + case effect.Define: + if op.Initialized { + return "define " + op.Symbol.Name + } + return "declare " + op.Symbol.Name + case effect.Write: + return "write " + op.Symbol.Name + case effect.Use: + return "use " + op.Symbol.Name + } + return "unknown" +} + +func sameOps(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +// Parameters are initialized on entry, and a binding's initializer is read +// before the binding it defines. That order is what makes `let x = x` resolve +// against an outer binding rather than itself. +func TestBuildPublishesReadsBeforeTheDefineTheyInitialize(t *testing.T) { + result, module := buildEffects(t, `fn add(a: i32, b: i32) -> i32 { + let total = a + b; + return total; +}`) + got := publishedOps(t, result, module, "add") + want := []string{ + "define a", "define b", + "use a", "use b", "define total", + "use total", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// An assignment reads its value before it writes its target. +func TestBuildPublishesAssignmentAsReadThenWrite(t *testing.T) { + result, module := buildEffects(t, `fn bump(start: i32) -> i32 { + let mut count = start; + count = count + 1; + return count; +}`) + got := publishedOps(t, result, module, "bump") + want := []string{ + "define start", + "use start", "define count", + "use count", "write count", + "use count", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// A declaration with no initializer declares storage without initializing it. +// That distinction is the whole basis of definite initialization. +func TestBuildDistinguishesUninitializedDeclaration(t *testing.T) { + result, module := buildEffects(t, `fn choose(flag: bool) -> i32 { + let mut value: i32; + if flag { + value = 7; + } else { + value = 3; + } + return value; +}`) + got := publishedOps(t, result, module, "choose") + want := []string{ + "define flag", + "declare value", + "use flag", + "write value", + "write value", + "use value", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + +// A branch condition belongs to the terminator site, not to the statement, so +// each site carries exactly the reads that happen at it. +func TestBuildPublishesBranchConditionAtTerminatorSite(t *testing.T) { + result, module := buildEffects(t, `fn gate(flag: bool) -> i32 { + if flag { + return 1; + } + return 0; +}`) + got := publishedOps(t, result, module, "gate") + want := []string{"define flag", "use flag"} + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index 59d069a6..de70a50a 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -191,6 +191,30 @@ func (r *Result) MatchCases(id ast.NodeID) ([]int, bool) { return cases, true } +// ArmBindings exposes the payload symbols one match arm binds, without leaking +// match artifacts into the effect producer. A discarded binding still binds +// storage, so it is reported like any other. +func (r *Result) ArmBindings(match ast.NodeID, caseIndex int) []*symbols.Symbol { + if r == nil { + return nil + } + evidence, found := r.Matches[match] + if !found { + return nil + } + arm, found := evidence.Arm(caseIndex) + if !found { + return nil + } + bound := make([]*symbols.Symbol, 0, len(arm.Bindings)) + for _, binding := range arm.Bindings { + if binding.Binding != nil { + bound = append(bound, binding.Binding) + } + } + return bound +} + // ForLoopGuaranteedEntry exposes typechecker proof that one loop executes its // body before its first condition check. func (r *Result) ForLoopGuaranteedEntry(id ast.NodeID) bool { From c9c37d42a86e6a57f3a2ab61053c538974849812 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:29:14 +0600 Subject: [PATCH 45/80] Publish arm payload bindings before the arm body reads them Match arm bindings were emitted while walking the match block's terminator, but they target the first site of the arm's body, which the block walk may already have appended statement effects to. Operations at a site are read in evaluation order, so an arm body reading its own payload could see the read published before the define. Emit bindings a site inherits on entry in a leading pass, then everything else. Cover it with the enum case that exercises it. The test also pins the match subject as absent. Definite initialization attaches a site condition for a branch terminator only, so a match on an uninitialized value is not diagnosed today, and publishing that read would start rejecting code that currently compiles. That stays registered as a behavior change awaiting separate approval rather than being fixed in passing. --- internal/semantics/effect/build.go | 20 +++++++++++-- internal/semantics/effect/build_test.go | 38 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 9efee2bb..c49a04ca 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -84,6 +84,21 @@ func (b *builder) buildFunction(fn *ast.FnDecl) { b.emit(entry, Define{Symbol: sym, Node: param.Name.ID(), Initialized: true}) } } + // Bindings that a site inherits on entry are published before that site's + // own effects, because a site's operations are read in evaluation order and + // an arm body may read the payload it binds. + b.eachSite(func(block *cfg.Block, site *cfg.Site) { + if site.Kind != cfg.SiteTerminator { + return + } + if terminator, ok := block.Terminator.(*cfg.SwitchVariant); ok { + b.buildMatchArms(site, terminator) + } + }) + b.eachSite(b.buildSite) +} + +func (b *builder) eachSite(visit func(*cfg.Block, *cfg.Site)) { for _, block := range b.graph.Blocks { if block == nil || !block.Reachable { continue @@ -92,7 +107,7 @@ func (b *builder) buildFunction(fn *ast.FnDecl) { if site == nil { continue } - b.buildSite(block, site) + visit(block, site) } } } @@ -111,7 +126,8 @@ func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { b.reads(site.ID, condition) } case *cfg.SwitchVariant: - b.buildMatchArms(site, terminator) + // Arm payload bindings are published in the leading pass above; the + // subject itself is read at the match statement's own site. case *cfg.Jump, *cfg.Return: // A jump carries no condition, and a return's value is read at the // return statement's own site. diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index c1df2651..7c308601 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -196,3 +196,41 @@ func TestBuildPublishesBranchConditionAtTerminatorSite(t *testing.T) { t.Fatalf("published %v, want %v", got, want) } } + +// An arm's payload binding is published before that arm body's own effects, +// because the body may read the payload it binds. The binding is published at +// the arm block's first site rather than on the case edge, which is equivalent +// because CFG construction gives every arm a fresh block reached only by its +// own case edge. +func TestBuildPublishesArmBindingBeforeArmBodyEffects(t *testing.T) { + result, module := buildEffects(t, `enum Result { + Ok: { value: i32 }, + Pending, +} + +fn choose(outcome: Result) -> i32 { + match outcome { + Result::Ok with { value = payload } => { + return payload; + } + Result::Pending => { + return 0; + } + } +}`) + got := publishedOps(t, result, module, "choose") + // The match subject is deliberately absent. Definite initialization attaches + // a site condition for a branch terminator only, so a match on an + // uninitialized value is not diagnosed today. Publishing that read here + // would start rejecting code that currently compiles, so it is registered as + // a behavior change in docs/compiler-framework/effect-stream-migration.md + // and left for separate approval. Change this expectation only together with + // that decision. + want := []string{ + "define outcome", + "define payload", "use payload", + } + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} From baefd36e1f507cffde4b129cf652c0b5f169f329 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:34:31 +0600 Subject: [PATCH 46/80] Consume published effects in definite initialization Check took five raw maps and rediscovered meaning from syntax in three switches, none of which had a default, so a new statement kind was dropped without any failure. It now takes the CFG and the published effects, and the file no longer imports ast at all. indexSites, transfer and checkReads are gone. What is left is the analysis itself: the initialized-symbol lattice, the intersection join that makes it a must-analysis, the optimistic-first-arrival merge, the FIFO worklist, the declaration-order report pass, and the single T0039 diagnostic anchored on the read. A Use carries its own location so nothing has to resolve a node back to syntax to report against it. Effects are replayed in evaluation order rather than checked against the state before the whole site. That is what lets parameters and match payload bindings arrive as ordinary defines instead of being seeded separately, and it agrees with the old behavior everywhere: reads are published before the define or write they precede. The match binding now lands in a site's Out rather than its In, because it is applied at the arm's first site instead of on the case edge; the arm body's read is covered either way, and the whitebox test says so. The producer replaces checkReads in the dispatch contract and needs no omissions, being exhaustive. Removing its ExprStmt case fails with 'publishStmt makes no decision about ast.ExprStmt'. It is named publishStmt so that message cannot be confused with cfg.buildStmt. go test ./... , race on project, pipeline and lsp, bundle and the x_test fixtures all pass with no diagnostic change. --- internal/contracts/node_dispatch_test.go | 18 +- internal/pipeline/pipeline.go | 9 +- .../semantics/definiteinit/initialization.go | 280 +++++++----------- .../definiteinit/initialization_test.go | 27 +- internal/semantics/effect/build.go | 12 +- internal/semantics/effect/model.go | 15 +- 6 files changed, 139 insertions(+), 222 deletions(-) diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index 090284a6..3cd1b43f 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -149,20 +149,10 @@ var statementSites = []dispatchSite{ "ContinueStmt": {ignore, "evaluates no expression"}, }, }, - { - file: "semantics/definiteinit/initialization.go", - fn: "checkReads", - inertDeclarations: true, - omitted: map[string]classification{ - "BlockStmt": {ignore, decomposedByCFGReason}, - "BadStmt": {ignore, "recovery node reads nothing"}, - "IfStmt": {ignore, "condition arrives separately through the CFG site condition"}, - "ForStmt": {ignore, "condition arrives separately through the CFG site condition"}, - "MatchStmt": {ignore, "subject arrives separately through the CFG site condition"}, - "BreakStmt": {ignore, "reads nothing"}, - "ContinueStmt": {ignore, "reads nothing"}, - }, - }, + // The effect producer replaced definiteinit.checkReads as the site that reads + // meaning out of a statement. It is exhaustive: every kind has a case, so it + // declares no omissions, and a new kind fails here first. + {file: "semantics/effect/build.go", fn: "publishStmt"}, { file: "semantics/typechecker/flow.go", fn: "applyConditionEdge", diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 3f14dba7..8609b0d1 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -487,14 +487,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di return true } if module.Phase < phase.DefiniteInit { - definiteinit.Check( - module.CFG, - module.TypedASTNodes, - module.Bindings.BlockScopes, - module.Bindings.NodeSymbols, - module.Typechecking.Matches, - phaseDiag, - ) + definiteinit.Check(module.CFG, module.Effects, phaseDiag) module.Phase = phase.DefiniteInit ctx.Metrics.AddPhaseAdvance() return true diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index c21787c1..0b3f27a7 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -1,12 +1,13 @@ package definiteinit import ( + "fmt" + "compiler/internal/diagnostics" - "compiler/internal/frontend/ast" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/semantics/effect" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/typecheckresult" ) type state map[symbols.SymbolID]struct{} @@ -16,22 +17,11 @@ type functionResult struct { Out map[cfg.SiteID]state } -type site struct { - cfgSite *cfg.Site - stmt ast.Stmt - condition ast.Expr - scope *symbols.Scope -} - // Check diagnoses reads not initialized on every reachable CFG predecessor. -func Check( - graphs *cfg.Module, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]typecheckresult.Match, - diag *diagnostics.DiagnosticBag, -) { +// +// It consumes published effects and never inspects syntax, so a new construct +// that defines, writes, or reads a binding needs no case here. +func Check(graphs *cfg.Module, effects effect.Result, diag *diagnostics.DiagnosticBag) { if graphs == nil { return } @@ -39,76 +29,39 @@ func Check( if graph == nil { continue } - fn, _ := nodes[ast.NodeID(graph.NodeID)].(*ast.FnDecl) - if fn == nil { - continue - } - analyzeFunction(fn, graph, nodes, blockScopes, resolvedSymbols, matches, diag) + analyzeFunction(graph, effects[graph.NodeID], diag) } } -func analyzeFunction( - fn *ast.FnDecl, - graph *cfg.Graph, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - matches map[ast.NodeID]typecheckresult.Match, - diag *diagnostics.DiagnosticBag, -) *functionResult { - sites, order, tracked := indexSites(fn, graph, nodes, blockScopes) +func analyzeFunction(graph *cfg.Graph, ops effect.SiteOps, diag *diagnostics.DiagnosticBag) *functionResult { result := &functionResult{In: make(map[cfg.SiteID]state), Out: make(map[cfg.SiteID]state)} if graph == nil || graph.Entry == nil || len(graph.Entry.Sites) == 0 { return result } + sites, order := indexSites(graph) + tracked := trackedSymbols(ops, order) + + // Parameters and match payload bindings arrive as initialized defines at the + // site that receives them, so entry needs no seeded state of its own. entry := graph.Entry.Sites[0].ID - entryState := make(state) - if fn != nil && fn.Body != nil { - functionScope := blockScopes[fn.Body.ID()] - for _, param := range fn.ParamsWithReceiver() { - if param.Name == nil { - continue - } - symbol, found := functionScope.Lookup(param.Name.Name) - if !found || symbol == nil { - continue - } - entryState[symbol.ID] = struct{}{} - tracked[symbol.ID] = symbol.Name - } - } - result.In[entry] = entryState + result.In[entry] = make(state) queue := []cfg.SiteID{entry} queued := map[cfg.SiteID]bool{entry: true} for len(queue) > 0 { id := queue[0] queue = queue[1:] queued[id] = false - node := sites[id] - if node == nil { + site := sites[id] + if site == nil { continue } - out := transfer(node, result.In[id]) + out := transfer(ops[id], result.In[id]) result.Out[id] = out - for _, edge := range node.cfgSite.Successors { + for _, edge := range site.Successors { if sites[edge.To] == nil { continue } edgeState := copyState(out) - if edge.Kind == cfg.EdgeVariantCase { - match, found := matches[ast.NodeID(node.cfgSite.NodeID)] - if found { - if arm, armFound := match.Arm(edge.Case); armFound { - for _, field := range arm.Bindings { - if field.Binding == nil { - continue - } - edgeState[field.Binding.ID] = struct{}{} - tracked[field.Binding.ID] = field.Binding.Name - } - } - } - } current, exists := result.In[edge.To] merged := edgeState if exists { @@ -124,156 +77,123 @@ func analyzeFunction( } } } + // Reporting walks declaration order rather than worklist order so diagnostics + // are deterministic. A site absent from In was never reached. for _, id := range order { if initialized, reachable := result.In[id]; reachable { - checkReads(sites[id], initialized, tracked, resolvedSymbols, diag) + checkReads(ops[id], initialized, tracked, diag) } } return result } -func indexSites( - fn *ast.FnDecl, - graph *cfg.Graph, - nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*symbols.Scope, -) (map[cfg.SiteID]*site, []cfg.SiteID, map[symbols.SymbolID]string) { - sites := make(map[cfg.SiteID]*site) +func indexSites(graph *cfg.Graph) (map[cfg.SiteID]*cfg.Site, []cfg.SiteID) { + sites := make(map[cfg.SiteID]*cfg.Site) order := make([]cfg.SiteID, 0) - tracked := make(map[symbols.SymbolID]string) - if fn == nil || graph == nil { - return sites, order, tracked - } for _, block := range graph.Blocks { if block == nil || !block.Reachable { continue } - for _, cfgSite := range block.Sites { - if cfgSite == nil { + for _, site := range block.Sites { + if site == nil { continue } - indexed := &site{ - cfgSite: cfgSite, - scope: blockScopes[ast.NodeID(cfgSite.ScopeID)], - } - if stmt, ok := nodes[ast.NodeID(cfgSite.NodeID)].(ast.Stmt); ok { - indexed.stmt = stmt - } - if branch, ok := block.Terminator.(*cfg.Branch); ok && cfgSite.Kind == cfg.SiteTerminator { - indexed.condition, _ = nodes[ast.NodeID(branch.ConditionID)].(ast.Expr) - } - switch binding := indexed.stmt.(type) { - case *ast.LetDecl: - if indexed.scope != nil { - if symbol, found := indexed.scope.LookupNode(binding); found && symbol != nil { - tracked[symbol.ID] = symbol.Name - } - } - case *ast.ConstDecl: - if indexed.scope != nil { - if symbol, found := indexed.scope.LookupNode(binding); found && symbol != nil { - tracked[symbol.ID] = symbol.Name - } - } + sites[site.ID] = site + order = append(order, site.ID) + } + } + return sites, order +} + +// trackedSymbols is the diagnosable universe: a binding this function defines. +// A symbol with no define belongs to an enclosing scope and is never reported. +func trackedSymbols(ops effect.SiteOps, order []cfg.SiteID) map[symbols.SymbolID]string { + tracked := make(map[symbols.SymbolID]string) + for _, id := range order { + for _, op := range ops[id] { + define, ok := op.(effect.Define) + if ok && define.Symbol != nil { + tracked[define.Symbol.ID] = define.Symbol.Name } - sites[cfgSite.ID] = indexed - order = append(order, cfgSite.ID) } } - return sites, order, tracked + return tracked } -func transfer(node *site, in state) state { +// transfer applies one site's effects in evaluation order. The lattice only +// gains initialized symbols, and the join intersects, so the fixed point +// terminates. +func transfer(ops []effect.Op, in state) state { out := copyState(in) - if node == nil || node.scope == nil { - return out + for _, op := range ops { + apply(out, op) } - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - if stmt.Value != nil { - if symbol, found := node.scope.LookupNode(stmt); found && symbol != nil { - out[symbol.ID] = struct{}{} - } + return out +} + +// checkReads reports a read of a tracked binding that is not initialized at +// that point. It replays the site's effects so a define earlier in the same +// site covers a read later in it. +func checkReads(ops []effect.Op, initialized state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { + if diag == nil { + return + } + current := copyState(initialized) + for _, op := range ops { + if use, ok := op.(effect.Use); ok { + reportUninitializedRead(use, current, tracked, diag) } - case *ast.ConstDecl: - if stmt.Value != nil { - if symbol, found := node.scope.LookupNode(stmt); found && symbol != nil { - out[symbol.ID] = struct{}{} - } + apply(current, op) + } +} + +// apply is the single place an effect changes initialization state. A new +// operation kind fails here by name rather than being silently ignored. +func apply(current state, op effect.Op) { + switch op := op.(type) { + case effect.Define: + if op.Initialized && op.Symbol != nil { + current[op.Symbol.ID] = struct{}{} } - case *ast.AssignStmt: - if ident, direct := stmt.Target.(*ast.Ident); direct && ident != nil { - if symbol, found := node.scope.Lookup(ident.Name); found && symbol != nil { - out[symbol.ID] = struct{}{} - } + case effect.Write: + if op.Symbol != nil { + current[op.Symbol.ID] = struct{}{} } + case effect.Use: + // A read leaves initialization state unchanged. + default: + panic(fmt.Sprintf("definiteinit: unhandled effect %T", op)) } - return out } -func checkReads( - node *site, - initialized state, - tracked map[symbols.SymbolID]string, - resolvedSymbols map[ast.NodeID]*symbols.Symbol, - diag *diagnostics.DiagnosticBag, -) { - if node == nil || diag == nil { +func reportUninitializedRead(use effect.Use, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { + if use.Symbol == nil { return } - checkExpr := func(expr ast.Expr) { - ast.Inspect(expr, func(node ast.Node) bool { - ident, ok := node.(*ast.Ident) - if !ok { - return true - } - symbol := resolvedSymbols[ident.ID()] - if symbol == nil { - return true - } - name, local := tracked[symbol.ID] - if !local { - return true - } - if _, present := initialized[symbol.ID]; present { - return true - } - if name == "" { - name = ident.Name - } - name = ir.StripSymbolInstance(name) - msg := "symbol `" + name + "` used before it's initialized" - diag.Add(diagnostics.NewError(msg). - WithCode(diagnostics.ErrUninitializedVariable). - WithPrimaryLabel(ast.LocOf(ident), msg). - WithHelp("assign a value before reading this symbol")) - return true - }) + name, local := tracked[use.Symbol.ID] + if !local { + return } - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - checkExpr(stmt.Value) - case *ast.ConstDecl: - checkExpr(stmt.Value) - case *ast.AssignStmt: - checkExpr(stmt.Value) - if _, direct := stmt.Target.(*ast.Ident); !direct { - checkExpr(stmt.Target) - } - case *ast.ExprStmt: - checkExpr(stmt.Expr) - case *ast.ReturnStmt: - checkExpr(stmt.Value) + if _, present := current[use.Symbol.ID]; present { + return + } + if name == "" { + name = use.Symbol.Name } - checkExpr(node.condition) + name = ir.StripSymbolInstance(name) + msg := "symbol `" + name + "` used before it's initialized" + diag.Add(diagnostics.NewError(msg). + WithCode(diagnostics.ErrUninitializedVariable). + WithPrimaryLabel(use.Location, msg). + WithHelp("assign a value before reading this symbol")) } func copyState(current state) state { - copy := make(state, len(current)) + copied := make(state, len(current)) for symbol := range current { - copy[symbol] = struct{}{} + copied[symbol] = struct{}{} } - return copy + return copied } func intersectState(left, right state) state { diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index ca478c19..3e483a66 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -14,6 +14,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" "compiler/internal/semantics/resolver" "compiler/internal/semantics/typechecker" "compiler/pkg/peeper" @@ -54,15 +55,13 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, if graph == nil { t.Fatal("choose function CFG missing") } - result := analyzeFunction( - fn, - graph, - module.TypedASTNodes, - module.Bindings.BlockScopes, - module.Bindings.NodeSymbols, - module.Typechecking.Matches, - diag, - ) + effects := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + }) + result := analyzeFunction(graph, effects[graph.NodeID], diag) return result, diag, module } @@ -192,8 +191,14 @@ fn choose(result: Result) -> i32 { if cfgSite.NodeID != returnID { continue } - if _, initialized := result.In[cfgSite.ID][binding.ID]; !initialized { - t.Fatalf("pattern binding absent at arm return: state=%#v", result.In[cfgSite.ID]) + // The binding is published as an initialized define at the arm + // block's first site, which is this return's own site, so it lands + // in Out rather than In. It used to be applied on the case edge and + // so appeared in In. The read of `payload` in the arm body is + // covered either way, because a site's effects are replayed in + // evaluation order and the define precedes the read. + if _, initialized := result.Out[cfgSite.ID][binding.ID]; !initialized { + t.Fatalf("pattern binding absent at arm return: state=%#v", result.Out[cfgSite.ID]) } return } diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index c49a04ca..bf94dafb 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -44,7 +44,7 @@ func Build(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node, queries BuildQueri if fn == nil { continue } - b := &builder{nodes: nodes, queries: queries, graph: graph, ops: make(map[cfg.SiteID][]Op)} + b := &builder{nodes: nodes, queries: queries, graph: graph, ops: make(SiteOps)} b.buildFunction(fn) result[graph.NodeID] = b.ops } @@ -55,7 +55,7 @@ type builder struct { nodes map[ast.NodeID]ast.Node queries BuildQueries graph *cfg.Graph - ops map[cfg.SiteID][]Op + ops SiteOps } func (b *builder) emit(site cfg.SiteID, op Op) { @@ -115,7 +115,7 @@ func (b *builder) eachSite(visit func(*cfg.Block, *cfg.Site)) { func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { stmt, _ := b.nodes[ast.NodeID(site.NodeID)].(ast.Stmt) if stmt != nil { - b.buildStmt(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], stmt) + b.publishStmt(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], stmt) } if site.Kind != cfg.SiteTerminator { return @@ -157,12 +157,12 @@ func (b *builder) buildMatchArms(site *cfg.Site, terminator *cfg.SwitchVariant) } } -// buildStmt publishes one statement's effects in evaluation order. +// publishStmt publishes one statement's effects in evaluation order. // // CFG construction panics on a statement it does not place, and this is now the // single producer of statement meaning, so it takes the same policy: a new kind // must be handled here or declared inert in internal/contracts. -func (b *builder) buildStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.Stmt) { +func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.Stmt) { switch node := stmt.(type) { case *ast.LetDecl: b.buildBinding(site, scope, node, node.Value) @@ -242,7 +242,7 @@ func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { return true } if sym := b.symbolOf(ident.ID()); sym != nil { - b.emit(site, Use{Symbol: sym, Node: ident.ID()}) + b.emit(site, Use{Symbol: sym, Node: ident.ID(), Location: ast.LocOf(ident)}) } return true }) diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index 88ad8c1c..a94c8ec0 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -14,6 +14,7 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/semantics/symbols" + "compiler/internal/source" ) // Op is one semantic effect on one binding. @@ -44,9 +45,14 @@ type Write struct { // Use reads a binding's value. Node is the reading identifier rather than the // enclosing statement, so a diagnostic anchors on the read itself. +// +// Location travels with the operation so a consumer never has to resolve the +// node back to syntax just to report against it. Define and Write carry no +// location because no current diagnostic anchors on them. type Use struct { - Symbol *symbols.Symbol - Node ast.NodeID + Symbol *symbols.Symbol + Node ast.NodeID + Location *source.Location } func (Define) effectOp() {} @@ -58,7 +64,10 @@ func (Use) effectOp() {} // A cfg.SiteID is only meaningful relative to one graph, so function identity // is the outer key. Slice order is evaluation order; consumers must not reorder // it. -type Result map[ir.NodeID]map[cfg.SiteID][]Op +type Result map[ir.NodeID]SiteOps + +// SiteOps holds one function's effects, keyed by the site they happen at. +type SiteOps map[cfg.SiteID][]Op // At returns the effects published for one site, in evaluation order. func (r Result) At(fn ir.NodeID, site cfg.SiteID) []Op { From b9dc61b54b68afa7a958d6068f3f27c8a2bf3adc Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:37:14 +0600 Subject: [PATCH 47/80] Validate published effects at the phase boundary The dispatch contract catches a construct nobody published effects for. It cannot catch effects that are published but malformed, so add the shape check: an operation with no symbol, a read with no span to report against, an operation naming a node absent from the typed AST, or effects filed against a site or function the graph does not contain. Failure is ICE0002, not a source diagnostic, because these are compiler invariants rather than user errors. Validate does not re-derive meaning. Whether a read should have been published for a given expression is the producer's decision, and re-deciding it here would be a second implementation of the thing under validation. Reporting follows cfg.Validate: accumulate every problem, sort before reporting because map iteration is unordered, and truncate at ten. Proven by removing the location from every published read: the pipeline fails with 'published semantic effects are malformed: function 22 site {0 1} operation 0 is a use with no source location to report against'. change-paths.md stop 9 now names the producer instead of definiteinit.checkReads, since that is where a contributor's work goes and where the failure names them. --- docs/compiler-framework/change-paths.md | 15 ++- internal/pipeline/pipeline.go | 4 + internal/semantics/effect/validate.go | 112 +++++++++++++++++++++ internal/semantics/effect/validate_test.go | 102 +++++++++++++++++++ 4 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 internal/semantics/effect/validate.go create mode 100644 internal/semantics/effect/validate_test.go diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 735353cb..7e4cc597 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -105,9 +105,16 @@ Which facts differ on the true and false edges? A `for` header carries no narrow condition, so it is classified `ignore` with that reason — an explicit decision, not an omission. -**9. Definite initialization** — `semantics/definiteinit/initialization.go`, `checkReads` -Which storage does the construct read? Classified `ignore` for `ForStmt`: the condition -arrives separately through the CFG site condition, so reading it here would double-count. +**9. Semantic effects** — `semantics/effect/build.go`, `publishStmt` +What does the construct define, write, and read, and in what order? This is the only +stop that reads meaning out of a statement. Publish it once here and definite +initialization needs nothing: it consumes `Define`, `Write` and `Use` and no longer +switches on an AST kind at all. +*Catches you:* **Visible** — `contracts.TestEveryStatementKindHasAPhaseDecision` fails +with `publishStmt makes no decision about ast.YourStmt`. The published artifact is then +shape-checked by `effect.Result.Validate`, which raises `ICE0002` if an operation names +no symbol, names a node absent from the typed AST, or lands at a site the graph does not +contain. See [`effect-stream-migration.md`](effect-stream-migration.md). **10. Ownership** — `semantics/ownership/ownership.go` (`applyStmt`) and `semantics/ownership/reference.go` (`symbolUseSequence`) @@ -166,7 +173,7 @@ dispatch sites until each one either handles the kind or declares why it is iner ``` resolveStmt · checkStmt · buildStmt · appendStmt · lowerElse -applyStmt · symbolUseSequence · checkReads · applyConditionEdge +applyStmt · symbolUseSequence · publishStmt · applyConditionEdge ``` An `exprNode` enrolls in `TestEveryExpressionKindHasAPhaseDecision` across four sites: diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 8609b0d1..6d43aca7 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -482,6 +482,10 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di CallArguments: module.Typechecking.CallArgumentsOrSource, ArmBindings: module.Typechecking.ArmBindings, }) + if err := module.Effects.Validate(module.CFG, module.TypedASTNodes); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "published semantic effects are malformed: "+err.Error(), nil, "") + } module.Phase = phase.Effects ctx.Metrics.AddPhaseAdvance() return true diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go new file mode 100644 index 00000000..46a0a297 --- /dev/null +++ b/internal/semantics/effect/validate.go @@ -0,0 +1,112 @@ +package effect + +import ( + "errors" + "fmt" + "sort" + "strings" + + "compiler/internal/frontend/ast" + "compiler/internal/ir" + "compiler/internal/ir/cfg" +) + +const maxReportedProblems = 10 + +// Validate checks the shape of published effects: that every operation names a +// symbol, that a read can be reported against a source span, and that every key +// refers to a site that exists in the graph it claims. +// +// It deliberately does not re-derive meaning. Whether a read should have been +// published for some expression is the producer's decision, and re-deciding it +// here would be a second implementation of the thing being validated. A missing +// operation is caught by the dispatch contract in internal/contracts, not here. +func (r Result) Validate(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node) error { + if len(r) == 0 { + return nil + } + problems := make([]string, 0) + sitesByFunction := make(map[ir.NodeID]map[cfg.SiteID]struct{}) + if graphs != nil { + for _, graph := range graphs.Functions { + if graph == nil { + continue + } + sitesByFunction[graph.NodeID] = graphSites(graph) + } + } + for fn, siteOps := range r { + known, found := sitesByFunction[fn] + if !found { + problems = append(problems, fmt.Sprintf("function %d has published effects but no control-flow graph", fn)) + continue + } + for site, ops := range siteOps { + if _, exists := known[site]; !exists { + problems = append(problems, fmt.Sprintf("function %d publishes effects at site %v, which the graph does not contain", fn, site)) + continue + } + problems = append(problems, validateOps(fn, site, ops, nodes)...) + } + } + if len(problems) == 0 { + return nil + } + // Map iteration order is unspecified, so an unsorted report would differ + // between runs of the same broken artifact. + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]ast.Node) []string { + problems := make([]string, 0) + for index, op := range ops { + where := fmt.Sprintf("function %d site %v operation %d", fn, site, index) + switch op := op.(type) { + case Define: + problems = append(problems, validateNode(where, "define", op.Symbol == nil, op.Node, nodes)...) + case Write: + problems = append(problems, validateNode(where, "write", op.Symbol == nil, op.Node, nodes)...) + case Use: + problems = append(problems, validateNode(where, "use", op.Symbol == nil, op.Node, nodes)...) + if op.Location == nil { + problems = append(problems, where+" is a use with no source location to report against") + } + default: + problems = append(problems, fmt.Sprintf("%s has unknown effect kind %T", where, op)) + } + } + return problems +} + +func validateNode(where, kind string, missingSymbol bool, node ast.NodeID, nodes map[ast.NodeID]ast.Node) []string { + problems := make([]string, 0, 2) + if missingSymbol { + problems = append(problems, fmt.Sprintf("%s is a %s with no symbol", where, kind)) + } + if nodes == nil { + return problems + } + if _, exists := nodes[node]; !exists { + problems = append(problems, fmt.Sprintf("%s is a %s naming node %d, which is not in the typed AST", where, kind, node)) + } + return problems +} + +func graphSites(graph *cfg.Graph) map[cfg.SiteID]struct{} { + sites := make(map[cfg.SiteID]struct{}) + for _, block := range graph.Blocks { + if block == nil { + continue + } + for _, site := range block.Sites { + if site != nil { + sites[site.ID] = struct{}{} + } + } + } + return sites +} diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go new file mode 100644 index 00000000..bbf56568 --- /dev/null +++ b/internal/semantics/effect/validate_test.go @@ -0,0 +1,102 @@ +package effect_test + +import ( + "strings" + "testing" + + "compiler/internal/ir" + "compiler/internal/ir/cfg" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/symbols" +) + +const validationSource = `fn bump(start: i32) -> i32 { + let mut count = start; + count = count + 1; + return count; +}` + +// A real artifact from the real producer must validate. Without this, every +// negative case below could pass against a fixture that was already broken. +func TestValidateAcceptsPublishedEffects(t *testing.T) { + result, module := buildEffects(t, validationSource) + if err := result.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("Validate() = %v, want nil for a published artifact", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(effect.Result, ir.NodeID, cfg.SiteID) + want string + }{ + { + name: "operation with no symbol", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{Node: 1}} + }, + want: "is a use with no symbol", + }, + { + name: "use with no source location", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{Symbol: &symbols.Symbol{Name: "x"}, Node: 1}} + }, + want: "is a use with no source location to report against", + }, + { + name: "operation naming an unknown node", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Write{Symbol: &symbols.Symbol{Name: "x"}, Node: 999999}} + }, + want: "which is not in the typed AST", + }, + { + name: "effects at a site the graph does not contain", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][cfg.SiteID{Block: 4242, Index: 7}] = []effect.Op{} + }, + want: "which the graph does not contain", + }, + { + name: "effects for a function with no graph", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[ir.NodeID(987654)] = effect.SiteOps{} + }, + want: "has published effects but no control-flow graph", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, module := buildEffects(t, validationSource) + fn, site := anySite(t, result) + test.damage(result, fn, site) + err := result.Validate(module.CFG, module.TypedASTNodes) + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +func TestValidateAcceptsEmptyResult(t *testing.T) { + if err := effect.Result(nil).Validate(nil, nil); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } +} + +// anySite returns one published site so a damage case has somewhere to write. +func anySite(t *testing.T, result effect.Result) (ir.NodeID, cfg.SiteID) { + t.Helper() + for fn, siteOps := range result { + for site := range siteOps { + return fn, site + } + } + t.Fatal("published result has no sites") + return 0, cfg.SiteID{} +} From b1a2505c862a887e781ffb081c69aea3d981c0df Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:38:09 +0600 Subject: [PATCH 48/80] Correct the effect stream plan against what shipped Three details in the plan were wrong and a tracked recovery document that misleads is worse than none. NodeSymbols does not answer definitions. The resolver indexes references only, so a declaration name and an assignment target resolve through the site's scope. Build takes narrow queries rather than a Module, because passing one would make project and the producer import each other. Result is a bare map rather than a struct with a single field. Also record what the implementation added: the leading pass for effects a site inherits on entry, the location carried by a read, and the move to replaying a site's effects in evaluation order. --- .../effect-stream-migration.md | 96 +++++++++++++------ 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md index 88cac00e..cf3632b8 100644 --- a/docs/compiler-framework/effect-stream-migration.md +++ b/docs/compiler-framework/effect-stream-migration.md @@ -1,6 +1,7 @@ # Effect stream migration -Status: **in progress**. Milestone 1 of an unknown number. +Status: **milestone 1 complete**. Definite initialization consumes published effects +and no longer imports `ast`. Ownership is the next consumer and is not yet planned. This document is the executable plan for publishing semantic effects once and migrating dataflow consumers onto them. It is tracked so that anyone — human or agent — picking the @@ -15,14 +16,17 @@ Adding a language feature should need parser, scope, and type rules, and nothing Today every later phase re-derives the same meaning from AST shape. `change-paths.md` records nine statement dispatch sites and four expression sites for one new node kind. -Four of the nine are pure mechanism — they rediscover read/write meaning the typechecker -already decided: +Four of the nine were pure mechanism — they rediscovered read/write meaning the +typechecker already decided: ``` ownership.applyStmt · ownership.symbolUseSequence definiteinit.checkReads · typechecker.applyConditionEdge ``` +`definiteinit.checkReads` is gone; the producer took its place in the contract. The other +three remain. + Two concrete failures this causes today: - **Silent omission.** `internal/semantics/definiteinit/initialization.go` switches over AST @@ -57,7 +61,9 @@ than a phase with its own analysis. Three operations. `Define` brings a binding into existence and records whether it is also initialized; `Write` stores to a binding that already exists; `Use` reads one. Each carries -the `*symbols.Symbol` and the `ast.NodeID` that anchors a diagnostic. +its `*symbols.Symbol` and the `ast.NodeID` it came from. `Use` also carries a +`*source.Location`, so a consumer reports against a read without resolving the node back to +syntax. `Define` and `Write` carry none, because no current diagnostic anchors on them. `Op` is sealed by an unexported marker method, the same idiom as `cfg.Terminator` and `typecheckresult.IterationPlan`. Go cannot make a consumer's type switch exhaustive, so @@ -78,9 +84,11 @@ these has a recorded trigger instead: ### Result and phase -`Result.Ops` is `map[ir.NodeID]map[cfg.SiteID][]Op` — function identity outer, site inner, -copying `flowresult.Result.SiteFacts`. A `cfg.SiteID` is `{Block, Index}` and is only -meaningful relative to one graph, so the outer key is required. +`Result` is `map[ir.NodeID]SiteOps` and `SiteOps` is `map[cfg.SiteID][]Op` — function +identity outer, site inner, following `flowresult.Result.SiteFacts`. A `cfg.SiteID` is +`{Block, Index}` and is only meaningful relative to one graph, so the outer key is +required. It is a bare map type rather than a struct with one field, matching +`ownershipresult.Result`, because `RULES.md` §1 forbids the single-field wrapper. New phase `Effects`, placed after `FlowTyped` and before `DefiniteInit`: @@ -104,21 +112,27 @@ Each step ends with its gate green and one commit. Do not start a step before th gate passes. Prefix every command with `GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1`. -### Step 1 — vocabulary and artifact slot +### Step 1 — vocabulary and artifact slot — **done** + +`effect/model.go` with `Op`, `Define`, `Write`, `Use`, `Result`, `SiteOps` and `At`. Add +`phase.Effects` with its `String` case; add `Module.Effects` with an ownership comment; add +the `resetToPhase` clause in phase order. -`effect/model.go` with `Op`, `Define`, `Write`, `Use`, `Result`, `New`, `At`. `New` -allocates every map so consumers never nil-check. Add `phase.Effects` with its `String` -case; add `Module.Effects` with an ownership comment; add the `resetToPhase` clause in phase -order; add the `nextModulePhase` and `importPrerequisitePhase` cases. +The phase transitions deliberately land in step 2, not here: without a production block a +module advances through `Effects` while actually running definite init, and its diagnostics +get tagged with the wrong phase. Gate: `go build ./...`, `go vet ./...`, tests for `internal/project`, `internal/pipeline`, `internal/phase`. Commit: `Add semantic effect artifact and phase slot` -### Step 2 — producer +### Step 2 — producer — **done** -`effect/build.go` with `Build(module) *Result`. Walk reachable blocks and their sites, -mirroring `definiteinit.indexSites` for site, scope, and statement rehydration. +`effect/build.go` with `Build(graphs, nodes, BuildQueries)`. Passing a `*project.Module` +would make `project` and this package import each other; `cfg.BuildQueries` establishes the +alternative, so the producer declares the narrow accessors it needs and the owning artifacts +supply matching methods. `typecheckresult.ArmBindings` was added for that reason, joining +`MatchCases` and `ForLoopGuaranteedEntry`. Emit in evaluation order: parameters as initialized defines at the entry site; `LetDecl` and `ConstDecl` as reads of the value then a define; `AssignStmt` as reads of the value then a @@ -126,34 +140,50 @@ write for an ident target, or reads of both for a projection target; `ExprStmt` `ReturnStmt` as reads; a `*cfg.Branch` terminator site as reads of its condition; match arm bindings as initialized defines at the arm body's entry site. -Resolve every identifier through `module.Bindings.NodeSymbols`, never `Scope.Lookup` by -name — name lookup walks parent scopes and can bind the wrong symbol under shadowing. +**References** resolve through `Bindings.NodeSymbols`. **Definitions do not**: the +resolver indexes references only, so a declaration name and an assignment target are absent +from that map and resolve through the site's scope, exactly as definite initialization did. +Reproducing that split is what keeps the step free of behavior change. Unifying it needs +separate approval, because scope lookup by name walks parents and can bind a shadowed +symbol. + Intercept `*ast.CallExpr` and walk `Typechecking.CallArgumentsOrSource(call)` so default-expanded arguments are covered. +Effects a site inherits on entry — parameters, match payload bindings — are published in a +leading pass, because a site's operations are read in evaluation order and an arm body may +read the payload it binds. + The producer is now the single place that inspects syntax for this meaning, so it takes `cfg.buildStmt`'s policy: `default: panic`. Gate: full `go test -count=1 ./...`. Nothing consumes the artifact yet, so nothing may change. Commit: `Publish semantic effects for every CFG site` -### Step 3 — migrate definite initialization +### Step 3 — migrate definite initialization — **done** -`Check(graphs *cfg.Module, effects *effect.Result, diag *diagnostics.DiagnosticBag)`. -Delete `indexSites`, `transfer`, and `checkReads`. +`Check(graphs *cfg.Module, effects effect.Result, diag *diagnostics.DiagnosticBag)`. +Delete the three AST switches. Preserve exactly, all pinned by existing tests: the set lattice with **intersection** join (a must-analysis); optimistic first arrival, intersect afterwards, requeue only on change; -the FIFO worklist; reads checked against `In` state, not `Out`; the report loop iterating -site order rather than worklist order; reachability by both `block.Reachable` and -non-arrival; `tracked` as the diagnosable universe; and the single `T0039` diagnostic -verbatim, anchored at the reading identifier, with no deduplication. +the FIFO worklist; the report loop iterating site order rather than worklist order; +reachability by both `block.Reachable` and non-arrival; `tracked` as the diagnosable +universe; and the single `T0039` diagnostic verbatim, anchored at the reading identifier, +with no deduplication. + +One representation change: effects are replayed in evaluation order within a site rather +than every read being checked against the state before the whole site. That is what lets +parameters and match bindings arrive as ordinary defines instead of being seeded +separately. It agrees with the old behavior everywhere, because reads are always published +before the define or write they precede. The visible consequence is that a match binding +lands in a site's `Out` rather than its `In`. Gate: focused tests, then full suite, then `go run ./scripts/bundle.go` and the `x_test` fixtures. **Zero diagnostic changes.** Commit: `Consume published effects in definite initialization` -### Step 4 — contract and validator +### Step 4 — contract and validator — **done** Add the producer to `statementSites` in `internal/contracts/node_dispatch_test.go` with `inertDeclarations: true`; remove the `checkReads` entry, whose function no longer exists. @@ -180,10 +210,20 @@ for separate approval and must **not** be corrected inside this migration. - **`ForStmt.Iterable` is invisible to `symbolUseSequence`** while `applyStmt` handles it. Ownership only; out of scope. -## Success test +## Success test — met + +`definiteinit/initialization.go` contains no `switch` over AST types and does not import +`ast` at all. Diagnostics are unchanged: the full suite, race on project, pipeline and +lsp, the bundle, and the `x_test` fixtures all pass without modification. + +What a contributor adding a statement kind now sees, in order: + +1. `cfg.buildStmt` panics if the kind reaches CFG construction unplaced. +2. `contracts.TestEveryStatementKindHasAPhaseDecision` fails with + `publishStmt makes no decision about ast.YourStmt`. +3. `effect.Result.Validate` raises `ICE0002` if what is published is malformed. -`definiteinit` contains no `switch` over AST types and imports `ast` only for `ast.NodeID`. -Diagnostics are byte-identical to before. +Definite initialization needs no change at any point. ## Picking this up cold From 4b5ada6ff269bd913d4a77c11c7cd7ac0cd2e87b Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 19:39:45 +0600 Subject: [PATCH 49/80] Inline two single-use helpers in the effect producer symbolOf only forwarded a map lookup, and callArguments was used once. RULES.md section 1 bans the pass-through and section 4 disallows a helper that is used once without clarifying complex logic. Both read better at the call site. --- internal/semantics/effect/build.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index bf94dafb..74481397 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -62,10 +62,6 @@ func (b *builder) emit(site cfg.SiteID, op Op) { b.ops[site] = append(b.ops[site], op) } -func (b *builder) symbolOf(id ast.NodeID) *symbols.Symbol { - return b.queries.Symbols[id] -} - func (b *builder) buildFunction(fn *ast.FnDecl) { if b.graph.Entry == nil || len(b.graph.Entry.Sites) == 0 { return @@ -232,7 +228,11 @@ func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { ast.Inspect(current, func(node ast.Node) bool { if call, ok := node.(*ast.CallExpr); ok && call != nil { walk(call.Callee) - for _, arg := range b.callArguments(call) { + arguments := call.Args + if b.queries.CallArguments != nil { + arguments = b.queries.CallArguments(call) + } + for _, arg := range arguments { walk(arg) } return false @@ -241,7 +241,7 @@ func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { if !ok || ident == nil { return true } - if sym := b.symbolOf(ident.ID()); sym != nil { + if sym := b.queries.Symbols[ident.ID()]; sym != nil { b.emit(site, Use{Symbol: sym, Node: ident.ID(), Location: ast.LocOf(ident)}) } return true @@ -249,10 +249,3 @@ func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { } walk(expr) } - -func (b *builder) callArguments(call *ast.CallExpr) []ast.Expr { - if b.queries.CallArguments == nil { - return call.Args - } - return b.queries.CallArguments(call) -} From 80ac95ee440df76fb46f4bc535b2380e049f9984 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 20:06:27 +0600 Subject: [PATCH 50/80] Answer copy class and drop obligation in one traversal IsImplicitCopyType, noCopyType and NeedsDrop each walked the same type structure with their own cycle guard, so classifying one type recursed it three times to answer three questions, and a case added to one was easy to forget in the others. OwnershipCapabilityOf composed them and said so: the predicates remained the behavioral source, which left the consolidation unrealized and two live layers to keep in agreement. One walk now establishes all three facts. It keeps both recursion shapes rather than flattening them: implicit copy is a for-all question over an enum payload's fields, while no-copy and drop are there-exists questions where one owned field settles it. The enumPayload flag carries the only context needed, that a struct copies implicitly as an enum payload but never as top-level bulk storage. A differential test drives the walk and the three predicates over a generated matrix of leaves, one-level and two-level composites and asserts they agree everywhere. Giving interfaces a drop obligation breaks 14 cases; letting a top-level struct copy implicitly breaks 68. Recursive and repeated-type cases are covered separately, since a type reached twice down separate branches is not a cycle and the guard must not suppress it. The predicates keep their 23 call sites for now. Migrating those and deleting them is the next commit, and the differential test is what makes that safe. --- internal/semantics/typeinfo/capabilities.go | 20 +--- .../semantics/typeinfo/capability_walk.go | 113 ++++++++++++++++++ .../typeinfo/capability_walk_test.go | 107 +++++++++++++++++ 3 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 internal/semantics/typeinfo/capability_walk.go create mode 100644 internal/semantics/typeinfo/capability_walk_test.go diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index f5127c30..642a870a 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -415,11 +415,9 @@ const ( CopyNever ) -// OwnershipCapability is the single classification new code should consume: -// how a type duplicates (Copy) and whether scope cleanup must destroy it -// (Drop). It composes the established predicates, which remain the exact -// behavioral source; this type exists so callers stop re-deriving decisions -// from their negations. +// OwnershipCapability is the canonical classification: how a type duplicates +// (Copy) and whether scope cleanup must destroy it (Drop). One traversal +// answers both, so the two cannot drift apart. // // Deliberate asymmetries preserved from the current language semantics: // - top-level structs and arrays never copy implicitly (bulk storage), @@ -433,17 +431,9 @@ type OwnershipCapability struct { Drop bool } -// OwnershipCapabilityOf classifies a type's ownership behavior. +// OwnershipCapabilityOf classifies a type's ownership behavior in one traversal. func OwnershipCapabilityOf(t Type) OwnershipCapability { - drop := NeedsDrop(t) - switch { - case IsImplicitCopyType(t): - return OwnershipCapability{Copy: CopyImplicit, Drop: drop} - case noCopyType(t): - return OwnershipCapability{Copy: CopyNever, Drop: drop} - default: - return OwnershipCapability{Copy: CopyExplicit, Drop: drop} - } + return ownershipCapability(t) } // UseKind is the ownership classification of one value use: what happens to diff --git a/internal/semantics/typeinfo/capability_walk.go b/internal/semantics/typeinfo/capability_walk.go new file mode 100644 index 00000000..98d3a9e1 --- /dev/null +++ b/internal/semantics/typeinfo/capability_walk.go @@ -0,0 +1,113 @@ +package typeinfo + +// ownershipCapability answers copy class and drop obligation in one traversal. +// +// IsImplicitCopyType, noCopyType and NeedsDrop each walk the same type +// structure with their own cycle guard, so a type is recursed three times to +// answer three questions about it. They also drift: a case added to one is easy +// to forget in the others. +// +// The three recursions differ in shape, and this keeps both shapes rather than +// flattening them. Implicit copy is a "for all" question — every field of an +// enum payload must itself copy implicitly. No-copy and drop are "there exists" +// questions — one owned field is enough. The enumPayload flag carries the one +// piece of context implicit copy needs: a struct copies implicitly only as an +// enum payload, never as top-level bulk storage. +// +// A cycle answers "not implicitly copyable, no drop", which is what all three +// predicates return when their guard fires. +func ownershipCapability(t Type) OwnershipCapability { + visiting := make(map[*DefinedType]bool) + + var walk func(Type, bool) capabilityFacts + walk = func(current Type, enumPayload bool) capabilityFacts { + if defined, ok := current.(*DefinedType); ok { + if defined == nil || visiting[defined] { + return capabilityFacts{} + } + visiting[defined] = true + defer delete(visiting, defined) + return walk(defined.Underlying, enumPayload) + } + switch typ := Underlying(current).(type) { + case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, + *CStrType, *RawPtrType, *AllocatorType, *NoneType: + return capabilityFacts{implicitCopy: true} + case *OwnedPtrType, *StringType: + return capabilityFacts{noCopy: true, drop: true} + case *InterfaceType: + // An interface value never copies implicitly, but owned-interface + // drop activation is tracked separately and is not a drop here. + return capabilityFacts{noCopy: true} + case *RefType: + if typ == nil { + return capabilityFacts{} + } + return capabilityFacts{implicitCopy: !typ.Mutable, noCopy: typ.Mutable} + case *OptionalType: + if typ == nil { + return capabilityFacts{} + } + return walk(typ.Inner, false) + case *ArrayType: + if typ == nil { + return capabilityFacts{} + } + // An owner array owns its storage whatever the element is. + if typ.Shape == ArrayOwner { + return capabilityFacts{noCopy: true, drop: true} + } + inner := walk(typ.Elem, false) + return capabilityFacts{noCopy: inner.noCopy, drop: inner.drop} + case *StructType: + if typ == nil { + return capabilityFacts{} + } + // Bulk storage never copies implicitly at top level; as an enum + // payload it does when every field does. + facts := capabilityFacts{implicitCopy: enumPayload} + for _, field := range typ.Fields { + inner := walk(field.Type, false) + facts.implicitCopy = facts.implicitCopy && inner.implicitCopy + facts.noCopy = facts.noCopy || inner.noCopy + facts.drop = facts.drop || inner.drop + } + return facts + case *EnumType: + if typ == nil { + return capabilityFacts{} + } + facts := capabilityFacts{implicitCopy: true} + for _, variant := range typ.Cases { + if variant.Payload == nil { + continue + } + inner := walk(variant.Payload, true) + facts.implicitCopy = facts.implicitCopy && inner.implicitCopy + facts.noCopy = facts.noCopy || inner.noCopy + facts.drop = facts.drop || inner.drop + } + return facts + default: + return capabilityFacts{} + } + } + + facts := walk(t, false) + switch { + case facts.implicitCopy: + return OwnershipCapability{Copy: CopyImplicit, Drop: facts.drop} + case facts.noCopy: + return OwnershipCapability{Copy: CopyNever, Drop: facts.drop} + default: + return OwnershipCapability{Copy: CopyExplicit, Drop: facts.drop} + } +} + +// capabilityFacts is what one traversal step establishes about a type. It is +// internal to the walk: callers consume OwnershipCapability. +type capabilityFacts struct { + implicitCopy bool + noCopy bool + drop bool +} diff --git a/internal/semantics/typeinfo/capability_walk_test.go b/internal/semantics/typeinfo/capability_walk_test.go new file mode 100644 index 00000000..93caa721 --- /dev/null +++ b/internal/semantics/typeinfo/capability_walk_test.go @@ -0,0 +1,107 @@ +package typeinfo + +import ( + "testing" +) + +// leafTypes covers every base case the three ownership predicates distinguish. +func leafTypes() []Type { + return []Type{ + &IntegerType{}, &ByteType{}, &CharType{}, &FloatType{}, &BoolType{}, + &CStrType{}, &RawPtrType{}, &AllocatorType{}, &NoneType{}, + &StringType{}, &InterfaceType{}, + &OwnedPtrType{Target: &IntegerType{}}, + &RefType{Target: &IntegerType{}}, + &RefType{Target: &IntegerType{}, Mutable: true}, + &TypeParameterType{Name: "T"}, + &InvalidType{}, + &UnknownType{}, + } +} + +// wrap produces every one-level composite around a type, so the matrix reaches +// the recursive branches of all three predicates. +func wrap(inner Type) []Type { + return []Type{ + &OptionalType{Inner: inner}, + &ArrayType{Shape: ArrayOwner, Elem: inner}, + &ArrayType{Shape: ArraySlice, Elem: inner}, + &StructType{Fields: []Field{{Name: "a", Type: inner}}}, + &StructType{Fields: []Field{{Name: "a", Type: &IntegerType{}}, {Name: "b", Type: inner}}}, + &EnumType{Cases: []VariantCase{{Name: "None"}, {Name: "Some", Payload: inner}}}, + &DefinedType{Name: "Named", Underlying: inner}, + } +} + +// capabilityMatrix builds leaves, one-level and two-level composites. +func capabilityMatrix() []Type { + matrix := leafTypes() + for _, leaf := range leafTypes() { + matrix = append(matrix, wrap(leaf)...) + } + for _, leaf := range []Type{&IntegerType{}, &StringType{}, &OwnedPtrType{Target: &IntegerType{}}} { + for _, once := range wrap(leaf) { + matrix = append(matrix, wrap(once)...) + } + } + return matrix +} + +// The single traversal must answer exactly what the three separate predicates +// answer today. This is the parity proof that has to pass before any caller is +// migrated or any predicate deleted. +func TestOwnershipCapabilityWalkMatchesEstablishedPredicates(t *testing.T) { + matrix := capabilityMatrix() + if len(matrix) < 100 { + t.Fatalf("matrix has %d types, too few to be meaningful", len(matrix)) + } + for index, typ := range matrix { + want := OwnershipCapability{Copy: CopyExplicit, Drop: NeedsDrop(typ)} + switch { + case IsImplicitCopyType(typ): + want.Copy = CopyImplicit + case noCopyType(typ): + want.Copy = CopyNever + } + got := ownershipCapability(typ) + if got != want { + t.Errorf("type %d (%s): walk = %+v, predicates = %+v", index, TypeText(typ), got, want) + } + } +} + +// A type that contains itself must terminate and answer what the guarded +// predicates answer, rather than recursing forever. +func TestOwnershipCapabilityWalkTerminatesOnRecursiveType(t *testing.T) { + node := &DefinedType{Name: "Node"} + node.Underlying = &StructType{Fields: []Field{ + {Name: "next", Type: &OptionalType{Inner: node}}, + {Name: "value", Type: &IntegerType{}}, + }} + + want := OwnershipCapability{Copy: CopyExplicit, Drop: NeedsDrop(node)} + switch { + case IsImplicitCopyType(node): + want.Copy = CopyImplicit + case noCopyType(node): + want.Copy = CopyNever + } + if got := ownershipCapability(node); got != want { + t.Fatalf("recursive type: walk = %+v, predicates = %+v", got, want) + } +} + +// A type reached twice down separate branches is not a cycle, so the guard must +// not suppress the second visit. +func TestOwnershipCapabilityWalkVisitsRepeatedTypeTwice(t *testing.T) { + owned := &DefinedType{Name: "Owned", Underlying: &OwnedPtrType{Target: &IntegerType{}}} + pair := &StructType{Fields: []Field{{Name: "a", Type: owned}, {Name: "b", Type: owned}}} + + got := ownershipCapability(pair) + if !got.Drop { + t.Fatalf("repeated owned field: walk = %+v, want a drop obligation", got) + } + if got.Copy != CopyNever { + t.Fatalf("repeated owned field: walk = %+v, want CopyNever", got) + } +} From 3e06e02ed4d8101efa5de24cce1cc6be22ac746f Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 20:10:53 +0600 Subject: [PATCH 51/80] Replace the three ownership predicates with the capability query OwnershipCapabilityOf was added as the classification new code should consume, but it composed IsImplicitCopyType, noCopyType and NeedsDrop and left them as the behavioral source. The result was two live layers and one production caller for the canonical query against twenty for the predicates, which is the consolidation stated but not carried out. Migrate every caller and delete all three. A call site now reads OwnershipCapabilityOf(t).Drop or .Copy == CopyImplicit, which is wider than the old predicate; RULES section 1 and the Step 4 boundary both rule out keeping IsX wrappers to avoid that, and one query is worth the width. The backend keeps its own typeNeedsDrop over ir.TypeTable. That answers physical emission from a lowered layout rather than source policy, and is explicitly allowed to stay. Coverage is preserved by a golden table: 283 matrix entries captured while the differential test still proved the traversal agreed with all three predicates on every one. It records decided language behavior, so a diff there is an ownership rule change wanting a deliberate decision, not a test to re-bless. capabilities.go loses 143 lines and the walk costs 113, most of it the comment explaining why implicit copy recurses for-all while no-copy and drop recurse there-exists. --- internal/ir/hir/lower/module_lower.go | 2 +- internal/semantics/consteval/consteval.go | 2 +- internal/semantics/ownership/expr.go | 8 +- internal/semantics/ownership/ownership.go | 14 +- internal/semantics/ownership/reference.go | 2 +- internal/semantics/typechecker/check_call.go | 4 +- internal/semantics/typechecker/check_fn.go | 2 +- internal/semantics/typechecker/check_stmt.go | 6 +- internal/semantics/typeinfo/capabilities.go | 143 ------------------ .../typeinfo/capability_walk_test.go | 77 +++++++--- internal/semantics/typeinfo/types_test.go | 12 +- 11 files changed, 79 insertions(+), 193 deletions(-) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index ddde3e35..74a96d67 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -1362,7 +1362,7 @@ func shouldDiscardBindingValue(sym *symbols.Symbol) bool { if sym == nil || sym.Used { return false } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.NeedsDrop(typ) { + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { return false } switch node := sym.ASTNode.(type) { diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index f2eef430..66580ddc 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -140,7 +140,7 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) ( func (e *evaluator) evalExpr(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { if e.module.Typechecking != nil { if construction, ok := e.module.Typechecking.VariantConstructions[expr.ID()]; ok { - if !typeinfo.IsImplicitCopyType(construction.EnumType) { + if typeinfo.OwnershipCapabilityOf(construction.EnumType).Copy != typeinfo.CopyImplicit { return nil, false } descriptor, variant := typeinfo.VariantDescriptorOf(construction.EnumType) diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 13477fae..30d8959b 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -223,10 +223,10 @@ func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { if a == nil || a.cleanup == nil || projection == nil || base == nil { return false } - if place.IsPlaceExpr(base) || !typeinfo.NeedsDrop(a.exprType(base)) { + if place.IsPlaceExpr(base) || !typeinfo.OwnershipCapabilityOf(a.exprType(base)).Drop { return false } - if typeinfo.NeedsDrop(a.exprType(projection)) { + if typeinfo.OwnershipCapabilityOf(a.exprType(projection)).Drop { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "ownership-bearing projection from temporary must be bound before use", ast.LocOf(projection), "") return true @@ -381,7 +381,7 @@ func (a *analyzer) publishedUse(arg ast.Expr, paramType typeinfo.Type) typeinfo. return kind } } - if paramType == nil || typeinfo.IsImplicitCopyType(paramType) { + if paramType == nil || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { return typeinfo.UseRead } return typeinfo.UseMove @@ -515,7 +515,7 @@ func ownershipTrackedSymbol(sym *symbols.Symbol) bool { } func ownershipTrackedType(t typeinfo.Type) bool { - if t == nil || typeinfo.IsImplicitCopyType(t) { + if t == nil || typeinfo.OwnershipCapabilityOf(t).Copy == typeinfo.CopyImplicit { return false } return true diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index a0470303..7f32184c 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -408,7 +408,7 @@ func (a *analyzer) applyBlockExit(node *site, st state, loans *loanContext) { if carrier := a.deadMatchCarrierAtExit[node.cfgSite.ID]; carrier != nil { if _, live := st.live[carrier]; live { a.reportLoanConflict([]place.Origin{{Root: carrier}}, nil, storageDestroy, node.block, loans) - if typ, ok := symbols.GetSymbolType(carrier); ok && typeinfo.NeedsDrop(typ) { + if typ, ok := symbols.GetSymbolType(carrier); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { cleanup = append(cleanup, carrier) } st.moved[carrier] = node.block @@ -448,7 +448,7 @@ func cleanupSymbols(scope *symbols.Scope, st state) []*symbols.Symbol { continue } typ, ok := symbols.GetSymbolType(sym) - if ok && typeinfo.NeedsDrop(typ) { + if ok && typeinfo.OwnershipCapabilityOf(typ).Drop { cleanup = append(cleanup, sym) } } @@ -504,7 +504,7 @@ func (a *analyzer) applyStmt(node *site, st state) { if _, ok := s.Target.(*ast.Ident); !ok { a.checkExpr(scope, s.Target, st, typeinfo.UseRead, loans, true) a.checkStorageAccess(s.Target, loans, storageMutate) - if typeinfo.NeedsDrop(a.exprType(s.Target)) { + if typeinfo.OwnershipCapabilityOf(a.exprType(s.Target)).Drop { a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} } } @@ -513,7 +513,7 @@ func (a *analyzer) applyStmt(node *site, st state) { if _, referenceTarget := referenceMutability(sym); !referenceTarget { a.checkStorageAccess(target, loans, storageMutate) } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.NeedsDrop(typ) { + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { if _, live := st.live[sym]; live { a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} } @@ -534,7 +534,7 @@ func (a *analyzer) applyStmt(node *site, st state) { a.cleanupBeforeReturn(scope, s, st, loans) case *ast.ExprStmt: a.checkExpr(scope, s.Expr, st, typeinfo.UseRead, loans, false) - if s.Expr != nil && !place.IsPlaceExpr(s.Expr) && typeinfo.NeedsDrop(a.exprType(s.Expr)) { + if s.Expr != nil && !place.IsPlaceExpr(s.Expr) && typeinfo.OwnershipCapabilityOf(a.exprType(s.Expr)).Drop { a.cleanup.DiscardedValue[ir.NodeID(s.Expr.ID())] = struct{}{} } case *ast.IfStmt: @@ -614,7 +614,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { delete(st.live, carrier) delete(st.references, carrier) if len(arm.Bindings) == 1 && arm.Bindings[0].Projection == typecheckresult.MatchWholePayload { - if arm.Bindings[0].Discard && typeinfo.NeedsDrop(arm.Bindings[0].Type) { + if arm.Bindings[0].Discard && typeinfo.OwnershipCapabilityOf(arm.Bindings[0].Type).Drop { a.cleanup.MatchWholePayloadDrops[ir.NodeID(arm.BodyID)] = struct{}{} } } else if payload, payloadFound := typeinfo.Underlying(arm.Payload).(*typeinfo.StructType); payloadFound && payload != nil { @@ -622,7 +622,7 @@ func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { for fieldIndex := len(payload.Fields) - 1; fieldIndex >= 0; fieldIndex-- { field := payload.Fields[fieldIndex] discarded, selected := listed[fieldIndex] - if typeinfo.NeedsDrop(field.Type) && (!selected || discarded) { + if typeinfo.OwnershipCapabilityOf(field.Type).Drop && (!selected || discarded) { drops = append(drops, fieldIndex) } } diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 4d2959fb..13cc814d 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -614,7 +614,7 @@ func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast if target, ok := stmt.Target.(*ast.Ident); ok && node.scope != nil { if sym, found := node.scope.Lookup(target.Name); found && trackedLiveSymbol(sym) { definitions[sym] = struct{}{} - if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.NeedsDrop(typ) { + if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { uses[sym] = target } } diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index bd86bdd6..73b75ba2 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -217,7 +217,7 @@ func (c *checker) typeDynamicArrayOwnerCall(scope *symbols.Scope, node *ast.Call argTypes = append(argTypes, c.typeExpr(scope, arg, fnType.Params[i+1])) } c.checkCall(scope, nil, node, fnType, node.Args, argTypes) - if op == symbols.CompilerOpResize && !typeinfo.IsImplicitCopyType(array.Elem) { + if op == symbols.CompilerOpResize && typeinfo.OwnershipCapabilityOf(array.Elem).Copy != typeinfo.CopyImplicit { c.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "resize requires implicitly copyable elements; grow Category B arrays with append", ast.LocOf(node), "") @@ -279,7 +279,7 @@ func (c *checker) publishValueUse(arg ast.Expr, paramType typeinfo.Type) { return } use := typeinfo.UseMove - if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference || typeinfo.IsImplicitCopyType(paramType) { + if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { use = typeinfo.UseRead } c.module.Typechecking.ValueUses[arg.ID()] = use diff --git a/internal/semantics/typechecker/check_fn.go b/internal/semantics/typechecker/check_fn.go index 41d4c891..ac2499bc 100644 --- a/internal/semantics/typechecker/check_fn.go +++ b/internal/semantics/typechecker/check_fn.go @@ -101,7 +101,7 @@ func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.F return true } paramType := typeinfo.TypeFromSyntax(params[index].Type, project.TypeSyntaxOptions(c.ctx, c.module, nil, false)) - if typeinfo.IsImplicitCopyType(paramType) { + if typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { return true } if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference { diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 209bd54c..5807449a 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -145,7 +145,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu return } evidenceComplete := true - if typeinfo.NeedsDrop(subjectType) && !place.IsPlaceExpr(node.Subject) { + if typeinfo.OwnershipCapabilityOf(subjectType).Drop && !place.IsPlaceExpr(node.Subject) { c.ctx.Diagnostics.Add(invalidOperationError(node.Subject, "ownership-bearing match subject must be a named place"). WithHelp("bind subject to a local before matching it")) @@ -252,7 +252,7 @@ func (c *checker) checkMatchStmt(scope *symbols.Scope, node *ast.MatchStmt, retu } carrierUse := typeinfo.UseRead for _, field := range armEvidence.Bindings { - if !typeinfo.IsImplicitCopyType(field.Type) { + if typeinfo.OwnershipCapabilityOf(field.Type).Copy != typeinfo.CopyImplicit { carrierUse = typeinfo.UseMove break } @@ -613,7 +613,7 @@ func (c *checker) checkForInStmt(scope *symbols.Scope, node *ast.ForStmt, return "for-in requires addressable array storage")) } } - if !typeinfo.IsImplicitCopyType(elem) { + if typeinfo.OwnershipCapabilityOf(elem).Copy != typeinfo.CopyImplicit { valid = false c.ctx.Diagnostics.Add(invalidExpressionError(node.Iterable, "for-in requires copyable sequence elements; iterate indexes and borrow move-only elements explicitly")) diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index 642a870a..742eee47 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -88,52 +88,6 @@ func IsCondition(t Type) bool { return ok } -func IsImplicitCopyType(t Type) bool { - visiting := make(map[*DefinedType]bool) - var check func(Type, bool) bool - check = func(current Type, enumPayload bool) bool { - if defined, ok := current.(*DefinedType); ok { - if defined == nil || visiting[defined] { - return false - } - visiting[defined] = true - defer delete(visiting, defined) - return check(defined.Underlying, enumPayload) - } - switch typ := Underlying(current).(type) { - case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, *CStrType, *RawPtrType, *AllocatorType, *NoneType: - return true - case *RefType: - return typ != nil && !typ.Mutable - case *OptionalType: - return typ != nil && check(typ.Inner, false) - case *StructType: - if typ == nil || !enumPayload { - return false - } - for _, field := range typ.Fields { - if !check(field.Type, false) { - return false - } - } - return true - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload, true) { - return false - } - } - return true - default: - return false - } - } - return check(t, false) -} - func IsSizedType(t Type) bool { visiting := make(map[*DefinedType]bool) var check func(Type) bool @@ -207,56 +161,6 @@ func IsSizedType(t Type) bool { return check(t) } -// noCopyType reports whether the type contains owned runtime state, so copies -// are forbidden outright. It is the CopyNever half of the ownership capability -// and is intentionally unexported: consumers classify through -// OwnershipCapabilityOf instead of re-deriving. -func noCopyType(t Type) bool { - seen := make(map[*DefinedType]bool) - var check func(Type) bool - check = func(current Type) bool { - switch typ := current.(type) { - case *DefinedType: - if typ == nil || seen[typ] { - return false - } - seen[typ] = true - defer delete(seen, typ) - return check(typ.Underlying) - } - switch typ := Underlying(current).(type) { - case *OwnedPtrType, *StringType, *InterfaceType: - return true - case *RefType: - return typ != nil && typ.Mutable - case *OptionalType: - return typ != nil && check(typ.Inner) - case *ArrayType: - return typ != nil && (typ.Shape == ArrayOwner || check(typ.Elem)) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if check(field.Type) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && check(variant.Payload) { - return true - } - } - } - return false - } - return check(t) -} - // IsLowerableType reports whether current backend lowering can represent type. // Owned pointers and safe references close recursive named composites without expanding storage; // abstract Self parameters remain semantic-only interface metadata. @@ -355,53 +259,6 @@ func IsLowerableType(t Type) bool { return check(t, false) } -// NeedsDrop reports whether normal scope cleanup must destroy runtime-owned -// state reachable through a value. Move-only borrows and plain composites do -// not need destruction; this is intentionally narrower than noCopyType. -func NeedsDrop(t Type) bool { - seen := make(map[*DefinedType]bool) - var check func(Type) bool - check = func(current Type) bool { - switch typ := current.(type) { - case *DefinedType: - if typ == nil || seen[typ] { - return false - } - seen[typ] = true - defer delete(seen, typ) - return check(typ.Underlying) - } - switch typ := Underlying(current).(type) { - case *OwnedPtrType, *StringType: - return true - case *OptionalType: - return typ != nil && check(typ.Inner) - case *ArrayType: - return typ != nil && (typ.Shape == ArrayOwner || check(typ.Elem)) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if check(field.Type) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if variant.Payload != nil && check(variant.Payload) { - return true - } - } - } - return false - } - return check(t) -} - // CopyClass classifies how a value of a type may be duplicated. type CopyClass uint8 diff --git a/internal/semantics/typeinfo/capability_walk_test.go b/internal/semantics/typeinfo/capability_walk_test.go index 93caa721..a84523ef 100644 --- a/internal/semantics/typeinfo/capability_walk_test.go +++ b/internal/semantics/typeinfo/capability_walk_test.go @@ -1,6 +1,7 @@ package typeinfo import ( + "strings" "testing" ) @@ -47,25 +48,57 @@ func capabilityMatrix() []Type { return matrix } -// The single traversal must answer exactly what the three separate predicates -// answer today. This is the parity proof that has to pass before any caller is -// migrated or any predicate deleted. -func TestOwnershipCapabilityWalkMatchesEstablishedPredicates(t *testing.T) { +// capabilityGolden records the answer for every type in capabilityMatrix, in +// matrix order, as a copy-class letter (i implicit, e explicit, n never) +// followed by a drop marker (+ or -). +// +// It was captured from the traversal at the point a differential test proved it +// agreed with IsImplicitCopyType, noCopyType and NeedsDrop on every entry. +// Those predicates are gone, so this table is what preserves their coverage. It +// records decided language behavior, not whatever the code happens to do now: a +// diff here means an ownership rule changed and wants a deliberate decision. +const capabilityGolden = "i-i-i-i-i-i-i-i-i-n+n-n+i-n-e-e-e-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-" + + "i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-i-i-n+e-e-e-i-" + + "i-i-n+e-e-e-i-i-n+n+n+n+n+n+n+n-n+n-n-n-n-n-n+n+n+n+n+n+n+i-n+e-e-e-i-i-" + + "n-n+n-n-n-n-n-e-n+e-e-e-e-e-e-n+e-e-e-e-e-e-n+e-e-e-e-e-i-n+e-e-e-i-" + + "i-n+n+n+n+n+n+n+e-n+e-e-e-e-e-e-n+e-e-e-i-e-e-n+e-e-e-i-e-i-n+e-e-e-i-i-" + + "i-n+e-e-e-i-i-" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + + "n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+n+" + +func TestOwnershipCapabilityMatchesGolden(t *testing.T) { matrix := capabilityMatrix() - if len(matrix) < 100 { - t.Fatalf("matrix has %d types, too few to be meaningful", len(matrix)) + if len(matrix)*2 != len(capabilityGolden) { + t.Fatalf("matrix has %d types but golden covers %d; regenerate deliberately", + len(matrix), len(capabilityGolden)/2) } - for index, typ := range matrix { - want := OwnershipCapability{Copy: CopyExplicit, Drop: NeedsDrop(typ)} - switch { - case IsImplicitCopyType(typ): - want.Copy = CopyImplicit - case noCopyType(typ): - want.Copy = CopyNever - } + var b strings.Builder + for _, typ := range matrix { got := ownershipCapability(typ) - if got != want { - t.Errorf("type %d (%s): walk = %+v, predicates = %+v", index, TypeText(typ), got, want) + switch got.Copy { + case CopyImplicit: + b.WriteByte('i') + case CopyExplicit: + b.WriteByte('e') + case CopyNever: + b.WriteByte('n') + } + if got.Drop { + b.WriteByte('+') + } else { + b.WriteByte('-') + } + } + got := b.String() + if got == capabilityGolden { + return + } + for i, typ := range matrix { + want := capabilityGolden[i*2 : i*2+2] + if got[i*2:i*2+2] != want { + t.Errorf("type %d (%s): capability = %s, golden = %s", + i, TypeText(typ), got[i*2:i*2+2], want) } } } @@ -79,15 +112,11 @@ func TestOwnershipCapabilityWalkTerminatesOnRecursiveType(t *testing.T) { {Name: "value", Type: &IntegerType{}}, }} - want := OwnershipCapability{Copy: CopyExplicit, Drop: NeedsDrop(node)} - switch { - case IsImplicitCopyType(node): - want.Copy = CopyImplicit - case noCopyType(node): - want.Copy = CopyNever - } + // Bulk storage never copies implicitly, and the guard firing on the second + // visit is what makes the self-reference terminate without a drop claim. + want := OwnershipCapability{Copy: CopyExplicit} if got := ownershipCapability(node); got != want { - t.Fatalf("recursive type: walk = %+v, predicates = %+v", got, want) + t.Fatalf("recursive type: capability = %+v, want %+v", got, want) } } diff --git a/internal/semantics/typeinfo/types_test.go b/internal/semantics/typeinfo/types_test.go index 7cad2a9f..66602a2f 100644 --- a/internal/semantics/typeinfo/types_test.go +++ b/internal/semantics/typeinfo/types_test.go @@ -74,13 +74,13 @@ func TestSliceIsUnsizedButSliceReferenceIsSized(t *testing.T) { func TestCopyCapabilitiesFollowStructuralModel(t *testing.T) { i32 := &IntegerType{Signed: true, Bits: 32} - if !IsImplicitCopyType(i32) || !IsImplicitCopyType(&RawPtrType{}) || !IsImplicitCopyType(&RefType{Target: i32}) { + if OwnershipCapabilityOf(i32).Copy != CopyImplicit || OwnershipCapabilityOf(&RawPtrType{}).Copy != CopyImplicit || OwnershipCapabilityOf(&RefType{Target: i32}).Copy != CopyImplicit { t.Fatalf("scalar, raw pointer, and shared reference should copy implicitly") } - if !IsImplicitCopyType(&OptionalType{Inner: i32}) || IsImplicitCopyType(&OptionalType{Inner: &StructType{}}) { + if OwnershipCapabilityOf(&OptionalType{Inner: i32}).Copy != CopyImplicit || OwnershipCapabilityOf(&OptionalType{Inner: &StructType{}}).Copy == CopyImplicit { t.Fatalf("optional copyability should follow payload copyability") } - if IsImplicitCopyType(&StructType{Fields: []Field{{Name: "value", Type: i32}}}) { + if OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "value", Type: i32}}}).Copy == CopyImplicit { t.Fatalf("struct should not copy implicitly") } if got := OwnershipCapabilityOf(&StructType{Fields: []Field{{Name: "value", Type: i32}}}); got.Copy != CopyExplicit { @@ -102,7 +102,7 @@ func TestAllocatorCapabilities(t *testing.T) { if !IsSizedType(allocator) { t.Fatal("allocator must be sized") } - if !IsImplicitCopyType(allocator) { + if OwnershipCapabilityOf(allocator).Copy != CopyImplicit { t.Fatal("allocator must copy implicitly") } if !IsEquatable(allocator) { @@ -435,8 +435,8 @@ func TestNeedsDropSeparatesOwnershipFromMoveOnlyTypes(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := NeedsDrop(tc.typ); got != tc.want { - t.Fatalf("NeedsDrop(%s) = %v, want %v", TypeText(tc.typ), got, tc.want) + if got := OwnershipCapabilityOf(tc.typ).Drop; got != tc.want { + t.Fatalf("drop obligation for %s = %v, want %v", TypeText(tc.typ), got, tc.want) } }) } From da4543ba9f25eb57a7f28ebd511543fde502d296 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 20:26:12 +0600 Subject: [PATCH 52/80] Enumerate ownership uses from the published effect stream symbolUseSequence walked eight statement kinds to work out which symbols a site reads, which is what the producer already publishes. Worse, it disagreed with applyStmt: applyStmt handles ForStmt.Iterable and this walk never visited it, so liveness and borrow-ending saw a different program than the effect analysis did. Reading one stream means they cannot disagree. Its signature is unchanged, so computeSymbolLiveness and newLoanContext are untouched. It has left the dispatch contract, taking statement sites from nine to eight. Ownership's liveness reads a match subject, so the producer had to publish it, and that closed a real gap: definite initialization attached a site condition for a branch terminator only, so a match on an uninitialized value was never diagnosed. It is now, covered by TestInitializationChecksMatchSubject. No existing test, fixture or bundled program changed, so nothing that compiled before fails now. The behavior-change register records it as closed rather than pending, and names the coupling it demonstrates: evidence published for one consumer strengthens every other consumer. symbolUsesAndDefinitions still derives definitions from syntax. The producer also emits defines for parameters and match payload bindings, which that analysis does not count today, so moving it needs its own parity work. --- docs/compiler-framework/change-paths.md | 4 +- .../effect-stream-migration.md | 52 +++++++++++--- internal/contracts/node_dispatch_test.go | 11 --- .../definiteinit/initialization_test.go | 32 +++++++++ internal/semantics/effect/build.go | 12 ++-- internal/semantics/effect/build_test.go | 11 ++- internal/semantics/ownership/ownership.go | 5 +- .../semantics/ownership/ownership_test.go | 8 +++ internal/semantics/ownership/reference.go | 71 +++++-------------- 9 files changed, 120 insertions(+), 86 deletions(-) diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 7e4cc597..70dbab29 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -168,12 +168,12 @@ by review, not by a test. This is the largest honest gap in the pipeline. ### What Walk 1 forced automatically Adding one `stmtNode` implementation enrolls the kind in -`TestEveryStatementKindHasAPhaseDecision`, which then fails at **all nine** statement +`TestEveryStatementKindHasAPhaseDecision`, which then fails at **all eight** statement dispatch sites until each one either handles the kind or declares why it is inert: ``` resolveStmt · checkStmt · buildStmt · appendStmt · lowerElse -applyStmt · symbolUseSequence · publishStmt · applyConditionEdge +applyStmt · publishStmt · applyConditionEdge ``` An `exprNode` enrolls in `TestEveryExpressionKindHasAPhaseDecision` across four sites: diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md index cf3632b8..c4df928f 100644 --- a/docs/compiler-framework/effect-stream-migration.md +++ b/docs/compiler-framework/effect-stream-migration.md @@ -1,7 +1,8 @@ # Effect stream migration -Status: **milestone 1 complete**. Definite initialization consumes published effects -and no longer imports `ast`. Ownership is the next consumer and is not yet planned. +Status: **in progress**. Definite initialization consumes published effects and no +longer imports `ast`. Ownership's use enumeration consumes them too; the rest of +ownership still decides from syntax. This document is the executable plan for publishing semantic effects once and migrating dataflow consumers onto them. It is tracked so that anyone — human or agent — picking the @@ -202,13 +203,22 @@ Commit: `Require a phase decision for every published effect` `RULES.md` §10 forbids mixing a behavior change into a refactor. Both of these are recorded for separate approval and must **not** be corrected inside this migration. -- **Match subjects are never read-checked.** `definiteinit` attaches a site condition only - for `*cfg.Branch`; `*cfg.SwitchVariant` is not handled, so a match on an uninitialized - value is not diagnosed. The effect stream makes emitting that read natural, which would - start rejecting code that compiles today. Step 2 must reproduce the gap. Closing it needs - its own approval and an `x_test` fixture. -- **`ForStmt.Iterable` is invisible to `symbolUseSequence`** while `applyStmt` handles it. - Ownership only; out of scope. +- **Match subjects were never read-checked — now closed.** `definiteinit` attached a site + condition only for `*cfg.Branch`, so a match on an uninitialized value was not + diagnosed. Ownership's liveness *did* read the subject, so migrating it onto the shared + producer forced the subject to be published, and publishing it closed the + definite-initialization gap at the same time. Covered by + `TestInitializationChecksMatchSubject`. No existing test, fixture or bundled program + changed, so nothing that compiled before fails now. + + This is the coupling a shared stream creates: evidence published for one consumer + strengthens every other consumer, whether or not that was the intent. Weigh it before + publishing anything new. +- **`ForStmt.Iterable` disagreement — now moot for enumeration.** `symbolUseSequence` + never visited it while `applyStmt` did. Both now read the same published stream, so + they cannot disagree. The iterable's reads are still not published, which preserves + today's behavior; `applyStmt` continues to handle it directly for the sequence-carrier + loan. ## Success test — met @@ -247,3 +257,27 @@ first needs `ValueUses` extended past call arguments, with a matching extension `UseCopy` is never published, so the two `UseCopy` diagnostics in `ownership/expr.go` are presently dead and would activate for the first time — they need tests before that. Ownership's loans, liveness, and borrow-ending stay local to ownership. + +## Milestone 2 — ownership + +Not planned as a whole; landing slice by slice. + +**Done — use enumeration.** `symbolUseSequence` walked eight statement kinds to +enumerate the symbols a site reads. That duplicated the producer and disagreed with +`applyStmt` about `ForStmt.Iterable`. It now reads the stream and has left the dispatch +contract, taking statement sites from nine to eight. + +**Still deciding from syntax**, roughly by size: + +- `checkExpr`, 23 expression cases. About a third is enumeration; the rest is + storage-access checks, loan bookkeeping and per-shape diagnostics. Collapsing it fully + needs projected places in the vocabulary, because a diagnostic like "move-only indexed + element cannot be used by value" depends on the shape of the place, not just the symbol. +- `applyStmt`, eight statement cases mixing enumeration with loan installation and + cleanup planning. +- `symbolUsesAndDefinitions` still derives definitions from `LetDecl`, `ConstDecl` and + `AssignStmt`. Deliberately left: the producer also emits defines for parameters and for + match payload bindings, which this analysis does not count as definitions today, so + switching it over would change liveness and needs its own parity work first. +- The 44 hard-coded use-kind literals, which need `Use.Kind`, which in turn needs + `typecheckresult.ValueUses` extended past call arguments. diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index 3cd1b43f..e017552c 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -138,17 +138,6 @@ var statementSites = []dispatchSite{ "ContinueStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, }, }, - { - file: "semantics/ownership/reference.go", - fn: "symbolUseSequence", - inertDeclarations: true, - omitted: map[string]classification{ - "BlockStmt": {ignore, decomposedByCFGReason}, - "BadStmt": {ignore, "recovery node evaluates no expression"}, - "BreakStmt": {ignore, "evaluates no expression"}, - "ContinueStmt": {ignore, "evaluates no expression"}, - }, - }, // The effect producer replaced definiteinit.checkReads as the site that reads // meaning out of a statement. It is exhaustive: every kind has a case, so it // declares no omissions, and a new kind fails here first. diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 3e483a66..313e0e30 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -214,3 +214,35 @@ func hasDiagnosticCode(diag *diagnostics.DiagnosticBag, code string) bool { } return false } + +// A match subject is a read like any other. This was previously undiagnosed: +// the analysis attached a site condition for a branch terminator only, so +// nothing checked the subject of a match. Publishing the subject as an ordinary +// read closed the gap, and this analysis learned nothing about matches to get it. +func TestInitializationChecksMatchSubject(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `enum Outcome { + Ok: { value: i32 }, + Pending, +} + +fn choose(flag: bool) -> i32 { + let mut outcome: Outcome; + if flag { + outcome = Outcome::Pending; + } + match outcome { + Outcome::Ok with { value = payload } => { + return payload; + } + Outcome::Pending => { + return 0; + } + } +}`) + if !hasDiagnosticCode(diag, diagnostics.ErrUninitializedVariable) { + t.Fatalf("expected uninitialized match subject diagnostic:\n%s", diag.EmitAllToString()) + } + if got := diag.EmitAllToString(); !strings.Contains(got, "symbol `outcome` used before it's initialized") { + t.Fatalf("diagnostic does not name the match subject:\n%s", got) + } +} diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 74481397..3208d19b 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -179,10 +179,14 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St b.reads(site, node.Expr) case *ast.ReturnStmt: b.reads(site, node.Value) - case *ast.IfStmt, *ast.ForStmt, *ast.MatchStmt: - // Control-flow statements reach this producer at their terminator site. - // The condition or subject is published there, from the terminator, so - // that a site carries exactly the reads that happen at it. + case *ast.MatchStmt: + // A match reaches this producer at its terminator site, and at a plain + // statement site when semantic evidence was too incomplete for CFG to + // decompose it. Publishing the subject here covers both. + b.reads(site, node.Subject) + case *ast.IfStmt, *ast.ForStmt: + // A branch condition is published from the terminator, which names it + // directly, so a site carries exactly the reads that happen at it. case *ast.BlockStmt: // Blocks are decomposed by CFG construction; a scope-exit site names one // but evaluates nothing. diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 7c308601..da251233 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -219,15 +219,12 @@ fn choose(outcome: Result) -> i32 { } }`) got := publishedOps(t, result, module, "choose") - // The match subject is deliberately absent. Definite initialization attaches - // a site condition for a branch terminator only, so a match on an - // uninitialized value is not diagnosed today. Publishing that read here - // would start rejecting code that currently compiles, so it is registered as - // a behavior change in docs/compiler-framework/effect-stream-migration.md - // and left for separate approval. Change this expectation only together with - // that decision. + // The subject is published at the match's own site. Ownership's liveness + // needs it, and publishing it also closed the gap where definite + // initialization never checked a match subject for initialization. want := []string{ "define outcome", + "use outcome", "define payload", "use payload", } if !sameOps(got, want) { diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 7f32184c..da5ca1fd 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -9,6 +9,7 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" @@ -30,6 +31,7 @@ type analyzer struct { graph *cfg.Graph sites map[cfg.SiteID]*site order []cfg.SiteID + effects effect.SiteOps cleanup *ownershipresult.CleanupPlan function *ast.FnDecl functionScope *symbols.Scope @@ -57,7 +59,7 @@ type state struct { // value-flow rules from becoming ad hoc type rules. func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult.Result { result := make(ownershipresult.Result) - if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Bindings == nil || module.CFG == nil { + if ctx == nil || module == nil || module.AST == nil || module.ModuleScope == nil || module.Bindings == nil || module.Effects == nil || module.CFG == nil { return result } for _, graph := range module.CFG.Functions { @@ -116,6 +118,7 @@ func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast graph: cfgFn, sites: sites, order: order, + effects: module.Effects[cfgFn.NodeID], cleanup: cleanup, function: fn, functionScope: scope, diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index fefb078e..0b868874 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -15,6 +15,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" + "compiler/internal/semantics/effect" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/resolver" @@ -54,6 +55,12 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { LoopGuaranteedEntry: module.Typechecking.ForLoopGuaranteedEntry, }) module.Flow = typechecker.CheckFlow(ctx, module) + module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + }) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} } @@ -84,6 +91,7 @@ func inspectFunctionAnalysis(t *testing.T, result *ownershipResult, name string) graph: cfgFn, sites: sites, order: order, + effects: result.module.Effects[cfgFn.NodeID], cleanup: cleanup, function: fn, functionScope: scope, diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 13cc814d..3df6e54b 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -8,6 +8,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir/cfg" "compiler/internal/project" + "compiler/internal/semantics/effect" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" @@ -630,62 +631,28 @@ func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast return uses, definitions } +// symbolUseSequence returns the symbols this site reads, in evaluation order. +// +// It reads published effects rather than walking the statement itself. The +// walk it replaced enumerated eight statement kinds and missed ForStmt.Iterable, +// which applyStmt does handle, so liveness and borrow-ending saw a different +// program than the effect analysis did. One producer means they cannot disagree. func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) bool) []symbolUse { - if a == nil || node == nil || node.cfgSite == nil || - (node.cfgSite.Kind != cfg.SiteStatement && node.cfgSite.Kind != cfg.SiteTerminator) || node.stmt == nil || - a.module == nil || a.module.Bindings == nil || include == nil { + if a == nil || a.module == nil || node == nil || node.cfgSite == nil || include == nil { return nil } - var expressions []ast.Expr - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - expressions = append(expressions, stmt.Value) - case *ast.ConstDecl: - expressions = append(expressions, stmt.Value) - case *ast.AssignStmt: - expressions = append(expressions, stmt.Value) - if _, binding := stmt.Target.(*ast.Ident); !binding { - expressions = append(expressions, stmt.Target) - } - case *ast.ReturnStmt: - expressions = append(expressions, stmt.Value) - case *ast.ExprStmt: - expressions = append(expressions, stmt.Expr) - case *ast.IfStmt: - expressions = append(expressions, stmt.Cond) - case *ast.ForStmt: - expressions = append(expressions, stmt.Cond) - case *ast.MatchStmt: - expressions = append(expressions, stmt.Subject) - } - - var uses []symbolUse - var inspectExpr func(ast.Expr) - inspectExpr = func(expr ast.Expr) { - if expr == nil { - return + ops := a.effects[node.cfgSite.ID] + uses := make([]symbolUse, 0, len(ops)) + for _, op := range ops { + use, isUse := op.(effect.Use) + if !isUse || !include(use.Symbol) { + continue } - ast.Inspect(expr, func(current ast.Node) bool { - if call, ok := current.(*ast.CallExpr); ok && call != nil { - inspectExpr(call.Callee) - for _, arg := range a.module.Typechecking.CallArgumentsOrSource(call) { - inspectExpr(arg) - } - return false - } - ident, ok := current.(*ast.Ident) - if !ok || ident == nil { - return true - } - sym := a.module.Bindings.NodeSymbols[ident.ID()] - if include(sym) { - uses = append(uses, symbolUse{symbol: sym, site: ident}) - } - return true - }) - } - for _, expr := range expressions { - inspectExpr(expr) + syntax, found := a.module.TypedASTNodes[use.Node] + if !found { + continue + } + uses = append(uses, symbolUse{symbol: use.Symbol, site: syntax}) } return uses } From 1001510069ead783ae519b5ae6478a00a874db6d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 20:39:45 +0600 Subject: [PATCH 53/80] Require a phase decision for every semantic type kind AST type syntax had a dispatch contract; the semantic types that syntax lowers into did not. Adding a typeinfo.Type and forgetting that it must answer a capability, compare equal, or lower to a runtime type compiled clean and gave a wrong answer rather than a failure. Contract the three sites where a missing case is silently wrong: ownershipCapability answers move-on-use with no drop, SameType answers not equal, and intern answers ir.InvalidType. IsSizedType, IsLowerableType and the LSP formatters are deliberately outside it, because their defaults reject or degrade rather than answer wrongly, and classifying kinds there would be ceremony around decisions that are already correct. Fingerprinting needs no contract at all: Text() is an interface method, so the compiler enforces it. Kinds are found by their TypeNode marker, the same way the AST families are found by theirs. Extraction is looser than the AST contract in one respect, recorded in the file: these functions switch on a derived value rather than a parameter, and some peel DefinedType with an assertion before switching, so both forms count and the filter to declared kinds is what keeps it honest. Nine kinds are classified rather than cased, each with the reason the code actually gives. Two of those reasons record real answers rather than irrelevance: TypeParameterType is conservatively move-on-use until generic instantiation, and whether a function value should copy implicitly is an open language question that the default currently answers by accident. Proven three ways. A new type kind fails at all three sites naming what each decides. Classifying a handled kind fails with 'delete the entry'. Classifying a kind that does not exist fails too. Walk 3 in change-paths.md still told contributors to answer IsImplicitCopyType, noCopyType and NeedsDrop, which no longer exist, and still said nothing catches them at capability and lowering. Both corrected, and the gap-list bullet now states the real boundary instead of claiming no contract exists. --- docs/compiler-framework/change-paths.md | 34 ++-- internal/contracts/type_dispatch_test.go | 215 +++++++++++++++++++++++ 2 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 internal/contracts/type_dispatch_test.go diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 70dbab29..e688051f 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -234,10 +234,10 @@ lowering carry an opinion of its own; the third recorded an opinion nothing cons ## Walk 3 — adding a type -A new `typeinfo.Type` is the change shape with the **weakest** automatic coverage. The -contract added for AST type *syntax* does not help here: `typeinfo.Type` is the semantic -type model, a different family with no contract of its own. Read this walk as a -checklist you must run manually. +A new `typeinfo.Type` is the semantic type model — a different family from AST type +*syntax*, and it has its own contract. `contracts.TestEverySemanticTypeKindHasAPhaseDecision` +holds you to the three sites where a missing case is silently wrong rather than loudly +rejected. The rest of this walk is still a manual checklist. If your type also needs new syntax to write it, that syntax node joins the `typeNode` family and `TestEveryTypeKindHasAPhaseDecision` will hold you to `TypeFromSyntax` and @@ -255,17 +255,22 @@ This is the step that decides whether your type is safe by default. The governin > Contains a reference, pointer, or allocation inside → move. Check the type, apply > the rule. No per-type policy tables. -Answer, in this file: `IsImplicitCopyType`, `noCopyType`, `NeedsDrop`, and the -composite `OwnershipCapabilityOf`. Also consider `IsSizedType`, `IsLowerableType`, -`IsEquatable`, `IsOrderable`, `IsArithmetic`, `IsIntegral`, `IsCondition`. +Answer it in one place: `ownershipCapability` in `capability_walk.go`, which decides +copy class and drop obligation in a single traversal. `OwnershipCapabilityOf` is the +public query over it. `IsImplicitCopyType`, `noCopyType` and `NeedsDrop` no longer exist. +Also consider `IsSizedType`, `IsLowerableType`, `IsEquatable`, `IsOrderable`, +`IsArithmetic`, `IsIntegral`, `IsCondition`. Get this right and ownership, cleanup, and drop emission follow with no further work — that is the whole point of the capability model. -*Catches you:* — nothing. A `default:` branch will quietly classify your type as -non-copyable, which is safe but may be wrong. +*Catches you:* **Visible** — `TestEverySemanticTypeKindHasAPhaseDecision/ownershipCapability` +fails naming your kind and what the site decides. Its `default:` still answers move-on-use +with no drop, so the contract is what stops that silent answer standing in for a decision. **3. HIR type lowering** — `ir/hir/lower/lower_types.go` The largest type switch in the compiler (~31 cases). Map your type to an `ir.TypeID`. -*Catches you:* — nothing; unmapped types fall to `ir.InvalidType`. +*Catches you:* **Visible** — `TestEverySemanticTypeKindHasAPhaseDecision/intern` fails +naming your kind. Unmapped types still fall to `ir.InvalidType`, so the contract is the +only thing between you and a silently invalid runtime type. **4. Export fingerprint** — `project/export_fingerprint.go`, `semanticTypeKey` Incremental correctness. If your type is not keyed distinctly, a dependent module can @@ -307,9 +312,12 @@ representation, cover both 32- and 64-bit. Stated plainly, because a contributor deserves to know which parts of the walk are on the honor system: -- **Semantic type kinds have no dispatch contract.** AST type *syntax* is covered by - `TestEveryTypeKindHasAPhaseDecision`, but adding a `typeinfo.Type` and forgetting - capability, lowering, or fingerprinting still compiles and passes. +- **Semantic type kinds are covered at three sites, not everywhere.** + `TestEverySemanticTypeKindHasAPhaseDecision` holds capability, type identity and HIR + lowering. `IsSizedType`, `IsLowerableType` and the LSP hover formatters are deliberately + outside it, because their defaults reject or degrade rather than answer wrongly. + Fingerprinting needs no contract: `Text()` is an interface method, so the compiler + enforces it. - **HIR and MIR have no dispatch contract.** `mir.Instr`/`mir.Terminator` are sealed, so the node set is closed and the two cannot be confused, but nothing proves the backend classifies every member; that is still a runtime panic. diff --git a/internal/contracts/type_dispatch_test.go b/internal/contracts/type_dispatch_test.go new file mode 100644 index 00000000..208cdb83 --- /dev/null +++ b/internal/contracts/type_dispatch_test.go @@ -0,0 +1,215 @@ +// This file owns the phase-coverage contract for semantic type kinds. +// +// node_dispatch_test.go covers AST *syntax*: statements, expressions and type +// syntax. This covers the semantic types those lower into — the typeinfo.Type +// family. Adding one and forgetting that it must answer a capability, lower to +// a runtime type, or compare equal used to compile clean and fail much later, +// or silently produce a wrong answer. +package contracts + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strings" + "testing" +) + +// typeDispatchSite is one function that must answer for every semantic type. +// +// Only sites that must be *total* belong here. A narrow predicate whose default +// is a legitimate answer does not: classifying kinds there would be ceremony +// around a decision that is already correct. +type typeDispatchSite struct { + file string + fn string + // why records what this site answers, so a contributor reading a failure + // knows what decision they owe rather than only that one is missing. + why string + // omitted classifies kinds this site implements no case for. + omitted map[string]classification +} + +// A missed kind at each of these is silently wrong rather than loudly rejected, +// which is what earns them a contract. IsSizedType and IsLowerableType are +// deliberately absent: their default rejects, so a forgotten kind produces a +// diagnostic rather than a wrong answer. +var typeKindSites = []typeDispatchSite{ + { + file: "semantics/typeinfo/capability_walk.go", + fn: "ownershipCapability", + why: "how the type copies and whether scope cleanup must destroy it", + omitted: map[string]classification{ + "InvalidType": {ignore, "a recovery type makes no capability claim; invalid source never reaches ownership"}, + "UnknownType": {ignore, "an unresolved type makes no capability claim; resolution replaces it first"}, + "NamedType": {ignore, "a bare name carries no structure to classify; it is replaced by the type it names"}, + "TypeParameterType": {ignore, "conservatively move-on-use with no drop until instantiation-aware " + + "queries arrive with generic support, as OwnershipCapability documents"}, + "FuncType": {ignore, "a function value is a code pointer owning no storage, so the walk's default of " + + "move-on-use with no drop is safe. Whether it should copy implicitly is an open language " + + "question, not a missing case"}, + }, + }, + { + file: "semantics/typeinfo/relations.go", + fn: "SameType", + why: "whether two types are the same type", + omitted: map[string]classification{ + "DefinedType": {contextual, "nominal identity is settled before the switch by sameNominalStruct, and " + + "Underlying peels the definition away for the structural comparison that follows"}, + }, + }, + { + file: "ir/hir/lower/lower_types.go", + fn: "intern", + why: "which runtime type the backend materializes", + omitted: map[string]classification{ + "EnumType": {contextual, "a named enum is interned through internDefined, which owns variant layout"}, + "OptionalType": {contextual, "an optional is shaped by loweredRuntimeType before it reaches interning"}, + "TypeParameterType": {reject, "a type parameter has no runtime representation; instantiation must " + + "substitute it before lowering, and reaching here yields ir.InvalidType"}, + }, + }, +} + +func TestEverySemanticTypeKindHasAPhaseDecision(t *testing.T) { + kinds := declaredTypeKinds(t) + if len(kinds) < 10 { + t.Fatalf("found %d semantic type kinds, expected the full family", len(kinds)) + } + for _, site := range typeKindSites { + t.Run(site.fn, func(t *testing.T) { + handled := handledTypeKinds(t, site.file, site.fn, kinds) + for _, kind := range kinds { + entry, classified := site.omitted[kind] + if slices.Contains(handled, kind) { + if classified { + t.Errorf("%s handles %s but still classifies it %s (%q); delete the entry", + site.fn, kind, entry.decision, entry.reason) + } + continue + } + if !classified { + t.Errorf("%s makes no decision about typeinfo.%s; it decides %s, so add a case or declare why the kind is inert", + site.fn, kind, site.why) + } + } + }) + } +} + +func TestSemanticTypeOmissionReasonsNameRealKinds(t *testing.T) { + kinds := declaredTypeKinds(t) + for _, site := range typeKindSites { + for kind, entry := range site.omitted { + if !slices.Contains(kinds, kind) { + t.Errorf("%s classifies type kind %s that no longer exists", site.fn, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) + } + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) + } + } + } +} + +// declaredTypeKinds returns every type implementing typeinfo.Type, found by its +// TypeNode marker method exactly as the AST families are found by theirs. +func declaredTypeKinds(t *testing.T) []string { + t.Helper() + path := filepath.Join(internalDir(t), "semantics", "typeinfo", "types.go") + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + kinds := make([]string, 0) + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "TypeNode" { + continue + } + if name, ok := receiverTypeName(fn); ok { + kinds = append(kinds, name) + } + } + slices.Sort(kinds) + return kinds +} + +// handledTypeKinds collects the kinds a function distinguishes, from both type +// switch cases and single type assertions. +// +// This is looser than the AST contract, which requires the switch operand to be +// a parameter. These functions switch on a derived value — Underlying(t) — and +// some peel DefinedType with an assertion before switching. Filtering the names +// to declared kinds is what keeps it honest: a switch over anything else +// contributes nothing. +func handledTypeKinds(t *testing.T, file, fn string, kinds []string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + decl := findFuncDecl(parsed, fn) + if decl == nil { + t.Fatalf("function %s not found in %s", fn, file) + } + // A site inside package typeinfo names its kinds unqualified; one outside it + // names them through the import's local alias. + inTypeinfo := parsed.Name != nil && parsed.Name.Name == "typeinfo" + local := importLocalName(parsed, "compiler/internal/semantics/typeinfo") + handled := make([]string, 0) + record := func(expr ast.Expr) { + star, ok := expr.(*ast.StarExpr) + if !ok { + return + } + var name string + switch target := star.X.(type) { + case *ast.Ident: + if !inTypeinfo { + return + } + name = target.Name + case *ast.SelectorExpr: + if inTypeinfo { + return + } + pkg, ok := target.X.(*ast.Ident) + if !ok || pkg.Name != local { + return + } + name = target.Sel.Name + default: + return + } + if slices.Contains(kinds, name) && !slices.Contains(handled, name) { + handled = append(handled, name) + } + } + ast.Inspect(decl.Body, func(node ast.Node) bool { + switch current := node.(type) { + case *ast.TypeSwitchStmt: + for _, stmt := range current.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + record(expr) + } + } + case *ast.TypeAssertExpr: + if current.Type != nil { + record(current.Type) + } + } + return true + }) + return handled +} From 5d1534c7d9815463bc4b5c42c1bdfb4c0e5f11c8 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:16:30 +0600 Subject: [PATCH 54/80] Require a phase decision for every lowered node kind mir.Instr and mir.Terminator were sealed, so the sets are closed and the two cannot be confused, but sealing is membership rather than coverage. A node could be a full member and still be dropped: the backend's failure for an unclassified instruction was a runtime panic, and appendInstr and setBlockTerm switched over the sets with no default at all, so a new node silently carried no source location. Contract all three lowered families. hir.Stmt answers at MIR lowering and at constant folding; mir.Instr and mir.Terminator answer at the lowering site that stamps their location and at the backend that emits them. Adding a node now fails by name at test time instead of panicking in the backend. The extractor is shared with the semantic type contract rather than copied. It resolves a kind name against the site's own package, so a site inside the declaring package and one outside it are both read correctly, and the backend's two switches over disjoint families are separated by the same filter that keeps the whole thing honest. Six omissions, each with the reason the code gives. Four structured statements never reach lowerCFGStmt because CFG decomposed them, and its default panics rather than skipping, which makes that an invariant. Print and DynamicArrayOp are constructed with their own expression location, which is more precise than the statement location appendInstr stamps. Proven by adding a mir.Instr and an hir.Stmt: each fails at both of its sites, and the backend one fails as a test rather than at runtime. The membership test said this contract did not exist; it now names it. Walk 2 in change-paths.md said nothing catches you at folding and that MIR has no dispatch contract. Both corrected, and the gap-list bullet is gone. The HIR/MIR structural validator bullet stays, narrowed: kinds are classified now, shapes still are not checked. --- docs/compiler-framework/change-paths.md | 20 +- internal/contracts/ir_dispatch_test.go | 270 +++++++++++++++++++++++ internal/contracts/type_dispatch_test.go | 107 +-------- internal/ir/mir/model_membership_test.go | 5 +- 4 files changed, 287 insertions(+), 115 deletions(-) create mode 100644 internal/contracts/ir_dispatch_test.go diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index e688051f..3952c976 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -132,7 +132,9 @@ If you add a HIR node, it needs `forEachChild` and `appendText` there too. **12. HIR folding** — `ir/hir/fold/fold.go`, `foldStmt` Constant folding over typed HIR. `hir.For` is handled so folding descends into all five blocks. -*Catches you:* — nothing. HIR has no dispatch contract and no validator. +*Catches you:* **Visible** — `contracts.TestEveryLoweredNodeKindHasAPhaseDecision` +fails with `foldStmt makes no decision about hir.YourStmt`. HIR still has no structural +validator, so a malformed HIR artifact is caught only when a later phase trips over it. **13. MIR lowering** — `ir/mir/module_lower.go` Lower normalized control flow and consume the cleanup plan. `hir.For` is read in @@ -140,8 +142,9 @@ Lower normalized control flow and consume the cleanup plan. `hir.For` is read in `lowerCFGTerminator` (to emit the header and latch). *Catches you:* **Automatic**, partly — `mir.Instr` and `mir.Terminator` are sealed by unexported markers, so the set is closed to the `mir` package and an instruction can no -longer be used where a terminator belongs. What still catches nothing is forgetting to -classify a new node in the backend: MIR has no dispatch contract. +longer be used where a terminator belongs. Coverage is **Visible**: +`TestEveryLoweredNodeKindHasAPhaseDecision` holds `lowerCFGStmt`, `appendInstr` and +`setBlockTerm`, so a new node that nothing lowers or stamps fails by name. **14. Backend** — `backend/llvm/` **The for-loop change touched no backend file at all.** This is the single most @@ -318,12 +321,11 @@ the honor system: outside it, because their defaults reject or degrade rather than answer wrongly. Fingerprinting needs no contract: `Text()` is an interface method, so the compiler enforces it. -- **HIR and MIR have no dispatch contract.** `mir.Instr`/`mir.Terminator` are sealed, - so the node set is closed and the two cannot be confused, but nothing proves the - backend classifies every member; that is still a runtime panic. -- **No structural validator exists for HIR or MIR.** CFG topology and ownership - evidence have boundary validators; the two lowered representations do not, so a - malformed HIR or MIR artifact is caught only when the backend trips over it. +- **No structural validator exists for HIR or MIR.** Every node kind now has to be + classified at lowering and in the backend, but nothing checks the *shape* of a + lowered artifact: CFG topology and ownership evidence have boundary validators, the + two lowered representations do not, so a malformed HIR or MIR is still caught only + when the backend trips over it. - **Nothing requires a fixture.** A construct can reach the backend with no end-to-end coverage at all. - **Nothing requires an LSP update**, so a new construct can be invisible to hover and diff --git a/internal/contracts/ir_dispatch_test.go b/internal/contracts/ir_dispatch_test.go new file mode 100644 index 00000000..b0794edd --- /dev/null +++ b/internal/contracts/ir_dispatch_test.go @@ -0,0 +1,270 @@ +// This file owns the phase-coverage contract for the lowered representations. +// +// node_dispatch_test.go covers AST syntax and type_dispatch_test.go covers the +// semantic type model. This covers what those lower into: hir.Stmt, mir.Instr +// and mir.Terminator. +// +// Membership in those sets is already sealed at compile time by the marker +// methods and by ir/mir/model_membership_test.go. Membership is not coverage: +// a node can be a full member of mir.Instr and still be dropped silently by a +// lowering pass or the backend. That is what this file holds. +package contracts + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strings" + "testing" +) + +// irFamily is one lowered node set and the sites that must answer for it. +type irFamily struct { + // name appears in failures, so a contributor knows which set they extended. + name string + // declared is the file declaring the set, and marker the method that marks + // membership in it. + declared string + marker string + // pkgPath and pkgName resolve how a site names these kinds: unqualified + // when the site lives in the declaring package, through the import's local + // alias otherwise. + pkgPath string + pkgName string + sites []irDispatchSite +} + +// Control flow is already blocks and terminators by the time MIR lowering walks +// CFG sites, so a structured statement never arrives at one. lowerCFGStmt's +// default panics rather than skipping, which is what makes that an invariant +// instead of an assumption. +const decomposedBeforeMIRReason = "control flow is decomposed into CFG blocks and terminators before MIR " + + "lowering, so this never reaches a CFG site; the default panics if it does" + +type irDispatchSite struct { + file string + fn string + why string + // omitted classifies kinds this site implements no case for. + omitted map[string]classification +} + +var irFamilies = []irFamily{ + { + name: "hir.Stmt", + declared: "ir/hir/model.go", + marker: "stmtNode", + pkgPath: "compiler/internal/ir/hir", + pkgName: "hir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "lowerCFGStmt", + why: "how the statement becomes MIR", + omitted: map[string]classification{ + "Block": {contextual, decomposedBeforeMIRReason}, + "For": {contextual, decomposedBeforeMIRReason}, + "If": {contextual, decomposedBeforeMIRReason}, + "SwitchVariant": {contextual, decomposedBeforeMIRReason}, + }, + }, + { + file: "ir/hir/fold/fold.go", + fn: "foldStmt", + why: "how constant folding rewrites the statement", + }, + }, + }, + { + name: "mir.Instr", + declared: "ir/mir/model.go", + marker: "instrNode", + pkgPath: "compiler/internal/ir/mir", + pkgName: "mir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "appendInstr", + why: "which source location the instruction carries", + omitted: map[string]classification{ + "Print": {ignore, "constructed with its own expression location, which is more precise " + + "than the statement location this stamps"}, + "DynamicArrayOp": {ignore, "constructed with its own expression location, which is more " + + "precise than the statement location this stamps"}, + }, + }, + { + file: "backend/llvm/emitter.go", + fn: "GenerateLLVMIR", + why: "what the backend emits for the instruction", + }, + }, + }, + { + name: "mir.Terminator", + declared: "ir/mir/model.go", + marker: "termNode", + pkgPath: "compiler/internal/ir/mir", + pkgName: "mir", + sites: []irDispatchSite{ + { + file: "ir/mir/module_lower.go", + fn: "setBlockTerm", + why: "which source location the terminator carries", + }, + { + file: "backend/llvm/emitter.go", + fn: "GenerateLLVMIR", + why: "what the backend emits for the terminator", + }, + }, + }, +} + +func TestEveryLoweredNodeKindHasAPhaseDecision(t *testing.T) { + for _, family := range irFamilies { + kinds := declaredMarkerKinds(t, family.declared, family.marker) + if len(kinds) == 0 { + t.Fatalf("%s: no kinds found via %s in %s", family.name, family.marker, family.declared) + } + for _, site := range family.sites { + t.Run(family.name+"/"+site.fn, func(t *testing.T) { + handled := handledKindsIn(t, site.file, site.fn, family.pkgPath, family.pkgName, kinds) + for _, kind := range kinds { + entry, classified := site.omitted[kind] + if slices.Contains(handled, kind) { + if classified { + t.Errorf("%s handles %s.%s but still classifies it %s (%q); delete the entry", + site.fn, family.pkgName, kind, entry.decision, entry.reason) + } + continue + } + if !classified { + t.Errorf("%s makes no decision about %s.%s; it decides %s, so add a case or declare why the kind is inert", + site.fn, family.pkgName, kind, site.why) + } + } + }) + } + } +} + +func TestLoweredOmissionReasonsNameRealKinds(t *testing.T) { + for _, family := range irFamilies { + kinds := declaredMarkerKinds(t, family.declared, family.marker) + for _, site := range family.sites { + for kind, entry := range site.omitted { + if !slices.Contains(kinds, kind) { + t.Errorf("%s classifies %s kind %s that is no longer a member", + site.fn, family.name, kind) + } + if strings.TrimSpace(entry.reason) == "" { + t.Errorf("%s classifies %s as %s without a reason", site.fn, kind, entry.decision) + } + if entry.decision.String() == "unknown" { + t.Errorf("%s classifies %s with an invalid decision", site.fn, kind) + } + } + } + } +} + +// declaredMarkerKinds returns every type in one file that implements a marker +// method, which is how each node family in this repository declares membership. +func declaredMarkerKinds(t *testing.T, file, marker string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + kinds := make([]string, 0) + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != marker { + continue + } + if name, ok := receiverTypeName(fn); ok && !slices.Contains(kinds, name) { + kinds = append(kinds, name) + } + } + slices.Sort(kinds) + return kinds +} + +// handledKindsIn collects the kinds a function distinguishes, from type switch +// cases and from single type assertions. +// +// It is looser than the AST contract, which requires the switch operand to be a +// parameter: these sites switch on derived values, and some peel one kind with +// an assertion before switching. Filtering names to declared kinds is what keeps +// it honest — a switch over anything else contributes nothing. A site holding +// two switches over disjoint families, as the backend does for instructions and +// terminators, is separated by that same filter. +func handledKindsIn(t *testing.T, file, fn, pkgPath, pkgName string, kinds []string) []string { + t.Helper() + path := filepath.Join(internalDir(t), filepath.FromSlash(file)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + decl := findFuncDecl(parsed, fn) + if decl == nil { + t.Fatalf("function %s not found in %s", fn, file) + } + // A site inside the declaring package names kinds unqualified; one outside + // names them through the import's local alias. + own := parsed.Name != nil && parsed.Name.Name == pkgName + local := importLocalName(parsed, pkgPath) + handled := make([]string, 0) + record := func(expr ast.Expr) { + star, ok := expr.(*ast.StarExpr) + if !ok { + return + } + var name string + switch target := star.X.(type) { + case *ast.Ident: + if !own { + return + } + name = target.Name + case *ast.SelectorExpr: + if own { + return + } + pkg, ok := target.X.(*ast.Ident) + if !ok || pkg.Name != local { + return + } + name = target.Sel.Name + default: + return + } + if slices.Contains(kinds, name) && !slices.Contains(handled, name) { + handled = append(handled, name) + } + } + ast.Inspect(decl.Body, func(node ast.Node) bool { + switch current := node.(type) { + case *ast.TypeSwitchStmt: + for _, stmt := range current.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + record(expr) + } + } + case *ast.TypeAssertExpr: + if current.Type != nil { + record(current.Type) + } + } + return true + }) + return handled +} diff --git a/internal/contracts/type_dispatch_test.go b/internal/contracts/type_dispatch_test.go index 208cdb83..df1dbd2f 100644 --- a/internal/contracts/type_dispatch_test.go +++ b/internal/contracts/type_dispatch_test.go @@ -8,10 +8,6 @@ package contracts import ( - "go/ast" - "go/parser" - "go/token" - "path/filepath" "slices" "strings" "testing" @@ -75,13 +71,13 @@ var typeKindSites = []typeDispatchSite{ } func TestEverySemanticTypeKindHasAPhaseDecision(t *testing.T) { - kinds := declaredTypeKinds(t) + kinds := declaredMarkerKinds(t, "semantics/typeinfo/types.go", "TypeNode") if len(kinds) < 10 { t.Fatalf("found %d semantic type kinds, expected the full family", len(kinds)) } for _, site := range typeKindSites { t.Run(site.fn, func(t *testing.T) { - handled := handledTypeKinds(t, site.file, site.fn, kinds) + handled := handledKindsIn(t, site.file, site.fn, "compiler/internal/semantics/typeinfo", "typeinfo", kinds) for _, kind := range kinds { entry, classified := site.omitted[kind] if slices.Contains(handled, kind) { @@ -101,7 +97,7 @@ func TestEverySemanticTypeKindHasAPhaseDecision(t *testing.T) { } func TestSemanticTypeOmissionReasonsNameRealKinds(t *testing.T) { - kinds := declaredTypeKinds(t) + kinds := declaredMarkerKinds(t, "semantics/typeinfo/types.go", "TypeNode") for _, site := range typeKindSites { for kind, entry := range site.omitted { if !slices.Contains(kinds, kind) { @@ -116,100 +112,3 @@ func TestSemanticTypeOmissionReasonsNameRealKinds(t *testing.T) { } } } - -// declaredTypeKinds returns every type implementing typeinfo.Type, found by its -// TypeNode marker method exactly as the AST families are found by theirs. -func declaredTypeKinds(t *testing.T) []string { - t.Helper() - path := filepath.Join(internalDir(t), "semantics", "typeinfo", "types.go") - parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - kinds := make([]string, 0) - for _, decl := range parsed.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Name.Name != "TypeNode" { - continue - } - if name, ok := receiverTypeName(fn); ok { - kinds = append(kinds, name) - } - } - slices.Sort(kinds) - return kinds -} - -// handledTypeKinds collects the kinds a function distinguishes, from both type -// switch cases and single type assertions. -// -// This is looser than the AST contract, which requires the switch operand to be -// a parameter. These functions switch on a derived value — Underlying(t) — and -// some peel DefinedType with an assertion before switching. Filtering the names -// to declared kinds is what keeps it honest: a switch over anything else -// contributes nothing. -func handledTypeKinds(t *testing.T, file, fn string, kinds []string) []string { - t.Helper() - path := filepath.Join(internalDir(t), filepath.FromSlash(file)) - parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - decl := findFuncDecl(parsed, fn) - if decl == nil { - t.Fatalf("function %s not found in %s", fn, file) - } - // A site inside package typeinfo names its kinds unqualified; one outside it - // names them through the import's local alias. - inTypeinfo := parsed.Name != nil && parsed.Name.Name == "typeinfo" - local := importLocalName(parsed, "compiler/internal/semantics/typeinfo") - handled := make([]string, 0) - record := func(expr ast.Expr) { - star, ok := expr.(*ast.StarExpr) - if !ok { - return - } - var name string - switch target := star.X.(type) { - case *ast.Ident: - if !inTypeinfo { - return - } - name = target.Name - case *ast.SelectorExpr: - if inTypeinfo { - return - } - pkg, ok := target.X.(*ast.Ident) - if !ok || pkg.Name != local { - return - } - name = target.Sel.Name - default: - return - } - if slices.Contains(kinds, name) && !slices.Contains(handled, name) { - handled = append(handled, name) - } - } - ast.Inspect(decl.Body, func(node ast.Node) bool { - switch current := node.(type) { - case *ast.TypeSwitchStmt: - for _, stmt := range current.Body.List { - clause, ok := stmt.(*ast.CaseClause) - if !ok { - continue - } - for _, expr := range clause.List { - record(expr) - } - } - case *ast.TypeAssertExpr: - if current.Type != nil { - record(current.Type) - } - } - return true - }) - return handled -} diff --git a/internal/ir/mir/model_membership_test.go b/internal/ir/mir/model_membership_test.go index 3db478ee..bbbdb6ee 100644 --- a/internal/ir/mir/model_membership_test.go +++ b/internal/ir/mir/model_membership_test.go @@ -5,8 +5,9 @@ package mir // what keep a node in the set its position requires: drop a marker, or move a // node between the sets, and this file stops compiling. // -// This is membership only. That the backend classifies every member is a -// separate contract, and one the repository does not yet have for MIR. +// This is membership only. That every member is actually classified — by MIR +// lowering and by the backend — is a separate contract, held by +// contracts.TestEveryLoweredNodeKindHasAPhaseDecision. var ( _ Instr = (*Assign)(nil) _ Instr = (*Store)(nil) From 5a74aa23d5753b4dbeef2c44b11be99b69413c00 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:32:40 +0600 Subject: [PATCH 55/80] Derive liveness definitions from published effects symbolUsesAndDefinitions rebuilt the set of symbols a site defines by switching on LetDecl, ConstDecl and AssignStmt and resolving each through the scope, which is what the producer already publishes as Define and Write. Doing that surfaced a real limit of a site-keyed artifact: some bindings are established by the edge into a site rather than by the site. A parameter exists before the entry site runs, and a match payload binding is created by its case edge before the arm body does. Definite initialization never noticed, because it replays a site's operations in evaluation order. Liveness treats a site as a set, so counting those as definitions killed a borrow one site early and let a self-assigned match reference lose the loan on its source. Define now records OnEntry for exactly that, and liveness skips those. The flag is a real property of where a binding comes into existence rather than a consumer convenience: it is the difference between let x = e, which defines during the site after evaluating e, and a payload binding, which is already there when the site starts. reference.go is down to four AST cases, all in referenceHolder, which peels a place rather than enumerating uses. --- internal/semantics/effect/build.go | 4 +- internal/semantics/effect/model.go | 9 ++++ internal/semantics/ownership/reference.go | 63 +++++++++++++---------- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 3208d19b..53bf7e7d 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -77,7 +77,7 @@ func (b *builder) buildFunction(fn *ast.FnDecl) { if !found || sym == nil { continue } - b.emit(entry, Define{Symbol: sym, Node: param.Name.ID(), Initialized: true}) + b.emit(entry, Define{Symbol: sym, Node: param.Name.ID(), Initialized: true, OnEntry: true}) } } // Bindings that a site inherits on entry are published before that site's @@ -148,7 +148,7 @@ func (b *builder) buildMatchArms(site *cfg.Site, terminator *cfg.SwitchVariant) if sym == nil { continue } - b.emit(edge.To, Define{Symbol: sym, Node: ast.NodeID(terminator.NodeID), Initialized: true}) + b.emit(edge.To, Define{Symbol: sym, Node: ast.NodeID(terminator.NodeID), Initialized: true, OnEntry: true}) } } } diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index a94c8ec0..376874c3 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -34,6 +34,15 @@ type Define struct { // itself belongs. Node ast.NodeID Initialized bool + // OnEntry marks a binding that already exists when the site begins rather + // than being established by it: a function parameter, or a match payload + // binding, which the case edge creates before the arm body runs. + // + // Operations at a site are read in evaluation order, so a consumer that + // replays them needs no distinction. One that treats a site as a set does: + // liveness must not conclude a binding is dead before a site that merely + // receives it. + OnEntry bool } // Write stores to a binding that already exists. diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 3df6e54b..1b963ff5 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -590,42 +590,53 @@ func (a *analyzer) computeSymbolLiveness() { } } +// symbolUsesAndDefinitions reports what one site defines and what it reads, +// both from published effects. +// +// A write also counts as a use when the written symbol needs dropping: the +// pre-assignment drop reads the old value, so the target must stay live up to +// the assignment that replaces it. That is ownership policy and stays here. func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast.Node, map[*symbols.Symbol]struct{}) { uses := make(map[*symbols.Symbol]ast.Node) definitions := make(map[*symbols.Symbol]struct{}) - if a == nil || node == nil || node.cfgSite == nil || - (node.cfgSite.Kind != cfg.SiteStatement && node.cfgSite.Kind != cfg.SiteTerminator) || node.stmt == nil { + if a == nil || a.module == nil || node == nil || node.cfgSite == nil { return uses, definitions } - addDefinition := func(binding ast.Node) { - if node.scope == nil || binding == nil { + recordUse := func(sym *symbols.Symbol, at ast.NodeID) { + syntax, found := a.module.TypedASTNodes[at] + if !found { return } - if sym, found := node.scope.LookupNode(binding); found && trackedLiveSymbol(sym) { - definitions[sym] = struct{}{} + if previous, seen := uses[sym]; seen { + uses[sym] = earlierNode(previous, syntax) + return } + uses[sym] = syntax } - - switch stmt := node.stmt.(type) { - case *ast.LetDecl: - addDefinition(stmt) - case *ast.ConstDecl: - addDefinition(stmt) - case *ast.AssignStmt: - if target, ok := stmt.Target.(*ast.Ident); ok && node.scope != nil { - if sym, found := node.scope.Lookup(target.Name); found && trackedLiveSymbol(sym) { - definitions[sym] = struct{}{} - if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { - uses[sym] = target - } + for _, op := range a.effects[node.cfgSite.ID] { + switch op := op.(type) { + case effect.Define: + // A binding that merely arrives at this site was established by the + // edge into it, so killing liveness here would end a borrow one site + // too early. + if op.OnEntry { + continue + } + if trackedLiveSymbol(op.Symbol) { + definitions[op.Symbol] = struct{}{} + } + case effect.Write: + if !trackedLiveSymbol(op.Symbol) { + continue + } + definitions[op.Symbol] = struct{}{} + if typ, typed := symbols.GetSymbolType(op.Symbol); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { + recordUse(op.Symbol, op.Node) + } + case effect.Use: + if trackedLiveSymbol(op.Symbol) { + recordUse(op.Symbol, op.Node) } - } - } - for _, use := range a.symbolUseSequence(node, trackedLiveSymbol) { - if previous, found := uses[use.symbol]; !found { - uses[use.symbol] = use.site - } else { - uses[use.symbol] = earlierNode(previous, use.site) } } return uses, definitions From a99f3b71d9885b9c8a348af5f37b212f979801ad Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:38:01 +0600 Subject: [PATCH 56/80] Publish use kinds, borrows and discards from one expression walk The producer enumerated reads with ast.Inspect, which found identifiers but said nothing about what happened to the values they named. Ownership therefore kept deciding that itself, from AST shape, at forty-four hardcoded literals. Replace the sweep with a structured walk that carries a use kind down to the identifier holding the value, mirroring the propagation ownership performed: a projection reads its base, a literal moves what it stores, concatenation consumes its left operand, and a call argument takes the typechecker's published decision rather than a guess. Borrow and Discard join the vocabulary because a reference and a dropped value are not reads, and ownership needs both to stop looking at syntax. Adding two operations made every consumer say what it does with them, exactly as intended: definite initialization panicked by name until told, and the artifact validator rejected them until extended. Both now handle them explicitly, and definite initialization is unaffected because the place behind a borrow is still published as its own read. The full suite, the bundle and the x_test fixtures pass unchanged, which is what says the walk emits the same uses the sweep did. This is the vocabulary ownership's expression walk needs. Consuming it there is the next step; the kinds are published and proven first so that step is a deletion rather than a rewrite. --- internal/pipeline/pipeline.go | 10 +- .../semantics/definiteinit/initialization.go | 6 + .../definiteinit/initialization_test.go | 10 +- internal/semantics/effect/build.go | 136 +++++++++++++----- internal/semantics/effect/build_test.go | 10 +- internal/semantics/effect/model.go | 29 +++- internal/semantics/effect/validate.go | 10 ++ .../semantics/ownership/ownership_test.go | 10 +- internal/semantics/typecheckresult/result.go | 21 +++ 9 files changed, 185 insertions(+), 57 deletions(-) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 6d43aca7..e3ce7107 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -477,10 +477,12 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di } if module.Phase < phase.Effects { module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ - Symbols: module.Bindings.NodeSymbols, - Scopes: module.Bindings.BlockScopes, - CallArguments: module.Typechecking.CallArgumentsOrSource, - ArmBindings: module.Typechecking.ArmBindings, + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, }) if err := module.Effects.Validate(module.CFG, module.TypedASTNodes); err != nil { phaseDiag.AddError(diagnostics.ErrInvalidEvidence, diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index 0b3f27a7..5ca7ed8f 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -161,6 +161,12 @@ func apply(current state, op effect.Op) { } case effect.Use: // A read leaves initialization state unchanged. + case effect.Borrow: + // Taking a reference changes no initialization state. The place being + // borrowed is published separately as a read, so an uninitialized + // borrow is still reported through that. + case effect.Discard: + // A discarded value changes no initialization state. default: panic(fmt.Sprintf("definiteinit: unhandled effect %T", op)) } diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 313e0e30..5dfcd21c 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -56,10 +56,12 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, t.Fatal("choose function CFG missing") } effects := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ - Symbols: module.Bindings.NodeSymbols, - Scopes: module.Bindings.BlockScopes, - CallArguments: module.Typechecking.CallArgumentsOrSource, - ArmBindings: module.Typechecking.ArmBindings, + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, }) result := analyzeFunction(graph, effects[graph.NodeID], diag) return result, diag, module diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 53bf7e7d..14cd0b44 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -6,6 +6,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir/cfg" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" ) // BuildQueries supplies the published facts this producer needs. Like @@ -25,6 +26,13 @@ type BuildQueries struct { CallArguments func(*ast.CallExpr) []ast.Expr // ArmBindings returns the payload symbols one match arm binds. ArmBindings func(match ast.NodeID, caseIndex int) []*symbols.Symbol + // StringConcatenation reports a binary expression the typechecker resolved + // as string concatenation, which consumes its left operand. + StringConcatenation func(ast.NodeID) bool + // ValueUse returns the typechecker's decision for one expression, where it + // made one. Call arguments have one; most positions do not, and the walk + // decides those from the position itself. + ValueUse func(ast.NodeID) (typeinfo.UseKind, bool) } // Build publishes the semantic effects of every reachable CFG site. @@ -119,7 +127,7 @@ func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { switch terminator := block.Terminator.(type) { case *cfg.Branch: if condition, ok := b.nodes[ast.NodeID(terminator.ConditionID)].(ast.Expr); ok { - b.reads(site.ID, condition) + b.value(site.ID, condition, typeinfo.UseRead) } case *cfg.SwitchVariant: // Arm payload bindings are published in the leading pass above; the @@ -165,10 +173,10 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St case *ast.ConstDecl: b.buildBinding(site, scope, node, node.Value) case *ast.AssignStmt: - b.reads(site, node.Value) + b.value(site, node.Value, typeinfo.UseMove) ident, direct := node.Target.(*ast.Ident) if !direct || ident == nil { - b.reads(site, node.Target) + b.value(site, node.Target, typeinfo.UseRead) return } sym, found := scope.Lookup(ident.Name) @@ -176,14 +184,15 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St b.emit(site, Write{Symbol: sym, Node: ident.ID()}) } case *ast.ExprStmt: - b.reads(site, node.Expr) + b.value(site, node.Expr, typeinfo.UseRead) + b.emit(site, Discard{Node: node.Expr.ID(), Location: ast.LocOf(node.Expr)}) case *ast.ReturnStmt: - b.reads(site, node.Value) + b.value(site, node.Value, typeinfo.UseMove) case *ast.MatchStmt: // A match reaches this producer at its terminator site, and at a plain // statement site when semantic evidence was too incomplete for CFG to // decompose it. Publishing the subject here covers both. - b.reads(site, node.Subject) + b.value(site, node.Subject, typeinfo.UseRead) case *ast.IfStmt, *ast.ForStmt: // A branch condition is published from the terminator, which names it // directly, so a site carries exactly the reads that happen at it. @@ -206,7 +215,7 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St // buildBinding publishes a declaration's initializer reads before the define // they initialize, so `let x = x` reads an outer binding rather than itself. func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.Stmt, value ast.Expr) { - b.reads(site, value) + b.value(site, value, typeinfo.UseMove) if scope == nil { return } @@ -217,39 +226,90 @@ func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.S b.emit(site, Define{Symbol: sym, Node: decl.ID(), Initialized: value != nil}) } -// reads publishes one Use per identifier the expression evaluates, in traversal -// order. Call arguments come from published call evidence so that arguments the -// typechecker expanded from defaults are covered. -func (b *builder) reads(site cfg.SiteID, expr ast.Expr) { - if expr == nil { +// value publishes what one expression does to the bindings it names. +// +// It mirrors the propagation ownership used to perform itself: a position +// decides the kind, and the kind travels down to the identifier that carries +// the value. A projection reads its base, a literal moves what it stores, and a +// call argument takes the typechecker's published decision. +// +// This is the expression dispatch site for published effects. A new expression +// kind must be handled here or declared inert in internal/contracts. +func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { + switch node := expr.(type) { + case nil: return - } - var walk func(ast.Expr) - walk = func(current ast.Expr) { - if current == nil { + case *ast.Ident: + if sym := b.queries.Symbols[node.ID()]; sym != nil { + b.emit(site, Use{Symbol: sym, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) + } + case *ast.AddressExpr: + b.emit(site, Borrow{Node: node.ID(), Location: ast.LocOf(node), Mutable: node.Mode == ast.AddressMutable}) + b.value(site, node.Expr, typeinfo.UseRead) + case *ast.SelectorExpr: + // A field selection reads the aggregate it projects from. + b.value(site, node.Expr, typeinfo.UseRead) + case *ast.IndexExpr: + b.value(site, node.Expr, typeinfo.UseRead) + b.value(site, node.Index, typeinfo.UseRead) + case *ast.RangeExpr: + b.value(site, node.Start, typeinfo.UseRead) + b.value(site, node.End, typeinfo.UseRead) + case *ast.StructLit: + for _, field := range node.Fields { + b.value(site, field.Value, typeinfo.UseMove) + } + case *ast.VariantLit: + b.value(site, node.Payload, typeinfo.UseMove) + case *ast.ArrayLit: + for _, element := range node.Values { + b.value(site, element, typeinfo.UseMove) + } + case *ast.CallExpr: + b.value(site, node.Callee, typeinfo.UseRead) + arguments := node.Args + if b.queries.CallArguments != nil { + arguments = b.queries.CallArguments(node) + } + for _, argument := range arguments { + b.value(site, argument, b.argumentKind(argument)) + } + case *ast.FreeExpr: + b.value(site, node.Expr, typeinfo.UseMove) + case *ast.PrintExpr: + b.value(site, node.Expr, typeinfo.UseRead) + case *ast.UnaryExpr: + b.value(site, node.Expr, typeinfo.UseRead) + case *ast.BinaryExpr: + if b.queries.StringConcatenation != nil && b.queries.StringConcatenation(node.ID()) { + // Concatenation consumes the left operand into the result. + b.value(site, node.Left, typeinfo.UseMove) + b.value(site, node.Right, typeinfo.UseRead) return } - ast.Inspect(current, func(node ast.Node) bool { - if call, ok := node.(*ast.CallExpr); ok && call != nil { - walk(call.Callee) - arguments := call.Args - if b.queries.CallArguments != nil { - arguments = b.queries.CallArguments(call) - } - for _, arg := range arguments { - walk(arg) - } - return false - } - ident, ok := node.(*ast.Ident) - if !ok || ident == nil { - return true - } - if sym := b.queries.Symbols[ident.ID()]; sym != nil { - b.emit(site, Use{Symbol: sym, Node: ident.ID(), Location: ast.LocOf(ident)}) - } - return true - }) + b.value(site, node.Left, typeinfo.UseRead) + b.value(site, node.Right, typeinfo.UseRead) + case *ast.IsExpr: + b.value(site, node.Value, typeinfo.UseRead) + case *ast.AsExpr: + b.value(site, node.Expr, typeinfo.UseMove) + case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, + *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: + // These name no binding whose value is used. + default: + panic(fmt.Sprintf("effect: unhandled AST expression %T", expr)) + } +} + +// argumentKind takes the typechecker's decision where it made one. An absent +// decision means the call did not resolve, which is invalid source continuing +// through the phase, and a read is the answer that claims least. +func (b *builder) argumentKind(argument ast.Expr) typeinfo.UseKind { + if argument == nil || b.queries.ValueUse == nil { + return typeinfo.UseRead + } + if kind, found := b.queries.ValueUse(argument.ID()); found { + return kind } - walk(expr) + return typeinfo.UseRead } diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index da251233..034ca4d3 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -46,10 +46,12 @@ func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } result := effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ - Symbols: module.Bindings.NodeSymbols, - Scopes: module.Bindings.BlockScopes, - CallArguments: module.Typechecking.CallArgumentsOrSource, - ArmBindings: module.Typechecking.ArmBindings, + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, }) if result == nil { t.Fatal("Build published no result") diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index 376874c3..b5727468 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -14,6 +14,7 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" "compiler/internal/source" ) @@ -62,11 +63,33 @@ type Use struct { Symbol *symbols.Symbol Node ast.NodeID Location *source.Location + // Kind is what happens to the value here: observed, duplicated, or + // consumed. The producer decides it from the position the value occupies + // and, for a call argument, from the typechecker's published decision. + Kind typeinfo.UseKind } -func (Define) effectOp() {} -func (Write) effectOp() {} -func (Use) effectOp() {} +// Borrow takes a reference to a place rather than reading its value. Mutable +// separates `&mut x` from `&x`, which is the difference that decides whether a +// second borrow conflicts. +type Borrow struct { + Node ast.NodeID + Location *source.Location + Mutable bool +} + +// Discard is a value produced and dropped, as an expression statement does. +// The value never reaches a binding, so anything owned in it dies here. +type Discard struct { + Node ast.NodeID + Location *source.Location +} + +func (Define) effectOp() {} +func (Write) effectOp() {} +func (Use) effectOp() {} +func (Borrow) effectOp() {} +func (Discard) effectOp() {} // Result holds published effects for one semantic generation. // diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index 46a0a297..63bb113e 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -75,6 +75,16 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a if op.Location == nil { problems = append(problems, where+" is a use with no source location to report against") } + case Borrow: + problems = append(problems, validateNode(where, "borrow", false, op.Node, nodes)...) + if op.Location == nil { + problems = append(problems, where+" is a borrow with no source location to report against") + } + case Discard: + problems = append(problems, validateNode(where, "discard", false, op.Node, nodes)...) + if op.Location == nil { + problems = append(problems, where+" is a discard with no source location to report against") + } default: problems = append(problems, fmt.Sprintf("%s has unknown effect kind %T", where, op)) } diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 0b868874..a8f96089 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -56,10 +56,12 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { }) module.Flow = typechecker.CheckFlow(ctx, module) module.Effects = effect.Build(module.CFG, module.TypedASTNodes, effect.BuildQueries{ - Symbols: module.Bindings.NodeSymbols, - Scopes: module.Bindings.BlockScopes, - CallArguments: module.Typechecking.CallArgumentsOrSource, - ArmBindings: module.Typechecking.ArmBindings, + Symbols: module.Bindings.NodeSymbols, + Scopes: module.Bindings.BlockScopes, + CallArguments: module.Typechecking.CallArgumentsOrSource, + ArmBindings: module.Typechecking.ArmBindings, + StringConcatenation: module.Typechecking.StringConcatenation, + ValueUse: module.Typechecking.ValueUse, }) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index de70a50a..c1e817cc 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -191,6 +191,27 @@ func (r *Result) MatchCases(id ast.NodeID) ([]int, bool) { return cases, true } +// StringConcatenation reports whether a binary expression was resolved as a +// string concatenation, which consumes its left operand. +func (r *Result) StringConcatenation(id ast.NodeID) bool { + if r == nil { + return false + } + _, found := r.StringConcatenations[id] + return found +} + +// ValueUse exposes the use kind the typechecker decided for one expression. +// Coverage is call arguments today, so an absent answer is normal rather than +// a missing decision. +func (r *Result) ValueUse(id ast.NodeID) (typeinfo.UseKind, bool) { + if r == nil { + return typeinfo.UseRead, false + } + kind, found := r.ValueUses[id] + return kind, found +} + // ArmBindings exposes the payload symbols one match arm binds, without leaking // match artifacts into the effect producer. A discarded binding still binds // storage, so it is reported like any other. From 7c3f846707e7ee3495d698bd827644fbe4f9d3ce Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:44:15 +0600 Subject: [PATCH 57/80] Publish the place a value is used from, not just its root A use named a symbol, which is enough to ask whether a binding is initialized but not enough for ownership. Moving out of pair.left is a different decision from moving pair, and the diagnostics say so: move-only variant payload cannot be moved from a partial place, move-only indexed element cannot be used by value. Those depend on the shape of the place, so a symbol could not carry them and checkExpr had to keep reading syntax. Use now names a Place: a root binding and the projections taken from it. The projections are place.OriginProjection, reused rather than restated, so a consumer already reasoning about origins needs no translation. Landed in two steps so each was provable. First Use carried a Place whose projections were always empty, which is a pure refactor the suite settles. Then the producer resolved field and index chains for real. Both passed unchanged, because a consumer that only cares which binding was touched reads the root and a projected use still roots at the same binding. An index expression contributes two things and they stay separate: the element is the place, the index is an ordinary value read beside it. This was the blocker on collapsing ownership's expression walk. The vocabulary now carries everything that walk decides. --- .../semantics/definiteinit/initialization.go | 8 +-- internal/semantics/effect/build.go | 57 ++++++++++++++++++- internal/semantics/effect/build_test.go | 47 ++++++++++++++- internal/semantics/effect/model.go | 15 ++++- internal/semantics/effect/validate.go | 2 +- internal/semantics/effect/validate_test.go | 2 +- internal/semantics/ownership/reference.go | 8 +-- 7 files changed, 125 insertions(+), 14 deletions(-) diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index 5ca7ed8f..f2be06cb 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -173,18 +173,18 @@ func apply(current state, op effect.Op) { } func reportUninitializedRead(use effect.Use, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { - if use.Symbol == nil { + if use.Place.Root == nil { return } - name, local := tracked[use.Symbol.ID] + name, local := tracked[use.Place.Root.ID] if !local { return } - if _, present := current[use.Symbol.ID]; present { + if _, present := current[use.Place.Root.ID]; present { return } if name == "" { - name = use.Symbol.Name + name = use.Place.Root.Name } name = ir.StripSymbolInstance(name) msg := "symbol `" + name + "` used before it's initialized" diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 14cd0b44..366b4dba 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -5,6 +5,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir/cfg" + "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) @@ -241,15 +242,27 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { return case *ast.Ident: if sym := b.queries.Symbols[node.ID()]; sym != nil { - b.emit(site, Use{Symbol: sym, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) + b.emit(site, Use{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) } case *ast.AddressExpr: b.emit(site, Borrow{Node: node.ID(), Location: ast.LocOf(node), Mutable: node.Mode == ast.AddressMutable}) b.value(site, node.Expr, typeinfo.UseRead) case *ast.SelectorExpr: - // A field selection reads the aggregate it projects from. + // A field of a place is itself a place, so the use lands on the + // projection rather than on the whole aggregate. A consumer that only + // cares which binding was touched still reads the root. + if projected, ok := b.placeOf(node); ok { + b.emit(site, Use{Place: projected, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) + return + } b.value(site, node.Expr, typeinfo.UseRead) case *ast.IndexExpr: + if projected, ok := b.placeOf(node); ok { + b.emit(site, Use{Place: projected, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) + // The index is a separate value, not part of the place. + b.value(site, node.Index, typeinfo.UseRead) + return + } b.value(site, node.Expr, typeinfo.UseRead) b.value(site, node.Index, typeinfo.UseRead) case *ast.RangeExpr: @@ -313,3 +326,43 @@ func (b *builder) argumentKind(argument ast.Expr) typeinfo.UseKind { } return typeinfo.UseRead } + +// placeOf resolves an expression to the storage it names, following the same +// shapes the canonical place walk recognises. It reports false for anything +// that produces a value without naming storage, such as a call result. +func (b *builder) placeOf(expr ast.Expr) (Place, bool) { + switch node := expr.(type) { + case *ast.Ident: + sym := b.queries.Symbols[node.ID()] + if sym == nil { + return Place{}, false + } + return Place{Root: sym}, true + case *ast.SelectorExpr: + if node.Name == nil { + return Place{}, false + } + return b.project(node.Expr, place.OriginProjection{ + Kind: place.OriginField, + Field: node.Name.Name, + }) + case *ast.IndexExpr: + return b.project(node.Expr, place.OriginProjection{Kind: place.OriginIndex}) + default: + return Place{}, false + } +} + +func (b *builder) project(base ast.Expr, step place.OriginProjection) (Place, bool) { + rooted, ok := b.placeOf(base) + if !ok { + return Place{}, false + } + // Copy rather than append in place: sibling projections off one base must + // not share backing storage. + projections := make([]place.OriginProjection, 0, len(rooted.Projections)+1) + projections = append(projections, rooted.Projections...) + projections = append(projections, step) + rooted.Projections = projections + return rooted, true +} diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 034ca4d3..021849e5 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -14,6 +14,7 @@ import ( "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/effect" + "compiler/internal/semantics/place" "compiler/internal/semantics/resolver" "compiler/internal/semantics/typechecker" "compiler/pkg/peeper" @@ -102,7 +103,7 @@ func describe(op effect.Op) string { case effect.Write: return "write " + op.Symbol.Name case effect.Use: - return "use " + op.Symbol.Name + return "use " + op.Place.Root.Name } return "unknown" } @@ -233,3 +234,47 @@ fn choose(outcome: Result) -> i32 { t.Fatalf("published %v, want %v", got, want) } } + +// A use of a field or an element names that place, not the whole aggregate. +// Ownership needs the distinction: moving out of `pair.left` is a different +// decision from moving `pair`, and its diagnostics say so. +func TestBuildPublishesProjectedPlaces(t *testing.T) { + result, module := buildEffects(t, `struct Pair { left: i32, right: i32 } + +fn read(values: [3]i32, pair: Pair, index: i32) -> i32 { + return pair.left + values[index]; +}`) + symbol, found := module.ModuleScope.Lookup("read") + if !found { + t.Fatal("function read missing") + } + fn := symbol.ASTNode.(*ast.FnDecl) + graph := module.CFG.Function(ir.NodeID(fn.ID())) + if graph == nil { + t.Fatal("function read has no CFG") + } + + projected := make([]string, 0) + for _, block := range graph.Blocks { + for _, site := range block.Sites { + for _, op := range result.At(graph.NodeID, site.ID) { + use, ok := op.(effect.Use) + if !ok || len(use.Place.Projections) == 0 { + continue + } + for _, step := range use.Place.Projections { + switch step.Kind { + case place.OriginField: + projected = append(projected, use.Place.Root.Name+"."+step.Field) + case place.OriginIndex: + projected = append(projected, use.Place.Root.Name+"[]") + } + } + } + } + } + want := []string{"pair.left", "values[]"} + if !sameOps(projected, want) { + t.Fatalf("projected places = %v, want %v", projected, want) + } +} diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index b5727468..ef0b8251 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -13,6 +13,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir" "compiler/internal/ir/cfg" + "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/internal/source" @@ -53,6 +54,18 @@ type Write struct { Node ast.NodeID } +// Place identifies storage: a root binding and the projections taken from it to +// reach the value being used. +// +// Projections are reused from the canonical place walk rather than restated, so +// a consumer that already reasons about origins needs no translation. Empty +// projections mean the whole binding. A consumer that only cares which binding +// was touched reads Root and ignores the rest. +type Place struct { + Root *symbols.Symbol + Projections []place.OriginProjection +} + // Use reads a binding's value. Node is the reading identifier rather than the // enclosing statement, so a diagnostic anchors on the read itself. // @@ -60,7 +73,7 @@ type Write struct { // node back to syntax just to report against it. Define and Write carry no // location because no current diagnostic anchors on them. type Use struct { - Symbol *symbols.Symbol + Place Place Node ast.NodeID Location *source.Location // Kind is what happens to the value here: observed, duplicated, or diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index 63bb113e..dc661fce 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -71,7 +71,7 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a case Write: problems = append(problems, validateNode(where, "write", op.Symbol == nil, op.Node, nodes)...) case Use: - problems = append(problems, validateNode(where, "use", op.Symbol == nil, op.Node, nodes)...) + problems = append(problems, validateNode(where, "use", op.Place.Root == nil, op.Node, nodes)...) if op.Location == nil { problems = append(problems, where+" is a use with no source location to report against") } diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go index bbf56568..ebc3c76a 100644 --- a/internal/semantics/effect/validate_test.go +++ b/internal/semantics/effect/validate_test.go @@ -41,7 +41,7 @@ func TestValidateReportsDefects(t *testing.T) { { name: "use with no source location", damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { - result[fn][site] = []effect.Op{effect.Use{Symbol: &symbols.Symbol{Name: "x"}, Node: 1}} + result[fn][site] = []effect.Op{effect.Use{Place: effect.Place{Root: &symbols.Symbol{Name: "x"}}, Node: 1}} }, want: "is a use with no source location to report against", }, diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 1b963ff5..ee79a0b4 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -634,8 +634,8 @@ func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast recordUse(op.Symbol, op.Node) } case effect.Use: - if trackedLiveSymbol(op.Symbol) { - recordUse(op.Symbol, op.Node) + if trackedLiveSymbol(op.Place.Root) { + recordUse(op.Place.Root, op.Node) } } } @@ -656,14 +656,14 @@ func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) b uses := make([]symbolUse, 0, len(ops)) for _, op := range ops { use, isUse := op.(effect.Use) - if !isUse || !include(use.Symbol) { + if !isUse || !include(use.Place.Root) { continue } syntax, found := a.module.TypedASTNodes[use.Node] if !found { continue } - uses = append(uses, symbolUse{symbol: use.Symbol, site: syntax}) + uses = append(uses, symbolUse{symbol: use.Place.Root, site: syntax}) } return uses } From b1cdadf4b282aa25829696edfde4a8370adf66cf Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:55:28 +0600 Subject: [PATCH 58/80] Bracket the operations a call evaluates Ownership's expression walk cannot be driven by a flat sequence of uses, because a call is a lifetime and not just a position. Temporaries created while computing an argument live until the call completes, and a reservation taken for a receiver activates when the call starts. checkCall expresses both by marking the loan stacks and truncating them in a defer, which a flat stream has nowhere to hang. Publish the boundary. CallBegin and CallEnd bracket everything a call evaluates, and nest as calls nest. This is a fact about evaluation rather than one analysis's bookkeeping: any consumer modelling temporaries needs to know where a call starts and ends. The validator checks the pairs balance and do not cross, since a consumer restores state saved at the matching start and would otherwise restore the wrong mark. Dropping the close in the producer reports 'leaves call 11 open'. Definite initialization declares them inert, which is the contract working: the arguments between the brackets are published as ordinary reads and it already handles those. Ownership still walks syntax. The boundary lands first so that change is a deletion rather than a redesign. --- .../semantics/definiteinit/initialization.go | 3 ++ internal/semantics/effect/build.go | 2 ++ internal/semantics/effect/model.go | 29 +++++++++++++++---- internal/semantics/effect/validate.go | 18 ++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index f2be06cb..02645ebb 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -167,6 +167,9 @@ func apply(current state, op effect.Op) { // borrow is still reported through that. case effect.Discard: // A discarded value changes no initialization state. + case effect.CallBegin, effect.CallEnd: + // A call boundary bounds temporaries, which initialization does not + // track. Its arguments are published as ordinary reads between them. default: panic(fmt.Sprintf("definiteinit: unhandled effect %T", op)) } diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 366b4dba..eeed9340 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -279,6 +279,7 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { b.value(site, element, typeinfo.UseMove) } case *ast.CallExpr: + b.emit(site, CallBegin{Node: node.ID(), Location: ast.LocOf(node)}) b.value(site, node.Callee, typeinfo.UseRead) arguments := node.Args if b.queries.CallArguments != nil { @@ -287,6 +288,7 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { for _, argument := range arguments { b.value(site, argument, b.argumentKind(argument)) } + b.emit(site, CallEnd{Node: node.ID()}) case *ast.FreeExpr: b.value(site, node.Expr, typeinfo.UseMove) case *ast.PrintExpr: diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index ef0b8251..b84dbb2c 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -91,6 +91,23 @@ type Borrow struct { Mutable bool } +// CallBegin and CallEnd bracket the operations a call evaluates. Everything +// between them happens while the call is in progress. +// +// The bracket is a fact about evaluation, not one analysis's bookkeeping: a +// temporary created while computing an argument lives until the call completes, +// and a reservation taken for a receiver activates when the call starts. Any +// consumer modelling temporaries needs that boundary, and a flat sequence of +// uses cannot express it. Calls nest, so the pair nests too. +type CallBegin struct { + Node ast.NodeID + Location *source.Location +} + +type CallEnd struct { + Node ast.NodeID +} + // Discard is a value produced and dropped, as an expression statement does. // The value never reaches a binding, so anything owned in it dies here. type Discard struct { @@ -98,11 +115,13 @@ type Discard struct { Location *source.Location } -func (Define) effectOp() {} -func (Write) effectOp() {} -func (Use) effectOp() {} -func (Borrow) effectOp() {} -func (Discard) effectOp() {} +func (Define) effectOp() {} +func (Write) effectOp() {} +func (Use) effectOp() {} +func (Borrow) effectOp() {} +func (Discard) effectOp() {} +func (CallBegin) effectOp() {} +func (CallEnd) effectOp() {} // Result holds published effects for one semantic generation. // diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index dc661fce..bda2c4bf 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -63,6 +63,7 @@ func (r Result) Validate(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node) erro func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]ast.Node) []string { problems := make([]string, 0) + open := make([]ast.NodeID, 0) for index, op := range ops { where := fmt.Sprintf("function %d site %v operation %d", fn, site, index) switch op := op.(type) { @@ -85,10 +86,27 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a if op.Location == nil { problems = append(problems, where+" is a discard with no source location to report against") } + case CallBegin: + problems = append(problems, validateNode(where, "call start", false, op.Node, nodes)...) + open = append(open, op.Node) + case CallEnd: + // A consumer restores state saved at the matching start, so an + // unbalanced or crossed pair would restore the wrong mark. + if len(open) == 0 { + problems = append(problems, fmt.Sprintf("%s ends a call that never started", where)) + continue + } + if last := open[len(open)-1]; last != op.Node { + problems = append(problems, fmt.Sprintf("%s ends call %d while call %d is still open", where, op.Node, last)) + } + open = open[:len(open)-1] default: problems = append(problems, fmt.Sprintf("%s has unknown effect kind %T", where, op)) } } + for _, unclosed := range open { + problems = append(problems, fmt.Sprintf("function %d site %v leaves call %d open", fn, site, unclosed)) + } return problems } From cb383035e87cb778e66020cc4c681febfbba20a0 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 00:57:52 +0600 Subject: [PATCH 59/80] Publish the sequence a loop iterates The producer published a loop's condition from the terminator but never its iterated sequence, matching the walk it replaced. Ownership always read it, so the two disagreed, and definite initialization never checked it at all: a loop over an uninitialized bound or array went undiagnosed. Publish it at the loop's own site. That closes the disagreement recorded in the behavior-change register and strengthens initialization at the same time, covered by TestInitializationChecksLoopIterable. Nothing that compiled before fails: suite, bundle and fixtures unchanged. It also completes the coverage the op stream needs. Every expression position applyStmt walks is now published, so replacing that walk is a deletion. --- .../definiteinit/initialization_test.go | 22 +++++++++++++++++++ internal/semantics/effect/build.go | 7 +++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index 5dfcd21c..e1e53ba9 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -248,3 +248,25 @@ fn choose(flag: bool) -> i32 { t.Fatalf("diagnostic does not name the match subject:\n%s", got) } } + +// A loop's iterated sequence is a read like any other. It was previously +// unchecked here: the analysis saw only a statement's own expressions and a +// branch condition, and a for statement carries neither. Ownership always read +// it, so publishing it once closed the gap for initialization too. +func TestInitializationChecksLoopIterable(t *testing.T) { + _, diag, _ := analyzeInitializationSource(t, `fn choose(flag: bool) -> i32 { + let mut limit: i32; + if flag { + limit = 3; + } + for i in 0..limit { + } + return 0; +}`) + if !hasDiagnosticCode(diag, diagnostics.ErrUninitializedVariable) { + t.Fatalf("expected uninitialized loop bound diagnostic:\n%s", diag.EmitAllToString()) + } + if got := diag.EmitAllToString(); !strings.Contains(got, "symbol `limit` used before it's initialized") { + t.Fatalf("diagnostic does not name the loop bound:\n%s", got) + } +} diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index eeed9340..e2fb2ff2 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -194,7 +194,12 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St // statement site when semantic evidence was too incomplete for CFG to // decompose it. Publishing the subject here covers both. b.value(site, node.Subject, typeinfo.UseRead) - case *ast.IfStmt, *ast.ForStmt: + case *ast.ForStmt: + // The condition is published from the terminator, which names it + // directly. The iterated sequence is evaluated by the loop itself and + // belongs here. + b.value(site, node.Iterable, typeinfo.UseRead) + case *ast.IfStmt: // A branch condition is published from the terminator, which names it // directly, so a site carries exactly the reads that happen at it. case *ast.BlockStmt: From 166639530b5349e460d721c1a00403e50f7281ba Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 01:14:15 +0600 Subject: [PATCH 60/80] Let a place name a temporary, not only a binding A published place rooted at a binding, so a value that lives in no binding could not be named at all. Ownership has two policies that key on exactly that case: a discarded value dies where it is produced only when nothing owns it, and a projection out of a temporary must be bound before use. Both asked place.IsPlaceExpr, which is the syntax question the stream was meant to answer. Place now carries a temporary as an alternative root, with exactly one of the two set. The validator enforces that: a place naming neither names nothing, and one naming both would let two consumers reach different answers depending on which field they read. Projections off a temporary are published for the first time, which is what the receiver-call and call-result-index fixtures exercise. Discard carries its place, and ownership's discarded-value planning now reads it instead of re-deriving. That removes the IsPlaceExpr question from ownership.go entirely; the one in expr.go belongs to projection-base planning, which is part of the expression walk and moves with it. Suite, race, bundle and fixtures unchanged. --- internal/semantics/effect/build.go | 60 +++++++++++++++++----- internal/semantics/effect/model.go | 14 ++++- internal/semantics/effect/validate.go | 18 ++++++- internal/semantics/effect/validate_test.go | 14 ++++- internal/semantics/ownership/ownership.go | 26 ++++++++-- 5 files changed, 112 insertions(+), 20 deletions(-) diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index e2fb2ff2..897ca09b 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -186,7 +186,11 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St } case *ast.ExprStmt: b.value(site, node.Expr, typeinfo.UseRead) - b.emit(site, Discard{Node: node.Expr.ID(), Location: ast.LocOf(node.Expr)}) + b.emit(site, Discard{ + Place: b.placeOrTemporary(node.Expr), + Node: node.Expr.ID(), + Location: ast.LocOf(node.Expr), + }) case *ast.ReturnStmt: b.value(site, node.Value, typeinfo.UseMove) case *ast.MatchStmt: @@ -256,19 +260,12 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { // A field of a place is itself a place, so the use lands on the // projection rather than on the whole aggregate. A consumer that only // cares which binding was touched still reads the root. - if projected, ok := b.placeOf(node); ok { - b.emit(site, Use{Place: projected, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) - return - } - b.value(site, node.Expr, typeinfo.UseRead) + b.projection(site, node, node.Expr, place.OriginProjection{ + Kind: place.OriginField, Field: fieldName(node), + }, kind) case *ast.IndexExpr: - if projected, ok := b.placeOf(node); ok { - b.emit(site, Use{Place: projected, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) - // The index is a separate value, not part of the place. - b.value(site, node.Index, typeinfo.UseRead) - return - } - b.value(site, node.Expr, typeinfo.UseRead) + b.projection(site, node, node.Expr, place.OriginProjection{Kind: place.OriginIndex}, kind) + // The index is a separate value, not part of the place. b.value(site, node.Index, typeinfo.UseRead) case *ast.RangeExpr: b.value(site, node.Start, typeinfo.UseRead) @@ -373,3 +370,40 @@ func (b *builder) project(base ast.Expr, step place.OriginProjection) (Place, bo rooted.Projections = projections return rooted, true } + +func fieldName(selector *ast.SelectorExpr) string { + if selector == nil || selector.Name == nil { + return "" + } + return selector.Name.Name +} + +// projection publishes a use of one projected place. When the base names +// storage the use roots at that binding; otherwise the base is a temporary, +// which still has its own effects and is walked before the projection is +// published. +func (b *builder) projection(site cfg.SiteID, whole, base ast.Expr, step place.OriginProjection, kind typeinfo.UseKind) { + if rooted, ok := b.project(base, step); ok { + b.emit(site, Use{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole), Kind: kind}) + return + } + b.value(site, base, typeinfo.UseRead) + b.emit(site, Use{ + Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, + Node: whole.ID(), + Location: ast.LocOf(whole), + Kind: kind, + }) +} + +// placeOrTemporary names what an expression denotes: the binding it reaches, or +// the expression itself when it produces a value that lives nowhere. +func (b *builder) placeOrTemporary(expr ast.Expr) Place { + if expr == nil { + return Place{} + } + if rooted, ok := b.placeOf(expr); ok { + return rooted + } + return Place{Temporary: expr.ID()} +} diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index b84dbb2c..436895f7 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -62,7 +62,16 @@ type Write struct { // projections mean the whole binding. A consumer that only cares which binding // was touched reads Root and ignores the rest. type Place struct { - Root *symbols.Symbol + // Root is the binding the storage belongs to. It is nil for a temporary: + // a value that never lives in a binding, such as a call result. + Root *symbols.Symbol + // Temporary names the expression that produced the value when Root is nil. + // Exactly one of Root and Temporary is set, which the validator enforces. + // + // Ownership needs the distinction because a temporary has nobody to own it: + // a projection out of one has to be bound before use, and a discarded one + // dies where it is produced. + Temporary ast.NodeID Projections []place.OriginProjection } @@ -111,6 +120,9 @@ type CallEnd struct { // Discard is a value produced and dropped, as an expression statement does. // The value never reaches a binding, so anything owned in it dies here. type Discard struct { + // Place is what was discarded, so a consumer can tell a dropped temporary + // from a statement that merely names storage. + Place Place Node ast.NodeID Location *source.Location } diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index bda2c4bf..e9a005ca 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -72,7 +72,8 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a case Write: problems = append(problems, validateNode(where, "write", op.Symbol == nil, op.Node, nodes)...) case Use: - problems = append(problems, validateNode(where, "use", op.Place.Root == nil, op.Node, nodes)...) + problems = append(problems, validatePlace(where, "use", op.Place)...) + problems = append(problems, validateNode(where, "use", false, op.Node, nodes)...) if op.Location == nil { problems = append(problems, where+" is a use with no source location to report against") } @@ -82,6 +83,7 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a problems = append(problems, where+" is a borrow with no source location to report against") } case Discard: + problems = append(problems, validatePlace(where, "discard", op.Place)...) problems = append(problems, validateNode(where, "discard", false, op.Node, nodes)...) if op.Location == nil { problems = append(problems, where+" is a discard with no source location to report against") @@ -110,6 +112,20 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a return problems } +// validatePlace enforces that a place names exactly one root. A place with +// neither names nothing; one with both would let a consumer reach two different +// answers depending on which field it read. +func validatePlace(where, kind string, at Place) []string { + switch { + case at.Root == nil && at.Temporary == 0: + return []string{fmt.Sprintf("%s is a %s whose place names neither a binding nor a temporary", where, kind)} + case at.Root != nil && at.Temporary != 0: + return []string{fmt.Sprintf("%s is a %s whose place names both binding %s and temporary %d", + where, kind, at.Root.Name, at.Temporary)} + } + return nil +} + func validateNode(where, kind string, missingSymbol bool, node ast.NodeID, nodes map[ast.NodeID]ast.Node) []string { problems := make([]string, 0, 2) if missingSymbol { diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go index ebc3c76a..bbeb803c 100644 --- a/internal/semantics/effect/validate_test.go +++ b/internal/semantics/effect/validate_test.go @@ -32,11 +32,21 @@ func TestValidateReportsDefects(t *testing.T) { want string }{ { - name: "operation with no symbol", + name: "place naming no root at all", damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { result[fn][site] = []effect.Op{effect.Use{Node: 1}} }, - want: "is a use with no symbol", + want: "names neither a binding nor a temporary", + }, + { + name: "place naming two roots", + damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { + result[fn][site] = []effect.Op{effect.Use{ + Place: effect.Place{Root: &symbols.Symbol{Name: "x"}, Temporary: 1}, + Node: 1, + }} + }, + want: "names both binding x and temporary", }, { name: "use with no source location", diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index da5ca1fd..cd453ce8 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -489,6 +489,28 @@ func (a *analyzer) checkScopeDestruction(scope *symbols.Scope, site ast.Node, lo } } +// planDiscardedDrops records a drop for each value the site produces and throws +// away. +// +// Only a temporary is discarded in the sense that matters: a value that names +// no binding has nobody left to own it, so the drop happens here. A discarded +// expression that names storage still belongs to that storage. The producer +// makes the distinction, so this no longer asks the syntax. +func (a *analyzer) planDiscardedDrops(node *site) { + if a == nil || a.cleanup == nil || node == nil || node.cfgSite == nil { + return + } + for _, op := range a.effects[node.cfgSite.ID] { + discard, isDiscard := op.(effect.Discard) + if !isDiscard || discard.Place.Root != nil { + continue + } + if typeinfo.OwnershipCapabilityOf(a.module.EffectiveExprType(discard.Node)).Drop { + a.cleanup.DiscardedValue[ir.NodeID(discard.Node)] = struct{}{} + } + } +} + func (a *analyzer) applyStmt(node *site, st state) { if a == nil || node == nil || node.scope == nil || node.stmt == nil { return @@ -537,9 +559,7 @@ func (a *analyzer) applyStmt(node *site, st state) { a.cleanupBeforeReturn(scope, s, st, loans) case *ast.ExprStmt: a.checkExpr(scope, s.Expr, st, typeinfo.UseRead, loans, false) - if s.Expr != nil && !place.IsPlaceExpr(s.Expr) && typeinfo.OwnershipCapabilityOf(a.exprType(s.Expr)).Drop { - a.cleanup.DiscardedValue[ir.NodeID(s.Expr.ID())] = struct{}{} - } + a.planDiscardedDrops(node) case *ast.IfStmt: a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) case *ast.ForStmt: From 58a80dcaa60a47ef97ee19eb9b9adcfce524f03b Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 01:19:11 +0600 Subject: [PATCH 61/80] Publish a method receiver as a use, not a field projection The producer walked a call's callee as an ordinary value, so a method call published point.copy as a field projection of point. There is no such field. Consumers that only read the root were unaffected, which is why nothing failed, but ownership consuming projections would have been handed a place that does not exist. A method callee names a method. The value the call uses is the receiver, used the way the receiver parameter demands, which the typechecker already decided. Found by probing what the stream actually publishes for a receiver call rather than reasoning about it. Covered now, together with the field projection beside it so the two cannot be confused again. --- internal/semantics/effect/build.go | 9 +++- internal/semantics/effect/build_test.go | 59 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 897ca09b..465c4646 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -282,7 +282,14 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { } case *ast.CallExpr: b.emit(site, CallBegin{Node: node.ID(), Location: ast.LocOf(node)}) - b.value(site, node.Callee, typeinfo.UseRead) + if selector, method := node.Callee.(*ast.SelectorExpr); method && selector != nil { + // A method callee names a method, not storage. The value the call + // uses is the receiver, and it is used the way the receiver + // parameter demands, which the typechecker published. + b.value(site, selector.Expr, b.argumentKind(selector.Expr)) + } else { + b.value(site, node.Callee, typeinfo.UseRead) + } arguments := node.Args if b.queries.CallArguments != nil { arguments = b.queries.CallArguments(node) diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 021849e5..0a2eda94 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -278,3 +278,62 @@ fn read(values: [3]i32, pair: Pair, index: i32) -> i32 { t.Fatalf("projected places = %v, want %v", projected, want) } } + +// A method callee names a method, not storage. Publishing `point.copy` as a +// field projection of point would claim a field that does not exist, and would +// hand ownership a projected place where the real effect is a use of the +// receiver. +// +// The receiver is also where an implicit borrow lives: nothing in this source +// says `&`, but the receiver parameter is `&Point`, so passing `point` borrows +// it. The typechecker records that in ImplicitCallArguments. +func TestBuildPublishesMethodReceiverAsWholeUse(t *testing.T) { + result, module := buildEffects(t, `struct Point { x: i32, y: i32 } + +fn (self: &Point) copy() -> Point { + return .{x = self.x, y = self.y}; +} + +fn choose(point: Point) -> i32 { + let duplicate = point.copy(); + return duplicate.x; +}`) + symbol, found := module.ModuleScope.Lookup("choose") + if !found { + t.Fatal("function choose missing") + } + fn := symbol.ASTNode.(*ast.FnDecl) + graph := module.CFG.Function(ir.NodeID(fn.ID())) + + var receiver, field *effect.Use + for _, block := range graph.Blocks { + for _, site := range block.Sites { + for _, op := range result.At(graph.NodeID, site.ID) { + use, ok := op.(effect.Use) + if !ok || use.Place.Root == nil { + continue + } + switch use.Place.Root.Name { + case "point": + copied := use + receiver = &copied + case "duplicate": + copied := use + field = &copied + } + } + } + } + if receiver == nil || len(receiver.Place.Projections) != 0 { + t.Fatalf("receiver use = %+v, want a whole binding with no projection", receiver) + } + if field == nil || len(field.Place.Projections) != 1 { + t.Fatalf("field use = %+v, want one projection", field) + } + // The receiver parameter is a reference, so the typechecker recorded the + // adaptation. That evidence is what a future consumer reads to know the + // call borrows rather than moves. + if len(module.Typechecking.ImplicitCallArguments) == 0 { + t.Fatal("expected the implicit receiver borrow to be published") + } +} From 0ecc08cbfad5fb1818e5970dcf3b99ff89dece9d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 01:30:28 +0600 Subject: [PATCH 62/80] Bring the migration record in line with what shipped The document still described a three-operation vocabulary with Borrow, Discard, use kinds and places listed as deliberately absent. All seven operations exist, and the reasons they were added are more useful than the reasons they were once withheld, so record those instead. The one genuinely absent operation keeps its trigger. Add what the work found. Four design boundaries, each discovered by migrating a consumer and watching a real test fail rather than by reading: bindings established by an edge, a call being a lifetime, temporaries needing to be places, and two facts nobody publishes. The first three are closed and say how. Record the fourth precisely, because it is what stops the expression walk being replaced. A reference parameter makes an argument take a borrow access rather than a read, and the stream publishes the same UseRead for that and for an implicit-copy argument. Slicing forces a borrow access off the index being a range, which an index projection does not say. Both are facts the typechecker already has. Correct the record on implicit borrows while here: Peeper rejects passing a value to a plain reference parameter, so implicit adaptation is receiver and pipe only, and the typechecker already publishes it as ImplicitCallArguments where nothing reads it. List the three bugs this work introduced and fixed, since each was found by probing the published stream rather than by review, and the same class will recur. --- .../effect-stream-migration.md | 175 +++++++++++++----- 1 file changed, 127 insertions(+), 48 deletions(-) diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md index c4df928f..bc2c9873 100644 --- a/docs/compiler-framework/effect-stream-migration.md +++ b/docs/compiler-framework/effect-stream-migration.md @@ -1,8 +1,8 @@ # Effect stream migration -Status: **in progress**. Definite initialization consumes published effects and no -longer imports `ast`. Ownership's use enumeration consumes them too; the rest of -ownership still decides from syntax. +Status: **in progress**. Definite initialization is fully migrated. Ownership consumes +published effects for use enumeration, liveness definitions and discarded values; its +expression walk still reads syntax, blocked on two facts nobody publishes yet. This document is the executable plan for publishing semantic effects once and migrating dataflow consumers onto them. It is tracked so that anyone — human or agent — picking the @@ -60,28 +60,42 @@ than a phase with its own analysis. ### Vocabulary -Three operations. `Define` brings a binding into existence and records whether it is also -initialized; `Write` stores to a binding that already exists; `Use` reads one. Each carries -its `*symbols.Symbol` and the `ast.NodeID` it came from. `Use` also carries a -`*source.Location`, so a consumer reports against a read without resolving the node back to -syntax. `Define` and `Write` carry none, because no current diagnostic anchors on them. +It started at three operations and grew to seven, each time because a consumer needed the +distinction. `Define` brings a binding into existence; `Write` stores to one that already +exists; `Use` reads a place; `Borrow` takes a reference to one; `Discard` throws a value +away; `CallBegin` and `CallEnd` bracket a call. The section below records what each +addition bought. + +Every operation carries the `ast.NodeID` it came from. `Use`, `Borrow` and `Discard` also +carry a `*source.Location`, so a consumer reports against them without resolving the node +back to syntax. `Define` and `Write` carry none, because no current diagnostic anchors on +them. `Op` is sealed by an unexported marker method, the same idiom as `cfg.Terminator` and `typecheckresult.IterationPlan`. Go cannot make a consumer's type switch exhaustive, so `internal/contracts` carries that half. -### Deliberately absent +### What the vocabulary grew, and why + +Adding a channel before a consumer needs it is what commit `7ec06e9` had to delete, so +each of these was held back until a consumer actually asked. All were added by migrating +ownership, and each arrived with the consumer that needed it in the same change: -Adding a channel before a consumer needs it is what commit `7ec06e9` had to delete. Each of -these has a recorded trigger instead: +| Added | Because | +| --- | --- | +| `Use.Kind` | ownership decided read/copy/move at forty-four hardcoded literals. The kind now comes from the position, and for a call argument from the typechecker's published decision | +| `Borrow` | a reference is not a read, and shared versus mutable is what decides whether a second borrow conflicts | +| `Discard` | a value produced and thrown away dies where it is produced, when nothing owns it | +| `Place` with projections | moving out of `pair.left` is a different decision from moving `pair`, with its own diagnostic | +| `Place.Temporary` | ownership keys two policies on a value that lives in no binding, which a binding-rooted place could not name | +| `CallBegin` / `CallEnd` | a call is a lifetime: argument temporaries die when it completes, receiver reservations activate when it starts | +| `Define.OnEntry` | a parameter and a match payload binding exist before their site runs, and liveness must not treat that as a definition within the site | + +Still absent, with its trigger recorded: | Absent | Add when | | --- | --- | -| `Borrow` | ownership migrates and needs shared/mutable borrow distinct from read | -| `Discard` | ownership migrates and needs the `DiscardedValue` cleanup channel | -| `Use.Kind` (read/copy/move) | ownership migrates **and** `typecheckresult.ValueUses` covers more than call arguments. Today it covers only those, so a `Kind` field now would carry false data for roughly twenty constructs | -| `Place` with projections | field- or index-level initialization tracking is wanted. Definite initialization tracks whole symbols only | -| `Region` (deferred/repeated body) | a construct exists whose body does not execute at its syntactic position — a lambda. CFG back-edges already give "runs 0..N times" for loops, so a loop does not justify it | +| `Region` (deferred or repeated body) | a construct exists whose body does not execute at its syntactic position — a lambda. CFG back-edges already give "runs 0..N times" for a loop, so a loop does not justify it | ### Result and phase @@ -249,35 +263,100 @@ Definite initialization needs no change at any point. 7. If a gate fails for a reason this document does not predict, stop and report it. Do not improvise around a failing gate. -## After milestone 1 - -Ownership is the next consumer. It needs `Borrow`, `Discard`, and `Use.Kind`; `Use.Kind` -first needs `ValueUses` extended past call arguments, with a matching extension to -`ownershipresult.validateValueUses`, which today enforces only the call-argument case. -`UseCopy` is never published, so the two `UseCopy` diagnostics in `ownership/expr.go` are -presently dead and would activate for the first time — they need tests before that. -Ownership's loans, liveness, and borrow-ending stay local to ownership. - -## Milestone 2 — ownership - -Not planned as a whole; landing slice by slice. - -**Done — use enumeration.** `symbolUseSequence` walked eight statement kinds to -enumerate the symbols a site reads. That duplicated the producer and disagreed with -`applyStmt` about `ForStmt.Iterable`. It now reads the stream and has left the dispatch -contract, taking statement sites from nine to eight. - -**Still deciding from syntax**, roughly by size: - -- `checkExpr`, 23 expression cases. About a third is enumeration; the rest is - storage-access checks, loan bookkeeping and per-shape diagnostics. Collapsing it fully - needs projected places in the vocabulary, because a diagnostic like "move-only indexed - element cannot be used by value" depends on the shape of the place, not just the symbol. -- `applyStmt`, eight statement cases mixing enumeration with loan installation and - cleanup planning. -- `symbolUsesAndDefinitions` still derives definitions from `LetDecl`, `ConstDecl` and - `AssignStmt`. Deliberately left: the producer also emits defines for parameters and for - match payload bindings, which this analysis does not count as definitions today, so - switching it over would change liveness and needs its own parity work first. -- The 44 hard-coded use-kind literals, which need `Use.Kind`, which in turn needs - `typecheckresult.ValueUses` extended past call arguments. +## Vocabulary as it stands + +``` +Define{Place-less: Symbol, Node, Initialized, OnEntry} +Write{Symbol, Node} +Use{Place, Node, Location, Kind} +Borrow{Node, Location, Mutable} +Discard{Place, Node, Location} +CallBegin{Node, Location} / CallEnd{Node} + +Place{Root *symbols.Symbol | Temporary ast.NodeID, Projections []place.OriginProjection} +``` + +Exactly one of `Place.Root` and `Place.Temporary` is set; the validator enforces it, and +checks that call brackets balance and do not cross. + +## Four boundaries, found by attempting the work + +Each was discovered by trying to migrate a consumer and watching a real test fail, not by +reading. Three are closed. + +1. **Bindings established by an edge, not a site — closed.** A parameter exists before the + entry site runs; a match payload binding is created by its case edge. Definite + initialization never noticed, because it replays a site's operations in order. + Liveness treats a site as a set, so counting those as definitions killed a borrow one + site early. `Define.OnEntry` records the difference. +2. **A call is a lifetime, not a position — closed.** Temporaries created while computing + an argument live until the call completes; a reservation for a receiver activates when + the call starts. `CallBegin`/`CallEnd` bracket it, and nest. +3. **Temporaries are places too — closed.** A value that lives in no binding could not be + named, but ownership keys two policies on exactly that case. `Place.Temporary` names + the producing expression. +4. **Two facts nobody publishes — open.** See below. + +## What blocks the expression walk + +`ownership.checkExpr` cannot yet be replaced, because two decisions it makes are not +recoverable from the stream: + +- **Reference-parameter position.** `Read(reference)` where the parameter is `&i32` takes + a shared-borrow storage access, not a plain read, *because of the parameter type*. The + stream publishes `Use{Kind: UseRead}`, which is also what an implicit-copy argument + publishes, so the two cannot be told apart. Note this is not an implicit borrow: Peeper + rejects `Read(value)` with `cannot implicitly convert i32 to &i32`. Implicit adaptation + happens only for a method receiver or a piped first argument, and the typechecker + already records that in `ImplicitCallArguments` — evidence that is published and unread. +- **Slicing.** `a[0..2]` forces a shared or mutable borrow access, decided by the index + being a `RangeExpr`. A `Place` with an `OriginIndex` projection does not say whether the + index was a range. + +Closing either means publishing one more fact from the typechecker, which already knows +both. Until then, migrating `checkExpr` would leave ownership deriving some decisions from +the stream and some from syntax, which is the double-derivation this work exists to remove. + +## Migrated so far + +| Consumer | State | +| --- | --- | +| definite initialization | fully migrated; no AST switch, does not import `ast` | +| ownership use enumeration | `symbolUseSequence` reads the stream | +| ownership liveness definitions | `symbolUsesAndDefinitions` reads the stream | +| ownership discarded values | reads `Discard.Place`; `IsPlaceExpr` gone from `ownership.go` | +| ownership expression walk | **not migrated** — 20 AST cases, blocked above | +| ownership statement policy | 10 AST cases, and most is policy rather than enumeration | +| usage | needs no migration; it has no AST switch and no state | + +## Behavior this surfaced + +Publishing evidence for one consumer strengthens every other consumer. Two real gaps +closed as a side effect, each covered by a test and neither breaking an existing fixture: + +- a `match` on an uninitialized subject was never diagnosed; +- a loop over an uninitialized bound or sequence was never diagnosed. + +Weigh that before publishing anything new: it is a feature, but it is also a behavior +change that arrives without being asked for. + +## Bugs introduced by this work, and fixed + +Recorded because each was found by probing the real stream rather than by review, and the +same class will recur: + +- match arm bindings were emitted while walking the match terminator, so they could land + after the arm body's own operations at the same site; +- counting edge-established bindings as definitions ended a borrow one site early; +- a method callee was published as a field projection of the receiver, naming a field that + does not exist. + +## Picking up the expression walk + +1. Publish reference-parameter position and slicing, from the typechecker, which knows both. +2. Port `checkIdent`, the partial-move diagnostics, storage-access classification and loan + bookkeeping onto `applyUse` / `applyBorrow`, driven by the site's operations. +3. Delete `checkExpr`. Ownership keeps its statement policy; that is policy, not enumeration. + +Do not start step 2 before step 1. The suite is a strong net — it caught every mistake +listed above — but a half-ported loan analysis is the worst state to hand over. From 607964326d15cc7dada31e86a133a83b446305ed Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 01:41:45 +0600 Subject: [PATCH 63/80] Specify the expression walk replacement as one change Read every function the port touches and wrote down what it takes, after attempting it and reverting. Attempting it found a third missing fact. An assignment to a projection is published as a read, but ownership gives an assignment target a mutate access, so Write has to carry a place the way Use does. That is invisible until the operation loop exists, which is why the previous two attempts at enumerating the gaps both came up short. The consequence is that this is one indivisible change. Publishing the three facts first leaves channels nobody reads, which commit 7ec06e9 already had to delete once, so the two publications written while investigating are reverted rather than left in. Record the shape of the consumer, including why a mutable borrow inside a call bracket is a reservation rather than a borrow, and why projectionBase disappears when a projected place is one operation. Record that the helpers do not need rewriting: they take an ast.Expr, which the operation's node recovers. Record what it deletes, and that the intrinsic special cases in checkCall exist only to reach parameter types the typechecker already publishes. --- .../effect-stream-migration.md | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/docs/compiler-framework/effect-stream-migration.md b/docs/compiler-framework/effect-stream-migration.md index bc2c9873..9329a893 100644 --- a/docs/compiler-framework/effect-stream-migration.md +++ b/docs/compiler-framework/effect-stream-migration.md @@ -313,9 +313,9 @@ recoverable from the stream: being a `RangeExpr`. A `Place` with an `OriginIndex` projection does not say whether the index was a range. -Closing either means publishing one more fact from the typechecker, which already knows -both. Until then, migrating `checkExpr` would leave ownership deriving some decisions from -the stream and some from syntax, which is the double-derivation this work exists to remove. +There is a third, found only by attempting the port: `Write` carries a symbol, but an +assignment to a projection needs a place. See the port specification below, which lists all +three together because they land with the port rather than before it. ## Migrated so far @@ -351,12 +351,60 @@ same class will recur: - a method callee was published as a field projection of the receiver, naming a field that does not exist. -## Picking up the expression walk - -1. Publish reference-parameter position and slicing, from the typechecker, which knows both. -2. Port `checkIdent`, the partial-move diagnostics, storage-access classification and loan - bookkeeping onto `applyUse` / `applyBorrow`, driven by the site's operations. -3. Delete `checkExpr`. Ownership keeps its statement policy; that is policy, not enumeration. - -Do not start step 2 before step 1. The suite is a strong net — it caught every mistake -listed above — but a half-ported loan analysis is the worst state to hand over. +## Replacing the expression walk + +Every function involved has been read. It is one indivisible change, not a sequence of +small ones, because each piece below is unobservable until the others are in place. Land +it as a single commit or not at all: publishing the facts first leaves channels nobody +reads, which is what commit `7ec06e9` had to delete. + +### Facts to publish, with the port + +- **Reference-parameter position.** A reference parameter makes its argument take a borrow + access rather than a read. `Use{Kind: UseRead}` is published for that *and* for an + implicit-copy argument, so the two are indistinguishable. The typechecker has the + parameter type at `publishValueUse`; publish presence plus mutability. +- **Slicing.** `a[0..2]` takes a borrow access, mutable when the slice's own type is a + mutable reference. The producer can see both — the index being a `RangeExpr` and the + result type — so this needs no typechecker change, only an expression-type query. +- **`Write` must carry a `Place`.** `a.b = x` writes a projection. It is published today as + `Use{Kind: UseRead}`, but ownership gives an assignment target `storageMutate`. Without a + place on `Write`, driving that from operations either loses the mutate or performs a read + access and a mutate access where there was one. + +### Shape of the consumer + +`applyStmt` keeps its statement policy and stops calling `checkExpr`. Policy splits around +evaluation: what must observe state *before* the site's values are computed (an +assignment's reference capture, a return's escape checks) runs first, then the operation +loop, then what follows (target handling, cleanup planning, the loop carrier loan). + +The loop itself: + +- `CallBegin` pushes the temporary and reservation marks; `CallEnd` activates reservations + and truncates both. Calls nest, so the marks are a stack. +- `Use` with no projections drives the move state and the use-after-move and copy + diagnostics; with projections it drives the partial-move diagnostics; rooted at a + temporary it drives projection-base planning. +- `Borrow` drives the storage access. Shared or mutable comes from the operation; a mutable + borrow **inside a call bracket** is a reservation rather than a borrow, which is why the + bracket has to be in the vocabulary. +- `projectionBase` disappears. It existed so a base and its projection did not both take a + storage access; one operation for the projected place replaces both. + +The helpers do not need rewriting. `checkStorageAccess`, `originsForExpr`, `referenceHolder` +and `activateCallReservations` all take an `ast.Expr`, which is recoverable from the +operation's node. + +### What this deletes + +`checkExpr`, `checkIdent`, `checkSelector`, `checkAddressExpr`, `checkLiteralFields`, +`checkCall`, `checkMethodCall` and `checkCallArgument`. Every distinction they draw is +already decided by the typechecker: the intrinsic special cases in `checkCall` exist only +to reach the right parameter types, and the kinds those produce are published already. + +### Before starting + +Read this whole file. The suite is a strong net and caught every mistake this work made, +but ownership is the compiler's most delicate analysis and a half-ported loan model is the +worst possible state to hand over. Do not begin without room to finish. From 2e1ccb2ba11fe7bc65ae5b3dfc119a557d48aa5f Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 02:06:15 +0600 Subject: [PATCH 64/80] Consume published effects in ownership's expression walk Ownership decided for itself what every expression did to a binding, at forty-nine hardcoded use kinds across a twenty-case walk, duplicating decisions the typechecker had already made. It now consumes the published stream, and checkExpr, checkIdent, checkSelector, checkAddressExpr, checkLiteralFields, checkCall, checkMethodCall, checkCallArgument and publishedUse are gone. Three facts had to be published for this, each carrying something a use kind could not. A reference parameter borrows its argument, and UseRead is what an implicit-copy argument publishes too, so the two were indistinguishable. Slicing borrows rather than reads, decided by the index being a range. An assignment to a projection writes a place, so Write names one the way Use does. Attempting it found more. A borrow names the place it borrows, or a consumer seeing a borrow and a read of the same place charges it twice. A raw address takes a pointer rather than a tracked reference and participates in no loan. A borrow handed to a call outlives the expression that wrote it, so it is a loan rather than only an access, and a mutable one is reserved until the call starts. Reaching through a binding spends it exactly as naming it does, which is what ends a slice's borrow at its last use. applyStmt keeps what a statement carries beyond evaluating its expressions: reinitializing an assignment target, unwinding at a return, planning a discarded drop, installing a sequence loop's carrier borrow. Policy that must observe state before evaluation runs before the operation loop, and the rest after. If and match statements left it entirely: their whole effect is evaluating something, which the stream now carries. The two dispatch contracts followed the work: applyStmt classifies if and match as inert with that reason, and the expression site is the producer rather than ownership. Suite, race, bundle and the x_test fixtures pass unchanged. No diagnostic text, code or location changed. --- internal/contracts/node_dispatch_test.go | 12 +- internal/pipeline/pipeline.go | 2 + .../semantics/definiteinit/initialization.go | 31 +- .../definiteinit/initialization_test.go | 2 + internal/semantics/effect/build.go | 139 ++++++- internal/semantics/effect/build_test.go | 35 +- internal/semantics/effect/model.go | 21 +- internal/semantics/effect/validate.go | 4 +- internal/semantics/effect/validate_test.go | 2 +- internal/semantics/ownership/effects.go | 260 +++++++++++++ internal/semantics/ownership/expr.go | 355 ------------------ internal/semantics/ownership/ownership.go | 166 ++++---- .../semantics/ownership/ownership_test.go | 2 + internal/semantics/ownership/reference.go | 32 +- internal/semantics/typechecker/check_call.go | 9 +- internal/semantics/typecheckresult/result.go | 19 + 16 files changed, 613 insertions(+), 478 deletions(-) create mode 100644 internal/semantics/ownership/effects.go diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index e017552c..78c12d86 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -85,6 +85,12 @@ var declarationStatements = map[string]classification{ "BadDecl": {ignore, "the parser never produces BadDecl; it is tolerated for synthetic trees"}, } +// applyStmt now owns only the policy a statement carries beyond evaluating its +// expressions. A statement whose whole effect is evaluating something has +// nothing left to do there: the published effects carry it. +const evaluatedFromEffectsReason = "the condition or subject is evaluated from published effects, and the " + + "statement carries no ownership policy of its own" + const ( decomposedByCFGReason = "blocks are decomposed by CFG construction and are never a site statement" elsePositionReason = "parseIfStmt produces only a block or else-if in else position; anything else is an internal invariant violation (the default panics)" @@ -136,6 +142,8 @@ var statementSites = []dispatchSite{ "BadStmt": {ignore, "recovery node carries no ownership effect"}, "BreakStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, "ContinueStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, + "IfStmt": {ignore, evaluatedFromEffectsReason}, + "MatchStmt": {ignore, evaluatedFromEffectsReason}, }, }, // The effect producer replaced definiteinit.checkReads as the site that reads @@ -356,7 +364,9 @@ func importLocalName(file *ast.File, importPath string) string { var expressionSites = []dispatchSite{ {file: "semantics/resolver/resolver.go", fn: "resolveExpr"}, {file: "semantics/typechecker/check_expr.go", fn: "typeExprBase"}, - {file: "semantics/ownership/expr.go", fn: "checkExpr"}, + // Ownership no longer walks expressions. The producer decides what each one + // does to a binding, once, and ownership consumes that. + {file: "semantics/effect/build.go", fn: "value"}, { file: "ir/hir/lower/module_lower.go", fn: "lowerASTExpr", diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index e3ce7107..28b013c9 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -483,6 +483,8 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di ArmBindings: module.Typechecking.ArmBindings, StringConcatenation: module.Typechecking.StringConcatenation, ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, }) if err := module.Effects.Validate(module.CFG, module.TypedASTNodes); err != nil { phaseDiag.AddError(diagnostics.ErrInvalidEvidence, diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index 02645ebb..8f0f5a3a 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -8,6 +8,7 @@ import ( "compiler/internal/ir/cfg" "compiler/internal/semantics/effect" "compiler/internal/semantics/symbols" + "compiler/internal/source" ) type state map[symbols.SymbolID]struct{} @@ -140,8 +141,13 @@ func checkReads(ops []effect.Op, initialized state, tracked map[symbols.SymbolID } current := copyState(initialized) for _, op := range ops { - if use, ok := op.(effect.Use); ok { - reportUninitializedRead(use, current, tracked, diag) + switch op := op.(type) { + case effect.Use: + reportUninitializedRead(op.Place, op.Location, current, tracked, diag) + case effect.Borrow: + // Borrowing storage that holds nothing yet is the same error as + // reading it. + reportUninitializedRead(op.Place, op.Location, current, tracked, diag) } apply(current, op) } @@ -156,15 +162,14 @@ func apply(current state, op effect.Op) { current[op.Symbol.ID] = struct{}{} } case effect.Write: - if op.Symbol != nil { - current[op.Symbol.ID] = struct{}{} + if op.Place.Root != nil { + current[op.Place.Root.ID] = struct{}{} } case effect.Use: // A read leaves initialization state unchanged. case effect.Borrow: - // Taking a reference changes no initialization state. The place being - // borrowed is published separately as a read, so an uninitialized - // borrow is still reported through that. + // Taking a reference changes no initialization state, but it does read + // the place, which is reported where reads are checked. case effect.Discard: // A discarded value changes no initialization state. case effect.CallBegin, effect.CallEnd: @@ -175,25 +180,25 @@ func apply(current state, op effect.Op) { } } -func reportUninitializedRead(use effect.Use, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { - if use.Place.Root == nil { +func reportUninitializedRead(at effect.Place, location *source.Location, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { + if at.Root == nil { return } - name, local := tracked[use.Place.Root.ID] + name, local := tracked[at.Root.ID] if !local { return } - if _, present := current[use.Place.Root.ID]; present { + if _, present := current[at.Root.ID]; present { return } if name == "" { - name = use.Place.Root.Name + name = at.Root.Name } name = ir.StripSymbolInstance(name) msg := "symbol `" + name + "` used before it's initialized" diag.Add(diagnostics.NewError(msg). WithCode(diagnostics.ErrUninitializedVariable). - WithPrimaryLabel(use.Location, msg). + WithPrimaryLabel(location, msg). WithHelp("assign a value before reading this symbol")) } diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index e1e53ba9..ce1e386d 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -62,6 +62,8 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, ArmBindings: module.Typechecking.ArmBindings, StringConcatenation: module.Typechecking.StringConcatenation, ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, }) result := analyzeFunction(graph, effects[graph.NodeID], diag) return result, diag, module diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index 465c4646..c6591b7d 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -34,6 +34,13 @@ type BuildQueries struct { // made one. Call arguments have one; most positions do not, and the walk // decides those from the position itself. ValueUse func(ast.NodeID) (typeinfo.UseKind, bool) + // ExprType is the flow-refined type of an expression, which decides whether + // a slice borrows mutably. + ExprType func(ast.NodeID) typeinfo.Type + // ReferenceArgument reports an argument whose parameter is a reference, and + // whether that reference is mutable. The borrow exists because of the + // parameter type rather than because the source wrote an ampersand. + ReferenceArgument func(ast.NodeID) (mutable bool, found bool) } // Build publishes the semantic effects of every reachable CFG site. @@ -175,15 +182,7 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St b.buildBinding(site, scope, node, node.Value) case *ast.AssignStmt: b.value(site, node.Value, typeinfo.UseMove) - ident, direct := node.Target.(*ast.Ident) - if !direct || ident == nil { - b.value(site, node.Target, typeinfo.UseRead) - return - } - sym, found := scope.Lookup(ident.Name) - if found && sym != nil { - b.emit(site, Write{Symbol: sym, Node: ident.ID()}) - } + b.writeTarget(site, scope, node.Target) case *ast.ExprStmt: b.value(site, node.Expr, typeinfo.UseRead) b.emit(site, Discard{ @@ -254,8 +253,7 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { b.emit(site, Use{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) } case *ast.AddressExpr: - b.emit(site, Borrow{Node: node.ID(), Location: ast.LocOf(node), Mutable: node.Mode == ast.AddressMutable}) - b.value(site, node.Expr, typeinfo.UseRead) + b.borrow(site, node, node.Expr, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) case *ast.SelectorExpr: // A field of a place is itself a place, so the use lands on the // projection rather than on the whole aggregate. A consumer that only @@ -264,6 +262,16 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { Kind: place.OriginField, Field: fieldName(node), }, kind) case *ast.IndexExpr: + // Slicing does not read an element out; it borrows a run of the + // sequence, mutably when the slice itself is a mutable reference. + // A full range writes no bounds at all, so the index may be absent + // rather than an empty range. + _, ranged := node.Index.(*ast.RangeExpr) + if ranged || node.Index == nil { + b.value(site, node.Index, typeinfo.UseRead) + b.borrow(site, node, node.Expr, b.mutableReference(node.ID()), false) + return + } b.projection(site, node, node.Expr, place.OriginProjection{Kind: place.OriginIndex}, kind) // The index is a separate value, not part of the place. b.value(site, node.Index, typeinfo.UseRead) @@ -283,10 +291,10 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { case *ast.CallExpr: b.emit(site, CallBegin{Node: node.ID(), Location: ast.LocOf(node)}) if selector, method := node.Callee.(*ast.SelectorExpr); method && selector != nil { - // A method callee names a method, not storage. The value the call - // uses is the receiver, and it is used the way the receiver - // parameter demands, which the typechecker published. - b.value(site, selector.Expr, b.argumentKind(selector.Expr)) + // A method callee names a method, not storage. The receiver is the + // value the call uses, and it is used the way the receiver + // parameter demands, borrow included, exactly like any argument. + b.argument(site, selector.Expr) } else { b.value(site, node.Callee, typeinfo.UseRead) } @@ -295,7 +303,7 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { arguments = b.queries.CallArguments(node) } for _, argument := range arguments { - b.value(site, argument, b.argumentKind(argument)) + b.argument(site, argument) } b.emit(site, CallEnd{Node: node.ID()}) case *ast.FreeExpr: @@ -414,3 +422,102 @@ func (b *builder) placeOrTemporary(expr ast.Expr) Place { } return Place{Temporary: expr.ID()} } + +// writeTarget publishes the store an assignment performs. +// +// A projection target still evaluates the values inside it — an index is read +// to reach the element it selects — so those are published before the write +// itself. +func (b *builder) writeTarget(site cfg.SiteID, scope *symbols.Scope, target ast.Expr) { + switch node := target.(type) { + case *ast.Ident: + sym, found := scope.Lookup(node.Name) + if !found || sym == nil { + return + } + b.emit(site, Write{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node)}) + case *ast.SelectorExpr: + b.writeProjection(site, node, node.Expr, place.OriginProjection{ + Kind: place.OriginField, Field: fieldName(node), + }) + case *ast.IndexExpr: + b.value(site, node.Index, typeinfo.UseRead) + b.writeProjection(site, node, node.Expr, place.OriginProjection{Kind: place.OriginIndex}) + default: + // Anything else is not a place; the typechecker rejects it as a target, + // and its own effects are all it contributes here. + b.value(site, target, typeinfo.UseRead) + } +} + +func (b *builder) writeProjection(site cfg.SiteID, whole, base ast.Expr, step place.OriginProjection) { + if rooted, ok := b.project(base, step); ok { + b.emit(site, Write{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole)}) + return + } + b.value(site, base, typeinfo.UseRead) + b.emit(site, Write{ + Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, + Node: whole.ID(), + Location: ast.LocOf(whole), + }) +} + +// mutableReference reports whether an expression's own type is a mutable +// reference, which is what makes a slice of it a mutable borrow. +func (b *builder) mutableReference(id ast.NodeID) bool { + if b.queries.ExprType == nil { + return false + } + _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(b.queries.ExprType(id))) + return reference && mutable +} + +// argument publishes what one call argument does. +// +// A reference parameter borrows, whether or not the source wrote an ampersand, +// and that borrow is the argument's whole effect: publishing a read beside it +// would charge the same place twice. Everything else is an ordinary value use. +func (b *builder) argument(site cfg.SiteID, argument ast.Expr) { + if argument == nil { + return + } + mutable, borrows := false, false + if b.queries.ReferenceArgument != nil { + mutable, borrows = b.queries.ReferenceArgument(argument.ID()) + } + if !borrows { + b.value(site, argument, b.argumentKind(argument)) + return + } + operand := argument + if address, explicit := argument.(*ast.AddressExpr); explicit { + operand = address.Expr + } + // Values evaluated to reach the place, such as an index, still happen. + if index, indexed := operand.(*ast.IndexExpr); indexed { + b.value(site, index.Index, typeinfo.UseRead) + } + b.emit(site, Borrow{ + Place: b.placeOrTemporary(operand), + Node: argument.ID(), + Location: ast.LocOf(argument), + Mutable: mutable, + Argument: true, + }) +} + +// borrow publishes a reference taken to a place. Values inside the operand that +// are evaluated to reach it, such as an index, are published first. +func (b *builder) borrow(site cfg.SiteID, whole, operand ast.Expr, mutable, raw bool) { + if index, indexed := operand.(*ast.IndexExpr); indexed { + b.value(site, index.Index, typeinfo.UseRead) + } + b.emit(site, Borrow{ + Place: b.placeOrTemporary(operand), + Node: whole.ID(), + Location: ast.LocOf(whole), + Mutable: mutable, + Raw: raw, + }) +} diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 0a2eda94..c2583ec8 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -53,6 +53,8 @@ func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) ArmBindings: module.Typechecking.ArmBindings, StringConcatenation: module.Typechecking.StringConcatenation, ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, }) if result == nil { t.Fatal("Build published no result") @@ -101,7 +103,7 @@ func describe(op effect.Op) string { } return "declare " + op.Symbol.Name case effect.Write: - return "write " + op.Symbol.Name + return "write " + op.Place.Root.Name case effect.Use: return "use " + op.Place.Root.Name } @@ -305,27 +307,30 @@ fn choose(point: Point) -> i32 { fn := symbol.ASTNode.(*ast.FnDecl) graph := module.CFG.Function(ir.NodeID(fn.ID())) - var receiver, field *effect.Use + var receiver *effect.Borrow + var field *effect.Use for _, block := range graph.Blocks { for _, site := range block.Sites { for _, op := range result.At(graph.NodeID, site.ID) { - use, ok := op.(effect.Use) - if !ok || use.Place.Root == nil { - continue - } - switch use.Place.Root.Name { - case "point": - copied := use - receiver = &copied - case "duplicate": - copied := use - field = &copied + switch op := op.(type) { + case effect.Borrow: + if op.Place.Root != nil && op.Place.Root.Name == "point" { + copied := op + receiver = &copied + } + case effect.Use: + if op.Place.Root != nil && op.Place.Root.Name == "duplicate" { + copied := op + field = &copied + } } } } } - if receiver == nil || len(receiver.Place.Projections) != 0 { - t.Fatalf("receiver use = %+v, want a whole binding with no projection", receiver) + // The receiver parameter is `&Point`, so the call borrows the receiver + // rather than reading it, and it borrows the whole binding. + if receiver == nil || len(receiver.Place.Projections) != 0 || receiver.Mutable { + t.Fatalf("receiver borrow = %+v, want a shared borrow of a whole binding", receiver) } if field == nil || len(field.Place.Projections) != 1 { t.Fatalf("field use = %+v, want one projection", field) diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index 436895f7..c5caa890 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -47,11 +47,14 @@ type Define struct { OnEntry bool } -// Write stores to a binding that already exists. +// Write stores to storage that already exists. It names a place for the same +// reason Use does: `a.b = x` writes a field, and an assignment target takes a +// mutating access whether it is a whole binding or a projection out of one. type Write struct { - Symbol *symbols.Symbol + Place Place // Node is the assignment target. - Node ast.NodeID + Node ast.NodeID + Location *source.Location } // Place identifies storage: a root binding and the projections taken from it to @@ -94,10 +97,22 @@ type Use struct { // Borrow takes a reference to a place rather than reading its value. Mutable // separates `&mut x` from `&x`, which is the difference that decides whether a // second borrow conflicts. +// +// It names the place it borrows, so it is the whole access: a consumer that saw +// both a borrow and a separate read of the same place would charge that place +// twice. type Borrow struct { + Place Place Node ast.NodeID Location *source.Location Mutable bool + // Argument marks a borrow handed to a call. It outlives the expression that + // wrote it, because the callee holds it for as long as the call runs, so a + // consumer tracking loans records one rather than only checking an access. + Argument bool + // Raw marks taking a raw pointer. It reads the place but takes no tracked + // reference to it, so it neither conflicts with a borrow nor creates one. + Raw bool } // CallBegin and CallEnd bracket the operations a call evaluates. Everything diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index e9a005ca..ae8d21e6 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -70,7 +70,8 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a case Define: problems = append(problems, validateNode(where, "define", op.Symbol == nil, op.Node, nodes)...) case Write: - problems = append(problems, validateNode(where, "write", op.Symbol == nil, op.Node, nodes)...) + problems = append(problems, validatePlace(where, "write", op.Place)...) + problems = append(problems, validateNode(where, "write", false, op.Node, nodes)...) case Use: problems = append(problems, validatePlace(where, "use", op.Place)...) problems = append(problems, validateNode(where, "use", false, op.Node, nodes)...) @@ -78,6 +79,7 @@ func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]a problems = append(problems, where+" is a use with no source location to report against") } case Borrow: + problems = append(problems, validatePlace(where, "borrow", op.Place)...) problems = append(problems, validateNode(where, "borrow", false, op.Node, nodes)...) if op.Location == nil { problems = append(problems, where+" is a borrow with no source location to report against") diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go index bbeb803c..a3637100 100644 --- a/internal/semantics/effect/validate_test.go +++ b/internal/semantics/effect/validate_test.go @@ -58,7 +58,7 @@ func TestValidateReportsDefects(t *testing.T) { { name: "operation naming an unknown node", damage: func(result effect.Result, fn ir.NodeID, site cfg.SiteID) { - result[fn][site] = []effect.Op{effect.Write{Symbol: &symbols.Symbol{Name: "x"}, Node: 999999}} + result[fn][site] = []effect.Op{effect.Write{Place: effect.Place{Root: &symbols.Symbol{Name: "x"}}, Node: 999999}} }, want: "which is not in the typed AST", }, diff --git a/internal/semantics/ownership/effects.go b/internal/semantics/ownership/effects.go new file mode 100644 index 00000000..573420d3 --- /dev/null +++ b/internal/semantics/ownership/effects.go @@ -0,0 +1,260 @@ +package ownership + +import ( + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/semantics/effect" + "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" +) + +// callFrame remembers where a call's loans start, so completing the call can +// give back the temporaries its arguments created. +type callFrame struct { + call *ast.CallExpr + temporary int + reserved int +} + +// applyEffects runs one site's published operations in evaluation order. +// +// This is what replaced ownership's own expression walk. Every decision it +// needs is published: what happens to a value, whether a reference is taken, +// and where a call begins and ends. Syntax is recovered only to reuse the +// helpers that already report against it, never to work out meaning. +func (a *analyzer) applyEffects(node *site, st state, loans *loanContext) { + if a == nil || node == nil || node.cfgSite == nil { + return + } + calls := make([]callFrame, 0, 2) + for _, op := range a.effects[node.cfgSite.ID] { + switch op := op.(type) { + case effect.CallBegin: + call, _ := a.module.TypedASTNodes[op.Node].(*ast.CallExpr) + calls = append(calls, callFrame{ + call: call, + temporary: len(loans.temporary), + reserved: len(loans.reserved), + }) + case effect.CallEnd: + if len(calls) == 0 { + continue + } + frame := calls[len(calls)-1] + calls = calls[:len(calls)-1] + // Reservations activate as the call starts, which is observable + // only once its arguments are evaluated; the temporaries those + // arguments created die with the call. + a.activateCallReservations(frame.call, frame.reserved, loans) + loans.temporary = loans.temporary[:frame.temporary] + loans.reserved = loans.reserved[:frame.reserved] + case effect.Write: + // Assigning a whole binding reinitializes it, so a moved one is a + // legal target. Assigning through a projection is different: it + // reaches into storage that was moved away. + if op.Place.Root != nil && len(op.Place.Projections) > 0 { + a.reportUseAfterMove(op.Place.Root, st, effect.Use{ + Place: op.Place, Node: op.Node, Location: op.Location, + }) + } + case effect.Use: + a.applyUse(node, op, st, loans) + case effect.Borrow: + a.applyBorrow(node, op, st, loans, calls) + } + } +} + +func (a *analyzer) applyUse(node *site, op effect.Use, st state, loans *loanContext) { + syntax, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if op.Place.Root == nil { + // A value with no owner. Only a projection out of one has an effect + // here, and it is that the projection must be bound before use. + a.planProjectionBaseDrop(syntax, projectionBaseOf(syntax)) + return + } + if a.reportUseAfterMove(op.Place.Root, st, op) { + return + } + if len(op.Place.Projections) == 0 { + a.applyWholeUse(node, op, st, loans, syntax) + return + } + a.applyProjectedUse(op, st, loans, syntax) +} + +// applyWholeUse is the effect of using a binding entire: its move state changes, +// and the storage it names is accessed. +func (a *analyzer) applyWholeUse(node *site, op effect.Use, st state, loans *loanContext, syntax ast.Expr) { + sym := op.Place.Root + a.applyUseKind(sym, op, st, syntax) + if _, reference := referenceMutability(sym); reference { + loans.useReference(sym) + return + } + if syntax != nil { + a.checkStorageAccess(syntax, loans, storageAccessForUse(a.exprType(syntax), op.Kind)) + } + if referenceHoldingSymbol(sym) { + loans.useReference(sym) + } +} + +// applyProjectedUse is the effect of using part of a binding. Consuming a part +// is what a partial move is, and the language does not allow it out of a +// move-only place. +func (a *analyzer) applyProjectedUse(op effect.Use, st state, loans *loanContext, syntax ast.Expr) { + if syntax == nil { + return + } + // Reaching through a binding spends it the same way naming it does, so a + // reference reached through here is one use closer to its last. + if sym := op.Place.Root; sym != nil { + if _, reference := referenceMutability(sym); reference || referenceHoldingSymbol(sym) { + loans.useReference(sym) + } + } + if a.planProjectionBaseDrop(syntax, projectionBaseOf(syntax)) { + return + } + a.checkStorageAccess(syntax, loans, storageAccessForUse(a.exprType(syntax), op.Kind)) + if op.Kind == typeinfo.UseRead || !ownershipTrackedType(a.exprType(syntax)) { + return + } + if a.partialVariantPayloadMove(syntax) { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only variant payload cannot be moved from partial place; borrow it instead", op.Location, "") + return + } + if _, indexed := syntax.(*ast.IndexExpr); indexed { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", op.Location, "") + return + } + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "move-only subexpression must be bound before it can be consumed", op.Location, "") +} + +// applyBorrow records a reference taken to a place. A mutable borrow taken while +// a call is being evaluated is a reservation rather than a borrow: it does not +// take effect until the call it is an argument to actually starts. +func (a *analyzer) applyBorrow(node *site, op effect.Borrow, st state, loans *loanContext, calls []callFrame) { + syntax, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if op.Place.Root != nil && a.reportUseAfterMove(op.Place.Root, st, effect.Use{ + Place: op.Place, Node: op.Node, Location: op.Location, Kind: typeinfo.UseRead, + }) { + return + } + access := storageSharedBorrow + if op.Mutable { + access = storageMutableBorrow + if op.Argument { + // A mutable borrow handed to a call does not take effect until the + // call starts, so it is reserved here and activated there. + access = storageMutableReservation + } + } + if op.Raw { + // A raw pointer is not a tracked reference: it neither conflicts with a + // live borrow nor becomes one. + return + } + if sym := op.Place.Root; sym != nil { + if _, reference := referenceMutability(sym); reference || referenceHoldingSymbol(sym) { + loans.useReference(sym) + } + } + borrowed := borrowedExpr(syntax) + if borrowed == nil { + return + } + a.checkStorageAccess(borrowed, loans, access) + if op.Argument { + a.installArgumentLoan(borrowed, op, loans, calls) + } +} + +// installArgumentLoan records the loan a call holds on an argument for as long +// as it runs. A mutable one is reserved until the call starts; a shared one is +// a temporary that dies when the call completes. +func (a *analyzer) installArgumentLoan(borrowed ast.Expr, op effect.Borrow, loans *loanContext, calls []callFrame) { + origins := a.originsForExpr(borrowed) + if len(origins) == 0 { + return + } + var call ast.Node + if len(calls) > 0 && calls[len(calls)-1].call != nil { + call = calls[len(calls)-1].call + } + loan := referenceLoan{ + id: loanID{node: borrowed}, + origins: origins, + mutable: op.Mutable, + site: borrowed, + } + if op.Mutable { + loans.reserved = append(loans.reserved, loanFact{ + loan: loan, + holder: a.referenceHolder(borrowed), + keepingAlive: call, + }) + return + } + loans.addTemporary([]referenceLoan{loan}, call) +} + +func (a *analyzer) reportUseAfterMove(sym *symbols.Symbol, st state, op effect.Use) bool { + site, moved := st.moved[sym] + if !moved { + return false + } + diag := a.ctx.Diagnostics.AddError(diagnostics.ErrUseAfterMove, "value used after move", op.Location, "") + if site != nil { + diag.WithSecondaryLabel(ast.LocOf(site), "moved here") + } + return true +} + +// projectionBaseOf returns the expression a projection projects from. +func projectionBaseOf(expr ast.Expr) ast.Expr { + switch node := expr.(type) { + case *ast.SelectorExpr: + return node.Expr + case *ast.IndexExpr: + return node.Expr + } + return nil +} + +// borrowedExpr returns the place an address expression borrows. A borrow +// published for a reference argument names the argument itself. +func borrowedExpr(expr ast.Expr) ast.Expr { + if address, taken := expr.(*ast.AddressExpr); taken { + return address.Expr + } + return expr +} + +// applyUseKind is what happens to a binding's value at one use: a move leaves it +// dead, and a copy is rejected for anything the language will not duplicate. A +// read leaves it as it was. +func (a *analyzer) applyUseKind(sym *symbols.Symbol, op effect.Use, st state, syntax ast.Expr) { + if !ownershipTrackedSymbol(sym) { + return + } + switch op.Kind { + case typeinfo.UseCopy: + if symType, typed := symbols.GetSymbolType(sym); typed { + if _, mutable, ok := typeinfo.ReferenceTarget(typeinfo.Underlying(symType)); ok && mutable { + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "mutable reference cannot be copied; pass it directly to transfer or reborrow", op.Location, "") + return + } + } + a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, + "copy of move-only value requires a consuming context", op.Location, "") + case typeinfo.UseMove: + st.moved[sym] = syntax + delete(st.live, sym) + } +} diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 30d8959b..86181cc4 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -1,145 +1,14 @@ package ownership import ( - "fmt" - "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/ir" - "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) -func (a *analyzer) checkExpr( - scope *symbols.Scope, - expr ast.Expr, - st state, - use typeinfo.UseKind, - loans *loanContext, - projectionBase bool, -) { - if a == nil || expr == nil { - return - } - switch e := expr.(type) { - case *ast.Ident: - a.checkIdent(scope, e, st, use) - sym := a.module.Bindings.NodeSymbols[e.ID()] - if _, reference := referenceMutability(sym); reference { - loans.useReference(sym) - return - } - if !projectionBase { - a.checkStorageAccess(e, loans, storageAccessForUse(a.exprType(e), use)) - } - if referenceHoldingSymbol(sym) { - loans.useReference(sym) - } - case *ast.AddressExpr: - access := storageSharedBorrow - if e.Mode == ast.AddressMutable { - access = storageMutableBorrow - } - a.checkAddressExpr(scope, e, st, loans, access) - case *ast.SelectorExpr: - a.checkSelector(scope, e, st, use, loans) - if !projectionBase { - a.checkStorageAccess(e, loans, storageAccessForUse(a.exprType(e), use)) - } - case *ast.IndexExpr: - if typeinfo.IsInvalidOrUnknown(a.exprType(e)) { - return - } - _, slicing := e.Index.(*ast.RangeExpr) - a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, true) - a.checkExpr(scope, e.Index, st, typeinfo.UseRead, loans, false) - if !projectionBase { - access := storageAccessForUse(a.exprType(e), use) - if slicing { - access = storageSharedBorrow - if _, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(a.exprType(e))); reference && mutable { - access = storageMutableBorrow - } - } - a.checkStorageAccess(e, loans, access) - } - if slicing { - return - } - if a.planProjectionBaseDrop(e, e.Expr) { - return - } - if use != typeinfo.UseRead && ownershipTrackedType(a.exprType(e)) { - if a.partialVariantPayloadMove(e) { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(e), "") - return - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", ast.LocOf(e), "") - } - case *ast.RangeExpr: - a.checkExpr(scope, e.Start, st, typeinfo.UseRead, loans, false) - a.checkExpr(scope, e.End, st, typeinfo.UseRead, loans, false) - case *ast.StructLit: - a.checkLiteralFields(scope, e.Fields, st, loans) - case *ast.VariantLit: - a.checkExpr(scope, e.Payload, st, typeinfo.UseMove, loans, false) - case *ast.ArrayLit: - for _, value := range e.Values { - a.checkExpr(scope, value, st, typeinfo.UseMove, loans, false) - } - case *ast.CallExpr: - a.checkCall(scope, e, st, loans) - case *ast.FreeExpr: - a.checkExpr(scope, e.Expr, st, typeinfo.UseMove, loans, false) - case *ast.PrintExpr: - a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, false) - case *ast.UnaryExpr: - a.checkExpr(scope, e.Expr, st, typeinfo.UseRead, loans, false) - case *ast.BinaryExpr: - if _, concat := a.module.Typechecking.StringConcatenations[e.ID()]; concat { - a.checkExpr(scope, e.Left, st, typeinfo.UseMove, loans, false) - a.checkExpr(scope, e.Right, st, typeinfo.UseRead, loans, false) - return - } - a.checkExpr(scope, e.Left, st, typeinfo.UseRead, loans, false) - a.checkExpr(scope, e.Right, st, typeinfo.UseRead, loans, false) - case *ast.IsExpr: - a.checkExpr(scope, e.Value, st, typeinfo.UseRead, loans, false) - case *ast.AsExpr: - a.checkExpr(scope, e.Expr, st, typeinfo.UseMove, loans, false) - case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: - return - default: - panic(fmt.Sprintf("ownership: unhandled expression %T", expr)) - } -} - -func (a *analyzer) checkLiteralFields(scope *symbols.Scope, fields []ast.StructLitField, st state, loans *loanContext) { - for _, field := range fields { - a.checkExpr(scope, field.Value, st, typeinfo.UseMove, loans, false) - } -} - -func (a *analyzer) checkAddressExpr( - scope *symbols.Scope, - expr *ast.AddressExpr, - st state, - loans *loanContext, - access storageAccess, -) { - if expr == nil { - return - } - a.checkExpr(scope, expr.Expr, st, typeinfo.UseRead, loans, true) - if expr.Mode != ast.AddressRaw { - a.checkStorageAccess(expr.Expr, loans, access) - } -} - func storageAccessForUse(typ typeinfo.Type, use typeinfo.UseKind) storageAccess { if use == typeinfo.UseMove && ownershipTrackedType(typ) { return storageConsume @@ -147,78 +16,6 @@ func storageAccessForUse(typ typeinfo.Type, use typeinfo.UseKind) storageAccess return storageRead } -func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, use typeinfo.UseKind) { - if scope == nil || ident == nil { - return - } - var sym *symbols.Symbol - var ok bool - if a.module != nil && a.module.Bindings != nil { - sym = a.module.Bindings.NodeSymbols[ident.ID()] - ok = sym != nil - } - if !ok { - sym, ok = scope.Lookup(ident.Name) - } - if !ok || sym == nil { - return - } - if site, moved := st.moved[sym]; moved { - diag := a.ctx.Diagnostics.AddError(diagnostics.ErrUseAfterMove, - "value used after move", ast.LocOf(ident), "") - if site != nil { - diag.WithSecondaryLabel(ast.LocOf(site), "moved here") - } - return - } - if !ownershipTrackedSymbol(sym) { - return - } - switch use { - case typeinfo.UseCopy: - if symType, ok := symbols.GetSymbolType(sym); ok { - if _, mutable, ok := typeinfo.ReferenceTarget(typeinfo.Underlying(symType)); ok && mutable { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "mutable reference cannot be copied; pass it directly to transfer or reborrow", ast.LocOf(ident), "") - return - } - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "copy of move-only value requires a consuming context", ast.LocOf(ident), "") - case typeinfo.UseMove: - st.moved[sym] = ident - delete(st.live, sym) - } -} - -func (a *analyzer) checkSelector( - scope *symbols.Scope, - selector *ast.SelectorExpr, - st state, - use typeinfo.UseKind, - loans *loanContext, -) { - if selector == nil { - return - } - a.checkExpr(scope, selector.Expr, st, typeinfo.UseRead, loans, true) - if a.planProjectionBaseDrop(selector, selector.Expr) { - return - } - if use == typeinfo.UseRead { - return - } - if ownershipTrackedType(a.exprType(selector)) { - if a.partialVariantPayloadMove(selector) { - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only variant payload cannot be moved from partial place; borrow it instead", ast.LocOf(selector), "") - return - } - a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, - "move-only subexpression must be bound before it can be consumed", ast.LocOf(selector), "") - } -} - func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { if a == nil || a.cleanup == nil || projection == nil || base == nil { return false @@ -235,158 +32,6 @@ func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { return false } -func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, loans *loanContext) { - if call == nil { - return - } - args := a.module.Typechecking.CallArgumentsOrSource(call) - temporaryMark := len(loans.temporary) - reservationMark := len(loans.reserved) - defer func() { - loans.temporary = loans.temporary[:temporaryMark] - loans.reserved = loans.reserved[:reservationMark] - }() - if selector, ok := call.Callee.(*ast.SelectorExpr); ok && selector != nil { - if a.checkMethodCall(scope, selector, call, args, st, loans) { - a.activateCallReservations(call, reservationMark, loans) - } - return - } - a.checkExpr(scope, call.Callee, st, typeinfo.UseRead, loans, false) - if ident, ok := call.Callee.(*ast.Ident); ok && ident != nil { - sym := a.module.Bindings.NodeSymbols[ident.ID()] - if sym != nil && sym.CompilerOp == symbols.CompilerOpAlloc { - for _, arg := range args { - a.checkExpr(scope, arg, st, a.publishedUse(arg, nil), loans, false) - } - return - } - if sym != nil && sym.CompilerOp == symbols.CompilerOpFromBytes { - definition, found := intrinsics.LookupFunction(sym.CompilerOp) - if !found { - panic("missing from_bytes intrinsic definition") - } - fn := definition.Signature(nil, a.ctx.Target) - for i, arg := range args { - if i >= len(fn.Params) { - a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) - continue - } - a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) - } - return - } - } - fn, ok := a.exprType(call.Callee).(*typeinfo.FuncType) - if !ok || fn == nil || len(args) != len(fn.Params) { - for _, arg := range args { - a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) - } - return - } - for i, arg := range args { - a.checkCallArgument(scope, arg, fn.Params[i], call, st, loans) - } - a.activateCallReservations(call, reservationMark, loans) -} - -func (a *analyzer) checkMethodCall( - scope *symbols.Scope, - selector *ast.SelectorExpr, - call *ast.CallExpr, - args []ast.Expr, - st state, - loans *loanContext, -) bool { - fn, ok := a.exprType(selector).(*typeinfo.FuncType) - if !ok || fn == nil || selector == nil || call == nil { - if selector != nil { - a.checkExpr(scope, selector.Expr, st, typeinfo.UseRead, loans, false) - } - for _, arg := range args { - a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) - } - return false - } - a.checkCallArgument(scope, selector.Expr, fn.Params[0], call, st, loans) - if len(args)+1 != len(fn.Params) { - for _, arg := range args { - a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, false) - } - return false - } - for i, arg := range args { - a.checkCallArgument(scope, arg, fn.Params[i+1], call, st, loans) - } - return true -} - -func (a *analyzer) checkCallArgument( - scope *symbols.Scope, - arg ast.Expr, - paramType typeinfo.Type, - call *ast.CallExpr, - st state, - loans *loanContext, -) { - _, mutable, reference := typeinfo.ReferenceValueTarget(paramType) - if !reference { - a.checkExpr(scope, arg, st, a.publishedUse(arg, paramType), loans, false) - return - } - access := storageSharedBorrow - if mutable { - access = storageMutableReservation - } - if explicitBorrow, explicit := arg.(*ast.AddressExpr); explicit { - a.checkAddressExpr(scope, explicitBorrow, st, loans, access) - } else { - a.checkExpr(scope, arg, st, typeinfo.UseRead, loans, true) - a.checkStorageAccess(arg, loans, access) - } - origins := a.originsForExpr(arg) - if len(origins) == 0 { - return - } - loan := referenceLoan{ - id: loanID{node: arg}, - origins: origins, - mutable: mutable, - site: arg, - } - if mutable { - loans.reserved = append(loans.reserved, loanFact{ - loan: loan, - holder: a.referenceHolder(arg), - keepingAlive: call, - }) - return - } - loans.addTemporary([]referenceLoan{loan}, call) -} - -// publishedUse resolves the ownership use kind for one value use: the -// typechecker's published classification when present, otherwise the capability -// fallback. -// -// The fallback is reachable only on diagnostics-continued paths, where the -// typechecker exited before publishing. It stays deliberately: ownership still -// runs on broken source, and turning an absent classification into an internal -// error here would report a compiler bug for a program the user has already been -// told is invalid. For error-free programs the absence is a compiler bug, and -// ownershipresult.Validate is the one place that says so. -func (a *analyzer) publishedUse(arg ast.Expr, paramType typeinfo.Type) typeinfo.UseKind { - if a.module != nil && a.module.Typechecking != nil { - if kind, ok := a.module.Typechecking.ValueUses[arg.ID()]; ok { - return kind - } - } - if paramType == nil || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { - return typeinfo.UseRead - } - return typeinfo.UseMove -} - func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { if a == nil || a.module == nil || expr == nil { return nil diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index cd453ce8..1fef4076 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -517,83 +517,113 @@ func (a *analyzer) applyStmt(node *site, st state) { } scope := node.scope loans := a.newLoanContext(node, st) + + // Policy that has to observe state before the site's values are evaluated. + var boundReference []referenceLoan + boundHasReference := false switch s := node.stmt.(type) { case *ast.LetDecl: - a.applyBinding(scope, s, s.Value, st, loans) + boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) case *ast.ConstDecl: - a.applyBinding(scope, s, s.Value, st, loans) + boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) case *ast.AssignStmt: - reference, hasReference := a.referenceValueForExpr(s.Value, st) + boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) delete(a.cleanup.BeforeAssign, ir.NodeID(s.ID())) - a.checkExpr(scope, s.Value, st, typeinfo.UseMove, loans, false) - if _, ok := s.Target.(*ast.Ident); !ok { - a.checkExpr(scope, s.Target, st, typeinfo.UseRead, loans, true) - a.checkStorageAccess(s.Target, loans, storageMutate) - if typeinfo.OwnershipCapabilityOf(a.exprType(s.Target)).Drop { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - } - if target, ok := s.Target.(*ast.Ident); ok && scope != nil { - if sym, found := scope.Lookup(target.Name); found { - if _, referenceTarget := referenceMutability(sym); !referenceTarget { - a.checkStorageAccess(target, loans, storageMutate) - } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { - if _, live := st.live[sym]; live { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - } - if ownershipTrackedSymbol(sym) { - delete(st.moved, sym) - st.live[sym] = struct{}{} - } - a.updatePointerSymbol(sym, scope, s.Value, st) - a.updateReferenceSymbol(sym, reference, hasReference, st) - } - } case *ast.ReturnStmt: a.checkPointerEscape(scope, s.Value, st) a.validateReferenceReturn(scope, s, st) - a.checkExpr(scope, s.Value, st, typeinfo.UseMove, loans, false) + } + + // Evaluation itself, from published effects. + a.applyEffects(node, st, loans) + + // Policy that follows evaluation. + switch s := node.stmt.(type) { + case *ast.LetDecl: + a.applyBinding(scope, s, s.Value, st, boundReference, boundHasReference) + case *ast.ConstDecl: + a.applyBinding(scope, s, s.Value, st, boundReference, boundHasReference) + case *ast.AssignStmt: + a.applyAssignTarget(scope, s, st, loans, boundReference, boundHasReference) + case *ast.ReturnStmt: releaseIterationLoans(st, loans, 0) a.cleanupBeforeReturn(scope, s, st, loans) case *ast.ExprStmt: - a.checkExpr(scope, s.Expr, st, typeinfo.UseRead, loans, false) a.planDiscardedDrops(node) - case *ast.IfStmt: - a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) case *ast.ForStmt: - if s.Iterable == nil { - a.checkExpr(scope, s.Cond, st, typeinfo.UseRead, loans, false) - break - } - // A range loop borrows nothing; only a sequence loop holds the iterated - // storage for the loop's lifetime through its published carrier. - evidence := a.module.Typechecking.ForIterations[s.ID()] - sequence, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) - if !isSequence { - a.checkExpr(scope, s.Iterable, st, typeinfo.UseRead, loans, false) - break - } - a.checkExpr(scope, s.Iterable, st, typeinfo.UseRead, loans, true) - a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) - origins := a.originsForExpr(s.Iterable) - if ident, ok := s.Iterable.(*ast.Ident); ok { - sym := a.module.Bindings.NodeSymbols[ident.ID()] - if value, found := st.references[sym]; found { - origins = referenceOrigins(value) - } - } else if value, hasValue := a.referenceValueForExpr(s.Iterable, st); hasValue { - origins = referenceOrigins(value) + a.applyLoopCarrier(s, st, loans) + } +} + +// applyAssignTarget records what replacing a value does to the storage that +// held it: the old value is dropped, the binding is live again, and any pointer +// or reference it carried is refreshed. +func (a *analyzer) applyAssignTarget( + scope *symbols.Scope, + s *ast.AssignStmt, + st state, + loans *loanContext, + reference []referenceLoan, + hasReference bool, +) { + if _, direct := s.Target.(*ast.Ident); !direct { + a.checkStorageAccess(s.Target, loans, storageMutate) + if typeinfo.OwnershipCapabilityOf(a.exprType(s.Target)).Drop { + a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} } - if len(origins) > 0 { - st.references[sequence.Carrier] = []referenceLoan{{ - id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), - }} + return + } + target := s.Target.(*ast.Ident) + if scope == nil { + return + } + sym, found := scope.Lookup(target.Name) + if !found { + return + } + if _, referenceTarget := referenceMutability(sym); !referenceTarget { + a.checkStorageAccess(target, loans, storageMutate) + } + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { + if _, live := st.live[sym]; live { + a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} + } + } + if ownershipTrackedSymbol(sym) { + delete(st.moved, sym) + st.live[sym] = struct{}{} + } + a.updatePointerSymbol(sym, scope, s.Value, st) + a.updateReferenceSymbol(sym, reference, hasReference, st) +} + +// applyLoopCarrier installs the borrow a sequence loop holds on the storage it +// walks. A range loop borrows nothing. +func (a *analyzer) applyLoopCarrier(s *ast.ForStmt, st state, loans *loanContext) { + if s.Iterable == nil { + return + } + evidence := a.module.Typechecking.ForIterations[s.ID()] + sequence, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) + if !isSequence { + return + } + a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) + origins := a.originsForExpr(s.Iterable) + if ident, ok := s.Iterable.(*ast.Ident); ok { + sym := a.module.Bindings.NodeSymbols[ident.ID()] + if value, found := st.references[sym]; found { + origins = referenceOrigins(value) } - case *ast.MatchStmt: - a.checkExpr(scope, s.Subject, st, typeinfo.UseRead, loans, false) + } else if value, hasValue := a.referenceValueForExpr(s.Iterable, st); hasValue { + origins = referenceOrigins(value) + } + if len(origins) == 0 { + return } + st.references[sequence.Carrier] = []referenceLoan{{ + id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), + }} } func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { @@ -698,12 +728,20 @@ func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { return ids } -func (a *analyzer) applyBinding(scope *symbols.Scope, stmt ast.Stmt, value ast.Expr, st state, loans *loanContext) { +// applyBinding records what declaring a binding does to ownership state. Its +// initializer was already evaluated from published effects; what remains is the +// binding itself becoming live and taking on whatever the value carried. +func (a *analyzer) applyBinding( + scope *symbols.Scope, + stmt ast.Stmt, + value ast.Expr, + st state, + reference []referenceLoan, + hasReference bool, +) { if scope == nil || stmt == nil { return } - reference, hasReference := a.referenceValueForExpr(value, st) - a.checkExpr(scope, value, st, typeinfo.UseMove, loans, false) sym, found := scope.LookupNode(stmt) if !found || sym == nil { return diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index a8f96089..ce015ce8 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -62,6 +62,8 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { ArmBindings: module.Typechecking.ArmBindings, StringConcatenation: module.Typechecking.StringConcatenation, ValueUse: module.Typechecking.ValueUse, + ExprType: module.EffectiveExprType, + ReferenceArgument: module.Typechecking.ReferenceArgument, }) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index ee79a0b4..e23f3401 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -626,17 +626,21 @@ func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast definitions[op.Symbol] = struct{}{} } case effect.Write: - if !trackedLiveSymbol(op.Symbol) { + if op.Place.Root == nil || !trackedLiveSymbol(op.Place.Root) { continue } - definitions[op.Symbol] = struct{}{} - if typ, typed := symbols.GetSymbolType(op.Symbol); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { - recordUse(op.Symbol, op.Node) + definitions[op.Place.Root] = struct{}{} + if typ, typed := symbols.GetSymbolType(op.Place.Root); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { + recordUse(op.Place.Root, op.Node) } case effect.Use: if trackedLiveSymbol(op.Place.Root) { recordUse(op.Place.Root, op.Node) } + case effect.Borrow: + if trackedLiveSymbol(op.Place.Root) { + recordUse(op.Place.Root, op.Node) + } } } return uses, definitions @@ -655,15 +659,27 @@ func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) b ops := a.effects[node.cfgSite.ID] uses := make([]symbolUse, 0, len(ops)) for _, op := range ops { - use, isUse := op.(effect.Use) - if !isUse || !include(use.Place.Root) { + // Borrowing a place uses the binding it belongs to, exactly as reading + // it does: the loan has to outlive the borrow, so the last borrow is a + // last use. + var at effect.Place + var node ast.NodeID + switch op := op.(type) { + case effect.Use: + at, node = op.Place, op.Node + case effect.Borrow: + at, node = op.Place, op.Node + default: + continue + } + if !include(at.Root) { continue } - syntax, found := a.module.TypedASTNodes[use.Node] + syntax, found := a.module.TypedASTNodes[node] if !found { continue } - uses = append(uses, symbolUse{symbol: use.Place.Root, site: syntax}) + uses = append(uses, symbolUse{symbol: at.Root, site: syntax}) } return uses } diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index 73b75ba2..ee574f8f 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -279,10 +279,17 @@ func (c *checker) publishValueUse(arg ast.Expr, paramType typeinfo.Type) { return } use := typeinfo.UseMove - if _, _, reference := typeinfo.ReferenceValueTarget(paramType); reference || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { + _, mutable, reference := typeinfo.ReferenceValueTarget(paramType) + if reference || typeinfo.OwnershipCapabilityOf(paramType).Copy == typeinfo.CopyImplicit { use = typeinfo.UseRead } c.module.Typechecking.ValueUses[arg.ID()] = use + // A reference parameter borrows its argument. The use kind cannot carry + // that: an implicit-copy argument publishes UseRead too, so a consumer could + // not tell a borrow from a plain read. + if reference { + c.module.Typechecking.ReferenceArguments[arg.ID()] = mutable + } } func (c *checker) checkOptionalAllocatorArity(scope *symbols.Scope, node *ast.CallExpr) bool { diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index c1e817cc..df3158e3 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -151,6 +151,14 @@ type Result struct { // used expression's node ID. Reference parameters publish UseRead; the // borrow machinery in ownership still governs them. ValueUses map[ast.NodeID]typeinfo.UseKind + // ReferenceArguments records an argument whose parameter is a reference. + // Presence is the fact; the value reports whether that reference is mutable, + // which separates a shared borrow from a mutable reservation. + // + // The borrow follows from the parameter type, so it is invisible in the + // argument: passing a reference-typed value to a reference parameter writes + // no ampersand and produces no address expression. + ReferenceArguments map[ast.NodeID]bool } func New() *Result { @@ -168,6 +176,7 @@ func New() *Result { ForIterations: make(map[ast.NodeID]ForIteration), ExprTypes: make(map[ast.NodeID]typeinfo.Type), ValueUses: make(map[ast.NodeID]typeinfo.UseKind), + ReferenceArguments: make(map[ast.NodeID]bool), } } @@ -212,6 +221,16 @@ func (r *Result) ValueUse(id ast.NodeID) (typeinfo.UseKind, bool) { return kind, found } +// ReferenceArgument reports whether an argument's parameter is a reference and, +// when it is, whether that reference is mutable. +func (r *Result) ReferenceArgument(id ast.NodeID) (mutable bool, found bool) { + if r == nil { + return false, false + } + mutable, found = r.ReferenceArguments[id] + return mutable, found +} + // ArmBindings exposes the payload symbols one match arm binds, without leaking // match artifacts into the effect producer. A discarded binding still binds // storage, so it is reported like any other. From 04748a8806596de81a9e308e13c7a1f0d347c2e0 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 02:14:47 +0600 Subject: [PATCH 65/80] Add a code tour from source file to executable Nothing described the whole path end to end. The framework documents cover architecture and change paths, but a newcomer had no single walk from a .peep file to a binary, and the phase ladder is not obvious from any one package. Sixteen sections following one source file: entry through the CLI and driver, module loading and the dependency graph, the eighteen-rung phase ladder with what each rung publishes and where it is stored, the front end, semantic analysis, CFG sites and typed edges, the effect stream, the analyses that consume it, HIR and MIR lowering, LLVM text emission, and the clang invocations that turn it into an executable. Diagrams for the shape of things and short code samples for the substance, each marked simplified and naming the file it came from. Every claim was checked against the tree rather than written from memory: site counts, toolchain entry points, MIR membership, site kinds and artifact slots. Also records the parts that are easy to get wrong: that NodeSymbols indexes references rather than definitions, that a parameter or match binding exists before its site runs, that a call is a lifetime rather than a position, and that a projection out of a temporary has no binding to root at. --- Code-tour.md | 629 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 + 2 files changed, 634 insertions(+) create mode 100644 Code-tour.md diff --git a/Code-tour.md b/Code-tour.md new file mode 100644 index 00000000..3c826a05 --- /dev/null +++ b/Code-tour.md @@ -0,0 +1,629 @@ +# Code tour: from `.peep` to an executable + +A guided walk through the Peeper compiler, following one source file all the way to a +native binary. Read it top to bottom the first time; after that, jump to the phase you +need. + +Every code sample here is **simplified** — real signatures carry more parameters and more +error handling. Each one names the file it came from so you can read the real thing. + +Related reading: [`RULES.md`](RULES.md) for what code is acceptable, +[`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md) for phase discipline, and +[`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) for +the file-by-file walk when you are *changing* something rather than learning it. + +--- + +## 1. The thirty-second version + +```mermaid +flowchart LR + SRC[".peep source"] --> LEX[lexer] + LEX --> PAR[parser] + PAR --> AST[AST] + AST --> SEM[semantic analysis] + SEM --> CFG[control-flow graph] + CFG --> ANA[flow · effects · ownership] + ANA --> HIR[HIR] + HIR --> MIR[MIR] + MIR --> LL["LLVM IR text"] + LL --> CLANG["clang"] + CLANG --> OBJ["object files"] + OBJ --> LINK["linker"] + LINK --> EXE["executable"] +``` + +The compiler itself stops at **LLVM IR text**. Turning that into a binary is `clang`, +invoked as an external tool. Peeper does not link anything by hand. + +--- + +## 2. Package map + +| Package | What lives there | +| --- | --- | +| `cmd/` | CLI commands: `build`, `run`, `check`, `dump`, `doctor` | +| `internal/driver` | Thin wrapper that builds a `CompilerContext` and compiles one file | +| `internal/pipeline` | Module loading and the phase ladder that drives everything | +| `internal/project` | `Module`, `CompilerContext` — where every phase artifact is stored | +| `internal/frontend` | `token`, `lexer`, `parser`, `ast` | +| `internal/semantics` | Collector, binder, resolver, const eval, typechecker, effects, definite init, ownership, usage, plus the artifact packages | +| `internal/ir` | `cfg`, `hir`, `mir`, and the shared `ir` node/type model | +| `internal/backend/llvm` | MIR → LLVM IR text | +| `internal/contracts` | Test-tier contracts that force a decision for every node kind | +| `internal/toolchain` | Finds `clang` and the sysroot; builds its command lines | +| `internal/diagnostics` | Errors, warnings, source rendering, phase attribution | +| `runtime/` | `peeper_rt.c` — the C runtime linked into every binary | +| `_builtin_library/core` | The `core` standard library, written in Peeper | + +--- + +## 3. Entry: command → driver → pipeline + +```mermaid +sequenceDiagram + participant U as user + participant C as cmd/build.go + participant D as driver + participant P as pipeline + participant T as toolchain + + U->>C: peeper build main.peep + C->>D: CompileFile(ctx, path) + D->>P: pipeline.Run(ctx, entry) + P-->>D: every module at phase.Backend + D-->>C: modules carrying LLVMIR + C->>T: Resolve(clang, sysroot) + C->>T: clang -c mod_0.ll -o mod_0.o + C->>T: clang @objects.rsp -o app + C-->>U: executable +``` + +`driver.CompileFile` is deliberately small — it exists so the CLI, the LSP and the tests +all enter the compiler the same way. + +--- + +## 4. Module loading + +Before any phase runs, the loader walks imports and builds a dependency graph. + +```go +// internal/pipeline/loader.go (simplified) +func (l *moduleLoader) Load(entry *project.Module) error { + l.enqueue(entry) + for len(l.queue) > 0 { + module := l.pop() + l.loadModule(module) // read the file, lex, parse + l.resolveImports(module) // each import becomes a graph edge + a queued module + } + return nil +} +``` + +Modules are identified by `moduleid.ID` — origin, namespace, dependency and import path — +never by file path, so a module keeps its identity if the project moves on disk. + +The dependency graph is the shared `internal/graph` package. `pipeline.Run` topologically +sorts it so a module is only advanced once everything it imports has reached the phase it +needs. + +```mermaid +flowchart TD + E["entry: main.peep"] --> A["import core/io"] + E --> B["import ./util"] + B --> C["import core/mem"] + P["prelude"]:::pre + E -.always depends on.-> P + A -.-> P + B -.-> P + classDef pre fill:#eef,stroke:#88a +``` + +Every module is given an edge to the **prelude**, which is how the prelude is guaranteed +to be first in topological order without any special case in the scheduler. + +--- + +## 5. The phase ladder + +This is the spine of the compiler. Each module carries a `Phase`, and +`advanceModulePhase` moves it forward **exactly one step per call**. + +```mermaid +flowchart TD + Setup --> Load --> Parsed --> Collected --> Bound --> Resolved + Resolved --> ConstEval --> Typechecked --> CFG --> FlowTyped + FlowTyped --> Effects --> DefiniteInit --> Ownership --> Usage + Usage --> HIR --> MIR --> Backend --> Finalize +``` + +```go +// internal/pipeline/pipeline.go (heavily simplified) +func advanceModulePhase(ctx *project.CompilerContext, module *project.Module) bool { + if module.Phase < phase.Collected { collector.Collect(ctx, module); module.Phase = phase.Collected; return true } + if module.Phase < phase.Bound { binder.Bind(ctx, module); module.Phase = phase.Bound; return true } + if module.Phase < phase.Resolved { resolver.Resolve(ctx, module); module.Phase = phase.Resolved; return true } + // ... one block per phase, in order ... +} +``` + +Why one phase per call? Because modules advance **in lockstep across the whole project**. +A module cannot be typechecked until every module it imports is typechecked, and the +scheduler enforces that by advancing everyone one rung at a time. + +### What each phase produces + +| Phase | Produces | Stored on `Module` as | +| --- | --- | --- | +| `Parsed` | syntax tree | `AST` | +| `Collected` | top-level symbols, method sets | `Bindings`, `ModuleScope` | +| `Bound` | operator/interface bindings | `Bindings` | +| `Resolved` | every identifier → symbol | `Bindings.NodeSymbols` | +| `ConstEval` | compile-time constants | `Constants` | +| `Typechecked` | types and typing decisions | `Typechecking`, `TypedASTNodes` | +| `CFG` | blocks, sites, edges | `CFG` | +| `FlowTyped` | per-use narrowing | `Flow` | +| `Effects` | ordered semantic effects | `Effects` | +| `DefiniteInit` | *diagnostics only* | — | +| `Ownership` | drop plan | `Ownership` | +| `Usage` | *warnings only* | — | +| `HIR` | typed high-level IR | `HIR` | +| `MIR` | flat, block-structured IR | `MIR` | +| `Backend` | LLVM IR text | `LLVMIR` | + +**Phase artifacts are the central design idea.** Each fact is produced by exactly one +phase, stored in exactly one place, and read by later phases. No phase reaches backwards +to recompute something an earlier one already decided. + +`Module.resetToPhase` clears artifacts in phase order, so incremental rebuilds cannot +leave stale evidence behind: + +```go +// internal/project/modules.go (simplified) +func (m *Module) resetToPhase(retained phase.Phase) { + if retained < phase.Typechecked { m.Typechecking = nil; m.TypedASTNodes = nil } + if retained < phase.CFG { m.CFG = nil } + if retained < phase.Effects { m.Effects = nil } + if retained < phase.Ownership { m.Ownership = nil } + // ... +} +``` + +--- + +## 6. Front end: text → AST + +```go +// internal/frontend — the whole front end, in three lines +tokens := lexer.New(path, source, diag).Tokenize() +module := parser.New(path, tokens, diag).ParseModule() +``` + +The parser is recursive descent and **error-recovering**: on a syntax error it emits a +diagnostic and produces a `BadStmt` / `BadExpr` node rather than bailing out. Later phases +treat recovery nodes as inert, which is why the LSP can still offer completion in a file +that does not parse cleanly. + +AST nodes carry: + +- a **`NodeID`** — stable identity used as the key for every later fact about that node; +- a **`Location`** — for diagnostics; +- `forEachChild` — the one canonical child walk. + +```go +// internal/frontend/ast (simplified) +type ForStmt struct { + Index, Value *Ident + Iterable Expr + Cond Expr + Body *BlockStmt +} + +func (s *ForStmt) forEachChild(visit func(Node)) { + visit(s.Index); visit(s.Value); visit(s.Iterable); visit(s.Cond); visit(s.Body) +} +``` + +Forgetting a field in `forEachChild` makes it invisible to `ast.Inspect`. A contract test +parses this package and fails naming the field you missed — see §13. + +--- + +## 7. Semantic analysis + +```mermaid +flowchart LR + C["collector
declare top-level names"] --> B["binder
operators, interfaces"] + B --> R["resolver
ident → symbol, scopes"] + R --> K["const eval
compile-time values"] + K --> T["typechecker
types + decisions"] +``` + +**Collector** walks top-level declarations and puts them in `ModuleScope`, plus builds +method sets. **Binder** wires up operator functions and interface members. **Resolver** +creates block scopes and maps every referencing identifier to its symbol: + +```go +// internal/semantics/resolver (simplified) +module.Bindings.NodeSymbols[ident.ID()] = symbol +module.Bindings.BlockScopes[block.ID()] = scope +``` + +> **Gotcha worth knowing.** `NodeSymbols` indexes *references*, not definitions. A +> declaration name and an assignment target are resolved through the block scope instead +> (`scope.LookupNode`, `scope.Lookup`). Two mechanisms, deliberately. + +**The typechecker** does more than check types — it *publishes decisions* later phases +depend on, so nothing has to re-derive them: + +```go +// internal/semantics/typecheckresult/result.go (excerpt) +type Result struct { + ExprTypes map[ast.NodeID]typeinfo.Type // the type of each expression + ValueUses map[ast.NodeID]typeinfo.UseKind // read / copy / move + ReferenceArguments map[ast.NodeID]bool // borrows; value = mutable + ImplicitConversions map[ast.NodeID]typeinfo.Conversion + ImplicitCallArguments map[ast.NodeID]typeinfo.Type // receiver/pipe adaptation + Matches map[ast.NodeID]Match // resolved case evidence + ForIterations map[ast.NodeID]ForIteration // loop lowering plan + // ... +} +``` + +--- + +## 8. Control-flow graph + +CFG turns structured syntax into blocks, **sites** and typed edges. + +```mermaid +flowchart TD + subgraph entry [b0] + S0["site 0: let x = 1"] + S1["site 1: terminator (if x > 0)"] + end + entry -->|EdgeTrue| then["b1: then"] + entry -->|EdgeFalse| els["b2: else"] + then -->|EdgeNormal| join["b3: join"] + els -->|EdgeNormal| join +``` + +A **site** is one ordered program point inside a block: + +```go +// internal/ir/cfg/model.go (simplified) +type SiteID struct{ Block, Index int } // dense and positional + +type Site struct { + ID SiteID + Kind SiteKind // statement | scope exit | terminator | join + NodeID ir.NodeID // the AST node this point stands for + ScopeID ir.NodeID + Successors, Predecessors []Edge +} +``` + +Edges keep their meaning — `EdgeTrue`, `EdgeFalse`, `EdgeVariantCase` with a case index — +so consumers never guess control flow from adjacency order. + +CFG does not import the typechecker. It asks for the two facts it needs through narrow +function types, which the typechecker result happens to satisfy: + +```go +// internal/ir/cfg/build.go +type BuildQueries struct { + MatchCases func(ast.NodeID) ([]int, bool) + LoopGuaranteedEntry func(ast.NodeID) bool +} +``` + +`cfg.Module.Validate()` then checks the topology it produced — block identity, termination, +adjacency in both directions, reachability — and raises an internal-compiler-error +diagnostic rather than a user-facing one, because malformed topology is a compiler bug. + +--- + +## 9. Effects: the semantic operation stream + +This is the newest layer, and the one that makes later analyses construct-agnostic. + +**The problem it solves:** definite initialization and ownership each used to walk the AST +themselves, re-deriving what every construct did to a binding. Two walks that had to agree +— and sometimes did not. + +One producer now translates each CFG site into ordered operations: + +```go +// internal/semantics/effect/model.go (simplified) +type Op interface{ effectOp() } // sealed set + +type Place struct { + Root *symbols.Symbol // the binding … + Temporary ast.NodeID // … or the expression, for a value owning nothing + Projections []place.OriginProjection // .field, [index] +} + +type Define struct{ Symbol *symbols.Symbol; Initialized, OnEntry bool } +type Write struct{ Place Place } +type Use struct{ Place Place; Kind typeinfo.UseKind } +type Borrow struct{ Place Place; Mutable, Argument, Raw bool } +type Discard struct{ Place Place } +type CallBegin struct{ Node ast.NodeID } +type CallEnd struct{ Node ast.NodeID } +``` + +The producer is the only code that reads syntax to decide meaning: + +```go +// internal/semantics/effect/build.go (simplified) +func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { + switch node := expr.(type) { + case *ast.Ident: + if sym := b.queries.Symbols[node.ID()]; sym != nil { + b.emit(site, Use{Place: Place{Root: sym}, Kind: kind}) + } + case *ast.BinaryExpr: + if b.queries.StringConcatenation(node.ID()) { // decided by the typechecker + b.value(site, node.Left, typeinfo.UseMove) // concat consumes its left + b.value(site, node.Right, typeinfo.UseRead) + return + } + b.value(site, node.Left, typeinfo.UseRead) + b.value(site, node.Right, typeinfo.UseRead) + case *ast.CallExpr: + b.emit(site, CallBegin{Node: node.ID()}) + b.value(site, node.Callee, typeinfo.UseRead) + for _, arg := range b.queries.CallArguments(node) { + b.argument(site, arg) // borrows if the parameter is a reference + } + b.emit(site, CallEnd{Node: node.ID()}) + // … one case per expression kind, then: default: panic(…) + } +} +``` + +Three subtleties that are easy to get wrong, all learned the hard way: + +- **`Define.OnEntry`** — a parameter, or a match payload binding, exists *before* its site + runs. It is created by the edge into the site, not by the site. Liveness must not treat + that as a definition within the site or it ends a borrow one step early. +- **`CallBegin`/`CallEnd`** — a call is a *lifetime*, not a position. A temporary created + while computing an argument lives until the call completes. A flat list of uses has + nowhere to hang that. +- **`Place.Temporary`** — `f().field` projects out of a value that lives in no binding. + Ownership treats that differently, so the vocabulary has to be able to say it. + +--- + +## 10. The analyses that consume it + +```mermaid +flowchart LR + E["Effects"] --> DI["definite init
is it initialized?"] + E --> OW["ownership
moves, borrows, drops"] + CFG["CFG"] --> DI + CFG --> OW + OW --> CP["CleanupPlan"] +``` + +They share **evidence, not machinery**. Each keeps its own lattice, join direction and +diagnostics — `COMPILER_GUIDELINES.md` §6 explicitly forbids extracting a shared solver. + +**Definite initialization** is a must-analysis: a symbol is initialized only if it is +initialized on *every* path, so the join is intersection. + +```go +// internal/semantics/definiteinit (simplified) +func apply(current state, op effect.Op) { + switch op := op.(type) { + case effect.Define: if op.Initialized { current[op.Symbol.ID] = struct{}{} } + case effect.Write: current[op.Place.Root.ID] = struct{}{} + case effect.Use: // a read changes nothing + case effect.Borrow: // nor does taking a reference + default: panic("unhandled effect") // a new op fails loudly here + } +} +``` + +It contains **no AST switch at all** and does not import `ast` beyond `NodeID`. + +**Ownership** tracks moves, loans and liveness, then writes the drop plan: + +```go +// internal/semantics/ownership/effects.go (simplified) +for _, op := range a.effects[site.ID] { + switch op := op.(type) { + case effect.CallBegin: + calls = append(calls, callFrame{ // remember where this call's loans start + call: callFor(op), temporary: len(loans.temporary), reserved: len(loans.reserved), + }) + case effect.CallEnd: + frame := pop(&calls) + a.activateCallReservations(frame.call, frame.reserved, loans) // fire at call start + loans.temporary = loans.temporary[:frame.temporary] // argument temporaries die + loans.reserved = loans.reserved[:frame.reserved] + case effect.Use: + a.applyUse(op, st, loans) // move state + storage access + case effect.Borrow: + a.applyBorrow(op, st, loans, calls) // access + loan + } +} +``` + +The result is the `CleanupPlan` — the **single source of drop obligations** over source +values: + +```go +// internal/semantics/ownershipresult/result.go +type CleanupPlan struct { + AfterScope map[cfg.SiteID][]symbols.SymbolID // scope exit + BeforeReturn map[ir.NodeID][]symbols.SymbolID // after the value is computed + BeforeAssign map[ir.NodeID]struct{} // replacing a value drops the old + DiscardedValue map[ir.NodeID]struct{} // a temporary nobody owns + // … +} +``` + +Lowering *reads* this plan. It never decides a drop for itself. + +--- + +## 11. Lowering: HIR → MIR + +**HIR** is typed and still structured — `If`, `For`, `Block` are real nodes. It consumes +published evidence rather than re-deciding anything: + +```go +// internal/ir/hir/lower (simplified) +conversion, converting := module.Typechecking.ImplicitConversions[expr.ID()] +iteration := module.Typechecking.ForIterations[stmt.ID()] // carrier, cursor, bounds +``` + +**MIR** is flat: basic blocks, instructions, terminators — close to what a backend wants. + +```go +// internal/ir/mir/model.go (simplified) +type Instr interface{ instrNode() } // Assign Store Print Drop DynamicArrayOp Call InterfaceCall +type Terminator interface{ termNode() } // Jump Branch SwitchVariant Ret +``` + +Both sets are **sealed** by unexported marker methods, so an instruction can never be used +where a terminator belongs. MIR lowering walks CFG sites and consumes the cleanup plan to +place drops. + +```mermaid +flowchart LR + A["AST
structured, untyped"] --> H["HIR
structured, typed"] + H --> M["MIR
flat blocks + terminators"] + M --> L["LLVM IR text"] +``` + +--- + +## 12. Backend and linking + +```go +// internal/backend/llvm/emitter.go (simplified) +func GenerateLLVMIR(mod *mir.Module, …) string { + for _, fn := range mod.Funcs { + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + switch typed := instr.(type) { + case *mir.Assign: emitValue(lb, typed.Value) + case *mir.Drop: emitDrop(lb, typed) + // … + } + } + switch term := block.Term.(type) { + case *mir.Jump: lb.branch(target) + case *mir.Branch: lb.condBranch(cond, then, els) + case *mir.Ret: lb.ret(value) + } + } + } + return b.String() +} +``` + +The emitter produces **LLVM IR text**, not bitcode. Then `cmd/build.go` shells out: + +```go +// cmd/build.go (simplified) +for i, module := range modules { + os.WriteFile(fmt.Sprintf("mod_%d.ll", i), []byte(module.LLVMIR), 0o644) + runCompilerTool(profile.ClangPath, profile.ObjectArgs(llPath, objectPath, debug)) +} +profile.WriteResponseFile(responsePath, objectPaths) +runCompilerTool(profile.LinkerPath, profile.LinkArgs(responsePath, stagedPath)) +``` + +`internal/toolchain` finds a *managed* clang and sysroot if one is installed, and falls +back to `clang` on `PATH` with a warning. The C runtime in `runtime/peeper_rt.c` is linked +in to provide allocation and printing. + +Object files go through a **response file** rather than a long command line, which keeps +the link working on platforms with tight argument limits. + +--- + +## 13. Guardrails + +The compiler is built so that *forgetting* something fails loudly. + +```mermaid +flowchart TD + N["you add an AST node kind"] --> C1["contracts: 8 statement sites
fail by name"] + T["you add a typeinfo.Type"] --> C2["contracts: capability, identity,
lowering fail by name"] + I["you add a mir.Instr"] --> C3["contracts: lowering and backend
fail by name"] + A["a phase publishes evidence"] --> V["validators check its shape
at the phase boundary"] +``` + +| Guard | Where | Catches | +| --- | --- | --- | +| Child traversal contract | `internal/contracts` | a node field missing from `forEachChild` | +| Statement/expression contract | `internal/contracts` | a node kind no phase decides about | +| Semantic type contract | `internal/contracts` | a `typeinfo.Type` with no capability, identity or lowering | +| Lowered node contract | `internal/contracts` | an HIR/MIR kind nothing lowers or emits | +| `cfg.Validate` | `internal/ir/cfg` | malformed topology | +| `effect.Validate` | `internal/semantics/effect` | operations with no symbol, unbalanced calls | +| `ownershipresult.Validate` | `internal/semantics/ownershipresult` | evidence that contradicts published types | + +A contract failure reads like this: + +``` +publishStmt makes no decision about ast.YieldStmt; add a case or declare why the kind is inert +``` + +Every omission must be either handled or **classified** — `traverse`, `ignore`, `reject` +or `contextual` — with a written reason that is itself checked for staleness. + +--- + +## 14. Adding a language feature + +For a new syntax construct, in order: + +1. **token** — a keyword or token kind, if the syntax needs one. +2. **AST node** — the struct, its family marker, and `forEachChild`. +3. **parser** — build the node, with recovery. +4. **resolver** — scopes and bindings. +5. **typechecker** — the type rule, and *publish* whatever later phases will need. +6. **CFG** — only if the control-flow shape is genuinely new. +7. **effects** — one case in `publishStmt`/`value` saying what it does to bindings. +8. **HIR/MIR** — only if no existing lowering shape can represent it. + +Steps 1–6 are unavoidable: where a name lives and what types are legal *is* the feature. +Step 7 is what buys you definite initialization, ownership, liveness, drops and usage +**for free** — they consume operations and never learn your construct exists. + +`docs/compiler-framework/change-paths.md` walks this in full, including the stops where +nothing catches you. + +--- + +## 15. Glossary + +| Term | Meaning | +| --- | --- | +| **NodeID** | Stable identity of one AST node; the key for every fact about it | +| **SymbolID** | Stable identity of one declaration | +| **SiteID** | `{Block, Index}` — one ordered program point in a CFG | +| **Place** | Storage: a root binding (or a temporary) plus projections | +| **Artifact** | A phase's published output, owned by exactly one phase | +| **Prelude** | Implicitly imported module every other module depends on | +| **Contract** | A test that forces an explicit decision for every node kind | +| **Validator** | A boundary check on an artifact's shape, reported as a compiler bug | + +--- + +## 16. Where to look when + +| Question | Start here | +| --- | --- | +| Why is my program rejected? | `internal/diagnostics/codes.go`, then the phase that owns the code | +| How does phase ordering work? | `internal/pipeline/pipeline.go`, `advanceModulePhase` | +| What does the typechecker publish? | `internal/semantics/typecheckresult/result.go` | +| Why is a value moved/dropped here? | `internal/semantics/effect`, then `internal/semantics/ownership` | +| What does the backend emit for X? | `internal/backend/llvm/emitter.go` | +| How do I add a node kind safely? | `internal/contracts`, and run the suite | diff --git a/README.md b/README.md index 90b3479c..aaa5f1c8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ server, and executable source fixtures. See the [open roadmap](https://github.com/PeeperLanguage/compiler/issues) for unfinished language and runtime work. +## Learning the compiler + +[`Code-tour.md`](Code-tour.md) walks one `.peep` file through every phase to a native +binary, with diagrams and the real entry points at each stop. + ## Binary installation Linux and macOS: From 6d1ce2a0acf877552a915385a2f456fe74820676 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 11:47:06 +0600 Subject: [PATCH 66/80] Validate the shape of lowered HIR and MIR CFG topology and ownership evidence were checked at their phase boundary; the two lowered representations were not. A malformed one was found when the backend tripped over it, which for a missing terminator meant a panic during emission rather than a diagnostic. MIR checks what emission assumes: every block ends, every transfer names a block the function contains, block identity is unambiguous, the entry exists, no case is selected twice. HIR checks the shape a walk cannot see, since a traversal skips a nil rather than reporting it: a function has a body, a block holds no empty slot, and a construct carrying a body has one. Neither re-derives meaning. Whether the right instruction was lowered is lowering's decision, and that every node kind is handled at all is already held by the dispatch contract. Emission is now gated on recorded errors, which the HIR and MIR blocks already were. Without that the diagnostic was written and the backend still ran, so the panic arrived first and the validator bought nothing. Proven by lowering a loop with no body and by dropping jump terminators: each reports by name, with no panic in either case. --- internal/ir/hir/validate.go | 94 +++++++++++++++++++++++++ internal/ir/hir/validate_test.go | 100 ++++++++++++++++++++++++++ internal/ir/mir/validate.go | 117 +++++++++++++++++++++++++++++++ internal/ir/mir/validate_test.go | 116 ++++++++++++++++++++++++++++++ internal/pipeline/pipeline.go | 14 ++++ 5 files changed, 441 insertions(+) create mode 100644 internal/ir/hir/validate.go create mode 100644 internal/ir/hir/validate_test.go create mode 100644 internal/ir/mir/validate.go create mode 100644 internal/ir/mir/validate_test.go diff --git a/internal/ir/hir/validate.go b/internal/ir/hir/validate.go new file mode 100644 index 00000000..ae677ca3 --- /dev/null +++ b/internal/ir/hir/validate.go @@ -0,0 +1,94 @@ +package hir + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +const maxReportedProblems = 10 + +// Validate checks the shape of a lowered function body: that a function has one, +// that no statement slot is empty, and that a construct carrying a body actually +// has it. +// +// It does not re-derive meaning. Whether the right statement was lowered for a +// construct is lowering's decision; that every statement kind is handled at all +// is held by the dispatch contract in internal/contracts. What is left is the +// shape, which nothing else checks and which the backend otherwise discovers by +// dereferencing a nil. +// +// A failure is a compiler bug, not a source error. +func (m *Module) Validate() error { + if m == nil || len(m.Funcs) == 0 { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Funcs { + if fn == nil { + problems = append(problems, "module holds a nil function") + continue + } + if fn.Body == nil { + problems = append(problems, fmt.Sprintf("function %s has no body", fn.Name)) + continue + } + problems = append(problems, validateStmt(fn.Name, fn.Body)...) + } + if len(problems) == 0 { + return nil + } + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +// validateStmt walks through the canonical child traversal rather than a switch +// of its own, so a new statement kind is covered here the moment it declares its +// children. What it adds is the checks a traversal cannot make: an empty slot is +// invisible to a walk that skips nils. +func validateStmt(fn string, stmt Stmt) []string { + problems := make([]string, 0) + if stmt == nil { + return append(problems, fmt.Sprintf("function %s holds a nil statement", fn)) + } + switch node := stmt.(type) { + case *Block: + for index, child := range node.Stmts { + if child == nil { + problems = append(problems, fmt.Sprintf("function %s holds a nil statement at block index %d", fn, index)) + continue + } + problems = append(problems, validateStmt(fn, child)...) + } + case *If: + problems = append(problems, validateBody(fn, "if", node.Then)...) + if node.Else != nil { + problems = append(problems, validateStmt(fn, node.Else)...) + } + case *For: + // Init, Bindings and Next are optional; a loop with no body is not. + problems = append(problems, validateBody(fn, "loop", node.Body)...) + for role, part := range map[string]*Block{"loop init": node.Init, "loop bindings": node.Bindings, "loop next": node.Next} { + if part != nil { + problems = append(problems, validateStmt(fn, part)...) + _ = role + } + } + case *SwitchVariant: + for _, arm := range node.Cases { + problems = append(problems, validateBody(fn, fmt.Sprintf("case %d", arm.Case), arm.Body)...) + } + } + return problems +} + +func validateBody(fn, role string, body *Block) []string { + if body == nil { + return []string{fmt.Sprintf("function %s has a %s with no body", fn, role)} + } + return validateStmt(fn, body) +} diff --git a/internal/ir/hir/validate_test.go b/internal/ir/hir/validate_test.go new file mode 100644 index 00000000..f4a99b00 --- /dev/null +++ b/internal/ir/hir/validate_test.go @@ -0,0 +1,100 @@ +package hir + +import ( + "strings" + "testing" +) + +func wellFormedHIR() *Module { + return &Module{ + Name: "probe", + Funcs: []*Function{{ + Name: "choose", + Body: &Block{Stmts: []Stmt{ + &If{Then: &Block{}, Else: &Block{}}, + &For{Body: &Block{}}, + &SwitchVariant{Cases: []VariantCaseBlock{{Case: 0, Body: &Block{}}}}, + &Return{}, + }}, + }}, + } +} + +// Without a positive case, a negative one could pass against a fixture that was +// already broken. +func TestValidateAcceptsWellFormedModule(t *testing.T) { + if err := wellFormedHIR().Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a well-formed module", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Module) + want string + }{ + { + name: "function with no body", + damage: func(m *Module) { m.Funcs[0].Body = nil }, + want: "function choose has no body", + }, + { + name: "nil statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = nil }, + want: "holds a nil statement at block index 3", + }, + { + name: "if with no then block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[0].(*If).Then = nil }, + want: "has a if with no body", + }, + { + name: "loop with no body", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[1].(*For).Body = nil }, + want: "has a loop with no body", + }, + { + name: "case arm with no body", + damage: func(m *Module) { + m.Funcs[0].Body.Stmts[2].(*SwitchVariant).Cases[0].Body = nil + }, + want: "has a case 0 with no body", + }, + { + name: "nil function", + damage: func(m *Module) { m.Funcs = append(m.Funcs, nil) }, + want: "module holds a nil function", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := wellFormedHIR() + test.damage(module) + err := module.Validate() + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +// A nested body is reached through the same walk, so a defect inside one is +// reported rather than skipped. +func TestValidateDescendsIntoNestedBodies(t *testing.T) { + module := wellFormedHIR() + module.Funcs[0].Body.Stmts[1].(*For).Body.Stmts = []Stmt{&If{Then: nil}} + err := module.Validate() + if err == nil || !strings.Contains(err.Error(), "has a if with no body") { + t.Fatalf("Validate() = %v, want the nested defect reported", err) + } +} + +func TestValidateAcceptsEmptyModule(t *testing.T) { + if err := (*Module)(nil).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } +} diff --git a/internal/ir/mir/validate.go b/internal/ir/mir/validate.go new file mode 100644 index 00000000..3c8dcbce --- /dev/null +++ b/internal/ir/mir/validate.go @@ -0,0 +1,117 @@ +package mir + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +const maxReportedProblems = 10 + +// Validate checks the shape of a lowered module: that every block ends, that +// every transfer names a block that exists, and that identity is unambiguous. +// +// It deliberately does not re-derive meaning. Whether the right instruction was +// emitted for a construct is lowering's decision, and re-deciding it here would +// be a second implementation of the thing being validated. That a node kind is +// classified at all is held by the dispatch contract in internal/contracts, not +// here. +// +// A failure is a compiler bug rather than a source error: MIR is built from +// evidence that earlier phases already accepted. +func (m *Module) Validate() error { + if m == nil || len(m.Funcs) == 0 { + return nil + } + problems := make([]string, 0) + for _, fn := range m.Funcs { + if fn == nil { + problems = append(problems, "module holds a nil function") + continue + } + problems = append(problems, validateFunction(fn)...) + } + if len(problems) == 0 { + return nil + } + // Function and block order is stable, but a report that grows from map + // iteration elsewhere would not be; sorting keeps a broken artifact + // reproducible between runs. + sort.Strings(problems) + if len(problems) > maxReportedProblems { + return fmt.Errorf("%s (%d more)", strings.Join(problems[:maxReportedProblems], "; "), len(problems)-maxReportedProblems) + } + return errors.New(strings.Join(problems, "; ")) +} + +func validateFunction(fn *Function) []string { + problems := make([]string, 0) + blocks := make(map[int]bool, len(fn.Blocks)) + for _, block := range fn.Blocks { + if block == nil { + problems = append(problems, fmt.Sprintf("function %s holds a nil block", fn.Name)) + continue + } + if blocks[block.ID] { + problems = append(problems, fmt.Sprintf("function %s declares block b%d twice", fn.Name, block.ID)) + continue + } + blocks[block.ID] = true + } + // Identity has to hold before transfers can be checked against it. + if len(problems) > 0 { + return problems + } + if len(fn.Blocks) > 0 && !blocks[fn.EntryID] { + problems = append(problems, fmt.Sprintf("function %s enters at b%d, which it does not contain", fn.Name, fn.EntryID)) + } + for _, block := range fn.Blocks { + for index, instr := range block.Instrs { + if instr == nil { + problems = append(problems, fmt.Sprintf("function %s block b%d holds a nil instruction at %d", fn.Name, block.ID, index)) + } + } + if block.Term == nil { + // Emission would otherwise fall off the end of a block. + problems = append(problems, fmt.Sprintf("function %s block b%d has no terminator", fn.Name, block.ID)) + continue + } + problems = append(problems, validateTransfers(fn, block, blocks)...) + } + return problems +} + +// validateTransfers checks that every block a terminator names exists. A +// transfer to a block that was never emitted becomes a label the backend cannot +// resolve. +func validateTransfers(fn *Function, block *Block, blocks map[int]bool) []string { + problems := make([]string, 0) + report := func(target int, role string) { + if !blocks[target] { + problems = append(problems, fmt.Sprintf("function %s block b%d transfers to b%d as its %s, which it does not contain", + fn.Name, block.ID, target, role)) + } + } + switch term := block.Term.(type) { + case *Jump: + report(term.TargetID, "jump target") + case *Branch: + report(term.ThenID, "true target") + report(term.ElseID, "false target") + case *SwitchVariant: + seen := make(map[int]bool, len(term.Targets)) + for _, target := range term.Targets { + report(target.TargetID, fmt.Sprintf("case %d target", target.Case)) + if seen[target.Case] { + problems = append(problems, fmt.Sprintf("function %s block b%d selects case %d twice", fn.Name, block.ID, target.Case)) + } + seen[target.Case] = true + } + case *Ret: + // A return leaves the function and names no block. + default: + problems = append(problems, fmt.Sprintf("function %s block b%d ends with unknown terminator %T", fn.Name, block.ID, block.Term)) + } + return problems +} diff --git a/internal/ir/mir/validate_test.go b/internal/ir/mir/validate_test.go new file mode 100644 index 00000000..f73a8de8 --- /dev/null +++ b/internal/ir/mir/validate_test.go @@ -0,0 +1,116 @@ +package mir + +import ( + "strings" + "testing" +) + +// wellFormed is one function with a branch and a join, the smallest shape that +// exercises every kind of transfer check. +func wellFormed() *Module { + return &Module{ + Name: "probe", + Funcs: []*Function{{ + Name: "choose", + EntryID: 0, + Blocks: []*Block{ + {ID: 0, Term: &Branch{ThenID: 1, ElseID: 2}}, + {ID: 1, Term: &Jump{TargetID: 3}}, + {ID: 2, Term: &Jump{TargetID: 3}}, + {ID: 3, Term: &Ret{}}, + }, + }}, + } +} + +// The positive case has to exist, or every negative case below could pass +// against a fixture that was already broken. +func TestValidateAcceptsWellFormedModule(t *testing.T) { + if err := wellFormed().Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a well-formed module", err) + } +} + +func TestValidateReportsDefects(t *testing.T) { + tests := []struct { + name string + damage func(*Module) + want string + }{ + { + name: "block without a terminator", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = nil }, + want: "block b1 has no terminator", + }, + { + name: "jump to a block that does not exist", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = &Jump{TargetID: 99} }, + want: "transfers to b99 as its jump target", + }, + { + name: "branch with a missing false target", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Term = &Branch{ThenID: 1, ElseID: 42} }, + want: "transfers to b42 as its false target", + }, + { + name: "duplicate block identity", + damage: func(m *Module) { m.Funcs[0].Blocks[2].ID = 1 }, + want: "declares block b1 twice", + }, + { + name: "entry naming a block the function does not contain", + damage: func(m *Module) { m.Funcs[0].EntryID = 7 }, + want: "enters at b7, which it does not contain", + }, + { + name: "nil instruction", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Instrs = []Instr{nil} }, + want: "holds a nil instruction at 0", + }, + { + name: "one case selected twice", + damage: func(m *Module) { + m.Funcs[0].Blocks[0].Term = &SwitchVariant{Targets: []VariantTarget{ + {Case: 0, TargetID: 1}, {Case: 0, TargetID: 2}, + }} + }, + want: "selects case 0 twice", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := wellFormed() + test.damage(module) + err := module.Validate() + if err == nil { + t.Fatalf("Validate() = nil, want a report containing %q", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() = %v, want a report containing %q", err, test.want) + } + }) + } +} + +// An unsorted report differs between runs of the same broken artifact, which is +// useless to whoever has to fix it. +func TestValidateReportsDefectsDeterministically(t *testing.T) { + module := wellFormed() + module.Funcs[0].Blocks[1].Term = nil + module.Funcs[0].Blocks[2].Term = nil + first := module.Validate() + for range 8 { + if got := module.Validate(); got.Error() != first.Error() { + t.Fatalf("Validate() = %v, want the stable report %v", got, first) + } + } +} + +func TestValidateAcceptsEmptyModule(t *testing.T) { + if err := (*Module)(nil).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for an empty artifact", err) + } + if err := (&Module{}).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a module with no functions", err) + } +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 28b013c9..ce549aa1 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -527,6 +527,10 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di return false } module.HIR = fold.ApplyTypedExpressionFolding(modhir) + if err := module.HIR.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "lowered HIR is malformed: "+err.Error(), nil, "") + } module.Phase = phase.HIR ctx.Metrics.AddPhaseAdvance() return true @@ -539,6 +543,10 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di return false } module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Constants.ModuleValues) + if err := module.MIR.Validate(); err != nil { + phaseDiag.AddError(diagnostics.ErrInvalidEvidence, + "lowered MIR is malformed: "+err.Error(), nil, "") + } module.Phase = phase.MIR ctx.Metrics.AddPhaseAdvance() return true @@ -549,6 +557,12 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di if module.Phase >= phase.Backend { return false } + // Emission assumes the MIR it is handed is well formed, and says so by + // panicking. The validator above is what makes that assumption safe, so + // nothing may reach emission once an error is recorded. + if diag != nil && diag.HasErrors() { + return false + } module.LLVMIR = llvm.GenerateLLVMIR(module.MIR, phaseDiag, ctx.Target, ctx.Config.BuildDebug) module.Phase = phase.Backend ctx.Metrics.AddPhaseAdvance() From 064e73f89b09149083d973bce8d83a22c5f7c3e7 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 11:50:26 +0600 Subject: [PATCH 67/80] Prove cleanup happens exactly once at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup paths had check-mode coverage only. type_automatic_cleanup_plan exercises scope exit, assignment replacement, a discarded temporary, a projection out of a temporary, a nested owned field and an explicit free, and proves the plan is accepted — but it never runs, so a drop emitted twice or emitted for storage that was moved away was never observed. Run the same paths. A double free aborts in the allocator, so reaching the final marker is the evidence; each path prints its own marker so a path that stopped being exercised fails rather than silently passing. Proven by emitting every scope drop twice: the fixture fails with 'free(): double free detected in tcache 2'. The channel audit Step 7 asks for found nothing to remove. All seven cleanup channels have both a writer in ownership and a reader in lowering; the one dead channel was already deleted in 7ec06e9. Cleanup keys are already validated against the CFG and typed nodes by ownershipresult.Validate. --- x_test/runtime_cleanup_paths/peeper.toml | 13 ++++ x_test/runtime_cleanup_paths/src/main.peep | 69 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 x_test/runtime_cleanup_paths/peeper.toml create mode 100644 x_test/runtime_cleanup_paths/src/main.peep diff --git a/x_test/runtime_cleanup_paths/peeper.toml b/x_test/runtime_cleanup_paths/peeper.toml new file mode 100644 index 00000000..5e335def --- /dev/null +++ b/x_test/runtime_cleanup_paths/peeper.toml @@ -0,0 +1,13 @@ +name = "runtime_cleanup_paths" +build = "program" +entry = "src/main.peep" + +# type_automatic_cleanup_plan covers the same paths in check mode, which proves +# the plan is accepted but never executes it. This runs them: a drop emitted +# twice aborts in the allocator, and a drop emitted for storage that was moved +# away corrupts the heap, so reaching the final marker is the exactly-once +# evidence. +[test] +mode = "run" +outcome = "success" +stdout_contains = ["scope", "replace", "discard", "projection", "nested", "match", "done"] diff --git a/x_test/runtime_cleanup_paths/src/main.peep b/x_test/runtime_cleanup_paths/src/main.peep new file mode 100644 index 00000000..b6cf9593 --- /dev/null +++ b/x_test/runtime_cleanup_paths/src/main.peep @@ -0,0 +1,69 @@ +struct Box { + value: i32, + ptr: *i32 +} + +enum Carrier { + Owned: { held: *i32 }, + Empty +} + +fn MakeBox() -> Box { + return .Box{ value = 7, ptr = alloc(0) }; +} + +// A projection out of a temporary: the base is owned by nobody, so its cleanup +// belongs to the site that produced it. +fn ReadTemporaryField() -> i32 { + return MakeBox().value; +} + +// Replacing a live owned value drops the old one before the new one lands. +fn ReplaceOwned() { + let mut held = alloc(0); + let next = alloc(0); + held = next; + println("replace"); +} + +// A value produced and thrown away dies where it is produced. +fn DiscardTemporary() { + alloc(0); + .Box{ value = 0, ptr = alloc(0) }; + println("discard"); +} + +// An owned value inside an aggregate is destroyed with it. +fn NestedOwned() { + let box = .Box{ value = 1, ptr = alloc(0) }; + println("nested"); +} + +// A payload the arm does not bind is dropped on that arm's edge. +fn MatchCarrier(carrier: Carrier) { + match carrier { + Carrier::Owned with { held = _ } => { + println("match"); + } + Carrier::Empty => { + println("match"); + } + } +} + +fn main() -> i32 { + { + let scoped = alloc(0); + println("scope"); + } + ReplaceOwned(); + DiscardTemporary(); + let _ = ReadTemporaryField(); + println("projection"); + NestedOwned(); + MatchCarrier(Carrier::Owned with .{ held = alloc(0) }); + let early = alloc(0); + free(early); + println("done"); + return 0; +} From 0a72ed19089f66be7740a927d6ff22eaee3de546 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 11:57:19 +0600 Subject: [PATCH 68/80] Settle the type capability inventory and its boundaries Step 4 asked for one canonical answer per shared type question. Copy class and drop obligation were consolidated earlier; what was left was the rest of the inventory, and the honest answer for most of it is that consolidation would make the code worse. Sized and Lowerable are the tempting merge and the one that must not happen. They key their cycle guards on different things, give opposite answers on a cycle, and disagree on interfaces and type parameters for real reasons: a linked list is lowerable and not sized. One traversal cannot hold both answers without carrying two guards, at which point it is two walkers sharing a body. The argument now sits above IsSizedType so it travels with the code. The shallow predicates stay shallow. IsIntegral through IsCondition are non-recursive switches over primitive kinds answering operator admissibility, not ownership; merging them is the giant capability object the step forbids. ReferenceTarget, LiteralFitsType and the six Contains predicates already have a single implementation each, the last six over one shared walker. The backend drop traversal reads like a second implementation of the drop obligation and is not. It walks the lowered type table, and it never decides whether a value is dropped: emitDrop is entered from exactly one mir.Drop, and every other call is that walk recursing into a drop already ordered upstream. Ownership decides policy, the backend expands representation. Said at typeNeedsDrop, including what it means if you ever want it to answer otherwise. That reframes G2 in the ownership vocabulary from a drift risk to a misfiling, and G1, G8 and G9 are closed outright now the three predicates are deleted. The gap table carries status per row rather than reading as if none had moved. G3 stays open and is the real one: owned interface drop policy does live only in the backend. No behavior change. The consolidation was proven equivalent before the predicates it replaced were deleted. --- docs/compiler-framework/README.md | 10 +- .../ownership-vocabulary.md | 22 +- docs/compiler-framework/type-capabilities.md | 126 ++++ internal/backend/llvm/drop_emit.go | 19 + internal/semantics/typeinfo/capabilities.go | 18 + task.md | 655 ++++++++++++++++++ 6 files changed, 835 insertions(+), 15 deletions(-) create mode 100644 docs/compiler-framework/type-capabilities.md create mode 100644 task.md diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index 45a33f4e..7e0169e6 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -572,10 +572,12 @@ answers where to go: it walks the real file sequence for three change shapes, tr from commits already in the repository, and names what catches you when a stop is missed — including the stops where nothing does. -Four of those stops are pure mechanism: they rediscover read and write meaning the -typechecker already decided. -[`effect-stream-migration.md`](effect-stream-migration.md) is the in-progress plan to -publish that meaning once and let the analyses consume it instead. +Four of those stops were pure mechanism: they rediscovered read and write meaning +the typechecker had already decided. +[`effect-stream-migration.md`](effect-stream-migration.md) records how that meaning +became a published artifact instead, and what the migration cost. +[`type-capabilities.md`](type-capabilities.md) does the same for derived type +questions: what became one walker, and which lookalikes must stay separate. | Area | Required question | | --- | --- | diff --git a/docs/compiler-framework/ownership-vocabulary.md b/docs/compiler-framework/ownership-vocabulary.md index e8138a7e..098b1211 100644 --- a/docs/compiler-framework/ownership-vocabulary.md +++ b/docs/compiler-framework/ownership-vocabulary.md @@ -54,17 +54,17 @@ re-derived in ownership from AST shapes plus hardcoded per-node rules**: Capability model gaps (each is a place where "auto" silently breaks): -| # | Gap | -| --- | --- | -| G1 | `IsNoCopyType` is dead in production; move-only is re-derived as `!IsImplicitCopyType` everywhere — but structs with only scalar fields are neither implicit-copy nor no-copy (explicit-copy middle class), so the negation is not equivalent | -| G2 | `NeedsDrop` implemented twice: `typeinfo/capabilities.go:356` and backend `drop_emit.go:303-373` over the IR type table — they agree today, drift tomorrow | -| G3 | Owned interface values: source says no-drop, backend raw-frees through a `TypeOwnedPtr`-to-interface special case — drop policy lives only in the backend | -| G4 | `FuncType` has no ownership character (closure move-only? nothing answers) | -| G5 | `TypeParameterType` treated move-only even when instantiated with a copyable argument — generics over `T` cannot copy | -| G6 | `NoneType` not implicit-copyable — `none` is move-on-use | -| G7 | Enum-payload copyability is non-compositional: same struct copyable as variant payload, move-only standalone (intentional, but must be stated as vocabulary, not accident) | -| G8 | Cycle-guard inconsistency in `IsNoCopyType` (`seen` never deleted on exit) | -| G9 | "Dynamic array owns" encoded three times (implicit-copy, no-copy, needs-drop, plus backend `Length == ""`) | +| # | Gap | Status | +| --- | --- | --- | +| G1 | `IsNoCopyType` is dead in production; move-only is re-derived as `!IsImplicitCopyType` everywhere — but structs with only scalar fields are neither implicit-copy nor no-copy (explicit-copy middle class), so the negation is not equivalent | **Closed.** All three predicates are deleted. `OwnershipCapability.Copy` names the three classes directly, so there is nothing left to negate | +| G2 | `NeedsDrop` implemented twice: `typeinfo/capabilities.go:356` and backend `drop_emit.go:303-373` over the IR type table — they agree today, drift tomorrow | **Closed as misfiled.** They answer different questions over different type universes: ownership decides *whether* a value is dropped and publishes that as a `mir.Drop`; the backend walk only decides how deep to expand one it was already handed, and can never originate a drop. Stated at `typeNeedsDrop` and in [`type-capabilities.md`](type-capabilities.md) | +| G3 | Owned interface values: source says no-drop, backend raw-frees through a `TypeOwnedPtr`-to-interface special case — drop policy lives only in the backend | **Open.** This one is a real policy decision made in the backend, and unlike G2 it is not expansion | +| G4 | `FuncType` has no ownership character (closure move-only? nothing answers) | Open — waits on lambdas existing | +| G5 | `TypeParameterType` treated move-only even when instantiated with a copyable argument — generics over `T` cannot copy | Open — a language decision, not a refactor | +| G6 | `NoneType` not implicit-copyable — `none` is move-on-use | Open — a language decision | +| G7 | Enum-payload copyability is non-compositional: same struct copyable as variant payload, move-only standalone (intentional, but must be stated as vocabulary, not accident) | **Stated.** Carried explicitly as the `enumPayload` flag through `ownershipCapability`, so it is now vocabulary rather than an accident of two predicates disagreeing | +| G8 | Cycle-guard inconsistency in `IsNoCopyType` (`seen` never deleted on exit) | **Closed.** One walk, one guard | +| G9 | "Dynamic array owns" encoded three times (implicit-copy, no-copy, needs-drop, plus backend `Length == ""`) | **Closed on the source side.** One `*ArrayType` case in `capability_walk.go`. The backend still recognises the shape, which is expansion, per G2 | Cleanup machinery fragilities found along the way: diff --git a/docs/compiler-framework/type-capabilities.md b/docs/compiler-framework/type-capabilities.md new file mode 100644 index 00000000..ca69a665 --- /dev/null +++ b/docs/compiler-framework/type-capabilities.md @@ -0,0 +1,126 @@ +# Type capabilities — what got consolidated, and what deliberately did not + +Step 4 of the maintainability plan asks that a type receive **one** canonical +answer per shared semantic question, and that consumers stop walking type +structure themselves. It also draws a line: *centralize only semantic questions +shared by multiple callers, and do not create a giant capability object for +unrelated properties.* + +This document is the inventory that line was drawn against. It exists so the +decisions below are not re-litigated by the next person who notices that two +functions in `typeinfo` look similar. + +## The one consolidation that happened + +Copy class and drop obligation were three separate recursive walkers: + +| Removed | What it answered | +| --- | --- | +| `IsImplicitCopyType` | does this duplicate on use without an explicit operation | +| `noCopyType` | does this refuse duplication entirely | +| `NeedsDrop` | does this carry a destructor obligation | + +All three walked the same structure with the same cycle guard, and every +consumer asked at least two of them about the same type. They are now one +traversal, `ownershipCapability` in `capability_walk.go`, returning +`OwnershipCapability{Copy, Drop}`. `OwnershipCapabilityOf` is the only public +spelling; there are no `IsX` wrappers left behind for old callers. + +The quantifiers differ inside that one walk and could not be collapsed further: +implicit copy is *for all* members, while no-copy and drop are *there exists*. +A `enumPayload` flag carries the one context the answer depends on — a struct +copies implicitly as an enum payload but never as top-level bulk storage. + +Equivalence with the three predicates was proven by a differential test over 283 +constructed types, then frozen as the `capabilityGolden` table so the answers +cannot drift silently. + +**Consumers:** 20 non-test call sites, in `typechecker`, `ownership`, and +`hir/lower`. + +## Already consolidated before this step + +`ContainsReference`, `ContainsStoredReference`, `ContainsAbstractSelf`, +`ContainsTypeParameter`, `ContainsInvalid` and `ContainsNamedEnum` are six +five-line wrappers over one shared `containsType` walker in `relations.go`, each +supplying a traversal mode and a predicate. That is the target shape, reached +already. Nothing to do. + +## Deliberately not consolidated + +### Sized and Lowerable + +These two are the tempting merge: both recursive, both over the same structure, +both with a cycle guard. They must stay apart, and the reason is not style. + +| | `IsSizedType` | `IsLowerableType` | +| --- | --- | --- | +| Guard key | `*DefinedType` | underlying `Type` | +| Answer on a cycle | `false` — a type containing itself inline has no size | depends on how the cycle was reached; through a pointer it is representable | +| Context parameter | none | `throughIndirection` | +| Interface | not sized | lowerable | +| Type parameter | sized | not lowerable | + +A linked list is lowerable and not sized. One traversal cannot hold both answers +without carrying two guards and two cycle rules, at which point it is two +walkers sharing a function body. Copy and drop merged because they genuinely +share a walk and a cycle rule; these do not. + +This is recorded as a comment above `IsSizedType` so the argument travels with +the code. + +**Consumers:** `IsSizedType` 1, `IsLowerableType` 6. + +### The shallow predicates + +`IsIntegral`, `IsArithmetic`, `IsOrderable`, `IsEquatable` and `IsCondition` are +4–10 lines each, non-recursive, one switch over primitive kinds. They answer +unrelated questions — operator admissibility, not ownership — and merging them +is precisely the "giant capability object for unrelated properties" the step +forbids. They stay as they are. + +**Consumers:** 13, 4, 1, 1, 3 respectively. + +### Reference target, collection shape, target width + +- `ReferenceTarget` is already a single accessor with 37 consumers. No duplicate + exists to remove. +- Collection shape is not a derived question at all: `ArrayShape` is a field on + `ArrayType`, decided when the type is built. +- Target-width representability is `LiteralFitsType`, one function, 4 consumers, + no second implementation. + +## Remaining structural traversal in the backend, and why it stays + +`internal/backend/llvm/drop_emit.go` has `typeHasRuntimeProperty`, reached +through `typeNeedsDrop`, `typeCarriesAllocatorID` and `typeNeedsRawFreeID`. It +recurses over types and asks about drops, which reads at a glance like a second +implementation of the source-level obligation. It is not, and the difference is +structural rather than a matter of trust: + +- It reads `ir.TypeTable`, the **lowered** type universe, not `typeinfo.Type`. + It can see representation choices no source type mentions, and it cannot see + source policy such as the explicit-copy class. +- It never decides *whether* a value is dropped. That decision arrives already + made, as a `mir.Drop` instruction. `emitDrop` is entered from exactly one + place — `emitter.go:311`, handling a `mir.Drop` — and every other call to + `emitDropValue` in the file is this traversal recursing into a drop that was + already ordered upstream. +- Its one non-drop caller decides an ABI shim: a declared-only function whose + signature carries owned storage needs one. Also physical. + +So ownership decides policy and the backend expands representation. A type can +be reachable in the backend walk without carrying a source-level drop +obligation, and neither side is wrong. The boundary is now stated in a comment +above `typeNeedsDrop`, including what to do if you ever find yourself wanting it +to answer the policy question: you are in the wrong phase. + +This closes gap **G2** in `ownership-vocabulary.md`, which recorded the two as +"implemented twice … they agree today, drift tomorrow". They are not two +implementations of one question; they are one policy decision and one structural +expansion, and only the policy side can originate a drop. + +## Behavior changes + +None. The consolidation was proven equivalent against the predicates it +replaced before they were deleted, and no consumer's answer changed. diff --git a/internal/backend/llvm/drop_emit.go b/internal/backend/llvm/drop_emit.go index 6e77acff..5582a8ae 100644 --- a/internal/backend/llvm/drop_emit.go +++ b/internal/backend/llvm/drop_emit.go @@ -300,6 +300,25 @@ const ( typePropertyNeedsRawFree ) +// typeNeedsDrop is not a second implementation of the source-level drop +// obligation, and it must not become one. +// +// typeinfo.OwnershipCapabilityOf decides *whether a value is dropped at all*. +// That decision reaches here already made, as a mir.Drop instruction; emitDrop +// is only ever entered from one, and every other call below is this function +// recursing into a drop that was already ordered. What it answers is narrower: +// given that this value is being dropped, does its runtime representation hold +// anything worth walking into. It reads the lowered ir.TypeTable, so it can see +// representation choices that no source type mentions, and it cannot see source +// policy such as an explicit-copy class. +// +// The other caller decides an ABI shim rather than a drop: a declared-only +// function whose signature carries owned storage needs one. Also physical. +// +// So a type may be reachable here and not carry a source-level drop obligation +// without either side being wrong. If you ever need this to answer "should this +// be dropped", you are in the wrong phase: ownership owns that, and the answer +// belongs in the mir.Drop it emits. func typeNeedsDrop(types *ir.TypeTable, id ir.TypeID) bool { return typeHasRuntimeProperty(types, id, typePropertyNeedsDrop, make(map[ir.TypeID]bool)) } diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index 742eee47..27c38157 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -88,6 +88,24 @@ func IsCondition(t Type) bool { return ok } +// IsSizedType and IsLowerableType are deliberately separate walkers, and a +// consolidation pass should leave them that way. +// +// They look alike — both recurse over the same structure with a cycle guard — +// but they share neither the guard nor its meaning. Sized keys its guard on +// *DefinedType and answers false on a cycle, because a type that contains +// itself inline has no size. Lowerable keys on the underlying type and answers +// whatever the recursion reached it through, because a self-referential type +// *is* representable when the cycle passes through a pointer. A linked list is +// lowerable and not sized, and one traversal cannot hold both answers without +// carrying two guards. +// +// They also disagree on ordinary types for real reasons: an interface has no +// inline size but does lower, and a type parameter is sized before +// instantiation but has nothing to emit. +// +// Copy and drop were merged into one traversal because they genuinely share a +// walk and a cycle rule. These do not. func IsSizedType(t Type) bool { visiting := make(map[*DefinedType]bool) var check func(Type) bool diff --git a/task.md b/task.md new file mode 100644 index 00000000..be4d7b3e --- /dev/null +++ b/task.md @@ -0,0 +1,655 @@ +# Compiler Maintainability Migration Handoff + +## Objective + +Make Peeper compiler clean, readable, and difficult to extend incorrectly. + +When contributor adds or changes syntax that uses a symbol, contributor should +declare semantic meaning once. Existing infrastructure should then handle ordinary +reads, writes, moves, copies, borrows, initialization, usage, and cleanup without +new feature-specific logic in every analysis. + +Goal is not zero edits. Syntax, scope, type rules, genuinely new control flow, and +new runtime representation still need explicit owners. Goal is eliminating repeated +decisions and silent omissions. + +No new Peeper language feature is part of this task. Do not add catch, error-union +syntax, or copy another language's syntax or semantics. Existing optional, enum, +loop, call, match, ownership, and default-argument behavior are validation cases, +not feature targets. + +## Mandatory workflow + +Follow `AGENTS.md`, `RULES.md`, and `go-style.md`. Repository rules override this +handoff. + +1. Read this file and `compiler-maintainability.localplan.md` completely. +2. Work one numbered step only after explicit maintainer approval. +3. Before each patch, re-run full `AGENTS.md` pre-patch gate against live source and + current diff. +4. After each patch, run post-patch gate and record explicit Rules check in local + plan. +5. Keep `compiler-maintainability.localplan.md` complete. Never commit it. +6. Do not commit, push, create/update PR, merge, or change GitHub tracking without + explicit maintainer authorization. +7. Preserve unrelated changes. Stop if unexpected work overlaps planned files. +8. Use `rg`/`rg --files` first. Use `apply_patch` for hand edits. +9. Verify behavior from current source and tests, not only docs or donor commits. +10. Do not proceed to next step when current step is merely compiling. Meet its + validation and review stop condition. + +## Branch policy + +Plan was produced on `docs/compiler-framework`. That branch contains useful work +mixed with broad experimental migration. It is read-only donor, not implementation +trunk. + +Before Step 1 implementation: + +- run `git status --short --branch`; +- record current HEAD and `origin/main` SHA; +- create `feature/compiler-maintainability` from verified `origin/main`; +- preserve untracked `task.md` and ignored + `compiler-maintainability.localplan.md`; +- inspect donor commits with `git show`; +- reproduce only approved behavior; +- do not merge or wholesale cherry-pick `docs/compiler-framework`; +- do not fetch, rebase, reset, stash, clean, delete, or rewrite history without + permission. + +## Architectural contract + +### Each entity owns one job + +| Entity | Owns | Must not own | +| --- | --- | --- | +| AST node | Parsed shape, location, canonical children | Type/ownership/backend policy | +| Module identity | Stable source/import identity | Phase-local semantic facts | +| NodeID | Syntax occurrence identity | Symbol/storage meaning | +| SymbolID | Declaration/binding identity | Flow state | +| Place/origin | Storage root and projections | Typechecking or cleanup policy | +| Type model | Source type structure and nominal identity | Per-use flow state | +| Type capability | Canonical derived copy/move/drop/etc. answer | Physical backend emission | +| Typechecker result | Resolved semantic decisions needing type knowledge | CFG topology | +| CFG | Blocks, sites, edges, reachability | Type or ownership re-derivation | +| Semantic operation | Ordered read/write/move/borrow/define/discard meaning | Analysis-specific state | +| Definite initialization | Initialized-state dataflow and diagnostics | Ownership/drop policy | +| Ownership | Move/loan/liveness state and cleanup decisions | Syntax parsing, physical layout | +| Usage | Used/unused accounting and warnings | Ownership state | +| Cleanup plan | Exact source-level destruction sites | Backend layout traversal | +| HIR/MIR | Lowering established evidence | Rediscovering source semantics | +| Backend layout | Physical representation | Source semantic acceptance | +| Artifact validator | Shape and cross-reference invariants | Duplicate analysis algorithm | + +### Desired flow + +```text +source + -> AST + -> binding/resolution artifacts + -> typechecker artifact + published semantic meaning + -> CFG artifact + -> flow-refined artifact + -> definite initialization / ownership / usage over CFG + semantic meaning + -> cleanup plan + -> HIR + -> MIR + -> backend +``` + +No lower phase reaches backward to recompute an upstream decision when explicit +evidence can cross boundary. + +### Realistic feature-extension target + +For future syntax node that evaluates value then stores it in symbol: + +```text +required feature-specific work: + parser shape + resolver scope/binding + typechecker rule and semantic publication + CFG only if control flow differs + HIR only if existing lowering shapes cannot represent it + +automatic common work: + symbol reads and writes + definite initialization + copy/move selection already published by typechecker + borrow conflicts + used/unused accounting + cleanup planning + MIR/backend when existing operations suffice +``` + +If future construct maps to existing semantic operations but still needs custom +ownership, definite-init, usage, or backend cases, migration is incomplete. + +## Hard design constraints + +- No pass-through wrappers or old names forwarding to new names. +- No ignored parameters retained to avoid caller updates. +- No duplicate maps during migration after one slice completes. +- No `map[type]any`, `Publish[T]`, service locator, global registry, reflection, or + generic phase engine. +- No visitor method on every AST node for each compiler phase. +- No phase behavior stored on syntax nodes. +- No new helper/package/file unless it removes current duplication, protects + invariant, or represents real phase/lifetime boundary. +- No one-field organizational struct. +- No backend source-policy decision. +- No validator that reimplements ownership or dataflow. +- No refactor mixed with newly discovered behavior change. Report bug and request + separate approval. +- No Go 1.27 upgrade in this task. Existing Go generics may be used only when same + implementation truly works across current types and improves clarity. +- No language syntax or semantics copied from external languages. + +## Donor map + +Inspect these commits only when their step is active: + +| Commit | Useful evidence | Known caution | +| --- | --- | --- | +| `0d1f4ee` | explicit binding/typechecking result ownership | broad 48-file migration; split smaller | +| `03b4004` | canonical module identity and constant result | do not combine with Step 1 | +| `de8185a` | AST dispatch contract | test-tier, not compile-tier | +| `18b4800` | child traversal enforcement | structural check can be fooled; mutation-test it | +| `26600b0` | attribute/substructure traversal hardening | keep recovery semantics | +| `8ad69ae` | ownership capability vocabulary | final code still composes three walkers | +| `c8eca73` | published value-use kinds | incomplete coverage; validator only checks some uses | +| `c687b50` | cleanup-plan consolidation | verify every removed channel behavior | +| `52ff007` | ownership artifact validator | does not prove exactly-once drop paths | +| `7ec06e9` | removal of unused result channel | confirm no consumer before deletion | +| `7b11a33` | CFG topology validation | validator distinct from CFG analysis | +| `6fa713a` | closed iteration evidence | useful valid-state model; avoid ceremonial interfaces | +| `2a5fcf0` | sealed MIR node families | does not itself enforce backend dispatch completeness | + +## Validation baseline + +Use isolated cache because normal ccache/cache paths can be read-only: + +```bash +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go vet ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go build ./... +git diff --check +``` + +When packaging or source behavior can be affected: + +```bash +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +``` + +Run focused packages first, then full suite. Record command, exit code, and meaningful +output. Environment failures stay separate from source failures. + +## Step 1 - Extract typechecker-owned result from mixed semantic storage + +### Goal + +Create one explicit typechecker generation artifact on fresh branch from +`origin/main`. This is first narrow slice of phase-result migration, not full donor +commit replay. + +### Why first + +Current `origin/main` `project.SemanticInfo` mixes binding, resolution, +typechecking, constant, method-index, and operation-catalog state. Common semantic +publication cannot be reliable until its owner and reset lifetime are clear. + +### Pre-change inspection + +Answer in local plan before editing: + +1. Every `SemanticInfo` field and all writers/consumers. +2. Exact phase that first makes each field valid. +3. Whether field is base typechecking, flow-refined, binding/resolution, + constant-evaluation, or shared catalog state. +4. Which fields donor `typecheckresult.Result` moved. +5. Which donor moves were later corrected; inspect subsequent commits touching + `typecheckresult`, `bindingresult`, and `project.Module`. +6. Reset behavior in `Module.resetToPhase`, `ResetSemanticData`, and + `CompilerContext.ResetModule`. +7. LSP/invalid-source paths that read partial typechecking evidence. +8. Eager const-evaluation paths that run before typechecking completes. + +### Approved Step 1 implementation boundary + +Move only facts produced as base typechecker decisions and consumed downstream. +Expected candidates, subject to live proof: + +```text +ExprTypes +CaseTests +Matches +ImplicitConversions +ImplicitCallArguments +CompilerCalls +StringConcatenations +VariantConstructions +ForIterations +InterfaceImplementations +``` + +Do not move merely because donor moved it. Keep outside this slice unless live +writer/consumer/lifecycle evidence proves typechecker ownership: + +```text +BlockScopes +ResolvedSymbols +ExpandedDefaultBindings +ConstValues +MethodSets +MethodSymbol +OperationFunctions +``` + +### Required implementation behavior + +- Add dedicated typechecker result only if it owns several coherent facts. +- Constructor initializes all required maps and protects valid empty state. +- Typechecker owns creation/population. +- Consumers read new owner directly; no old-field fallback. +- Remove migrated fields from old mixed structure in same slice. +- Move associated data models with their owner; no type aliases. +- Keep base type evidence distinct from flow-refined evidence. +- Preserve nil/partial behavior before typecheck and during invalid source. +- Preserve default-argument expansion NodeID provenance. +- Preserve eager consteval behavior when typechecker result is absent/incomplete. +- Preserve semantic export fingerprint and LSP hover/completion behavior. +- Update reset lifecycle atomically: retain through its producing phase, clear on + reset before that phase. +- Add lifecycle tests for nil, initialized-empty, populated, retained, and cleared + states. +- Do not introduce generic result container. +- Do not move binding/resolution fields as incidental cleanup. + +### Required validation + +At minimum: + +```bash +gofmt -w +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./internal/semantics/typechecker ./internal/semantics/consteval ./internal/project ./internal/pipeline ./internal/lsp +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -race -count=1 ./internal/project ./internal/pipeline ./internal/lsp +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +git diff --check +git status --short --branch +``` + +### Step 1 stop condition + +Stop for Codex review. Report: + +- branch and base SHA; +- field producer/consumer/lifecycle table; +- exact fields moved and explicitly deferred; +- all old reads/writes removed; +- reset and partial-source behavior; +- diff stat; +- exact validation results; +- mandatory Rules check. + +Do not begin Step 2. Do not commit. + +## Step 2 - Finish explicit phase artifacts and canonical identity + +### Goal + +Give remaining semantic facts one honest owner and stable lifetime, then replace +duplicated module identity with canonical identity. + +### Work slices + +This step is too broad for one patch. Claude must propose ordered sub-slices from +live inventory and wait for approval before each: + +1. binding/collection/resolution generation artifact; +2. constant result versus query/cache state; +3. remaining shared catalogs whose multi-writer lifecycle is genuine; +4. canonical module identity and indexes; +5. reset/invalidation convergence. + +### Requirements + +- One fact stored once. +- Producer, consumers, valid-from phase, reset-before phase documented. +- Reuse `NodeID`, `SymbolID`, module identity, and existing binding indexes. +- Keep generated/default provenance paired with binding identity. +- No name/path/pointer reconstruction when stable ID exists. +- No compatibility field, shadow map, or stale alias. +- No false phase split: if collector/binder/resolver truly build one staged symbol + graph with same lifetime, keep coherent result and document multi-writer contract. +- Directly update all consumers. +- Preserve diagnostics discard alongside artifact reset. +- Preserve incremental module reuse and dependency invalidation. + +### Stop condition + +After each approved sub-slice, show artifact ownership matrix and reset tests. Do not +continue automatically. + +## Step 3 - Canonical traversal and exhaustive phase decisions + +### Goal + +Adding AST statement/expression/type kind or child must create immediate named +failures at every phase requiring a decision. + +### Requirements + +- Reuse existing `ast.Inspect`, `ir.InspectExpr`, `ir.InspectPlace`, and + `hir.InspectStmt`; do not add parallel visitors. +- AST node owns canonical children only. +- Do not add resolver/typechecker/ownership methods to AST nodes. +- Add or transplant structural child-field tests only after understanding recovery + nodes, substructures, attributes, and generated/default AST. +- Add phase-dispatch contracts for real switch sites. +- Missing node must be handled or explicitly classified: + +```text +traverse +ignore +reject +contextual +``` + +- Every omission classification needs concrete reason. +- Test must fail when stale classification remains after real handler added. +- Mutation-prove each contract: remove one real case/child locally, capture expected + failure, restore probe before stop. +- Do not claim compile-time exhaustiveness from source-inspection tests. +- Evaluate compile-tier sealed interfaces only if boilerplate and phase-state costs + are lower than current checked-table approach. Do not implement visitor spike by + default. + +### Stop condition + +Produce matrix of node families versus phase sites, with guard strength: +automatic, visible test, loud runtime validation, or manual gap. + +## Step 4 - Canonical type capabilities + +### Goal + +Types receive one canonical derived answer for each shared semantic capability. +Consumers stop independently walking type structure. + +### Inventory first + +Audit current predicates and every caller, including: + +```text +copy / explicit-copy / no-copy +needs-drop +sized / lowerable +equatable / orderable +contains-reference / contains-stored-reference +pointer/reference target +collection shape +target-width representability +backend structural drop/layout traversal +``` + +### Requirements + +- Centralize only semantic questions shared by multiple callers. +- One recursive walker may return several tightly coupled ownership properties when + they share traversal and invariants. +- Do not create giant capability object for unrelated properties. +- Preserve aliases, nominal types, generic instantiation, recursive types, cycle + guards, aggregates, enums/optionals, interfaces, functions, arrays/slices, + ownership carriers, and invalid types. +- Update consumers directly and delete replaced walkers. +- Do not leave `IsX` wrappers around canonical function solely for old callers. +- Backend layout traversal may remain when it answers physical emission rather than + source policy. Rename/comment/test boundary if unclear. +- Type capability does not own per-expression use decision. +- Add exhaustive current-type table tests and recursion tests. +- If consolidation reveals inconsistent existing behavior, report it. Do not choose + new language semantics inside refactor. + +### Stop condition + +Show old/new behavior table, removed duplicate walkers, consumers, and remaining +backend-only traversal with justification. + +## Step 5 - Publish semantic operations once + +### Goal + +Represent common effect of existing syntax using small stable vocabulary so later +analyses do not each inspect AST shape. + +### Inventory + +Trace existing behavior for: + +- declarations with and without initializers; +- assignment and replacement; +- return values; +- expression statements/discarded values; +- conditions; +- calls, implicit receivers, piped calls, default arguments, intrinsics; +- address/shared/mutable borrow; +- selector/index/deref places; +- variant construction, case tests, match payload bindings; +- loops, break, continue, scope exits; +- explicit free/drop operations; +- string/array/collection operations. + +Record exact evaluation order and which phase currently decides read/copy/move. + +### Vocabulary constraints + +Candidate concepts—not mandated names: + +```text +UseValue(Read | Copy | Move) +DefinePlace +ReadPlace +WritePlace +BorrowPlace(shared | mutable) +UnpackPayload +DiscardValue +``` + +- Add operation only when current behavior needs it. +- Operation stores stable `NodeID`/`SymbolID`/place identity and diagnostic location, + not raw syntax pointer when avoidable. +- Typechecker publishes type-dependent decisions. +- Purely structural decisions may be normalized after CFG if that avoids redundant + evidence without losing completeness. +- Preserve ordered evaluation. +- Result owner and reset phase explicit. +- Missing required operation for valid accepted source fails validator. +- Invalid source uses deliberate recovery, not false compiler bug. +- Avoid one struct with unrelated optional fields; use valid closed shapes where + they clarify real alternatives. +- Do not create a framework package until boundary has multiple real producers or + consumers. + +### Pilot requirement + +Choose one existing construct whose symbol effects are currently repeated across at +least two analyses. Migrate producer plus one consumer only. Prove parity, stop, and +request review before expanding. + +### Stop condition + +Show old repeated decisions, new canonical publication, operation ordering tests, +validator behavior, and exact remaining consumers. + +## Step 6 - Convert dataflow consumers + +### Goal + +Definite initialization, ownership, and usage become separate state machines over +shared CFG sites and semantic operations. + +### Requirements + +- Migrate one construct family and one consumer per approved slice. +- Delete old AST switch/re-derivation only after parity tests pass. +- Definite initialization owns initialized-state lattice and diagnostics. +- Ownership owns liveness, moves, loans, conflicts, and cleanup decisions. +- Usage owns used/unused accounting and warnings. +- Share operation stream, not analysis state or diagnostics. +- Preserve CFG reachability, joins, loops/fixed points, and branch-local state. +- Preserve stable-place overlap and alias invalidation. +- Preserve unreachable-source semantics when a warning is syntax/typecheck-owned. +- Preserve exact source locations and diagnostic codes/messages unless separately + approved. +- New existing operation kind must force every consumer to handle, reject, or + explicitly ignore it. +- No default switch branch that silently does nothing. + +### Completion test + +Use an existing symbol-bearing construct as extension simulation. Modify only its +upstream normalization to emit already-known operations; confirm migrated consumers +need no construct-specific code. + +Do not add source syntax. + +## Step 7 - Cleanup and lowering convergence + +### Goal + +One cleanup plan owns source destruction decisions. HIR, MIR, and backend consume +published facts without rebuilding source semantics. + +### Requirements + +- Inventory every drop channel, flag, temporary cleanup path, and explicit free. +- Distinguish programmer-requested free, source cleanup plan, and MIR temporary + destruction. +- Remove write-only/dead/duplicate cleanup channels after consumer audit. +- Preserve evaluation-before-unwind and reverse lexical cleanup order. +- Preserve return, assignment replacement, discarded value, projection base, + match/payload, loop exit, and scope exit behavior. +- Ownership publishes decisions at stable NodeID/SiteID. +- HIR/MIR lower exact evidence. +- Backend only emits physical destruction for supplied MIR/layout. +- Backend may recursively traverse layout to emit nested destruction; it cannot + decide source liveness or invent missing cleanup. +- Avoid moving HIR earlier in pipeline if ownership still needs typed AST/CFG and + established phase order. +- Add exactly-once runtime regressions for owned nested values and every changed + cleanup path. + +### Stop condition + +Provide decision/emission ownership map and prove no duplicate policy remains in +touched paths. + +## Step 8 - Artifact validators and omission proof + +### Goal + +Normal `go test ./...` and pipeline execution catch missing contributor work. + +### Requirements + +- Each phase artifact documents producer, consumers, valid-from phase, reset rule, + and validator. +- Validate keys reference current AST nodes, symbols, CFG sites, or types. +- Validate legal operation/use/capability combinations. +- Validate CFG topology separately from user-facing CFG analysis. +- Validate cleanup keys and result generation; do not duplicate ownership dataflow + to “prove” exactly-once behavior. +- Seal IR/MIR node families where useful. +- Add dispatch contracts where Go cannot provide exhaustiveness. +- Add result reset/invalidation tests. +- Mutation-prove guards with temporary probes, then remove probes. +- Create no test-only production API. +- Simulate addition of a node, type, and semantic operation in tests/build probes. + Record which failure guides contributor at each missing step. +- Explicitly list any remaining unguarded work. + +### Required output + +```text +change kind | required decision | owner | guard | failure message | manual risk +``` + +## Step 9 - Final maintainability convergence + +### Goal + +Prove architecture is easier to understand and requires fewer repeated edits. + +### Audit + +- Every touched function still matches name, parameters, return, and behavior. +- No pass-through wrappers, stale aliases, ignored params, duplicate maps, shadow + registries, or temporary bridges. +- No files/modules split only for appearance. +- No unrelated refactor. +- No source behavior change hidden inside architecture work. +- AST traversal is canonical. +- Semantic facts published once. +- Identity stable across phases. +- Analyses consume operations, not source syntax, when semantics are common. +- Cleanup has one source decision owner. +- HIR/MIR/backend do not re-derive upstream facts. +- Invalid source/LSP recovery remains safe. +- Docs match live source and name honest enforcement gaps. + +### Comparison report + +Choose at least three existing change shapes: + +1. symbol-bearing statement/expression; +2. composite/owned type; +3. control-flow construct. + +For each, show before/after required files and decisions. Do not claim fewer touches +when same work merely moved behind wrappers or generated tables. + +### Full validation + +```bash +gofmt -w +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -race -count=1 ./internal/project ./internal/pipeline ./internal/lsp ./internal/semantics/... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go vet ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go build ./... +GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" GOCACHE=/tmp/peeper-maintainability-go-cache CCACHE_DISABLE=1 go test -count=1 ./x_test +git diff --check +git status --short --branch +``` + +### Final stop condition + +Return phase-owner map, omission matrix, before/after change paths, validation +evidence, Rules check, and honest remaining risks. Wait for explicit commit and +publication approval. + +## Codex review contract + +Codex will review Claude output after every approved step against: + +- live base SHA and exact diff; +- approved step boundary; +- producer/consumer/reset ownership; +- all touched functions, fields, aliases, parameters, and behavior; +- wrapper/duplication/helper rules; +- diagnostics and invalid-source/LSP recovery; +- stable identity and absence of re-derivation; +- evaluation order, CFG topology, flow joins, moves, loans, and cleanup; +- HIR/MIR/backend phase discipline; +- focused/full/race/vet/build/bundle/source-fixture results; +- unrelated work and Git publication state. + +## Initial authorization + +Only Step 1 is authorized. Complete Step 1, update +`compiler-maintainability.localplan.md`, and stop for Codex review. Do not start +Step 2. Do not commit. From a67c129c5ecadd22f859397b81835afd44f29ec4 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 08:29:23 +0000 Subject: [PATCH 69/80] Unify compiler structural and semantic infrastructure Centralize type structure, graph topology, worklist scheduling, place projection, and semantic effects. Make ownership consume effect evidence and make semantic-effect consumers exhaustive through a visitor contract. --- internal/contracts/node_dispatch_test.go | 43 +-- internal/contracts/type_dispatch_test.go | 25 +- internal/graph/directed.go | 213 +++++++++++++ internal/graph/directed_test.go | 44 +++ internal/graph/graph.go | 190 ++--------- internal/graph/worklist.go | 51 +++ internal/graph/worklist_test.go | 25 ++ internal/ir/cfg/analyze.go | 18 +- internal/ir/cfg/build.go | 49 ++- internal/ir/cfg/cfg_test.go | 25 +- internal/ir/cfg/model.go | 38 ++- internal/ir/cfg/validate.go | 52 +-- internal/ir/cfg/validate_test.go | 40 ++- internal/pipeline/pipeline.go | 1 + internal/project/export_fingerprint.go | 2 +- internal/project/export_fingerprint_test.go | 14 - .../semantics/definiteinit/initialization.go | 111 ++++--- .../definiteinit/initialization_test.go | 1 + internal/semantics/effect/build.go | 239 +++++++------- internal/semantics/effect/build_test.go | 17 + internal/semantics/effect/model.go | 43 ++- internal/semantics/effect/validate.go | 140 +++++--- internal/semantics/effect/visitor.go | 40 +++ internal/semantics/effect/visitor_test.go | 29 ++ internal/semantics/ownership/effects.go | 298 ++++++++++++++---- internal/semantics/ownership/expr.go | 6 +- internal/semantics/ownership/ownership.go | 186 +++-------- .../semantics/ownership/ownership_test.go | 1 + internal/semantics/ownership/reference.go | 235 +++++++------- internal/semantics/place/addressable.go | 103 ++++-- internal/semantics/symbols/symbol.go | 12 +- internal/semantics/typechecker/flow.go | 54 ++-- internal/semantics/typecheckresult/result.go | 18 ++ internal/semantics/typeinfo/capabilities.go | 137 ++++---- .../semantics/typeinfo/capability_walk.go | 233 +++++++++----- internal/semantics/typeinfo/relations.go | 82 ++--- internal/semantics/typeinfo/structure.go | 172 ++++++++++ internal/semantics/typeinfo/structure_test.go | 107 +++++++ internal/semantics/typeinfo/types.go | 2 + 39 files changed, 1962 insertions(+), 1134 deletions(-) create mode 100644 internal/graph/directed.go create mode 100644 internal/graph/directed_test.go create mode 100644 internal/graph/worklist.go create mode 100644 internal/graph/worklist_test.go create mode 100644 internal/semantics/effect/visitor.go create mode 100644 internal/semantics/effect/visitor_test.go create mode 100644 internal/semantics/typeinfo/structure.go create mode 100644 internal/semantics/typeinfo/structure_test.go diff --git a/internal/contracts/node_dispatch_test.go b/internal/contracts/node_dispatch_test.go index 78c12d86..aacbb165 100644 --- a/internal/contracts/node_dispatch_test.go +++ b/internal/contracts/node_dispatch_test.go @@ -85,16 +85,8 @@ var declarationStatements = map[string]classification{ "BadDecl": {ignore, "the parser never produces BadDecl; it is tolerated for synthetic trees"}, } -// applyStmt now owns only the policy a statement carries beyond evaluating its -// expressions. A statement whose whole effect is evaluating something has -// nothing left to do there: the published effects carry it. -const evaluatedFromEffectsReason = "the condition or subject is evaluated from published effects, and the " + - "statement carries no ownership policy of its own" - const ( - decomposedByCFGReason = "blocks are decomposed by CFG construction and are never a site statement" - elsePositionReason = "parseIfStmt produces only a block or else-if in else position; anything else is an internal invariant violation (the default panics)" - noConditionReason = "carries no branch condition" + elsePositionReason = "parseIfStmt produces only a block or else-if in else position; anything else is an internal invariant violation (the default panics)" ) // omissions returns the site's declared classifications including the shared @@ -130,43 +122,10 @@ var statementSites = []dispatchSite{ "MatchStmt": {reject, elsePositionReason}, }, }, - // The remaining sites run per CFG site, where control flow is already - // decomposed into blocks and edges. They extract the expressions a statement - // evaluates at that site, so statements carrying no expression are inert. - { - file: "semantics/ownership/ownership.go", - fn: "applyStmt", - inertDeclarations: true, - omitted: map[string]classification{ - "BlockStmt": {ignore, decomposedByCFGReason}, - "BadStmt": {ignore, "recovery node carries no ownership effect"}, - "BreakStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, - "ContinueStmt": {ignore, "transfer is a CFG edge, not a site-level ownership effect"}, - "IfStmt": {ignore, evaluatedFromEffectsReason}, - "MatchStmt": {ignore, evaluatedFromEffectsReason}, - }, - }, // The effect producer replaced definiteinit.checkReads as the site that reads // meaning out of a statement. It is exhaustive: every kind has a case, so it // declares no omissions, and a new kind fails here first. {file: "semantics/effect/build.go", fn: "publishStmt"}, - { - file: "semantics/typechecker/flow.go", - fn: "applyConditionEdge", - inertDeclarations: true, - omitted: map[string]classification{ - "BlockStmt": {ignore, noConditionReason}, - "ExprStmt": {ignore, noConditionReason}, - "AssignStmt": {ignore, noConditionReason}, - "ReturnStmt": {ignore, noConditionReason}, - "BadStmt": {ignore, noConditionReason}, - "BreakStmt": {ignore, noConditionReason}, - "ContinueStmt": {ignore, noConditionReason}, - "MatchStmt": {ignore, "match narrowing uses case tests, not a true/false condition edge"}, - "LetDecl": {ignore, noConditionReason}, - "ConstDecl": {ignore, noConditionReason}, - }, - }, } func internalDir(t *testing.T) string { diff --git a/internal/contracts/type_dispatch_test.go b/internal/contracts/type_dispatch_test.go index df1dbd2f..c9b7144b 100644 --- a/internal/contracts/type_dispatch_test.go +++ b/internal/contracts/type_dispatch_test.go @@ -33,21 +33,6 @@ type typeDispatchSite struct { // deliberately absent: their default rejects, so a forgotten kind produces a // diagnostic rather than a wrong answer. var typeKindSites = []typeDispatchSite{ - { - file: "semantics/typeinfo/capability_walk.go", - fn: "ownershipCapability", - why: "how the type copies and whether scope cleanup must destroy it", - omitted: map[string]classification{ - "InvalidType": {ignore, "a recovery type makes no capability claim; invalid source never reaches ownership"}, - "UnknownType": {ignore, "an unresolved type makes no capability claim; resolution replaces it first"}, - "NamedType": {ignore, "a bare name carries no structure to classify; it is replaced by the type it names"}, - "TypeParameterType": {ignore, "conservatively move-on-use with no drop until instantiation-aware " + - "queries arrive with generic support, as OwnershipCapability documents"}, - "FuncType": {ignore, "a function value is a code pointer owning no storage, so the walk's default of " + - "move-on-use with no drop is safe. Whether it should copy implicitly is an open language " + - "question, not a missing case"}, - }, - }, { file: "semantics/typeinfo/relations.go", fn: "SameType", @@ -57,6 +42,11 @@ var typeKindSites = []typeDispatchSite{ "Underlying peels the definition away for the structural comparison that follows"}, }, }, + { + file: "project/export_fingerprint.go", + fn: "semanticTypeKey", + why: "which stable semantic identity participates in exported API fingerprints", + }, { file: "ir/hir/lower/lower_types.go", fn: "intern", @@ -70,6 +60,11 @@ var typeKindSites = []typeDispatchSite{ }, } +// Semantic Type itself is now the compile-time extension contract: Type is +// sealed by unexported forEachChild and ownershipShape methods, so a new type +// cannot enter semantic code without declaring both canonical structure and +// ownership composition. No source-inspection test is needed for that boundary. + func TestEverySemanticTypeKindHasAPhaseDecision(t *testing.T) { kinds := declaredMarkerKinds(t, "semantics/typeinfo/types.go", "TypeNode") if len(kinds) < 10 { diff --git a/internal/graph/directed.go b/internal/graph/directed.go new file mode 100644 index 00000000..a290b71e --- /dev/null +++ b/internal/graph/directed.go @@ -0,0 +1,213 @@ +package graph + +import "slices" + +// Directed is the canonical directed-graph storage kernel. It owns ordered +// outgoing and incoming edge indexes once; domain graphs keep their semantic +// edge types and layer policy on top of this structure instead of maintaining +// private adjacency stores. +// +// Directed is intentionally not synchronized. Long-lived shared graphs may +// protect it with their own lock, while phase-local graphs such as CFGs avoid +// synchronization they do not need. +type Directed[Node comparable, Edge comparable] struct { + endpoints func(Edge) (Node, Node) + out map[Node][]Edge + in map[Node][]Edge +} + +func NewDirected[Node comparable, Edge comparable](endpoints func(Edge) (Node, Node)) *Directed[Node, Edge] { + if endpoints == nil { + panic("graph: Directed requires an edge endpoint function") + } + return &Directed[Node, Edge]{ + endpoints: endpoints, + out: make(map[Node][]Edge), + in: make(map[Node][]Edge), + } +} + +// AddEdge inserts edge once. Edge identity belongs to the domain edge value; +// two edges with the same endpoints but different semantic metadata are kept. +func (g *Directed[Node, Edge]) AddEdge(edge Edge) bool { + if g == nil { + return false + } + from, to := g.endpoints(edge) + if slices.Contains(g.out[from], edge) { + return false + } + g.out[from] = append(g.out[from], edge) + g.in[to] = append(g.in[to], edge) + return true +} + +// OutEdges returns outgoing edges in insertion order. The returned slice is a +// snapshot so consumers cannot corrupt the graph's reverse index accidentally. +func (g *Directed[Node, Edge]) OutEdges(id Node) []Edge { + if g == nil { + return nil + } + return append([]Edge(nil), g.out[id]...) +} + +// InEdges returns incoming edges in insertion order. +func (g *Directed[Node, Edge]) InEdges(id Node) []Edge { + if g == nil { + return nil + } + return append([]Edge(nil), g.in[id]...) +} + +func (g *Directed[Node, Edge]) Successors(id Node, include func(Edge) bool) []Node { + if g == nil { + return nil + } + result := make([]Node, 0, len(g.out[id])) + for _, edge := range g.out[id] { + if include != nil && !include(edge) { + continue + } + _, to := g.endpoints(edge) + result = append(result, to) + } + return result +} + +func (g *Directed[Node, Edge]) Predecessors(id Node, include func(Edge) bool) []Node { + if g == nil { + return nil + } + result := make([]Node, 0, len(g.in[id])) + for _, edge := range g.in[id] { + if include != nil && !include(edge) { + continue + } + from, _ := g.endpoints(edge) + result = append(result, from) + } + return result +} + +func (g *Directed[Node, Edge]) OutDegree(id Node, include func(Edge) bool) int { + if g == nil { + return 0 + } + return countEdges(g.out[id], include) +} + +func (g *Directed[Node, Edge]) InDegree(id Node, include func(Edge) bool) int { + if g == nil { + return 0 + } + return countEdges(g.in[id], include) +} + +func (g *Directed[Node, Edge]) TopoSort(ids []Node, include func(Edge) bool) ([]Node, [][]Node) { + if g == nil || len(ids) == 0 { + return nil, nil + } + index := make(map[Node]struct{}, len(ids)) + for _, id := range ids { + index[id] = struct{}{} + } + + const ( + visitNone = iota + visitTemp + visitDone + ) + state := make(map[Node]uint8, len(index)) + order := make([]Node, 0, len(index)) + stack := make([]Node, 0, len(index)) + cycles := make([][]Node, 0) + + var visit func(Node) + visit = func(id Node) { + switch state[id] { + case visitTemp: + cycles = append(cycles, extractDirectedCycle(stack, id)) + return + case visitDone: + return + } + state[id] = visitTemp + stack = append(stack, id) + for _, next := range g.Successors(id, include) { + if _, ok := index[next]; ok { + visit(next) + } + } + stack = stack[:len(stack)-1] + state[id] = visitDone + order = append(order, id) + } + + for _, id := range ids { + visit(id) + } + return order, cycles +} + +func (g *Directed[Node, Edge]) WeaklyConnectedComponents(ids []Node, include func(Edge) bool) [][]Node { + if g == nil || len(ids) == 0 { + return nil + } + index := make(map[Node]struct{}, len(ids)) + for _, id := range ids { + index[id] = struct{}{} + } + visited := make(map[Node]struct{}, len(index)) + components := make([][]Node, 0) + for _, start := range ids { + if _, ok := visited[start]; ok { + continue + } + queue := []Node{start} + visited[start] = struct{}{} + component := make([]Node, 0) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + component = append(component, current) + neighbors := g.Successors(current, include) + neighbors = append(neighbors, g.Predecessors(current, include)...) + for _, next := range neighbors { + if _, ok := index[next]; !ok { + continue + } + if _, ok := visited[next]; ok { + continue + } + visited[next] = struct{}{} + queue = append(queue, next) + } + } + components = append(components, component) + } + return components +} + +func countEdges[Edge comparable](edges []Edge, include func(Edge) bool) int { + if include == nil { + return len(edges) + } + total := 0 + for _, edge := range edges { + if include(edge) { + total++ + } + } + return total +} + +func extractDirectedCycle[Node comparable](stack []Node, target Node) []Node { + for i := len(stack) - 1; i >= 0; i-- { + if stack[i] == target { + cycle := append([]Node(nil), stack[i:]...) + cycle = append(cycle, target) + return cycle + } + } + return []Node{target} +} diff --git a/internal/graph/directed_test.go b/internal/graph/directed_test.go new file mode 100644 index 00000000..7a0fa58e --- /dev/null +++ b/internal/graph/directed_test.go @@ -0,0 +1,44 @@ +package graph + +import ( + "reflect" + "testing" +) + +type testDirectedEdge struct { + from string + to string + kind int +} + +func TestDirectedOwnsBothAdjacencyDirections(t *testing.T) { + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + first := testDirectedEdge{from: "a", to: "b", kind: 1} + second := testDirectedEdge{from: "a", to: "b", kind: 2} + if !g.AddEdge(first) || !g.AddEdge(second) || g.AddEdge(first) { + t.Fatal("edge identity should preserve semantic metadata and reject exact duplicates") + } + if got := g.OutEdges("a"); !reflect.DeepEqual(got, []testDirectedEdge{first, second}) { + t.Fatalf("out edges = %#v", got) + } + if got := g.InEdges("b"); !reflect.DeepEqual(got, []testDirectedEdge{first, second}) { + t.Fatalf("in edges = %#v", got) + } +} + +func TestDirectedAlgorithmsShareCanonicalAdjacency(t *testing.T) { + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + g.AddEdge(testDirectedEdge{from: "a", to: "b", kind: 1}) + g.AddEdge(testDirectedEdge{from: "b", to: "c", kind: 1}) + g.AddEdge(testDirectedEdge{from: "x", to: "y", kind: 2}) + includeOne := func(edge testDirectedEdge) bool { return edge.kind == 1 } + + order, cycles := g.TopoSort([]string{"a", "b", "c"}, includeOne) + if len(cycles) != 0 || !reflect.DeepEqual(order, []string{"c", "b", "a"}) { + t.Fatalf("topological result = %v, cycles = %v", order, cycles) + } + components := g.WeaklyConnectedComponents([]string{"a", "b", "c", "x", "y"}, includeOne) + if !reflect.DeepEqual(components, [][]string{{"a", "b", "c"}, {"x"}, {"y"}}) { + t.Fatalf("components = %#v", components) + } +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 0c6dffeb..53d02609 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -1,26 +1,27 @@ package graph -import ( - "slices" - "sync" -) +import "sync" type NodeID string type EdgeKind string +type edge struct { + from NodeID + to NodeID + kind EdgeKind +} + type Graph struct { mu sync.RWMutex edgeKind EdgeKind - out map[NodeID]map[EdgeKind]map[NodeID]struct{} - in map[NodeID]map[EdgeKind]map[NodeID]struct{} + directed *Directed[NodeID, edge] } func New(edgeKind EdgeKind) *Graph { return &Graph{ edgeKind: edgeKind, - out: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), - in: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), + directed: NewDirected(func(edge edge) (NodeID, NodeID) { return edge.from, edge.to }), } } @@ -37,8 +38,7 @@ func (g *Graph) AddEdge(from, to NodeID, kinds ...EdgeKind) { } g.mu.Lock() defer g.mu.Unlock() - g.addEdgeLocked(g.out, from, kind, to) - g.addEdgeLocked(g.in, to, kind, from) + g.directed.AddEdge(edge{from: from, to: to, kind: kind}) } func (g *Graph) Successors(id NodeID, kinds ...EdgeKind) []NodeID { @@ -47,7 +47,7 @@ func (g *Graph) Successors(id NodeID, kinds ...EdgeKind) []NodeID { } g.mu.RLock() defer g.mu.RUnlock() - return successorSnapshot(g.out[id], kindSet(kinds, g.edgeKind)) + return g.directed.Successors(id, g.edgeFilter(kinds)) } func (g *Graph) Predecessors(id NodeID, kinds ...EdgeKind) []NodeID { @@ -56,7 +56,7 @@ func (g *Graph) Predecessors(id NodeID, kinds ...EdgeKind) []NodeID { } g.mu.RLock() defer g.mu.RUnlock() - return successorSnapshot(g.in[id], kindSet(kinds, g.edgeKind)) + return g.directed.Predecessors(id, g.edgeFilter(kinds)) } func (g *Graph) OutDegree(id NodeID, kinds ...EdgeKind) int { @@ -65,7 +65,7 @@ func (g *Graph) OutDegree(id NodeID, kinds ...EdgeKind) int { } g.mu.RLock() defer g.mu.RUnlock() - return degree(g.out[id], kindSet(kinds, g.edgeKind)) + return g.directed.OutDegree(id, g.edgeFilter(kinds)) } func (g *Graph) InDegree(id NodeID, kinds ...EdgeKind) int { @@ -74,7 +74,7 @@ func (g *Graph) InDegree(id NodeID, kinds ...EdgeKind) int { } g.mu.RLock() defer g.mu.RUnlock() - return degree(g.in[id], kindSet(kinds, g.edgeKind)) + return g.directed.InDegree(id, g.edgeFilter(kinds)) } func (g *Graph) TopoSort(ids []NodeID, kinds ...EdgeKind) ([]NodeID, [][]NodeID) { @@ -83,57 +83,7 @@ func (g *Graph) TopoSort(ids []NodeID, kinds ...EdgeKind) ([]NodeID, [][]NodeID) } g.mu.RLock() defer g.mu.RUnlock() - - index := make(map[NodeID]struct{}, len(ids)) - for _, id := range ids { - if id != "" { - index[id] = struct{}{} - } - } - if len(index) == 0 { - return nil, nil - } - - const ( - visitNone = iota - visitTemp - visitDone - ) - - state := make(map[NodeID]uint8, len(index)) - order := make([]NodeID, 0, len(index)) - stack := make([]NodeID, 0, len(index)) - cycles := make([][]NodeID, 0) - allowedKinds := kindSet(kinds, g.edgeKind) - - var visit func(NodeID) - visit = func(id NodeID) { - switch state[id] { - case visitTemp: - cycles = append(cycles, extractCycle(stack, id)) - return - case visitDone: - return - } - state[id] = visitTemp - stack = append(stack, id) - for _, next := range successorSnapshot(g.out[id], allowedKinds) { - if _, ok := index[next]; ok { - visit(next) - } - } - stack = stack[:len(stack)-1] - state[id] = visitDone - order = append(order, id) - } - - for _, id := range ids { - if id != "" { - visit(id) - } - } - - return order, cycles + return g.directed.TopoSort(ids, g.edgeFilter(kinds)) } func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]NodeID { @@ -142,64 +92,18 @@ func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]N } g.mu.RLock() defer g.mu.RUnlock() - - index := make(map[NodeID]struct{}, len(ids)) - for _, id := range ids { - if id != "" { - index[id] = struct{}{} - } - } - if len(index) == 0 { - return nil - } - - allowedKinds := kindSet(kinds, g.edgeKind) - visited := make(map[NodeID]struct{}, len(index)) - components := make([][]NodeID, 0) - for _, start := range ids { - if start == "" { - continue - } - if _, ok := visited[start]; ok { - continue - } - queue := []NodeID{start} - visited[start] = struct{}{} - component := make([]NodeID, 0) - for len(queue) > 0 { - current := queue[0] - queue = queue[1:] - component = append(component, current) - neighbors := successorSnapshot(g.out[current], allowedKinds) - neighbors = append(neighbors, successorSnapshot(g.in[current], allowedKinds)...) - for _, next := range neighbors { - if _, ok := index[next]; !ok { - continue - } - if _, ok := visited[next]; ok { - continue - } - visited[next] = struct{}{} - queue = append(queue, next) - } - } - components = append(components, component) - } - return components + return g.directed.WeaklyConnectedComponents(ids, g.edgeFilter(kinds)) } -func (g *Graph) addEdgeLocked(index map[NodeID]map[EdgeKind]map[NodeID]struct{}, from NodeID, kind EdgeKind, to NodeID) { - edgesByKind, ok := index[from] - if !ok { - edgesByKind = make(map[EdgeKind]map[NodeID]struct{}) - index[from] = edgesByKind +func (g *Graph) edgeFilter(kinds []EdgeKind) func(edge) bool { + allowed := kindSet(kinds, g.edgeKind) + if len(allowed) == 0 { + return nil } - edges, ok := edgesByKind[kind] - if !ok { - edges = make(map[NodeID]struct{}) - edgesByKind[kind] = edges + return func(candidate edge) bool { + _, ok := allowed[candidate.kind] + return ok } - edges[to] = struct{}{} } func kindSet(kinds []EdgeKind, defaultKind EdgeKind) map[EdgeKind]struct{} { @@ -215,53 +119,5 @@ func kindSet(kinds []EdgeKind, defaultKind EdgeKind) map[EdgeKind]struct{} { allowed[kind] = struct{}{} } } - if len(allowed) == 0 { - return nil - } return allowed } - -func successorSnapshot(edgesByKind map[EdgeKind]map[NodeID]struct{}, allowed map[EdgeKind]struct{}) []NodeID { - if len(edgesByKind) == 0 { - return nil - } - result := make([]NodeID, 0) - for kind, edges := range edgesByKind { - if len(allowed) > 0 { - if _, ok := allowed[kind]; !ok { - continue - } - } - for id := range edges { - result = append(result, id) - } - } - return result -} - -func degree(edgesByKind map[EdgeKind]map[NodeID]struct{}, allowed map[EdgeKind]struct{}) int { - if len(edgesByKind) == 0 { - return 0 - } - total := 0 - for kind, edges := range edgesByKind { - if len(allowed) > 0 { - if _, ok := allowed[kind]; !ok { - continue - } - } - total += len(edges) - } - return total -} - -func extractCycle(stack []NodeID, target NodeID) []NodeID { - for i := range slices.Backward(stack) { - if stack[i] == target { - cycle := append([]NodeID{}, stack[i:]...) - cycle = append(cycle, target) - return cycle - } - } - return []NodeID{target} -} diff --git a/internal/graph/worklist.go b/internal/graph/worklist.go new file mode 100644 index 00000000..6c9e150e --- /dev/null +++ b/internal/graph/worklist.go @@ -0,0 +1,51 @@ +package graph + +// Worklist is the canonical FIFO scheduler for fixed-point and graph analyses. +// It deduplicates pending nodes while allowing a node to be scheduled again +// after it has been processed and new information reaches it. +type Worklist[Node comparable] struct { + queue []Node + queued map[Node]struct{} + next int +} + +func NewWorklist[Node comparable](initial ...Node) *Worklist[Node] { + work := &Worklist[Node]{queued: make(map[Node]struct{}, len(initial))} + for _, node := range initial { + work.Add(node) + } + return work +} + +// Add schedules node if it is not already pending. +func (w *Worklist[Node]) Add(node Node) bool { + if w == nil { + return false + } + if _, found := w.queued[node]; found { + return false + } + w.queued[node] = struct{}{} + w.queue = append(w.queue, node) + return true +} + +// Next returns the next pending node. Once returned, that node may be scheduled +// again if a transfer changes one of its inputs. +func (w *Worklist[Node]) Next() (Node, bool) { + var zero Node + if w == nil || w.next >= len(w.queue) { + return zero, false + } + node := w.queue[w.next] + w.next++ + delete(w.queued, node) + if w.next == len(w.queue) { + w.queue = w.queue[:0] + w.next = 0 + } else if w.next > 64 && w.next*2 >= len(w.queue) { + w.queue = append(w.queue[:0], w.queue[w.next:]...) + w.next = 0 + } + return node, true +} diff --git a/internal/graph/worklist_test.go b/internal/graph/worklist_test.go new file mode 100644 index 00000000..199aa84a --- /dev/null +++ b/internal/graph/worklist_test.go @@ -0,0 +1,25 @@ +package graph + +import "testing" + +func TestWorklistDeduplicatesPendingNodesAndAllowsReschedule(t *testing.T) { + work := NewWorklist(1, 2, 1) + if work.Add(2) { + t.Fatal("pending node was scheduled twice") + } + first, ok := work.Next() + if !ok || first != 1 { + t.Fatalf("first = %d, %v", first, ok) + } + if !work.Add(1) { + t.Fatal("processed node should be schedulable again") + } + second, _ := work.Next() + third, _ := work.Next() + if second != 2 || third != 1 { + t.Fatalf("remaining order = %d, %d", second, third) + } + if _, ok := work.Next(); ok { + t.Fatal("worklist should be empty") + } +} diff --git a/internal/ir/cfg/analyze.go b/internal/ir/cfg/analyze.go index c977698c..5395c1fb 100644 --- a/internal/ir/cfg/analyze.go +++ b/internal/ir/cfg/analyze.go @@ -131,7 +131,7 @@ func findMissingReturnBranches(fn *Graph) []*Block { } continue } - queue := append([]*Block(nil), block.Predecessors...) + queue := predecessorBlocks(fn, block) traceSeen := make(map[*Block]bool) for len(queue) > 0 { current := queue[0] @@ -147,13 +147,27 @@ func findMissingReturnBranches(fn *Graph) []*Block { } continue } - queue = append(queue, current.Predecessors...) + queue = append(queue, predecessorBlocks(fn, current)...) } } sortMissingBranches(found) return found } +func predecessorBlocks(fn *Graph, block *Block) []*Block { + if fn == nil || fn.BlockEdges == nil || block == nil { + return nil + } + ids := fn.BlockEdges.Predecessors(block.ID, nil) + blocks := make([]*Block, 0, len(ids)) + for _, id := range ids { + if id >= 0 && id < len(fn.Blocks) { + blocks = append(blocks, fn.Blocks[id]) + } + } + return blocks +} + // structuredControl reports whether a block is part of a structured construct // rather than a plain continuation. Missing-return reporting walks back to the // nearest such block to name the branch that falls through. A loop exit is a diff --git a/internal/ir/cfg/build.go b/internal/ir/cfg/build.go index ce8009f0..2d6b184d 100644 --- a/internal/ir/cfg/build.go +++ b/internal/ir/cfg/build.go @@ -4,6 +4,7 @@ import ( "fmt" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/source" ) @@ -309,11 +310,15 @@ func finalizeGraph(fn *Graph) { } } markReachable(fn.Entry, make(map[int]bool)) - rebuildPredecessors(fn) + rebuildBlockTopology(fn) finalizeSites(fn) } func finalizeSites(fn *Graph) { + if fn == nil { + return + } + fn.SiteEdges = graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) for _, block := range fn.Blocks { if block == nil { continue @@ -339,8 +344,6 @@ func finalizeSites(fn *Graph) { } for index, site := range block.Sites { site.ID = SiteID{Block: block.ID, Index: index} - site.Successors = nil - site.Predecessors = nil } } for _, block := range fn.Blocks { @@ -348,20 +351,20 @@ func finalizeSites(fn *Graph) { continue } for index := 0; index+1 < len(block.Sites); index++ { - connectSites(block.Sites[index], block.Sites[index+1], EdgeNormal, 0) + connectSites(fn, block.Sites[index], block.Sites[index+1], EdgeNormal, 0) } last := block.Sites[len(block.Sites)-1] switch term := block.Terminator.(type) { case *Jump: - connectBlockSite(last, term.Target, EdgeNormal, 0) + connectBlockSite(fn, last, term.Target, EdgeNormal, 0) case *Branch: - connectBlockSite(last, term.TrueTarget, EdgeTrue, 0) - connectBlockSite(last, term.FalseTarget, EdgeFalse, 0) + connectBlockSite(fn, last, term.TrueTarget, EdgeTrue, 0) + connectBlockSite(fn, last, term.FalseTarget, EdgeFalse, 0) case *Return: - connectBlockSite(last, fn.Exit, EdgeReturn, 0) + connectBlockSite(fn, last, fn.Exit, EdgeReturn, 0) case *SwitchVariant: for _, target := range term.Targets { - connectBlockSite(last, target.Target, EdgeVariantCase, target.Case) + connectBlockSite(fn, last, target.Target, EdgeVariantCase, target.Case) } case nil: default: @@ -370,25 +373,18 @@ func finalizeSites(fn *Graph) { } } -func connectBlockSite(from *Site, target *Block, kind EdgeKind, caseIndex int) { +func connectBlockSite(fn *Graph, from *Site, target *Block, kind EdgeKind, caseIndex int) { if target == nil || len(target.Sites) == 0 { return } - connectSites(from, target.Sites[0], kind, caseIndex) + connectSites(fn, from, target.Sites[0], kind, caseIndex) } -func connectSites(from, to *Site, kind EdgeKind, caseIndex int) { - if from == nil || to == nil { +func connectSites(fn *Graph, from, to *Site, kind EdgeKind, caseIndex int) { + if fn == nil || fn.SiteEdges == nil || from == nil || to == nil { return } - for _, existing := range from.Successors { - if existing.To == to.ID && existing.Kind == kind && existing.Case == caseIndex { - return - } - } - edge := Edge{From: from.ID, To: to.ID, Kind: kind, Case: caseIndex} - from.Successors = append(from.Successors, edge) - to.Predecessors = append(to.Predecessors, edge) + fn.SiteEdges.AddEdge(Edge{From: from.ID, To: to.ID, Kind: kind, Case: caseIndex}) } func markReachable(block *Block, seen map[int]bool) { @@ -405,19 +401,18 @@ func markReachable(block *Block, seen map[int]bool) { } } -func rebuildPredecessors(fn *Graph) { - for _, block := range fn.Blocks { - if block != nil { - block.Predecessors = nil - } +func rebuildBlockTopology(fn *Graph) { + if fn == nil { + return } + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) for _, block := range fn.Blocks { if block == nil || block.Terminator == nil { continue } for _, successor := range block.Terminator.Successors() { if successor != nil { - successor.Predecessors = append(successor.Predecessors, block) + fn.BlockEdges.AddEdge(BlockEdge{From: block.ID, To: successor.ID}) } } } diff --git a/internal/ir/cfg/cfg_test.go b/internal/ir/cfg/cfg_test.go index ead9bceb..b5804710 100644 --- a/internal/ir/cfg/cfg_test.go +++ b/internal/ir/cfg/cfg_test.go @@ -88,19 +88,21 @@ func TestBuildModuleCreatesCanonicalSiteAdjacency(t *testing.T) { t.Fatalf("entry sites = %#v, want one branch site", graph.Entry.Sites) } branchSite := graph.Entry.Sites[0] - if branchSite.Kind != SiteTerminator || branchSite.NodeID != 30 || len(branchSite.Successors) != 2 { + branchEdges := graph.SiteEdges.OutEdges(branchSite.ID) + if branchSite.Kind != SiteTerminator || branchSite.NodeID != 30 || len(branchEdges) != 2 { t.Fatalf("branch site = %#v, want two branch successors", branchSite) } wantKinds := map[EdgeKind]bool{EdgeTrue: false, EdgeFalse: false} - for _, successor := range branchSite.Successors { + for _, successor := range branchEdges { wantKinds[successor.Kind] = true site := graph.Blocks[successor.To.Block].Sites[successor.To.Index] - if len(site.Predecessors) != 1 || site.Predecessors[0].From != branchSite.ID || site.Predecessors[0].Kind != successor.Kind { - t.Fatalf("site %#v predecessors = %v, want branch %#v", site.ID, site.Predecessors, branchSite.ID) + predecessors := graph.SiteEdges.InEdges(site.ID) + if len(predecessors) != 1 || predecessors[0].From != branchSite.ID || predecessors[0].Kind != successor.Kind { + t.Fatalf("site %#v predecessors = %v, want branch %#v", site.ID, predecessors, branchSite.ID) } } if !wantKinds[EdgeTrue] || !wantKinds[EdgeFalse] { - t.Fatalf("branch edge kinds = %#v, want true and false", branchSite.Successors) + t.Fatalf("branch edge kinds = %#v, want true and false", branchEdges) } } @@ -116,10 +118,11 @@ func TestFinalizeSitesLabelsVariantCaseEdges(t *testing.T) { }} graph := &Graph{Entry: entry, Exit: &Block{ID: 3}, Blocks: []*Block{entry, first, second}} finalizeSites(graph) - if len(entry.Sites) != 1 || len(entry.Sites[0].Successors) != 2 { + edges := graph.SiteEdges.OutEdges(entry.Sites[0].ID) + if len(entry.Sites) != 1 || len(edges) != 2 { t.Fatalf("switch sites = %#v", entry.Sites) } - for caseIndex, edge := range entry.Sites[0].Successors { + for caseIndex, edge := range edges { if edge.Kind != EdgeVariantCase || edge.Case != caseIndex { t.Fatalf("switch edge %d = %#v", caseIndex, edge) } @@ -159,8 +162,8 @@ func TestBuildModuleCreatesSemanticVariantSwitchAndSharedJoin(t *testing.T) { if len(join.Sites) == 0 || join.Sites[0].NodeID != 40 { t.Fatalf("match join sites = %#v, want following statement", join.Sites) } - if len(graph.Entry.Sites) != 1 || len(graph.Entry.Sites[0].Successors) != 2 || - graph.Entry.Sites[0].Successors[0].Case != 1 || graph.Entry.Sites[0].Successors[1].Case != 0 { + caseEdges := graph.SiteEdges.OutEdges(graph.Entry.Sites[0].ID) + if len(graph.Entry.Sites) != 1 || len(caseEdges) != 2 || caseEdges[0].Case != 1 || caseEdges[1].Case != 0 { t.Fatalf("match case edges = %#v", graph.Entry.Sites) } } @@ -442,10 +445,10 @@ func TestAnalyzeDoesNotRebuildFinalizedTopology(t *testing.T) { body := &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: 10}} module := BuildModule(testModule(body, nil), BuildQueries{}) graph := module.Functions[0] - before := append([]*Block(nil), graph.Entry.Predecessors...) + before := graph.BlockEdges.InEdges(graph.Entry.ID) graph.Entry.Sites = nil Analyze(module, diagnostics.NewDiagnosticBag(), nil) - if graph.Entry.Sites != nil || !reflect.DeepEqual(graph.Entry.Predecessors, before) { + if graph.Entry.Sites != nil || !reflect.DeepEqual(graph.BlockEdges.InEdges(graph.Entry.ID), before) { t.Fatalf("Analyze mutated finalized topology: entry = %#v", graph.Entry) } } diff --git a/internal/ir/cfg/model.go b/internal/ir/cfg/model.go index 8f84c541..c5b19880 100644 --- a/internal/ir/cfg/model.go +++ b/internal/ir/cfg/model.go @@ -1,6 +1,7 @@ package cfg import ( + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/source" ) @@ -28,6 +29,11 @@ type Graph struct { Entry *Block Exit *Block Blocks []*Block + // SiteEdges is the canonical ordered site topology. Edge values retain CFG + // branch meaning; the shared graph kernel owns forward/reverse adjacency. + SiteEdges *graphcore.Directed[SiteID, Edge] + // BlockEdges is the canonical block topology derived from terminators. + BlockEdges *graphcore.Directed[int, BlockEdge] } // SiteID identifies one ordered semantic program point within a CFG block. @@ -54,6 +60,11 @@ type Edge struct { Case int } +type BlockEdge struct { + From int + To int +} + type SiteKind uint8 const ( @@ -65,13 +76,11 @@ const ( // Site records source identity and lexical scope at one CFG program point. type Site struct { - ID SiteID - Kind SiteKind - NodeID ir.NodeID - ScopeID ir.NodeID - Location *source.Location - Successors []Edge - Predecessors []Edge + ID SiteID + Kind SiteKind + NodeID ir.NodeID + ScopeID ir.NodeID + Location *source.Location } type BlockOrigin uint8 @@ -93,14 +102,13 @@ const ( ) type Block struct { - ID int - NodeID ir.NodeID - Origin BlockOrigin - Location *source.Location - Sites []*Site - Terminator Terminator - Predecessors []*Block - Reachable bool + ID int + NodeID ir.NodeID + Origin BlockOrigin + Location *source.Location + Sites []*Site + Terminator Terminator + Reachable bool } type Terminator interface { diff --git a/internal/ir/cfg/validate.go b/internal/ir/cfg/validate.go index 87f080eb..80e9e0ed 100644 --- a/internal/ir/cfg/validate.go +++ b/internal/ir/cfg/validate.go @@ -3,7 +3,6 @@ package cfg import ( "errors" "fmt" - "slices" "sort" "strings" ) @@ -150,6 +149,9 @@ func validateVariantCases(fn *Graph, block *Block, term *SwitchVariant) []string // same graph as one walking forwards. func validateBlockAdjacency(fn *Graph) []string { problems := make([]string, 0) + if fn.BlockEdges == nil { + return append(problems, fmt.Sprintf("function %d has no block topology", fn.NodeID)) + } forward := make(map[[2]int]bool) for _, block := range fn.Blocks { if block.Terminator == nil { @@ -161,24 +163,20 @@ func validateBlockAdjacency(fn *Graph) []string { } } } - recorded := make(map[[2]int]bool) for _, block := range fn.Blocks { - for _, predecessor := range block.Predecessors { - if predecessor == nil { - problems = append(problems, fmt.Sprintf("function %d block b%d lists a nil predecessor", fn.NodeID, block.ID)) - continue + for _, edge := range fn.BlockEdges.OutEdges(block.ID) { + pair := [2]int{edge.From, edge.To} + if edge.From != block.ID { + problems = append(problems, fmt.Sprintf("function %d block b%d owns an edge leaving b%d", fn.NodeID, block.ID, edge.From)) } - pair := [2]int{predecessor.ID, block.ID} if !forward[pair] { - problems = append(problems, fmt.Sprintf("function %d block b%d lists b%d as a predecessor, which does not transfer to it", fn.NodeID, block.ID, predecessor.ID)) + problems = append(problems, fmt.Sprintf("function %d block topology records b%d -> b%d, but the terminator does not", fn.NodeID, edge.From, edge.To)) } - recorded[pair] = true + delete(forward, pair) } } for pair := range forward { - if !recorded[pair] { - problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which does not list it as a predecessor", fn.NodeID, pair[0], pair[1])) - } + problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which is absent from block topology", fn.NodeID, pair[0], pair[1])) } return problems } @@ -216,11 +214,13 @@ func validateSites(fn *Graph) []string { func validateSiteEdges(fn *Graph) []string { problems := make([]string, 0) - recorded := make(map[Edge]bool) + if fn.SiteEdges == nil { + return append(problems, fmt.Sprintf("function %d has no site topology", fn.NodeID)) + } for _, block := range fn.Blocks { last := len(block.Sites) - 1 for index, site := range block.Sites { - for _, edge := range site.Successors { + for _, edge := range fn.SiteEdges.OutEdges(site.ID) { if edge.From != site.ID { problems = append(problems, fmt.Sprintf("function %d site b%d[%d] owns an edge leaving %v", fn.NodeID, block.ID, index, edge.From)) } @@ -231,9 +231,8 @@ func validateSiteEdges(fn *Graph) []string { if kind, ok := expectedEdgeKind(block, index == last, edge.Kind); !ok { problems = append(problems, fmt.Sprintf("function %d site b%d[%d] leaves on a %s edge, but %s", fn.NodeID, block.ID, index, edgeKindName(edge.Kind), kind)) } - recorded[edge] = true } - for _, edge := range site.Predecessors { + for _, edge := range fn.SiteEdges.InEdges(site.ID) { if edge.To != site.ID { problems = append(problems, fmt.Sprintf("function %d site b%d[%d] records an edge arriving at %v", fn.NodeID, block.ID, index, edge.To)) } @@ -243,27 +242,6 @@ func validateSiteEdges(fn *Graph) []string { } } } - if len(problems) > 0 { - return problems - } - // Both directions must describe the same edge set, so a consumer walking - // predecessors sees every transfer a successor walk would. - for _, block := range fn.Blocks { - for index, site := range block.Sites { - for _, edge := range site.Predecessors { - if !recorded[edge] { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] arrives on an edge %v does not send", fn.NodeID, block.ID, index, edge.From)) - } - } - } - } - for edge := range recorded { - target := siteAt(fn, edge.To) - found := slices.Contains(target.Predecessors, edge) - if !found { - problems = append(problems, fmt.Sprintf("function %d site %v sends an edge %v does not record", fn.NodeID, edge.From, edge.To)) - } - } return problems } diff --git a/internal/ir/cfg/validate_test.go b/internal/ir/cfg/validate_test.go index 391c0036..64a1f99c 100644 --- a/internal/ir/cfg/validate_test.go +++ b/internal/ir/cfg/validate_test.go @@ -5,6 +5,7 @@ import ( "testing" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/source" ) @@ -96,16 +97,16 @@ func TestValidateRejectsTopologyDefects(t *testing.T) { { name: "a predecessor records a transfer that does not exist", damage: func(fn *Graph) { - fn.Exit.Predecessors = append(fn.Exit.Predecessors, fn.Blocks[2]) + fn.BlockEdges.AddEdge(BlockEdge{From: fn.Blocks[2].ID, To: fn.Exit.ID}) }, - want: "as a predecessor, which does not transfer to it", + want: "but the terminator does not", }, { name: "a transfer goes unrecorded by its target", damage: func(fn *Graph) { - fn.Exit.Predecessors = nil + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) }, - want: "which does not list it as a predecessor", + want: "absent from block topology", }, { name: "a site carries the wrong identity", @@ -132,16 +133,20 @@ func TestValidateRejectsTopologyDefects(t *testing.T) { { name: "a site edge points at no site", damage: func(fn *Graph) { - last := fn.Entry.Sites[len(fn.Entry.Sites)-1] - last.Successors[0].To = SiteID{Block: 42, Index: 0} + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { + edge.To = SiteID{Block: 42, Index: 0} + return edge + }) }, want: "which is not a site", }, { name: "a branch leaves on a plain sequence edge", damage: func(fn *Graph) { - last := fn.Entry.Sites[len(fn.Entry.Sites)-1] - last.Successors[0].Kind = EdgeNormal + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { + edge.Kind = EdgeNormal + return edge + }) }, want: "a branch leaves only on a true or false edge", }, @@ -176,7 +181,7 @@ func TestValidateReportsDefectsDeterministically(t *testing.T) { for attempt := 0; attempt < 8; attempt++ { module := branchingModule(t) fn := module.Functions[0] - fn.Exit.Predecessors = nil + fn.BlockEdges = graphcore.NewDirected(func(edge BlockEdge) (int, int) { return edge.From, edge.To }) for _, block := range fn.Blocks { block.Reachable = !block.Reachable } @@ -194,6 +199,23 @@ func TestValidateReportsDefectsDeterministically(t *testing.T) { } } +func rewriteFirstSiteEdge(fn *Graph, rewrite func(Edge) Edge) { + replacement := graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) + rewritten := false + for _, block := range fn.Blocks { + for _, site := range block.Sites { + for _, edge := range fn.SiteEdges.OutEdges(site.ID) { + if !rewritten { + edge = rewrite(edge) + rewritten = true + } + replacement.AddEdge(edge) + } + } + } + fn.SiteEdges = replacement +} + func TestValidateAcceptsAnEmptyModule(t *testing.T) { var missing *Module if err := missing.Validate(); err != nil { diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index ce549aa1..9bfe49c2 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -485,6 +485,7 @@ func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, di ValueUse: module.Typechecking.ValueUse, ExprType: module.EffectiveExprType, ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, }) if err := module.Effects.Validate(module.CFG, module.TypedASTNodes); err != nil { phaseDiag.AddError(diagnostics.ErrInvalidEvidence, diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index c6c40588..9648b1d9 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -97,7 +97,7 @@ func semanticExportMetadata(ctx *CompilerContext, module *Module, sym *symbols.S return metadata } -func semanticTypeKey(typ symbols.Type, visiting map[typeinfo.Type]bool) string { +func semanticTypeKey(typ typeinfo.Type, visiting map[typeinfo.Type]bool) string { semantic, ok := typ.(typeinfo.Type) if !ok || semantic == nil { return "" diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index f5269957..18c56ffb 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -12,11 +12,6 @@ import ( "compiler/internal/semantics/typeinfo" ) -type unexpectedSemanticType struct{} - -func (*unexpectedSemanticType) TypeNode() {} -func (*unexpectedSemanticType) Text() string { return "unexpected" } - func fingerprintModule( t *testing.T, exported *symbols.Symbol, @@ -267,15 +262,6 @@ func TestSemanticTypeKeyIncludesNamedEnumIdentity(t *testing.T) { } } -func TestSemanticTypeKeyRejectsUnknownType(t *testing.T) { - defer func() { - if recover() == nil { - t.Fatal("semanticTypeKey accepted unknown semantic type") - } - }() - semanticTypeKey(&unexpectedSemanticType{}, make(map[typeinfo.Type]bool)) -} - func TestConstantKeyIncludesTypeAndValue(t *testing.T) { i32, _ := constvalue.NewIntText("1", "i32") i32Two, _ := constvalue.NewIntText("2", "i32") diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index 8f0f5a3a..b37bce1d 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -1,9 +1,8 @@ package definiteinit import ( - "fmt" - "compiler/internal/diagnostics" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/semantics/effect" @@ -46,19 +45,19 @@ func analyzeFunction(graph *cfg.Graph, ops effect.SiteOps, diag *diagnostics.Dia // site that receives them, so entry needs no seeded state of its own. entry := graph.Entry.Sites[0].ID result.In[entry] = make(state) - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false + work := graphcore.NewWorklist(entry) + for { + id, pending := work.Next() + if !pending { + break + } site := sites[id] if site == nil { continue } out := transfer(ops[id], result.In[id]) result.Out[id] = out - for _, edge := range site.Successors { + for _, edge := range graph.SiteEdges.OutEdges(site.ID) { if sites[edge.To] == nil { continue } @@ -72,10 +71,7 @@ func analyzeFunction(graph *cfg.Graph, ops effect.SiteOps, diag *diagnostics.Dia continue } result.In[edge.To] = merged - if !queued[edge.To] { - queue = append(queue, edge.To) - queued[edge.To] = true - } + work.Add(edge.To) } } // Reporting walks declaration order rather than worklist order so diagnostics @@ -110,12 +106,10 @@ func indexSites(graph *cfg.Graph) (map[cfg.SiteID]*cfg.Site, []cfg.SiteID) { // A symbol with no define belongs to an enclosing scope and is never reported. func trackedSymbols(ops effect.SiteOps, order []cfg.SiteID) map[symbols.SymbolID]string { tracked := make(map[symbols.SymbolID]string) + visitor := &initializationVisitor{tracked: tracked} for _, id := range order { for _, op := range ops[id] { - define, ok := op.(effect.Define) - if ok && define.Symbol != nil { - tracked[define.Symbol.ID] = define.Symbol.Name - } + effect.Visit(op, visitor) } } return tracked @@ -126,8 +120,9 @@ func trackedSymbols(ops effect.SiteOps, order []cfg.SiteID) map[symbols.SymbolID // terminates. func transfer(ops []effect.Op, in state) state { out := copyState(in) + visitor := &initializationVisitor{current: out, applyState: true} for _, op := range ops { - apply(out, op) + effect.Visit(op, visitor) } return out } @@ -139,47 +134,59 @@ func checkReads(ops []effect.Op, initialized state, tracked map[symbols.SymbolID if diag == nil { return } - current := copyState(initialized) + visitor := &initializationVisitor{ + current: initialized, tracked: tracked, diag: diag, + applyState: true, reportReads: true, + } + visitor.current = copyState(initialized) for _, op := range ops { - switch op := op.(type) { - case effect.Use: - reportUninitializedRead(op.Place, op.Location, current, tracked, diag) - case effect.Borrow: - // Borrowing storage that holds nothing yet is the same error as - // reading it. - reportUninitializedRead(op.Place, op.Location, current, tracked, diag) - } - apply(current, op) + effect.Visit(op, visitor) } } -// apply is the single place an effect changes initialization state. A new -// operation kind fails here by name rather than being silently ignored. -func apply(current state, op effect.Op) { - switch op := op.(type) { - case effect.Define: - if op.Initialized && op.Symbol != nil { - current[op.Symbol.ID] = struct{}{} - } - case effect.Write: - if op.Place.Root != nil { - current[op.Place.Root.ID] = struct{}{} - } - case effect.Use: - // A read leaves initialization state unchanged. - case effect.Borrow: - // Taking a reference changes no initialization state, but it does read - // the place, which is reported where reads are checked. - case effect.Discard: - // A discarded value changes no initialization state. - case effect.CallBegin, effect.CallEnd: - // A call boundary bounds temporaries, which initialization does not - // track. Its arguments are published as ordinary reads between them. - default: - panic(fmt.Sprintf("definiteinit: unhandled effect %T", op)) +// initializationVisitor is the exhaustive semantic-operation contract for +// definite initialization. Adding a new effect does not compile until this +// analysis explicitly classifies it. +type initializationVisitor struct { + current state + tracked map[symbols.SymbolID]string + diag *diagnostics.DiagnosticBag + applyState bool + reportReads bool +} + +func (v *initializationVisitor) VisitDefine(op effect.Define) { + if op.Symbol != nil && v.tracked != nil { + v.tracked[op.Symbol.ID] = op.Symbol.Name + } + if v.applyState && op.Initialized && op.Symbol != nil { + v.current[op.Symbol.ID] = struct{}{} } } +func (v *initializationVisitor) VisitWrite(op effect.Write) { + if v.applyState && op.Place.Root != nil { + v.current[op.Place.Root.ID] = struct{}{} + } +} + +func (v *initializationVisitor) VisitUse(op effect.Use) { + if v.reportReads { + reportUninitializedRead(op.Place, op.Location, v.current, v.tracked, v.diag) + } +} + +func (v *initializationVisitor) VisitBorrow(op effect.Borrow) { + if v.reportReads { + reportUninitializedRead(op.Place, op.Location, v.current, v.tracked, v.diag) + } +} + +func (*initializationVisitor) VisitIterate(effect.Iterate) {} +func (*initializationVisitor) VisitDiscard(effect.Discard) {} +func (*initializationVisitor) VisitCallBegin(effect.CallBegin) {} +func (*initializationVisitor) VisitCallEnd(effect.CallEnd) {} + func reportUninitializedRead(at effect.Place, location *source.Location, current state, tracked map[symbols.SymbolID]string, diag *diagnostics.DiagnosticBag) { if at.Root == nil { return diff --git a/internal/semantics/definiteinit/initialization_test.go b/internal/semantics/definiteinit/initialization_test.go index ce1e386d..e98c65da 100644 --- a/internal/semantics/definiteinit/initialization_test.go +++ b/internal/semantics/definiteinit/initialization_test.go @@ -64,6 +64,7 @@ func analyzeInitializationSource(t *testing.T, source string) (*functionResult, ValueUse: module.Typechecking.ValueUse, ExprType: module.EffectiveExprType, ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, }) result := analyzeFunction(graph, effects[graph.NodeID], diag) return result, diag, module diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index c6591b7d..e32d455d 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -41,6 +41,10 @@ type BuildQueries struct { // whether that reference is mutable. The borrow exists because of the // parameter type rather than because the source wrote an ampersand. ReferenceArgument func(ast.NodeID) (mutable bool, found bool) + // SequenceCarrier reports the hidden carrier a typed sequence loop keeps for + // the loop lifetime. Range loops return found=false. This is typechecker + // evidence; the effect producer must not rediscover iteration kind. + SequenceCarrier func(ast.NodeID) (carrier *symbols.Symbol, found bool) } // Build publishes the semantic effects of every reachable CFG site. @@ -135,7 +139,7 @@ func (b *builder) buildSite(block *cfg.Block, site *cfg.Site) { switch terminator := block.Terminator.(type) { case *cfg.Branch: if condition, ok := b.nodes[ast.NodeID(terminator.ConditionID)].(ast.Expr); ok { - b.value(site.ID, condition, typeinfo.UseRead) + b.value(site.ID, b.queries.Scopes[ast.NodeID(site.ScopeID)], condition, typeinfo.UseRead) } case *cfg.SwitchVariant: // Arm payload bindings are published in the leading pass above; the @@ -156,7 +160,7 @@ func (b *builder) buildMatchArms(site *cfg.Site, terminator *cfg.SwitchVariant) if b.queries.ArmBindings == nil { return } - for _, edge := range site.Successors { + for _, edge := range b.graph.SiteEdges.OutEdges(site.ID) { if edge.Kind != cfg.EdgeVariantCase { continue } @@ -181,27 +185,37 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St case *ast.ConstDecl: b.buildBinding(site, scope, node, node.Value) case *ast.AssignStmt: - b.value(site, node.Value, typeinfo.UseMove) - b.writeTarget(site, scope, node.Target) + b.value(site, scope, node.Value, typeinfo.UseMove) + b.writeTarget(site, scope, node.Target, node.ID(), node.Value) case *ast.ExprStmt: - b.value(site, node.Expr, typeinfo.UseRead) + b.value(site, scope, node.Expr, typeinfo.UseRead) b.emit(site, Discard{ - Place: b.placeOrTemporary(node.Expr), + Place: b.placeOrTemporary(scope, node.Expr), Node: node.Expr.ID(), Location: ast.LocOf(node.Expr), }) case *ast.ReturnStmt: - b.value(site, node.Value, typeinfo.UseMove) + b.value(site, scope, node.Value, typeinfo.UseMove) case *ast.MatchStmt: // A match reaches this producer at its terminator site, and at a plain // statement site when semantic evidence was too incomplete for CFG to // decompose it. Publishing the subject here covers both. - b.value(site, node.Subject, typeinfo.UseRead) + b.value(site, scope, node.Subject, typeinfo.UseRead) case *ast.ForStmt: // The condition is published from the terminator, which names it // directly. The iterated sequence is evaluated by the loop itself and - // belongs here. - b.value(site, node.Iterable, typeinfo.UseRead) + // belongs here. A typed sequence loop additionally holds a shared access + // to its iterable until the loop exit; publish that lifetime fact here + // instead of making ownership recognize ForStmt. + b.value(site, scope, node.Iterable, typeinfo.UseRead) + if node.Iterable != nil && b.queries.SequenceCarrier != nil { + if carrier, found := b.queries.SequenceCarrier(node.ID()); found && carrier != nil { + b.emit(site, Iterate{ + Loop: node.ID(), Place: b.placeOrTemporary(scope, node.Iterable), + Node: node.Iterable.ID(), Carrier: carrier, Location: ast.LocOf(node.Iterable), + }) + } + } case *ast.IfStmt: // A branch condition is published from the terminator, which names it // directly, so a site carries exactly the reads that happen at it. @@ -224,7 +238,7 @@ func (b *builder) publishStmt(site cfg.SiteID, scope *symbols.Scope, stmt ast.St // buildBinding publishes a declaration's initializer reads before the define // they initialize, so `let x = x` reads an outer binding rather than itself. func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.Stmt, value ast.Expr) { - b.value(site, value, typeinfo.UseMove) + b.value(site, scope, value, typeinfo.UseMove) if scope == nil { return } @@ -232,7 +246,11 @@ func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.S if !found || sym == nil { return } - b.emit(site, Define{Symbol: sym, Node: decl.ID(), Initialized: value != nil}) + valueID := ast.NodeID(0) + if value != nil { + valueID = value.ID() + } + b.emit(site, Define{Symbol: sym, Node: decl.ID(), Value: valueID, Initialized: value != nil}) } // value publishes what one expression does to the bindings it names. @@ -244,7 +262,7 @@ func (b *builder) buildBinding(site cfg.SiteID, scope *symbols.Scope, decl ast.S // // This is the expression dispatch site for published effects. A new expression // kind must be handled here or declared inert in internal/contracts. -func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { +func (b *builder) value(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr, kind typeinfo.UseKind) { switch node := expr.(type) { case nil: return @@ -253,14 +271,16 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { b.emit(site, Use{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) } case *ast.AddressExpr: - b.borrow(site, node, node.Expr, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) + b.borrow(site, scope, node, node.Expr, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) case *ast.SelectorExpr: // A field of a place is itself a place, so the use lands on the - // projection rather than on the whole aggregate. A consumer that only - // cares which binding was touched still reads the root. - b.projection(site, node, node.Expr, place.OriginProjection{ - Kind: place.OriginField, Field: fieldName(node), - }, kind) + // projection rather than on the whole aggregate. Structural projection + // shape comes from place.Project, the canonical place grammar. + projection, ok := place.Project(node) + if !ok { + return + } + b.projection(site, scope, node, projection.Base, projection.Step, kind) case *ast.IndexExpr: // Slicing does not read an element out; it borrows a run of the // sequence, mutably when the slice itself is a mutable reference. @@ -268,25 +288,29 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { // rather than an empty range. _, ranged := node.Index.(*ast.RangeExpr) if ranged || node.Index == nil { - b.value(site, node.Index, typeinfo.UseRead) - b.borrow(site, node, node.Expr, b.mutableReference(node.ID()), false) + b.value(site, scope, node.Index, typeinfo.UseRead) + b.borrow(site, scope, node, node.Expr, b.mutableReference(node.ID()), false) return } - b.projection(site, node, node.Expr, place.OriginProjection{Kind: place.OriginIndex}, kind) + projection, ok := place.Project(node) + if !ok { + return + } + b.projection(site, scope, node, projection.Base, projection.Step, kind) // The index is a separate value, not part of the place. - b.value(site, node.Index, typeinfo.UseRead) + b.value(site, scope, projection.Index, typeinfo.UseRead) case *ast.RangeExpr: - b.value(site, node.Start, typeinfo.UseRead) - b.value(site, node.End, typeinfo.UseRead) + b.value(site, scope, node.Start, typeinfo.UseRead) + b.value(site, scope, node.End, typeinfo.UseRead) case *ast.StructLit: for _, field := range node.Fields { - b.value(site, field.Value, typeinfo.UseMove) + b.value(site, scope, field.Value, typeinfo.UseMove) } case *ast.VariantLit: - b.value(site, node.Payload, typeinfo.UseMove) + b.value(site, scope, node.Payload, typeinfo.UseMove) case *ast.ArrayLit: for _, element := range node.Values { - b.value(site, element, typeinfo.UseMove) + b.value(site, scope, element, typeinfo.UseMove) } case *ast.CallExpr: b.emit(site, CallBegin{Node: node.ID(), Location: ast.LocOf(node)}) @@ -294,37 +318,37 @@ func (b *builder) value(site cfg.SiteID, expr ast.Expr, kind typeinfo.UseKind) { // A method callee names a method, not storage. The receiver is the // value the call uses, and it is used the way the receiver // parameter demands, borrow included, exactly like any argument. - b.argument(site, selector.Expr) + b.argument(site, scope, selector.Expr) } else { - b.value(site, node.Callee, typeinfo.UseRead) + b.value(site, scope, node.Callee, typeinfo.UseRead) } arguments := node.Args if b.queries.CallArguments != nil { arguments = b.queries.CallArguments(node) } for _, argument := range arguments { - b.argument(site, argument) + b.argument(site, scope, argument) } b.emit(site, CallEnd{Node: node.ID()}) case *ast.FreeExpr: - b.value(site, node.Expr, typeinfo.UseMove) + b.value(site, scope, node.Expr, typeinfo.UseMove) case *ast.PrintExpr: - b.value(site, node.Expr, typeinfo.UseRead) + b.value(site, scope, node.Expr, typeinfo.UseRead) case *ast.UnaryExpr: - b.value(site, node.Expr, typeinfo.UseRead) + b.value(site, scope, node.Expr, typeinfo.UseRead) case *ast.BinaryExpr: if b.queries.StringConcatenation != nil && b.queries.StringConcatenation(node.ID()) { // Concatenation consumes the left operand into the result. - b.value(site, node.Left, typeinfo.UseMove) - b.value(site, node.Right, typeinfo.UseRead) + b.value(site, scope, node.Left, typeinfo.UseMove) + b.value(site, scope, node.Right, typeinfo.UseRead) return } - b.value(site, node.Left, typeinfo.UseRead) - b.value(site, node.Right, typeinfo.UseRead) + b.value(site, scope, node.Left, typeinfo.UseRead) + b.value(site, scope, node.Right, typeinfo.UseRead) case *ast.IsExpr: - b.value(site, node.Value, typeinfo.UseRead) + b.value(site, scope, node.Value, typeinfo.UseRead) case *ast.AsExpr: - b.value(site, node.Expr, typeinfo.UseMove) + b.value(site, scope, node.Expr, typeinfo.UseMove) case *ast.ScopeResolution, *ast.NumberLit, *ast.StringLit, *ast.ByteLit, *ast.CharLit, *ast.BoolLit, *ast.NoneLit, *ast.BadExpr: // These name no binding whose value is used. @@ -346,63 +370,38 @@ func (b *builder) argumentKind(argument ast.Expr) typeinfo.UseKind { return typeinfo.UseRead } -// placeOf resolves an expression to the storage it names, following the same -// shapes the canonical place walk recognises. It reports false for anything -// that produces a value without naming storage, such as a call result. -func (b *builder) placeOf(expr ast.Expr) (Place, bool) { - switch node := expr.(type) { - case *ast.Ident: - sym := b.queries.Symbols[node.ID()] - if sym == nil { - return Place{}, false - } - return Place{Root: sym}, true - case *ast.SelectorExpr: - if node.Name == nil { - return Place{}, false - } - return b.project(node.Expr, place.OriginProjection{ - Kind: place.OriginField, - Field: node.Name.Name, - }) - case *ast.IndexExpr: - return b.project(node.Expr, place.OriginProjection{Kind: place.OriginIndex}) - default: +// placeOf resolves a syntactic place through the canonical place structure. +// The place package owns selector/index decomposition; this producer only maps +// the root identifier to its already-resolved symbol. +func (b *builder) placeOf(scope *symbols.Scope, expr ast.Expr) (Place, bool) { + root, projections, ok := place.Decompose(expr) + if !ok { return Place{}, false } -} - -func (b *builder) project(base ast.Expr, step place.OriginProjection) (Place, bool) { - rooted, ok := b.placeOf(base) - if !ok { + ident, ok := root.(*ast.Ident) + if !ok || ident == nil { return Place{}, false } - // Copy rather than append in place: sibling projections off one base must - // not share backing storage. - projections := make([]place.OriginProjection, 0, len(rooted.Projections)+1) - projections = append(projections, rooted.Projections...) - projections = append(projections, step) - rooted.Projections = projections - return rooted, true -} - -func fieldName(selector *ast.SelectorExpr) string { - if selector == nil || selector.Name == nil { - return "" + sym := b.queries.Symbols[ident.ID()] + if sym == nil && scope != nil { + sym, _ = scope.Lookup(ident.Name) + } + if sym == nil { + return Place{}, false } - return selector.Name.Name + return Place{Root: sym, Projections: append([]place.OriginProjection(nil), projections...)}, true } // projection publishes a use of one projected place. When the base names // storage the use roots at that binding; otherwise the base is a temporary, // which still has its own effects and is walked before the projection is // published. -func (b *builder) projection(site cfg.SiteID, whole, base ast.Expr, step place.OriginProjection, kind typeinfo.UseKind) { - if rooted, ok := b.project(base, step); ok { +func (b *builder) projection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, kind typeinfo.UseKind) { + if rooted, ok := b.placeOf(scope, whole); ok { b.emit(site, Use{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole), Kind: kind}) return } - b.value(site, base, typeinfo.UseRead) + b.value(site, scope, base, typeinfo.UseRead) b.emit(site, Use{ Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, Node: whole.ID(), @@ -413,11 +412,11 @@ func (b *builder) projection(site cfg.SiteID, whole, base ast.Expr, step place.O // placeOrTemporary names what an expression denotes: the binding it reaches, or // the expression itself when it produces a value that lives nowhere. -func (b *builder) placeOrTemporary(expr ast.Expr) Place { +func (b *builder) placeOrTemporary(scope *symbols.Scope, expr ast.Expr) Place { if expr == nil { return Place{} } - if rooted, ok := b.placeOf(expr); ok { + if rooted, ok := b.placeOf(scope, expr); ok { return rooted } return Place{Temporary: expr.ID()} @@ -428,37 +427,49 @@ func (b *builder) placeOrTemporary(expr ast.Expr) Place { // A projection target still evaluates the values inside it — an index is read // to reach the element it selects — so those are published before the write // itself. -func (b *builder) writeTarget(site cfg.SiteID, scope *symbols.Scope, target ast.Expr) { - switch node := target.(type) { - case *ast.Ident: - sym, found := scope.Lookup(node.Name) - if !found || sym == nil { +func (b *builder) writeTarget(site cfg.SiteID, scope *symbols.Scope, target ast.Expr, owner ast.NodeID, value ast.Expr) { + valueID := ast.NodeID(0) + if value != nil { + valueID = value.ID() + } + if ident, ok := target.(*ast.Ident); ok { + if ident == nil || scope == nil { return } - b.emit(site, Write{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node)}) - case *ast.SelectorExpr: - b.writeProjection(site, node, node.Expr, place.OriginProjection{ - Kind: place.OriginField, Field: fieldName(node), - }) - case *ast.IndexExpr: - b.value(site, node.Index, typeinfo.UseRead) - b.writeProjection(site, node, node.Expr, place.OriginProjection{Kind: place.OriginIndex}) - default: + sym := b.queries.Symbols[ident.ID()] + if sym == nil { + sym, _ = scope.Lookup(ident.Name) + } + if sym != nil { + b.emit(site, Write{Place: Place{Root: sym}, Node: ident.ID(), Owner: owner, Value: valueID, Location: ast.LocOf(ident)}) + } + return + } + + projection, projected := place.Project(target) + if !projected { // Anything else is not a place; the typechecker rejects it as a target, // and its own effects are all it contributes here. - b.value(site, target, typeinfo.UseRead) + b.value(site, scope, target, typeinfo.UseRead) + return + } + if projection.Index != nil { + b.value(site, scope, projection.Index, typeinfo.UseRead) } + b.writeProjection(site, scope, target, projection.Base, projection.Step, owner, valueID) } -func (b *builder) writeProjection(site cfg.SiteID, whole, base ast.Expr, step place.OriginProjection) { - if rooted, ok := b.project(base, step); ok { - b.emit(site, Write{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole)}) +func (b *builder) writeProjection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, owner, value ast.NodeID) { + if rooted, ok := b.placeOf(scope, whole); ok { + b.emit(site, Write{Place: rooted, Node: whole.ID(), Owner: owner, Value: value, Location: ast.LocOf(whole)}) return } - b.value(site, base, typeinfo.UseRead) + b.value(site, scope, base, typeinfo.UseRead) b.emit(site, Write{ Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, Node: whole.ID(), + Owner: owner, + Value: value, Location: ast.LocOf(whole), }) } @@ -478,7 +489,7 @@ func (b *builder) mutableReference(id ast.NodeID) bool { // A reference parameter borrows, whether or not the source wrote an ampersand, // and that borrow is the argument's whole effect: publishing a read beside it // would charge the same place twice. Everything else is an ordinary value use. -func (b *builder) argument(site cfg.SiteID, argument ast.Expr) { +func (b *builder) argument(site cfg.SiteID, scope *symbols.Scope, argument ast.Expr) { if argument == nil { return } @@ -487,7 +498,7 @@ func (b *builder) argument(site cfg.SiteID, argument ast.Expr) { mutable, borrows = b.queries.ReferenceArgument(argument.ID()) } if !borrows { - b.value(site, argument, b.argumentKind(argument)) + b.value(site, scope, argument, b.argumentKind(argument)) return } operand := argument @@ -495,12 +506,13 @@ func (b *builder) argument(site cfg.SiteID, argument ast.Expr) { operand = address.Expr } // Values evaluated to reach the place, such as an index, still happen. - if index, indexed := operand.(*ast.IndexExpr); indexed { - b.value(site, index.Index, typeinfo.UseRead) + if projection, projected := place.Project(operand); projected && projection.Index != nil { + b.value(site, scope, projection.Index, typeinfo.UseRead) } b.emit(site, Borrow{ - Place: b.placeOrTemporary(operand), + Place: b.placeOrTemporary(scope, operand), Node: argument.ID(), + Operand: operand.ID(), Location: ast.LocOf(argument), Mutable: mutable, Argument: true, @@ -509,13 +521,14 @@ func (b *builder) argument(site cfg.SiteID, argument ast.Expr) { // borrow publishes a reference taken to a place. Values inside the operand that // are evaluated to reach it, such as an index, are published first. -func (b *builder) borrow(site cfg.SiteID, whole, operand ast.Expr, mutable, raw bool) { - if index, indexed := operand.(*ast.IndexExpr); indexed { - b.value(site, index.Index, typeinfo.UseRead) +func (b *builder) borrow(site cfg.SiteID, scope *symbols.Scope, whole, operand ast.Expr, mutable, raw bool) { + if projection, projected := place.Project(operand); projected && projection.Index != nil { + b.value(site, scope, projection.Index, typeinfo.UseRead) } b.emit(site, Borrow{ - Place: b.placeOrTemporary(operand), + Place: b.placeOrTemporary(scope, operand), Node: whole.ID(), + Operand: operand.ID(), Location: ast.LocOf(whole), Mutable: mutable, Raw: raw, diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index c2583ec8..8b74e270 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -55,6 +55,7 @@ func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) ValueUse: module.Typechecking.ValueUse, ExprType: module.EffectiveExprType, ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, }) if result == nil { t.Fatal("Build published no result") @@ -106,10 +107,26 @@ func describe(op effect.Op) string { return "write " + op.Place.Root.Name case effect.Use: return "use " + op.Place.Root.Name + case effect.Iterate: + if op.Place.Root == nil { + return "iterate temporary" + } + return "iterate " + op.Place.Root.Name } return "unknown" } +func TestBuildPublishesSequenceIterationLifetime(t *testing.T) { + result, module := buildEffects(t, `fn walk(values: [2]i32) { + for value in values {} +}`) + got := publishedOps(t, result, module, "walk") + want := []string{"define values", "use values", "iterate values"} + if !sameOps(got, want) { + t.Fatalf("published %v, want %v", got, want) + } +} + func sameOps(got, want []string) bool { if len(got) != len(want) { return false diff --git a/internal/semantics/effect/model.go b/internal/semantics/effect/model.go index c5caa890..57fe399a 100644 --- a/internal/semantics/effect/model.go +++ b/internal/semantics/effect/model.go @@ -21,11 +21,13 @@ import ( // Op is one semantic effect on one binding. // -// The set is closed by the unexported marker method, so no kind can be -// introduced outside this package. Go cannot make a consumer's type switch -// exhaustive; internal/contracts carries that half. +// The set is closed by unexported methods, so no kind can be introduced +// outside this package. visit(Visitor) supplies the other half of the contract: +// a new semantic operation extends Visitor and therefore breaks every exhaustive +// consumer at compile time until it makes an explicit decision. type Op interface { effectOp() + visit(Visitor) } // Define brings a binding into existence. Initialized separates `let x = e`, @@ -34,7 +36,12 @@ type Define struct { Symbol *symbols.Symbol // Node is the declaration, which is where a diagnostic about the binding // itself belongs. - Node ast.NodeID + Node ast.NodeID + // Value names the initializer whose value enters Symbol. It is zero for a + // declaration without an initializer and for OnEntry bindings. Consumers + // that track reference/pointer provenance can therefore update the binding + // from this operation without rediscovering declaration syntax. + Value ast.NodeID Initialized bool // OnEntry marks a binding that already exists when the site begins rather // than being established by it: a function parameter, or a match payload @@ -53,7 +60,13 @@ type Define struct { type Write struct { Place Place // Node is the assignment target. - Node ast.NodeID + Node ast.NodeID + // Owner is the source construct performing the replacement. Cleanup plans + // key pre-assignment drops by this identity, while Node remains the target + // expression used for diagnostics and place typing. + Owner ast.NodeID + // Value is the expression whose value is stored into Place. + Value ast.NodeID Location *source.Location } @@ -102,8 +115,13 @@ type Use struct { // both a borrow and a separate read of the same place would charge that place // twice. type Borrow struct { - Place Place + Place Place + // Node is the source expression that creates the borrow (an AddressExpr or + // an adapted call argument). Operand is the place expression actually + // borrowed. Keeping both identities means consumers never have to peel + // syntax to rediscover that relationship. Node ast.NodeID + Operand ast.NodeID Location *source.Location Mutable bool // Argument marks a borrow handed to a call. It outlives the expression that @@ -115,6 +133,18 @@ type Borrow struct { Raw bool } +// Iterate records the long-lived shared access a sequence loop holds on its +// iterable storage. The ordinary Use for the iterable is still published in +// evaluation order; Iterate adds only the lifetime fact that lasts until the +// loop exit. Range loops publish no Iterate operation. +type Iterate struct { + Loop ast.NodeID + Place Place + Node ast.NodeID + Carrier *symbols.Symbol + Location *source.Location +} + // CallBegin and CallEnd bracket the operations a call evaluates. Everything // between them happens while the call is in progress. // @@ -146,6 +176,7 @@ func (Define) effectOp() {} func (Write) effectOp() {} func (Use) effectOp() {} func (Borrow) effectOp() {} +func (Iterate) effectOp() {} func (Discard) effectOp() {} func (CallBegin) effectOp() {} func (CallEnd) effectOp() {} diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index ae8d21e6..40d7cd5c 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -62,56 +62,102 @@ func (r Result) Validate(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node) erro } func validateOps(fn ir.NodeID, site cfg.SiteID, ops []Op, nodes map[ast.NodeID]ast.Node) []string { - problems := make([]string, 0) - open := make([]ast.NodeID, 0) + visitor := &validationVisitor{fn: fn, site: site, nodes: nodes} for index, op := range ops { - where := fmt.Sprintf("function %d site %v operation %d", fn, site, index) - switch op := op.(type) { - case Define: - problems = append(problems, validateNode(where, "define", op.Symbol == nil, op.Node, nodes)...) - case Write: - problems = append(problems, validatePlace(where, "write", op.Place)...) - problems = append(problems, validateNode(where, "write", false, op.Node, nodes)...) - case Use: - problems = append(problems, validatePlace(where, "use", op.Place)...) - problems = append(problems, validateNode(where, "use", false, op.Node, nodes)...) - if op.Location == nil { - problems = append(problems, where+" is a use with no source location to report against") - } - case Borrow: - problems = append(problems, validatePlace(where, "borrow", op.Place)...) - problems = append(problems, validateNode(where, "borrow", false, op.Node, nodes)...) - if op.Location == nil { - problems = append(problems, where+" is a borrow with no source location to report against") - } - case Discard: - problems = append(problems, validatePlace(where, "discard", op.Place)...) - problems = append(problems, validateNode(where, "discard", false, op.Node, nodes)...) - if op.Location == nil { - problems = append(problems, where+" is a discard with no source location to report against") - } - case CallBegin: - problems = append(problems, validateNode(where, "call start", false, op.Node, nodes)...) - open = append(open, op.Node) - case CallEnd: - // A consumer restores state saved at the matching start, so an - // unbalanced or crossed pair would restore the wrong mark. - if len(open) == 0 { - problems = append(problems, fmt.Sprintf("%s ends a call that never started", where)) - continue - } - if last := open[len(open)-1]; last != op.Node { - problems = append(problems, fmt.Sprintf("%s ends call %d while call %d is still open", where, op.Node, last)) - } - open = open[:len(open)-1] - default: - problems = append(problems, fmt.Sprintf("%s has unknown effect kind %T", where, op)) - } + visitor.index = index + Visit(op, visitor) } - for _, unclosed := range open { - problems = append(problems, fmt.Sprintf("function %d site %v leaves call %d open", fn, site, unclosed)) + for _, unclosed := range visitor.open { + visitor.problems = append(visitor.problems, fmt.Sprintf("function %d site %v leaves call %d open", fn, site, unclosed)) } - return problems + return visitor.problems +} + +type validationVisitor struct { + fn ir.NodeID + site cfg.SiteID + index int + nodes map[ast.NodeID]ast.Node + problems []string + open []ast.NodeID +} + +func (v *validationVisitor) where() string { + return fmt.Sprintf("function %d site %v operation %d", v.fn, v.site, v.index) +} + +func (v *validationVisitor) VisitDefine(op Define) { + where := v.where() + v.problems = append(v.problems, validateNode(where, "define", op.Symbol == nil, op.Node, v.nodes)...) + if op.Value != 0 { + v.problems = append(v.problems, validateNode(where, "define value", false, op.Value, v.nodes)...) + } +} + +func (v *validationVisitor) VisitWrite(op Write) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "write", op.Place)...) + v.problems = append(v.problems, validateNode(where, "write", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode(where, "write owner", false, op.Owner, v.nodes)...) + if op.Value != 0 { + v.problems = append(v.problems, validateNode(where, "write value", false, op.Value, v.nodes)...) + } +} + +func (v *validationVisitor) VisitUse(op Use) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "use", op.Place)...) + v.problems = append(v.problems, validateNode(where, "use", false, op.Node, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a use with no source location to report against") + } +} + +func (v *validationVisitor) VisitBorrow(op Borrow) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "borrow", op.Place)...) + v.problems = append(v.problems, validateNode(where, "borrow", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode(where, "borrow operand", false, op.Operand, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a borrow with no source location to report against") + } +} + +func (v *validationVisitor) VisitIterate(op Iterate) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "iteration", op.Place)...) + v.problems = append(v.problems, validateNode(where, "iteration", op.Carrier == nil, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode(where, "iteration owner", false, op.Loop, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is an iteration with no source location to report against") + } +} + +func (v *validationVisitor) VisitDiscard(op Discard) { + where := v.where() + v.problems = append(v.problems, validatePlace(where, "discard", op.Place)...) + v.problems = append(v.problems, validateNode(where, "discard", false, op.Node, v.nodes)...) + if op.Location == nil { + v.problems = append(v.problems, where+" is a discard with no source location to report against") + } +} + +func (v *validationVisitor) VisitCallBegin(op CallBegin) { + where := v.where() + v.problems = append(v.problems, validateNode(where, "call start", false, op.Node, v.nodes)...) + v.open = append(v.open, op.Node) +} + +func (v *validationVisitor) VisitCallEnd(op CallEnd) { + where := v.where() + if len(v.open) == 0 { + v.problems = append(v.problems, fmt.Sprintf("%s ends a call that never started", where)) + return + } + if last := v.open[len(v.open)-1]; last != op.Node { + v.problems = append(v.problems, fmt.Sprintf("%s ends call %d while call %d is still open", where, op.Node, last)) + } + v.open = v.open[:len(v.open)-1] } // validatePlace enforces that a place names exactly one root. A place with diff --git a/internal/semantics/effect/visitor.go b/internal/semantics/effect/visitor.go new file mode 100644 index 00000000..e36aa9e9 --- /dev/null +++ b/internal/semantics/effect/visitor.go @@ -0,0 +1,40 @@ +package effect + +// Visitor is the exhaustive consumer contract for semantic operations. +// +// Syntax additions that reuse existing operations never touch this interface. +// Adding a genuinely new semantic operation is different: the new Op must call +// a corresponding Visitor method from its visit implementation, which makes +// every semantic consumer fail compilation until it explicitly decides what +// the operation means. This is the deliberate "introduce new semantics to +// everyone" boundary; it is not used for AST dispatch throughout the compiler. +type Visitor interface { + VisitDefine(Define) + VisitWrite(Write) + VisitUse(Use) + VisitBorrow(Borrow) + VisitIterate(Iterate) + VisitDiscard(Discard) + VisitCallBegin(CallBegin) + VisitCallEnd(CallEnd) +} + +// Visit dispatches one operation through the exhaustive semantic visitor. +func Visit(op Op, visitor Visitor) { + if op == nil { + panic("effect: cannot visit a nil operation") + } + if visitor == nil { + panic("effect: cannot visit with a nil visitor") + } + op.visit(visitor) +} + +func (op Define) visit(visitor Visitor) { visitor.VisitDefine(op) } +func (op Write) visit(visitor Visitor) { visitor.VisitWrite(op) } +func (op Use) visit(visitor Visitor) { visitor.VisitUse(op) } +func (op Borrow) visit(visitor Visitor) { visitor.VisitBorrow(op) } +func (op Iterate) visit(visitor Visitor) { visitor.VisitIterate(op) } +func (op Discard) visit(visitor Visitor) { visitor.VisitDiscard(op) } +func (op CallBegin) visit(visitor Visitor) { visitor.VisitCallBegin(op) } +func (op CallEnd) visit(visitor Visitor) { visitor.VisitCallEnd(op) } diff --git a/internal/semantics/effect/visitor_test.go b/internal/semantics/effect/visitor_test.go new file mode 100644 index 00000000..7f232f2c --- /dev/null +++ b/internal/semantics/effect/visitor_test.go @@ -0,0 +1,29 @@ +package effect + +import ( + "reflect" + "testing" +) + +type recordingVisitor struct{ kinds []string } + +func (v *recordingVisitor) VisitDefine(Define) { v.kinds = append(v.kinds, "define") } +func (v *recordingVisitor) VisitWrite(Write) { v.kinds = append(v.kinds, "write") } +func (v *recordingVisitor) VisitUse(Use) { v.kinds = append(v.kinds, "use") } +func (v *recordingVisitor) VisitBorrow(Borrow) { v.kinds = append(v.kinds, "borrow") } +func (v *recordingVisitor) VisitIterate(Iterate) { v.kinds = append(v.kinds, "iterate") } +func (v *recordingVisitor) VisitDiscard(Discard) { v.kinds = append(v.kinds, "discard") } +func (v *recordingVisitor) VisitCallBegin(CallBegin) { v.kinds = append(v.kinds, "call-begin") } +func (v *recordingVisitor) VisitCallEnd(CallEnd) { v.kinds = append(v.kinds, "call-end") } + +func TestVisitorDispatchesEverySemanticOperation(t *testing.T) { + ops := []Op{Define{}, Write{}, Use{}, Borrow{}, Iterate{}, Discard{}, CallBegin{}, CallEnd{}} + visitor := &recordingVisitor{} + for _, op := range ops { + Visit(op, visitor) + } + want := []string{"define", "write", "use", "borrow", "iterate", "discard", "call-begin", "call-end"} + if !reflect.DeepEqual(visitor.kinds, want) { + t.Fatalf("visited %v, want %v", visitor.kinds, want) + } +} diff --git a/internal/semantics/ownership/effects.go b/internal/semantics/ownership/effects.go index 573420d3..3a2e74c8 100644 --- a/internal/semantics/ownership/effects.go +++ b/internal/semantics/ownership/effects.go @@ -3,7 +3,9 @@ package ownership import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + "compiler/internal/ir" "compiler/internal/semantics/effect" + "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) @@ -11,66 +13,250 @@ import ( // callFrame remembers where a call's loans start, so completing the call can // give back the temporaries its arguments created. type callFrame struct { - call *ast.CallExpr + call ast.Node temporary int reserved int } +// storedReference is the reference value carried by an initializer/RHS before +// that expression is evaluated. Evaluation can move the source binding, so +// consumers must snapshot provenance before replaying the site's effects. +type storedReference struct { + loans []referenceLoan + present bool +} + // applyEffects runs one site's published operations in evaluation order. // // This is what replaced ownership's own expression walk. Every decision it -// needs is published: what happens to a value, whether a reference is taken, -// and where a call begins and ends. Syntax is recovered only to reuse the -// helpers that already report against it, never to work out meaning. +// needs for ordinary storage/use behavior is published: what happens to a +// value, whether a reference is taken, and where a call begins and ends. AST +// identity is recovered only for source diagnostics and ownership-specific +// reference/raw-pointer provenance; generic action meaning is not re-derived. func (a *analyzer) applyEffects(node *site, st state, loans *loanContext) { if a == nil || node == nil || node.cfgSite == nil { return } - calls := make([]callFrame, 0, 2) - for _, op := range a.effects[node.cfgSite.ID] { - switch op := op.(type) { - case effect.CallBegin: - call, _ := a.module.TypedASTNodes[op.Node].(*ast.CallExpr) - calls = append(calls, callFrame{ - call: call, - temporary: len(loans.temporary), - reserved: len(loans.reserved), - }) - case effect.CallEnd: - if len(calls) == 0 { - continue - } - frame := calls[len(calls)-1] - calls = calls[:len(calls)-1] - // Reservations activate as the call starts, which is observable - // only once its arguments are evaluated; the temporaries those - // arguments created die with the call. - a.activateCallReservations(frame.call, frame.reserved, loans) - loans.temporary = loans.temporary[:frame.temporary] - loans.reserved = loans.reserved[:frame.reserved] - case effect.Write: - // Assigning a whole binding reinitializes it, so a moved one is a - // legal target. Assigning through a projection is different: it - // reaches into storage that was moved away. - if op.Place.Root != nil && len(op.Place.Projections) > 0 { - a.reportUseAfterMove(op.Place.Root, st, effect.Use{ - Place: op.Place, Node: op.Node, Location: op.Location, - }) - } - case effect.Use: - a.applyUse(node, op, st, loans) - case effect.Borrow: - a.applyBorrow(node, op, st, loans, calls) + ops := a.effects[node.cfgSite.ID] + visitor := &ownershipEffectVisitor{ + a: a, node: node, st: st, loans: loans, + storedReferences: a.captureStoredReferences(ops, st), + calls: make([]callFrame, 0, 2), + } + for _, op := range ops { + effect.Visit(op, visitor) + } +} + +type ownershipEffectVisitor struct { + a *analyzer + node *site + st state + loans *loanContext + storedReferences map[ast.NodeID]storedReference + calls []callFrame +} + +func (v *ownershipEffectVisitor) VisitDefine(op effect.Define) { + v.a.applyDefineEffect(v.node, op, v.st, v.storedReferences) +} + +func (v *ownershipEffectVisitor) VisitWrite(op effect.Write) { + v.a.applyWriteEffect(v.node, op, v.st, v.loans, v.storedReferences) +} + +func (v *ownershipEffectVisitor) VisitUse(op effect.Use) { + v.a.applyUse(v.node, op, v.st, v.loans) +} + +func (v *ownershipEffectVisitor) VisitBorrow(op effect.Borrow) { + v.a.applyBorrow(v.node, op, v.st, v.loans, v.calls) +} + +func (v *ownershipEffectVisitor) VisitIterate(op effect.Iterate) { + v.a.applyIterateEffect(op, v.st, v.loans) +} + +func (*ownershipEffectVisitor) VisitDiscard(effect.Discard) { + // Cleanup planning consumes Discard separately after evaluation. +} + +func (v *ownershipEffectVisitor) VisitCallBegin(op effect.CallBegin) { + call := v.a.module.TypedASTNodes[op.Node] + v.calls = append(v.calls, callFrame{ + call: call, temporary: len(v.loans.temporary), reserved: len(v.loans.reserved), + }) +} + +func (v *ownershipEffectVisitor) VisitCallEnd(effect.CallEnd) { + if len(v.calls) == 0 { + return + } + frame := v.calls[len(v.calls)-1] + v.calls = v.calls[:len(v.calls)-1] + // Reservations activate as the call starts, which is observable only once + // its arguments are evaluated; argument temporaries die with the call. + v.a.activateCallReservations(frame.call, frame.reserved, v.loans) + v.loans.temporary = v.loans.temporary[:frame.temporary] + v.loans.reserved = v.loans.reserved[:frame.reserved] +} + +// captureStoredReferences snapshots the reference provenance carried by values +// that a Define or Write will store. This runs before any operation at the site +// so a move performed while evaluating the source cannot erase the value that +// is about to enter the destination. +func (a *analyzer) captureStoredReferences(ops []effect.Op, st state) map[ast.NodeID]storedReference { + visitor := &storedReferenceVisitor{a: a, st: st, values: make(map[ast.NodeID]storedReference)} + for _, op := range ops { + effect.Visit(op, visitor) + } + return visitor.values +} + +type storedReferenceVisitor struct { + a *analyzer + st state + values map[ast.NodeID]storedReference +} + +func (v *storedReferenceVisitor) capture(valueID ast.NodeID) { + if valueID == 0 { + return + } + if _, captured := v.values[valueID]; captured { + return + } + value, _ := v.a.module.TypedASTNodes[valueID].(ast.Expr) + loans, present := v.a.referenceValueForExpr(value, v.st) + v.values[valueID] = storedReference{loans: loans, present: present} +} + +func (v *storedReferenceVisitor) VisitDefine(op effect.Define) { v.capture(op.Value) } +func (v *storedReferenceVisitor) VisitWrite(op effect.Write) { v.capture(op.Value) } +func (*storedReferenceVisitor) VisitUse(effect.Use) {} +func (*storedReferenceVisitor) VisitBorrow(effect.Borrow) {} +func (*storedReferenceVisitor) VisitIterate(effect.Iterate) {} +func (*storedReferenceVisitor) VisitDiscard(effect.Discard) {} +func (*storedReferenceVisitor) VisitCallBegin(effect.CallBegin) {} +func (*storedReferenceVisitor) VisitCallEnd(effect.CallEnd) {} + +// applyDefineEffect makes a newly defined binding own the value published by +// the semantic producer. The declaration syntax is irrelevant here: any future +// construct that publishes Define inherits the same ownership transition. +func (a *analyzer) applyDefineEffect(node *site, op effect.Define, st state, references map[ast.NodeID]storedReference) { + if op.Symbol == nil { + return + } + if op.Value != 0 { + value, _ := a.module.TypedASTNodes[op.Value].(ast.Expr) + reference := references[op.Value] + a.updatePointerSymbol(op.Symbol, node.scope, value, st) + a.updateReferenceSymbol(op.Symbol, reference.loans, reference.present, st) + } else if !op.OnEntry { + // An ordinary declaration without an initializer establishes empty + // storage. Entry bindings already carry state seeded by the edge/function + // entry and must not have that provenance erased here. + a.updatePointerSymbol(op.Symbol, node.scope, nil, st) + a.updateReferenceSymbol(op.Symbol, nil, false, st) + } + if ownershipTrackedSymbol(op.Symbol) { + delete(st.moved, op.Symbol) + st.live[op.Symbol] = struct{}{} + } +} + +// applyWriteEffect records replacement of existing storage. Place decides +// whether this is whole-binding reinitialization or mutation through a +// projection; Owner gives cleanup a stable identity independent of syntax kind. +func (a *analyzer) applyWriteEffect( + node *site, + op effect.Write, + st state, + loans *loanContext, + references map[ast.NodeID]storedReference, +) { + if op.Owner != 0 { + delete(a.cleanup.BeforeAssign, ir.NodeID(op.Owner)) + } + target, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if target == nil { + return + } + + // Assigning through a projection reaches into existing storage and cannot + // revive a root that was already moved away. + if op.Place.Root == nil || len(op.Place.Projections) > 0 { + if op.Place.Root != nil && a.reportUseAfterMove(op.Place.Root, st, effect.Use{ + Place: op.Place, Node: op.Node, Location: op.Location, + }) { + return } + a.checkStorageAccess(target, loans, storageMutate) + if op.Owner != 0 && typeinfo.OwnershipCapabilityOf(a.exprType(target)).Drop { + a.cleanup.BeforeAssign[ir.NodeID(op.Owner)] = struct{}{} + } + return + } + + sym := op.Place.Root + if _, referenceTarget := referenceMutability(sym); !referenceTarget { + a.checkStorageAccess(target, loans, storageMutate) + } + if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { + if _, live := st.live[sym]; live && op.Owner != 0 { + a.cleanup.BeforeAssign[ir.NodeID(op.Owner)] = struct{}{} + } + } + if ownershipTrackedSymbol(sym) { + delete(st.moved, sym) + st.live[sym] = struct{}{} + } + if op.Value == 0 { + return } + value, _ := a.module.TypedASTNodes[op.Value].(ast.Expr) + reference := references[op.Value] + a.updatePointerSymbol(sym, node.scope, value, st) + a.updateReferenceSymbol(sym, reference.loans, reference.present, st) +} + +// applyIterateEffect installs the long-lived shared access a sequence loop +// holds on its iterable. Iteration kind and carrier identity were decided by +// typechecking and published by effects; ownership does not inspect ForStmt or +// the iteration plan. +func (a *analyzer) applyIterateEffect(op effect.Iterate, st state, loans *loanContext) { + if op.Carrier == nil || op.Node == 0 { + return + } + iterable, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) + if iterable == nil { + return + } + a.checkStorageAccess(iterable, loans, storageSharedBorrow) + origins := a.originsForExpr(iterable) + if op.Place.Root != nil { + if value, found := st.references[op.Place.Root]; found { + origins = referenceOrigins(value) + } + } else if value, hasValue := a.referenceValueForExpr(iterable, st); hasValue { + origins = referenceOrigins(value) + } + if len(origins) == 0 { + return + } + st.references[op.Carrier] = []referenceLoan{{ + id: loanID{node: iterable}, origins: origins, site: iterable, loop: op.Loop, + }} } func (a *analyzer) applyUse(node *site, op effect.Use, st state, loans *loanContext) { syntax, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) if op.Place.Root == nil { // A value with no owner. Only a projection out of one has an effect - // here, and it is that the projection must be bound before use. - a.planProjectionBaseDrop(syntax, projectionBaseOf(syntax)) + // here, and it is that the projection must be bound before use. The + // effect place already names the temporary base; do not peel syntax. + base, _ := a.module.TypedASTNodes[op.Place.Temporary].(ast.Expr) + a.planProjectionBaseDrop(syntax, base) return } if a.reportUseAfterMove(op.Place.Root, st, op) { @@ -114,19 +300,16 @@ func (a *analyzer) applyProjectedUse(op effect.Use, st state, loans *loanContext loans.useReference(sym) } } - if a.planProjectionBaseDrop(syntax, projectionBaseOf(syntax)) { - return - } a.checkStorageAccess(syntax, loans, storageAccessForUse(a.exprType(syntax), op.Kind)) if op.Kind == typeinfo.UseRead || !ownershipTrackedType(a.exprType(syntax)) { return } - if a.partialVariantPayloadMove(syntax) { + if a.partialVariantPayloadMove(op.Node) { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "move-only variant payload cannot be moved from partial place; borrow it instead", op.Location, "") return } - if _, indexed := syntax.(*ast.IndexExpr); indexed { + if projection, projected := place.Project(syntax); projected && projection.Step.Kind == place.OriginIndex { a.ctx.Diagnostics.AddError(diagnostics.ErrInvalidCopy, "move-only indexed element cannot be used by value; borrow it with `&` or `&mut`", op.Location, "") return @@ -139,7 +322,6 @@ func (a *analyzer) applyProjectedUse(op effect.Use, st state, loans *loanContext // a call is being evaluated is a reservation rather than a borrow: it does not // take effect until the call it is an argument to actually starts. func (a *analyzer) applyBorrow(node *site, op effect.Borrow, st state, loans *loanContext, calls []callFrame) { - syntax, _ := a.module.TypedASTNodes[op.Node].(ast.Expr) if op.Place.Root != nil && a.reportUseAfterMove(op.Place.Root, st, effect.Use{ Place: op.Place, Node: op.Node, Location: op.Location, Kind: typeinfo.UseRead, }) { @@ -164,7 +346,7 @@ func (a *analyzer) applyBorrow(node *site, op effect.Borrow, st state, loans *lo loans.useReference(sym) } } - borrowed := borrowedExpr(syntax) + borrowed, _ := a.module.TypedASTNodes[op.Operand].(ast.Expr) if borrowed == nil { return } @@ -215,26 +397,6 @@ func (a *analyzer) reportUseAfterMove(sym *symbols.Symbol, st state, op effect.U return true } -// projectionBaseOf returns the expression a projection projects from. -func projectionBaseOf(expr ast.Expr) ast.Expr { - switch node := expr.(type) { - case *ast.SelectorExpr: - return node.Expr - case *ast.IndexExpr: - return node.Expr - } - return nil -} - -// borrowedExpr returns the place an address expression borrows. A borrow -// published for a reference argument names the argument itself. -func borrowedExpr(expr ast.Expr) ast.Expr { - if address, taken := expr.(*ast.AddressExpr); taken { - return address.Expr - } - return expr -} - // applyUseKind is what happens to a binding's value at one use: a move leaves it // dead, and a copy is rejected for anything the language will not duplicate. A // read leaves it as it was. diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 86181cc4..5cca46b7 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -39,11 +39,11 @@ func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { return a.module.EffectiveExprType(expr.ID()) } -func (a *analyzer) partialVariantPayloadMove(expr ast.Expr) bool { - if a == nil || a.module == nil || a.module.Flow == nil || expr == nil { +func (a *analyzer) partialVariantPayloadMove(id ast.NodeID) bool { + if a == nil || a.module == nil || a.module.Flow == nil || id == 0 { return false } - payload, ok := a.module.Flow.Payloads[expr.ID()] + payload, ok := a.module.Flow.Payloads[id] return ok && len(payload.Cases) > 0 && !payload.Direct } diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index 1fef4076..c5205b86 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -6,6 +6,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" @@ -188,23 +189,19 @@ func (a *analyzer) run() { } entry := a.graph.Entry.Sites[0].ID a.inStates = map[cfg.SiteID]state{entry: entryState} - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false + work := graphcore.NewWorklist(entry) + for { + id, pending := work.Next() + if !pending { + break + } node := a.sites[id] next := copyState(a.inStates[id]) - // Leaving a sequence loop releases the loans its carrier held. The CFG - // names the exit block's role, so this asks which loop is being left - // rather than inferring it from a block that carries no other role. + // Loop-owned loans are keyed by loop identity. Range loops publish no + // Iterate effect, so releasing by loop ID is harmless and avoids asking + // typechecker what kind of loop produced this CFG exit. if node != nil && node.cfgBlock != nil && node.cfgBlock.Origin == cfg.BlockLoopExit { - loopID := ast.NodeID(node.cfgBlock.NodeID) - evidence, found := a.module.Typechecking.ForIterations[loopID] - if _, sequence := evidence.Plan.(*typecheckresult.SequenceIteration); found && sequence { - releaseIterationLoans(next, nil, loopID) - } + releaseIterationLoans(next, nil, ast.NodeID(node.cfgBlock.NodeID)) } if node != nil { switch node.cfgSite.Kind { @@ -216,7 +213,7 @@ func (a *analyzer) run() { a.applyBlockExit(node, next, a.newLoanContext(node, next)) } } - for _, edge := range node.cfgSite.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(node.cfgSite.ID) { succ := edge.To if a.sites[succ] == nil { continue @@ -231,10 +228,7 @@ func (a *analyzer) run() { continue } a.inStates[succ] = merged - if !queued[succ] { - queue = append(queue, succ) - queued[succ] = true - } + work.Add(succ) } } } @@ -249,11 +243,10 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { } for _, node := range a.sites { - matchStmt, ok := node.stmt.(*ast.MatchStmt) - if !ok || node.cfgSite == nil || node.cfgSite.Kind != cfg.SiteTerminator { + if node == nil || node.cfgSite == nil || node.cfgSite.Kind != cfg.SiteTerminator { continue } - match, found := a.module.Typechecking.Matches[matchStmt.ID()] + match, found := a.module.Typechecking.Matches[ast.NodeID(node.cfgSite.NodeID)] if !found { continue } @@ -267,10 +260,14 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { armsByJoin := make(map[cfg.SiteID]map[ast.NodeID]struct{}) for _, arm := range match.Arms { for _, exit := range scopeExits[arm.BodyID] { - if exit == nil || exit.cfgSite == nil || len(exit.cfgSite.Successors) != 1 { + if exit == nil || exit.cfgSite == nil { + continue + } + edges := a.graph.SiteEdges.OutEdges(exit.cfgSite.ID) + if len(edges) != 1 { continue } - join := exit.cfgSite.Successors[0].To + join := edges[0].To for { joinNode := a.sites[join] if joinNode == nil || joinNode.cfgSite == nil { @@ -285,10 +282,11 @@ func (a *analyzer) planDeadMatchCarrierCleanup() { movesByJoin[join] = movesByJoin[join] || arm.CarrierUse == typeinfo.UseMove break } - if len(joinNode.cfgSite.Successors) != 1 { + edges = a.graph.SiteEdges.OutEdges(joinNode.cfgSite.ID) + if len(edges) != 1 { break } - join = joinNode.cfgSite.Successors[0].To + join = edges[0].To } } } @@ -344,7 +342,7 @@ func (a *analyzer) mergeState(nodeID cfg.SiteID, dst, src state, exists bool) (s return copyState(src), true } node := a.sites[nodeID] - if node == nil || node.cfgSite == nil || len(node.cfgSite.Predecessors) <= 1 { + if node == nil || node.cfgSite == nil || a.graph.SiteEdges.InDegree(node.cfgSite.ID, nil) <= 1 { if maps.Equal(dst.moved, src.moved) && maps.Equal(dst.live, src.live) && maps.Equal(dst.pointers, src.pointers) && sameReferenceValues(dst.references, src.references) { return dst, false @@ -518,114 +516,28 @@ func (a *analyzer) applyStmt(node *site, st state) { scope := node.scope loans := a.newLoanContext(node, st) - // Policy that has to observe state before the site's values are evaluated. - var boundReference []referenceLoan - boundHasReference := false - switch s := node.stmt.(type) { - case *ast.LetDecl: - boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) - case *ast.ConstDecl: - boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) - case *ast.AssignStmt: - boundReference, boundHasReference = a.referenceValueForExpr(s.Value, st) - delete(a.cleanup.BeforeAssign, ir.NodeID(s.ID())) - case *ast.ReturnStmt: + // Return provenance has to be checked against the incoming state, before + // evaluating the returned value can move its source. Storage transitions for + // declarations and assignments are published effects and require no syntax + // cases here. + if s, ok := node.stmt.(*ast.ReturnStmt); ok { a.checkPointerEscape(scope, s.Value, st) a.validateReferenceReturn(scope, s, st) } - // Evaluation itself, from published effects. + // Evaluation and generic storage transitions come from published effects. a.applyEffects(node, st, loans) + a.planDiscardedDrops(node) - // Policy that follows evaluation. - switch s := node.stmt.(type) { - case *ast.LetDecl: - a.applyBinding(scope, s, s.Value, st, boundReference, boundHasReference) - case *ast.ConstDecl: - a.applyBinding(scope, s, s.Value, st, boundReference, boundHasReference) - case *ast.AssignStmt: - a.applyAssignTarget(scope, s, st, loans, boundReference, boundHasReference) - case *ast.ReturnStmt: + // Return remains the one ownership statement policy whose checks straddle + // evaluation: provenance is validated above before the value can move, while + // cleanup happens after its effects have executed. + if s, ok := node.stmt.(*ast.ReturnStmt); ok { releaseIterationLoans(st, loans, 0) a.cleanupBeforeReturn(scope, s, st, loans) - case *ast.ExprStmt: - a.planDiscardedDrops(node) - case *ast.ForStmt: - a.applyLoopCarrier(s, st, loans) } } -// applyAssignTarget records what replacing a value does to the storage that -// held it: the old value is dropped, the binding is live again, and any pointer -// or reference it carried is refreshed. -func (a *analyzer) applyAssignTarget( - scope *symbols.Scope, - s *ast.AssignStmt, - st state, - loans *loanContext, - reference []referenceLoan, - hasReference bool, -) { - if _, direct := s.Target.(*ast.Ident); !direct { - a.checkStorageAccess(s.Target, loans, storageMutate) - if typeinfo.OwnershipCapabilityOf(a.exprType(s.Target)).Drop { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - return - } - target := s.Target.(*ast.Ident) - if scope == nil { - return - } - sym, found := scope.Lookup(target.Name) - if !found { - return - } - if _, referenceTarget := referenceMutability(sym); !referenceTarget { - a.checkStorageAccess(target, loans, storageMutate) - } - if typ, ok := symbols.GetSymbolType(sym); ok && typeinfo.OwnershipCapabilityOf(typ).Drop { - if _, live := st.live[sym]; live { - a.cleanup.BeforeAssign[ir.NodeID(s.ID())] = struct{}{} - } - } - if ownershipTrackedSymbol(sym) { - delete(st.moved, sym) - st.live[sym] = struct{}{} - } - a.updatePointerSymbol(sym, scope, s.Value, st) - a.updateReferenceSymbol(sym, reference, hasReference, st) -} - -// applyLoopCarrier installs the borrow a sequence loop holds on the storage it -// walks. A range loop borrows nothing. -func (a *analyzer) applyLoopCarrier(s *ast.ForStmt, st state, loans *loanContext) { - if s.Iterable == nil { - return - } - evidence := a.module.Typechecking.ForIterations[s.ID()] - sequence, isSequence := evidence.Plan.(*typecheckresult.SequenceIteration) - if !isSequence { - return - } - a.checkStorageAccess(s.Iterable, loans, storageSharedBorrow) - origins := a.originsForExpr(s.Iterable) - if ident, ok := s.Iterable.(*ast.Ident); ok { - sym := a.module.Bindings.NodeSymbols[ident.ID()] - if value, found := st.references[sym]; found { - origins = referenceOrigins(value) - } - } else if value, hasValue := a.referenceValueForExpr(s.Iterable, st); hasValue { - origins = referenceOrigins(value) - } - if len(origins) == 0 { - return - } - st.references[sequence.Carrier] = []referenceLoan{{ - id: loanID{node: s.Iterable}, origins: origins, site: s.Iterable, loop: s.ID(), - }} -} - func (a *analyzer) applyMatchEdge(node *site, edge cfg.Edge, st state) { if a == nil || node == nil || node.cfgSite == nil || edge.Kind != cfg.EdgeVariantCase { return @@ -727,29 +639,3 @@ func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { } return ids } - -// applyBinding records what declaring a binding does to ownership state. Its -// initializer was already evaluated from published effects; what remains is the -// binding itself becoming live and taking on whatever the value carried. -func (a *analyzer) applyBinding( - scope *symbols.Scope, - stmt ast.Stmt, - value ast.Expr, - st state, - reference []referenceLoan, - hasReference bool, -) { - if scope == nil || stmt == nil { - return - } - sym, found := scope.LookupNode(stmt) - if !found || sym == nil { - return - } - a.updatePointerSymbol(sym, scope, value, st) - a.updateReferenceSymbol(sym, reference, hasReference, st) - if ownershipTrackedSymbol(sym) { - delete(st.moved, sym) - st.live[sym] = struct{}{} - } -} diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index ce015ce8..5678c5f5 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -64,6 +64,7 @@ func checkOwnershipSource(t *testing.T, src string) *ownershipResult { ValueUse: module.Typechecking.ValueUse, ExprType: module.EffectiveExprType, ReferenceArgument: module.Typechecking.ReferenceArgument, + SequenceCarrier: module.Typechecking.SequenceCarrier, }) module.Ownership = Check(ctx, module) return &ownershipResult{DiagnosticBag: diag, ctx: ctx, module: module} diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index e23f3401..7d706d5a 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -6,6 +6,7 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/project" "compiler/internal/semantics/effect" @@ -216,7 +217,7 @@ func (a *analyzer) reportLoanConflict( addLoanConflictLabels(diag, conflict, reservedConflict, nil) } -func (a *analyzer) activateCallReservations(call *ast.CallExpr, mark int, loans *loanContext) { +func (a *analyzer) activateCallReservations(call ast.Node, mark int, loans *loanContext) { if a == nil || call == nil || loans == nil || mark >= len(loans.reserved) { return } @@ -297,39 +298,28 @@ func overlappingLoan(origins []place.Origin, facts []loanFact, exempt *symbols.S } func (a *analyzer) referenceHolder(expr ast.Expr) *symbols.Symbol { - if a == nil || a.module == nil || a.module.Bindings == nil { + if a == nil || a.module == nil || a.module.Bindings == nil || expr == nil { return nil } - for { - switch node := expr.(type) { - case *ast.AddressExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.SelectorExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.IndexExpr: - if node == nil { - return nil - } - expr = node.Expr - case *ast.Ident: - if node == nil { - return nil - } - sym := a.module.Bindings.NodeSymbols[node.ID()] - if _, reference := referenceMutability(sym); reference { - return sym - } - return nil - default: + if address, taken := expr.(*ast.AddressExpr); taken { + if address == nil { return nil } + expr = address.Expr + } + root, _, ok := place.Decompose(expr) + if !ok { + return nil } + ident, ok := root.(*ast.Ident) + if !ok || ident == nil { + return nil + } + sym := a.module.Bindings.NodeSymbols[ident.ID()] + if _, reference := referenceMutability(sym); reference { + return sym + } + return nil } func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLoan, bool) { @@ -549,23 +539,22 @@ func (a *analyzer) computeSymbolLiveness() { } a.symbolLiveIn = make(map[cfg.SiteID]map[*symbols.Symbol]ast.Node, len(a.order)) a.symbolLiveOut = make(map[cfg.SiteID]map[*symbols.Symbol]ast.Node, len(a.order)) - queue := make([]cfg.SiteID, 0, len(a.order)) - queued := make(map[cfg.SiteID]bool, len(a.order)) + work := graphcore.NewWorklist[cfg.SiteID]() for _, id := range slices.Backward(a.order) { - queue = append(queue, id) - queued[id] = true + work.Add(id) } - for len(queue) > 0 { - id := queue[0] - queue = queue[1:] - queued[id] = false + for { + id, pending := work.Next() + if !pending { + break + } out := make(map[*symbols.Symbol]ast.Node) node := a.sites[id] if node == nil || node.cfgSite == nil { continue } - for _, edge := range node.cfgSite.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(node.cfgSite.ID) { mergeSymbolLiveSets(out, a.symbolLiveIn[edge.To]) } uses, definitions := a.symbolUsesAndDefinitions(node) @@ -580,12 +569,8 @@ func (a *analyzer) computeSymbolLiveness() { } a.symbolLiveIn[id] = in a.symbolLiveOut[id] = out - for _, edge := range node.cfgSite.Predecessors { - pred := edge.From - if !queued[pred] { - queue = append(queue, pred) - queued[pred] = true - } + for _, edge := range a.graph.SiteEdges.InEdges(node.cfgSite.ID) { + work.Add(edge.From) } } } @@ -597,55 +582,76 @@ func (a *analyzer) computeSymbolLiveness() { // pre-assignment drop reads the old value, so the target must stay live up to // the assignment that replaces it. That is ownership policy and stays here. func (a *analyzer) symbolUsesAndDefinitions(node *site) (map[*symbols.Symbol]ast.Node, map[*symbols.Symbol]struct{}) { - uses := make(map[*symbols.Symbol]ast.Node) - definitions := make(map[*symbols.Symbol]struct{}) - if a == nil || a.module == nil || node == nil || node.cfgSite == nil { - return uses, definitions + visitor := &livenessEffectVisitor{ + a: a, + uses: make(map[*symbols.Symbol]ast.Node), + definitions: make(map[*symbols.Symbol]struct{}), } - recordUse := func(sym *symbols.Symbol, at ast.NodeID) { - syntax, found := a.module.TypedASTNodes[at] - if !found { - return - } - if previous, seen := uses[sym]; seen { - uses[sym] = earlierNode(previous, syntax) - return - } - uses[sym] = syntax + if a == nil || a.module == nil || node == nil || node.cfgSite == nil { + return visitor.uses, visitor.definitions } for _, op := range a.effects[node.cfgSite.ID] { - switch op := op.(type) { - case effect.Define: - // A binding that merely arrives at this site was established by the - // edge into it, so killing liveness here would end a borrow one site - // too early. - if op.OnEntry { - continue - } - if trackedLiveSymbol(op.Symbol) { - definitions[op.Symbol] = struct{}{} - } - case effect.Write: - if op.Place.Root == nil || !trackedLiveSymbol(op.Place.Root) { - continue - } - definitions[op.Place.Root] = struct{}{} - if typ, typed := symbols.GetSymbolType(op.Place.Root); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { - recordUse(op.Place.Root, op.Node) - } - case effect.Use: - if trackedLiveSymbol(op.Place.Root) { - recordUse(op.Place.Root, op.Node) - } - case effect.Borrow: - if trackedLiveSymbol(op.Place.Root) { - recordUse(op.Place.Root, op.Node) - } - } + effect.Visit(op, visitor) + } + return visitor.uses, visitor.definitions +} + +type livenessEffectVisitor struct { + a *analyzer + uses map[*symbols.Symbol]ast.Node + definitions map[*symbols.Symbol]struct{} +} + +func (v *livenessEffectVisitor) recordUse(sym *symbols.Symbol, at ast.NodeID) { + if sym == nil || v.a == nil || v.a.module == nil { + return + } + syntax, found := v.a.module.TypedASTNodes[at] + if !found { + return + } + if previous, seen := v.uses[sym]; seen { + v.uses[sym] = earlierNode(previous, syntax) + return + } + v.uses[sym] = syntax +} + +func (v *livenessEffectVisitor) VisitDefine(op effect.Define) { + // A binding that merely arrives at this site was established by the edge + // into it, so killing liveness here would end a borrow one site too early. + if !op.OnEntry && trackedLiveSymbol(op.Symbol) { + v.definitions[op.Symbol] = struct{}{} + } +} + +func (v *livenessEffectVisitor) VisitWrite(op effect.Write) { + if op.Place.Root == nil || !trackedLiveSymbol(op.Place.Root) { + return + } + v.definitions[op.Place.Root] = struct{}{} + if typ, typed := symbols.GetSymbolType(op.Place.Root); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { + v.recordUse(op.Place.Root, op.Node) + } +} + +func (v *livenessEffectVisitor) VisitUse(op effect.Use) { + if trackedLiveSymbol(op.Place.Root) { + v.recordUse(op.Place.Root, op.Node) + } +} + +func (v *livenessEffectVisitor) VisitBorrow(op effect.Borrow) { + if trackedLiveSymbol(op.Place.Root) { + v.recordUse(op.Place.Root, op.Node) } - return uses, definitions } +func (*livenessEffectVisitor) VisitIterate(effect.Iterate) {} +func (*livenessEffectVisitor) VisitDiscard(effect.Discard) {} +func (*livenessEffectVisitor) VisitCallBegin(effect.CallBegin) {} +func (*livenessEffectVisitor) VisitCallEnd(effect.CallEnd) {} + // symbolUseSequence returns the symbols this site reads, in evaluation order. // // It reads published effects rather than walking the statement itself. The @@ -656,34 +662,39 @@ func (a *analyzer) symbolUseSequence(node *site, include func(*symbols.Symbol) b if a == nil || a.module == nil || node == nil || node.cfgSite == nil || include == nil { return nil } - ops := a.effects[node.cfgSite.ID] - uses := make([]symbolUse, 0, len(ops)) - for _, op := range ops { - // Borrowing a place uses the binding it belongs to, exactly as reading - // it does: the loan has to outlive the borrow, so the last borrow is a - // last use. - var at effect.Place - var node ast.NodeID - switch op := op.(type) { - case effect.Use: - at, node = op.Place, op.Node - case effect.Borrow: - at, node = op.Place, op.Node - default: - continue - } - if !include(at.Root) { - continue - } - syntax, found := a.module.TypedASTNodes[node] - if !found { - continue - } - uses = append(uses, symbolUse{symbol: at.Root, site: syntax}) + visitor := &useSequenceEffectVisitor{a: a, include: include} + for _, op := range a.effects[node.cfgSite.ID] { + effect.Visit(op, visitor) + } + return visitor.uses +} + +type useSequenceEffectVisitor struct { + a *analyzer + include func(*symbols.Symbol) bool + uses []symbolUse +} + +func (v *useSequenceEffectVisitor) record(at effect.Place, node ast.NodeID) { + if !v.include(at.Root) { + return } - return uses + syntax, found := v.a.module.TypedASTNodes[node] + if !found { + return + } + v.uses = append(v.uses, symbolUse{symbol: at.Root, site: syntax}) } +func (*useSequenceEffectVisitor) VisitDefine(effect.Define) {} +func (*useSequenceEffectVisitor) VisitWrite(effect.Write) {} +func (v *useSequenceEffectVisitor) VisitUse(op effect.Use) { v.record(op.Place, op.Node) } +func (v *useSequenceEffectVisitor) VisitBorrow(op effect.Borrow) { v.record(op.Place, op.Node) } +func (*useSequenceEffectVisitor) VisitIterate(effect.Iterate) {} +func (*useSequenceEffectVisitor) VisitDiscard(effect.Discard) {} +func (*useSequenceEffectVisitor) VisitCallBegin(effect.CallBegin) {} +func (*useSequenceEffectVisitor) VisitCallEnd(effect.CallEnd) {} + func mergeSymbolLiveSets(dst, src map[*symbols.Symbol]ast.Node) { for sym, site := range src { if previous, found := dst[sym]; !found { diff --git a/internal/semantics/place/addressable.go b/internal/semantics/place/addressable.go index 9d482efd..fb9d0158 100644 --- a/internal/semantics/place/addressable.go +++ b/internal/semantics/place/addressable.go @@ -27,12 +27,76 @@ type Binding struct { // escape source. type BindingResolver func(*ast.Ident) (Binding, bool) +// Projection describes one syntactic projection from a base expression. +// +// This is the canonical structural definition of Peeper place projections. +// Consumers that only need to know how selector/index syntax is nested must use +// this API rather than maintaining their own AST switches. Semantic place +// resolution remains in Resolve, which enriches these structural projections +// with pointer, reference, optional-payload, and stable-index information. +type Projection struct { + Base ast.Expr + Step OriginProjection + Index ast.Expr +} + +// Project reports the direct projection represented by expr. Slices are not +// places: they borrow a range rather than select one independently addressable +// element, so an IndexExpr containing a RangeExpr is deliberately rejected. +func Project(expr ast.Expr) (Projection, bool) { + switch node := expr.(type) { + case *ast.SelectorExpr: + if node == nil || node.Expr == nil || node.Name == nil { + return Projection{}, false + } + return Projection{ + Base: node.Expr, + Step: OriginProjection{Kind: OriginField, Field: node.Name.Name}, + }, true + case *ast.IndexExpr: + if node == nil || node.Expr == nil || node.Index == nil { + return Projection{}, false + } + if _, slicing := node.Index.(*ast.RangeExpr); slicing { + return Projection{}, false + } + return Projection{ + Base: node.Expr, + Step: OriginProjection{Kind: OriginIndex}, + Index: node.Index, + }, true + default: + return Projection{}, false + } +} + +// Decompose peels every selector/index projection and returns the expression at +// the root plus the projection path in source order. It does not require that +// the root be an identifier: `make().field` therefore decomposes successfully +// and lets callers distinguish a temporary root from named storage themselves. +func Decompose(expr ast.Expr) (ast.Expr, []OriginProjection, bool) { + if expr == nil { + return nil, nil, false + } + projection, projected := Project(expr) + if !projected { + return expr, nil, true + } + root, path, ok := Decompose(projection.Base) + if !ok { + return nil, nil, false + } + path = append(path, projection.Step) + return root, path, true +} + func IsPlaceExpr(expr ast.Expr) bool { - if node, ok := expr.(*ast.Ident); ok { - return node != nil + root, _, ok := Decompose(expr) + if !ok { + return false } - base, ok := placeProjectionBase(expr) - return ok && IsPlaceExpr(base) + ident, identified := root.(*ast.Ident) + return identified && ident != nil } func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) bool { @@ -51,10 +115,11 @@ func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, res sym, found := scope.Lookup(e.Name) return found && addressableSymbol(sym) } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return false } + base := projection.Base if exprType != nil { if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(exprType(base))); ok { return true @@ -63,7 +128,7 @@ func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, res return true } } - return Addressable(scope, base, exprType, resolve) + return Addressable(scope, projection.Base, exprType, resolve) } func MutableAddressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (mutable bool, sharedReference typeinfo.Type, mutableBinding *symbols.Symbol) { @@ -94,10 +159,11 @@ func MutableAddressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFu return MutableAddressable(scope, index.Expr, exprType, resolve) } } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return false, nil, nil } + base := projection.Base if exprType != nil { baseType := typeinfo.Underlying(exprType(base)) if _, ok := baseType.(*typeinfo.RawPtrType); ok { @@ -143,10 +209,11 @@ func LocalRoot(scope, moduleScope *symbols.Scope, expr ast.Expr, exprType ExprTy } return nil, false } - base, ok := placeProjectionBase(expr) + projection, ok := Project(expr) if !ok { return nil, false } + base := projection.Base if exprType != nil { if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(exprType(base))); ok { return nil, false @@ -155,26 +222,6 @@ func LocalRoot(scope, moduleScope *symbols.Scope, expr ast.Expr, exprType ExprTy return LocalRoot(scope, moduleScope, base, exprType, resolve) } -func placeProjectionBase(expr ast.Expr) (ast.Expr, bool) { - switch node := expr.(type) { - case *ast.SelectorExpr: - if node == nil || node.Expr == nil { - return nil, false - } - return node.Expr, true - case *ast.IndexExpr: - if node == nil || node.Expr == nil || node.Index == nil { - return nil, false - } - if _, slicing := node.Index.(*ast.RangeExpr); slicing { - return nil, false - } - return node.Expr, true - default: - return nil, false - } -} - func addressableSymbol(sym *symbols.Symbol) bool { if sym == nil { return false diff --git a/internal/semantics/symbols/symbol.go b/internal/semantics/symbols/symbol.go index 15837d3c..00a904b4 100644 --- a/internal/semantics/symbols/symbol.go +++ b/internal/semantics/symbols/symbol.go @@ -7,6 +7,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/moduleid" + "compiler/internal/semantics/typeinfo" "compiler/internal/source" ) @@ -45,16 +46,11 @@ const ( SymbolUnknown Kind = "unknown" ) -type Type interface { - TypeNode() - Text() string -} - type Symbol struct { ID SymbolID Name string Kind Kind - Type Type + Type typeinfo.Type IsPub bool Mutable bool IsReceiver bool @@ -80,7 +76,7 @@ func New(name string, kind Kind, node ast.Node, location *source.Location) *Symb } } -func (s *Symbol) BindType(typ Type) bool { +func (s *Symbol) BindType(typ typeinfo.Type) bool { if s == nil || typ == nil { return false } @@ -91,7 +87,7 @@ func (s *Symbol) BindType(typ Type) bool { // SymbolType returns the semantic type stored on sym, or (nil, false) if sym // carries no type. // This is the canonical single-source-of-truth lookup shared across all passes. -func GetSymbolType(sym *Symbol) (Type, bool) { +func GetSymbolType(sym *Symbol) (typeinfo.Type, bool) { if sym == nil || sym.Type == nil { return nil, false } diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 4e22d023..1753b3e8 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -3,6 +3,7 @@ package typechecker import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" + graphcore "compiler/internal/graph" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/project" @@ -381,34 +382,32 @@ func (a *flowAnalyzer) run() { } entry := a.graph.Entry.Sites[0].ID a.inStates[entry] = entryState - queue := []cfg.SiteID{entry} - queued := map[cfg.SiteID]bool{entry: true} + work := graphcore.NewWorklist(entry) for _, id := range order { - if id == entry || len(a.sites[id].Predecessors) != 0 { + if id == entry || a.graph.SiteEdges.InDegree(id, nil) != 0 { continue } a.inStates[id] = copyFlowState(entryState) - queue = append(queue, id) - queued[id] = true + work.Add(id) } for { - if len(queue) == 0 { + id, pending := work.Next() + if !pending { + seeded := false for _, id := range order { if _, visited := a.inStates[id]; visited || !disconnected[id] { continue } a.inStates[id] = copyFlowState(entryState) - queue = append(queue, id) - queued[id] = true + work.Add(id) + seeded = true break } - if len(queue) == 0 { + if !seeded { break } + continue } - id := queue[0] - queue = queue[1:] - queued[id] = false site := a.sites[id] if site == nil { continue @@ -420,7 +419,7 @@ func (a *flowAnalyzer) run() { a.result.SiteFacts[a.graph.NodeID][id] = snapshotFlowState(input) next := copyFlowState(input) events := a.applySite(site, &next) - for _, edge := range site.Successors { + for _, edge := range a.graph.SiteEdges.OutEdges(site.ID) { if a.sites[edge.To] == nil { continue } @@ -441,10 +440,7 @@ func (a *flowAnalyzer) run() { continue } a.inStates[edge.To] = merged - if !queued[edge.To] { - queue = append(queue, edge.To) - queued[edge.To] = true - } + work.Add(edge.To) } } } @@ -784,17 +780,25 @@ func (a *flowAnalyzer) rawPointerOrigins(c *checker, scope *symbols.Scope, expr } func (a *flowAnalyzer) applyConditionEdge(site *cfg.Site, edge cfg.EdgeKind, st *flowState, events *flowExpressionEvents) { - if st == nil || (edge != cfg.EdgeTrue && edge != cfg.EdgeFalse) { + if a == nil || a.graph == nil || site == nil || st == nil || + (edge != cfg.EdgeTrue && edge != cfg.EdgeFalse) { return } - stmt, _ := a.module.TypedASTNodes[ast.NodeID(site.NodeID)].(ast.Stmt) - var condition ast.Expr - switch node := stmt.(type) { - case *ast.IfStmt: - condition = node.Cond - case *ast.ForStmt: - condition = node.Cond + // CFG owns control topology. Once a source construct has become a Branch, + // flow analysis must consume the branch's published condition identity + // rather than rediscovering whether the source statement was an if or loop. + if site.ID.Block < 0 || site.ID.Block >= len(a.graph.Blocks) { + return + } + block := a.graph.Blocks[site.ID.Block] + if block == nil { + return + } + branch, ok := block.Terminator.(*cfg.Branch) + if !ok || branch == nil || branch.ConditionID == 0 { + return } + condition, _ := a.module.TypedASTNodes[ast.NodeID(branch.ConditionID)].(ast.Expr) if condition == nil { return } diff --git a/internal/semantics/typecheckresult/result.go b/internal/semantics/typecheckresult/result.go index df3158e3..1e68d7d1 100644 --- a/internal/semantics/typecheckresult/result.go +++ b/internal/semantics/typecheckresult/result.go @@ -265,6 +265,24 @@ func (r *Result) ForLoopGuaranteedEntry(id ast.NodeID) bool { return found && iteration.GuaranteedEntry } +// SequenceCarrier exposes the hidden carrier a typed sequence loop keeps for +// the loop lifetime. Range loops have no carrier. Consumers ask this query +// instead of inspecting the concrete iteration plan themselves. +func (r *Result) SequenceCarrier(id ast.NodeID) (*symbols.Symbol, bool) { + if r == nil { + return nil, false + } + iteration, found := r.ForIterations[id] + if !found { + return nil, false + } + sequence, ok := iteration.Plan.(*SequenceIteration) + if !ok || sequence == nil || sequence.Carrier == nil { + return nil, false + } + return sequence.Carrier, true +} + // CallArgumentsOrSource returns published effective arguments when available. // Semantic phases that continue after diagnostics use source arguments when // typechecking could not publish complete call evidence. diff --git a/internal/semantics/typeinfo/capabilities.go b/internal/semantics/typeinfo/capabilities.go index 27c38157..e0ad86b4 100644 --- a/internal/semantics/typeinfo/capabilities.go +++ b/internal/semantics/typeinfo/capabilities.go @@ -119,7 +119,12 @@ func IsSizedType(t Type) bool { } visiting[defined] = true defer delete(visiting, defined) - return check(defined.Underlying) + result := false + ForEachChild(defined, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result } switch typ := Underlying(current).(type) { case *InvalidType, *UnknownType, *InterfaceType: @@ -133,7 +138,15 @@ func IsSizedType(t Type) bool { case *RawPtrType: return typ != nil case *OptionalType: - return typ != nil && check(typ.Inner) + if typ == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result case *ArrayType: if typ == nil || typ.Elem == nil { return false @@ -141,37 +154,42 @@ func IsSizedType(t Type) bool { if typ.Shape == ArraySlice { return false } - return check(typ.Elem) + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type) + return false + }) + return result case *StructType: if typ == nil { return false } - for _, field := range typ.Fields { - if !check(field.Type) { - return false - } - } - return true + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized case *EnumType: if typ == nil { return false } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload) { - return false - } - } - return true + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized case *FuncType: if typ == nil { return false } - for _, param := range typ.Params { - if !check(param) { - return false - } - } - return typ.Return == nil || check(typ.Return) + allSized := true + ForEachChild(typ, func(child TypeChild) bool { + allSized = check(child.Type) + return allSized + }) + return allSized default: return false } @@ -216,19 +234,35 @@ func IsLowerableType(t Type) bool { } return check(typ.Target, true) case *OptionalType: - return typ != nil && typ.Inner != nil && check(typ.Inner, throughIndirection) + if typ == nil || typ.Inner == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type, throughIndirection) + return false + }) + return result case *ArrayType: - return typ != nil && typ.Shape != ArraySlice && (typ.Shape == ArrayOwner || typ.Len != "") && typ.Elem != nil && check(typ.Elem, throughIndirection) + if typ == nil || typ.Shape == ArraySlice || typ.Shape != ArrayOwner && typ.Len == "" || typ.Elem == nil { + return false + } + result := false + ForEachChild(typ, func(child TypeChild) bool { + result = check(child.Type, throughIndirection) + return false + }) + return result case *StructType: if typ == nil { return false } - for _, field := range typ.Fields { - if !check(field.Type, throughIndirection) { - return false - } - } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *InterfaceType: if typ == nil { return false @@ -237,39 +271,36 @@ func IsLowerableType(t Type) bool { if len(method.Params) == 0 { return false } - for i, param := range method.Params { - if i == 0 { - continue - } - if ContainsAbstractSelf(param.Type) || !check(param.Type, throughIndirection) { - return false - } - } - if method.Return != nil && (ContainsAbstractSelf(method.Return) || !check(method.Return, throughIndirection)) { - return false - } } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + if child.Relation == TypeChildMethodReceiver { + return true + } + allLowerable = !ContainsAbstractSelf(child.Type) && check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *FuncType: if typ == nil { return false } - for _, param := range typ.Params { - if !check(param, throughIndirection) { - return false - } - } - return typ.Return == nil || check(typ.Return, throughIndirection) + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable case *EnumType: if typ == nil { return false } - for _, variant := range typ.Cases { - if variant.Payload != nil && !check(variant.Payload, throughIndirection) { - return false - } - } - return true + allLowerable := true + ForEachChild(typ, func(child TypeChild) bool { + allLowerable = check(child.Type, throughIndirection) + return allLowerable + }) + return allLowerable default: return false } diff --git a/internal/semantics/typeinfo/capability_walk.go b/internal/semantics/typeinfo/capability_walk.go index 98d3a9e1..cb135a96 100644 --- a/internal/semantics/typeinfo/capability_walk.go +++ b/internal/semantics/typeinfo/capability_walk.go @@ -1,92 +1,169 @@ package typeinfo -// ownershipCapability answers copy class and drop obligation in one traversal. -// -// IsImplicitCopyType, noCopyType and NeedsDrop each walk the same type -// structure with their own cycle guard, so a type is recursed three times to -// answer three questions about it. They also drift: a case added to one is easy -// to forget in the others. -// -// The three recursions differ in shape, and this keeps both shapes rather than -// flattening them. Implicit copy is a "for all" question — every field of an -// enum payload must itself copy implicitly. No-copy and drop are "there exists" -// questions — one owned field is enough. The enumPayload flag carries the one -// piece of context implicit copy needs: a struct copies implicitly only as an -// enum payload, never as top-level bulk storage. -// -// A cycle answers "not implicitly copyable, no drop", which is what all three -// predicates return when their guard fires. +// ownershipShapeKind describes how a semantic type contributes copy/drop +// behavior. It is part of the sealed Type contract: a new type must declare +// both its structural children and how ownership composes across them. +type ownershipShapeKind uint8 + +const ( + ownershipLeaf ownershipShapeKind = iota + ownershipAlias + ownershipOptional + ownershipBulk + ownershipStruct + ownershipEnum +) + +type ownershipShape struct { + kind ownershipShapeKind + facts capabilityFacts +} + +func leafOwnership(implicitCopy, noCopy, drop bool) ownershipShape { + return ownershipShape{kind: ownershipLeaf, facts: capabilityFacts{ + implicitCopy: implicitCopy, + noCopy: noCopy, + drop: drop, + }} +} + +func (*InvalidType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*UnknownType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*IntegerType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*ByteType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*CharType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*FloatType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*BoolType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*CStrType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*StringType) ownershipShape() ownershipShape { return leafOwnership(false, true, true) } +func (*NoneType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*AllocatorType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*NamedType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*TypeParameterType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } +func (*RawPtrType) ownershipShape() ownershipShape { return leafOwnership(true, false, false) } +func (*FuncType) ownershipShape() ownershipShape { return leafOwnership(false, false, false) } + +func (*DefinedType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipAlias} +} + +// An owned pointer owns its allocation as one value. The target remains a +// structural child for type queries, but copy/drop do not recursively inherit +// from the pointee because destroying the pointer is already the ownership act. +func (*OwnedPtrType) ownershipShape() ownershipShape { return leafOwnership(false, true, true) } + +func (t *RefType) ownershipShape() ownershipShape { + if t == nil { + return leafOwnership(false, false, false) + } + return leafOwnership(!t.Mutable, t.Mutable, false) +} + +func (*OptionalType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipOptional} +} + +func (t *ArrayType) ownershipShape() ownershipShape { + if t != nil && t.Shape == ArrayOwner { + return leafOwnership(false, true, true) + } + return ownershipShape{kind: ownershipBulk} +} + +func (*StructType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipStruct} +} + +func (*InterfaceType) ownershipShape() ownershipShape { + // Owned-interface drop activation is tracked separately by ownership. + return leafOwnership(false, true, false) +} + +func (*EnumType) ownershipShape() ownershipShape { + return ownershipShape{kind: ownershipEnum} +} + +// ownershipCapability answers copy class and drop obligation in one generic +// traversal. Type implementations declare structure and composition locally; +// the walker never names a concrete Type kind. func ownershipCapability(t Type) OwnershipCapability { - visiting := make(map[*DefinedType]bool) + visiting := make(map[Type]bool) var walk func(Type, bool) capabilityFacts walk = func(current Type, enumPayload bool) capabilityFacts { - if defined, ok := current.(*DefinedType); ok { - if defined == nil || visiting[defined] { - return capabilityFacts{} - } - visiting[defined] = true - defer delete(visiting, defined) - return walk(defined.Underlying, enumPayload) + if current == nil || isNilType(current) || visiting[current] { + return capabilityFacts{} } - switch typ := Underlying(current).(type) { - case *IntegerType, *ByteType, *CharType, *FloatType, *BoolType, - *CStrType, *RawPtrType, *AllocatorType, *NoneType: - return capabilityFacts{implicitCopy: true} - case *OwnedPtrType, *StringType: - return capabilityFacts{noCopy: true, drop: true} - case *InterfaceType: - // An interface value never copies implicitly, but owned-interface - // drop activation is tracked separately and is not a drop here. - return capabilityFacts{noCopy: true} - case *RefType: - if typ == nil { - return capabilityFacts{} - } - return capabilityFacts{implicitCopy: !typ.Mutable, noCopy: typ.Mutable} - case *OptionalType: - if typ == nil { - return capabilityFacts{} - } - return walk(typ.Inner, false) - case *ArrayType: - if typ == nil { - return capabilityFacts{} - } - // An owner array owns its storage whatever the element is. - if typ.Shape == ArrayOwner { - return capabilityFacts{noCopy: true, drop: true} - } - inner := walk(typ.Elem, false) - return capabilityFacts{noCopy: inner.noCopy, drop: inner.drop} - case *StructType: - if typ == nil { - return capabilityFacts{} - } - // Bulk storage never copies implicitly at top level; as an enum - // payload it does when every field does. + shape := current.ownershipShape() + if shape.kind == ownershipLeaf { + return shape.facts + } + + visiting[current] = true + defer delete(visiting, current) + + switch shape.kind { + case ownershipAlias: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + facts = walk(child.Type, enumPayload) + return false + } + return true + }) + return facts + case ownershipOptional: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + // Optional payloads are ordinary value storage; being nested in an + // enum does not turn bulk payload storage into implicit-copy data. + facts = walk(child.Type, false) + return false + } + return true + }) + return facts + case ownershipBulk: + facts := capabilityFacts{} + ForEachChild(current, func(child TypeChild) bool { + if ownsChild(child.Relation) { + inner := walk(child.Type, false) + // Fixed/slice array values never copy implicitly as bulk storage, + // but a non-copy/drop element still propagates through the value. + facts.noCopy = inner.noCopy + facts.drop = inner.drop + return false + } + return true + }) + return facts + case ownershipStruct: facts := capabilityFacts{implicitCopy: enumPayload} - for _, field := range typ.Fields { - inner := walk(field.Type, false) + ForEachChild(current, func(child TypeChild) bool { + if !ownsChild(child.Relation) { + return true + } + inner := walk(child.Type, false) facts.implicitCopy = facts.implicitCopy && inner.implicitCopy facts.noCopy = facts.noCopy || inner.noCopy facts.drop = facts.drop || inner.drop - } + return true + }) return facts - case *EnumType: - if typ == nil { - return capabilityFacts{} - } + case ownershipEnum: facts := capabilityFacts{implicitCopy: true} - for _, variant := range typ.Cases { - if variant.Payload == nil { - continue + ForEachChild(current, func(child TypeChild) bool { + if !ownsChild(child.Relation) { + return true } - inner := walk(variant.Payload, true) + inner := walk(child.Type, true) facts.implicitCopy = facts.implicitCopy && inner.implicitCopy facts.noCopy = facts.noCopy || inner.noCopy facts.drop = facts.drop || inner.drop - } + return true + }) return facts default: return capabilityFacts{} @@ -104,8 +181,16 @@ func ownershipCapability(t Type) OwnershipCapability { } } -// capabilityFacts is what one traversal step establishes about a type. It is -// internal to the walk: callers consume OwnershipCapability. +func ownsChild(relation TypeChildRelation) bool { + switch relation { + case TypeChildUnderlying, TypeChildOwnedTarget, TypeChildOptionalPayload, + TypeChildArrayElement, TypeChildStructField, TypeChildEnumPayload: + return true + default: + return false + } +} + type capabilityFacts struct { implicitCopy bool noCopy bool diff --git a/internal/semantics/typeinfo/relations.go b/internal/semantics/typeinfo/relations.go index 44624109..04fdd19f 100644 --- a/internal/semantics/typeinfo/relations.go +++ b/internal/semantics/typeinfo/relations.go @@ -290,68 +290,40 @@ func containsType(t Type, traversal typeTraversal, matches func(Type, bool) bool } seen[key] = struct{}{} - switch typ := current.(type) { - case *DefinedType: - return traversal.followDefined && typ != nil && visit(typ.Underlying, stored) - case *OwnedPtrType: - return typ != nil && visit(typ.Target, true) - case *RefType: - if traversal.referenceLeaf { - return false + matched := false + ForEachChild(current, func(child TypeChild) bool { + childStored, follow := traversedChildState(child.Relation, stored, traversal) + if !follow { + return true } - return typ != nil && visit(typ.Target, stored) - case *OptionalType: - return typ != nil && visit(typ.Inner, stored) - case *ArrayType: - return typ != nil && visit(typ.Elem, true) - case *FuncType: - if typ == nil || !traversal.followCallable { + if visit(child.Type, childStored) { + matched = true return false } - for _, param := range typ.Params { - if visit(param, false) { - return true - } - } - return visit(typ.Return, false) - case *StructType: - if typ == nil { - return false - } - for _, field := range typ.Fields { - if visit(field.Type, true) { - return true - } - } - case *EnumType: - if typ == nil { - return false - } - for _, variant := range typ.Cases { - if visit(variant.Payload, true) { - return true - } - } - case *InterfaceType: - if typ == nil || !traversal.followCallable { - return false - } - for _, method := range typ.Methods { - for _, param := range method.Params { - if visit(param.Type, false) { - return true - } - } - if visit(method.Return, false) { - return true - } - } - } - return false + return true + }) + return matched } return visit(t, false) } +func traversedChildState(relation TypeChildRelation, stored bool, traversal typeTraversal) (bool, bool) { + switch relation { + case TypeChildUnderlying: + return stored, traversal.followDefined + case TypeChildOwnedTarget, TypeChildArrayElement, TypeChildStructField, TypeChildEnumPayload: + return true, true + case TypeChildBorrowedTarget: + return stored, !traversal.referenceLeaf + case TypeChildOptionalPayload: + return stored, true + case TypeChildMethodReceiver, TypeChildCallableParameter, TypeChildCallableReturn: + return false, traversal.followCallable + default: + panic("typeinfo: unknown semantic type child relation") + } +} + func ReplaceAbstractSelf(t Type, ownerType Type) Type { switch typ := t.(type) { case *NamedType: diff --git a/internal/semantics/typeinfo/structure.go b/internal/semantics/typeinfo/structure.go new file mode 100644 index 00000000..8bb72570 --- /dev/null +++ b/internal/semantics/typeinfo/structure.go @@ -0,0 +1,172 @@ +package typeinfo + +import "reflect" + +// TypeChildRelation describes why one semantic type contains another. The +// relation is structural evidence, not an analysis result: ownership, sizing, +// lowerability, substitution, and future queries may interpret the same child +// differently while sharing one canonical declaration of where that child is. +type TypeChildRelation uint8 + +const ( + TypeChildUnderlying TypeChildRelation = iota + TypeChildOwnedTarget + TypeChildBorrowedTarget + TypeChildOptionalPayload + TypeChildArrayElement + TypeChildStructField + TypeChildEnumPayload + TypeChildMethodReceiver + TypeChildCallableParameter + TypeChildCallableReturn +) + +// TypeChild is one immediate semantic-type edge. A new composite Type must +// expose its children here through Type.forEachChild; recursive consumers must +// not rediscover fields with their own type switches. +type TypeChild struct { + Type Type + Relation TypeChildRelation +} + +// ForEachChild visits the immediate semantic children of typ in source/semantic +// order. It returns false when yield asks traversal to stop. +// +// This is the semantic-type equivalent of ast.Node.forEachChild: it owns type +// structure once while leaving recursion policy to the consumer. Cycle rules +// deliberately stay with each analysis because sizedness, lowerability, and +// ownership do not assign the same meaning to recursive edges. +func ForEachChild(typ Type, yield func(TypeChild) bool) bool { + if typ == nil || yield == nil { + return true + } + return typ.forEachChild(yield) +} + +// isNilType handles a typed-nil pointer stored in the Type interface without +// enumerating concrete type kinds. Type is sealed to this package and all +// current implementations are pointer types, but the kind guard keeps this +// helper correct if a value implementation is ever introduced. +func isNilType(typ Type) bool { + if typ == nil { + return true + } + value := reflect.ValueOf(typ) + return value.Kind() == reflect.Pointer && value.IsNil() +} + +func noTypeChildren(func(TypeChild) bool) bool { return true } + +func (*InvalidType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*UnknownType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*IntegerType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*ByteType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*CharType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*FloatType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*BoolType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*CStrType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*StringType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*NoneType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*AllocatorType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*NamedType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*TypeParameterType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*RawPtrType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } + +func (t *DefinedType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Underlying, TypeChildUnderlying) +} + +func (t *OwnedPtrType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Target, TypeChildOwnedTarget) +} + +func (t *RefType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Target, TypeChildBorrowedTarget) +} + +func (t *OptionalType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Inner, TypeChildOptionalPayload) +} + +func (t *ArrayType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + return yieldTypeChild(yield, t.Elem, TypeChildArrayElement) +} + +func (t *FuncType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, param := range t.Params { + if param != nil && !yield(TypeChild{Type: param, Relation: TypeChildCallableParameter}) { + return false + } + } + return t.Return == nil || yield(TypeChild{Type: t.Return, Relation: TypeChildCallableReturn}) +} + +func (t *StructType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, field := range t.Fields { + if field.Type != nil && !yield(TypeChild{Type: field.Type, Relation: TypeChildStructField}) { + return false + } + } + return true +} + +func (t *InterfaceType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, method := range t.Methods { + for index, param := range method.Params { + relation := TypeChildCallableParameter + if index == 0 { + relation = TypeChildMethodReceiver + } + if param.Type != nil && !yield(TypeChild{Type: param.Type, Relation: relation}) { + return false + } + } + if method.Return != nil && !yield(TypeChild{Type: method.Return, Relation: TypeChildCallableReturn}) { + return false + } + } + return true +} + +func (t *EnumType) forEachChild(yield func(TypeChild) bool) bool { + if t == nil { + return true + } + for _, variant := range t.Cases { + if variant.Payload != nil && !yield(TypeChild{Type: variant.Payload, Relation: TypeChildEnumPayload}) { + return false + } + } + return true +} + +func yieldTypeChild(yield func(TypeChild) bool, child Type, relation TypeChildRelation) bool { + if child == nil { + return true + } + return yield(TypeChild{Type: child, Relation: relation}) +} diff --git a/internal/semantics/typeinfo/structure_test.go b/internal/semantics/typeinfo/structure_test.go new file mode 100644 index 00000000..31a35c46 --- /dev/null +++ b/internal/semantics/typeinfo/structure_test.go @@ -0,0 +1,107 @@ +package typeinfo + +import ( + "reflect" + "testing" +) + +func TestForEachChildOwnsCompositeTypeStructure(t *testing.T) { + i32 := &IntegerType{Signed: true, Bits: 32} + text := &StringType{} + receiver := &RefType{Target: i32} + tests := []struct { + name string + typ Type + want []TypeChild + }{ + { + name: "defined", + typ: &DefinedType{Underlying: i32}, + want: []TypeChild{{Type: i32, Relation: TypeChildUnderlying}}, + }, + { + name: "owned target", + typ: &OwnedPtrType{Target: text}, + want: []TypeChild{{Type: text, Relation: TypeChildOwnedTarget}}, + }, + { + name: "borrowed target", + typ: &RefType{Target: text}, + want: []TypeChild{{Type: text, Relation: TypeChildBorrowedTarget}}, + }, + { + name: "optional payload", + typ: &OptionalType{Inner: text}, + want: []TypeChild{{Type: text, Relation: TypeChildOptionalPayload}}, + }, + { + name: "array element", + typ: &ArrayType{Len: "4", Elem: text}, + want: []TypeChild{{Type: text, Relation: TypeChildArrayElement}}, + }, + { + name: "struct fields", + typ: &StructType{Fields: []Field{{Name: "left", Type: i32}, {Name: "right", Type: text}}}, + want: []TypeChild{{Type: i32, Relation: TypeChildStructField}, {Type: text, Relation: TypeChildStructField}}, + }, + { + name: "enum payloads", + typ: &EnumType{Cases: []VariantCase{{Name: "Empty"}, {Name: "Value", Payload: text}}}, + want: []TypeChild{{Type: text, Relation: TypeChildEnumPayload}}, + }, + { + name: "function signature", + typ: &FuncType{Params: []Type{i32}, Return: text}, + want: []TypeChild{{Type: i32, Relation: TypeChildCallableParameter}, {Type: text, Relation: TypeChildCallableReturn}}, + }, + { + name: "interface methods", + typ: &InterfaceType{Methods: []Method{{ + Name: "read", + Params: []Field{{Name: "self", Type: receiver}}, + Return: text, + }}}, + want: []TypeChild{{Type: receiver, Relation: TypeChildMethodReceiver}, {Type: text, Relation: TypeChildCallableReturn}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got []TypeChild + ForEachChild(test.typ, func(child TypeChild) bool { + got = append(got, child) + return true + }) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("ForEachChild(%T) = %#v, want %#v", test.typ, got, test.want) + } + }) + } +} + +func TestTypeStructureDrivesRecursiveContainment(t *testing.T) { + stored := &StructType{Fields: []Field{{Name: "borrow", Type: &RefType{Target: &IntegerType{Signed: true, Bits: 32}}}}} + wrapped := &OptionalType{Inner: &ArrayType{Len: "2", Elem: stored}} + + if !ContainsReference(wrapped) { + t.Fatal("nested structural type should contain a reference") + } + if !ContainsStoredReference(wrapped) { + t.Fatal("reference inside stored composite children should be stored") + } +} + +func TestForEachChildAcceptsTypedNilTypes(t *testing.T) { + var optional *OptionalType + var typ Type = optional + called := false + if !ForEachChild(typ, func(TypeChild) bool { + called = true + return true + }) { + t.Fatal("typed nil traversal should complete") + } + if called { + t.Fatal("typed nil type should have no children") + } +} diff --git a/internal/semantics/typeinfo/types.go b/internal/semantics/typeinfo/types.go index 8359d224..6da45288 100644 --- a/internal/semantics/typeinfo/types.go +++ b/internal/semantics/typeinfo/types.go @@ -8,6 +8,8 @@ import ( type Type interface { TypeNode() Text() string + forEachChild(func(TypeChild) bool) bool + ownershipShape() ownershipShape } type InvalidType struct{} From 463a0ace93d6fff47b87ccd741d0fae145f31937 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 08:29:31 +0000 Subject: [PATCH 70/80] Support Go 1.23.2 as compiler baseline Backport the small set of post-1.23 convenience APIs used by tests, CLI, LSP, project loading, and registry code without changing compiler behavior. --- cmd/cli/get_test.go | 6 +++--- cmd/cli/init_test.go | 8 ++++---- cmd/cli/list_test.go | 4 ++-- cmd/cli/test_helpers_test.go | 25 +++++++++++++++++++++++++ cmd/dispatch.go | 3 ++- go.mod | 2 +- internal/backend/llvm/emitter_test.go | 2 +- internal/frontend/lexer/lexer_test.go | 2 +- internal/lsp/state.go | 6 ++++-- internal/project/imports.go | 5 ++--- pkg/registry/download.go | 2 +- x_test/fixtures_test.go | 3 ++- 12 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 cmd/cli/test_helpers_test.go diff --git a/cmd/cli/get_test.go b/cmd/cli/get_test.go index 56584783..c100eec4 100644 --- a/cmd/cli/get_test.go +++ b/cmd/cli/get_test.go @@ -102,7 +102,7 @@ mock_path = "./mock" mustWriteGetTest(t, filepath.Join(mockPackage, "src", "pkg.peep"), "original") mustWriteGetTest(t, filepath.Join(cachePackage, manifest.FileName), "name = \"stale\"\nbuild = \"lib\"\n") mustWriteGetTest(t, filepath.Join(cachePackage, "src", "pkg.peep"), "unlocked-cache") - t.Chdir(root) + chdirForTest(t, root) if err := installAllDependencies(); err != nil { t.Fatal(err) @@ -188,7 +188,7 @@ build = "lib" child = "github.com/acme/child" `) mustWriteGetTest(t, filepath.Join(root, "mock", "acme", "child-v1.0.0", manifest.FileName), "name = \"child\"\nbuild = \"lib\"\n") - t.Chdir(root) + chdirForTest(t, root) if err := installAllDependencies(); err != nil { t.Fatal(err) @@ -251,7 +251,7 @@ mock_path = "./mock" if err != nil { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) err = installAllDependencies() if test.wantError { diff --git a/cmd/cli/init_test.go b/cmd/cli/init_test.go index a661afa4..8f58c7b4 100644 --- a/cmd/cli/init_test.go +++ b/cmd/cli/init_test.go @@ -14,7 +14,7 @@ func TestInitCommandRejectsInvalidNamesWithoutArtifacts(t *testing.T) { for _, name := range []string{"1app", "bad/name", "_app"} { t.Run(name, func(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := InitCommand([]string{name}); err == nil { t.Fatalf("InitCommand(%q) succeeded", name) } @@ -67,7 +67,7 @@ func TestInitCommandPreflightsPathConflictsBeforeWriting(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) test.setup(t) if err := InitCommand([]string{"app"}); err == nil { t.Fatal("InitCommand succeeded") @@ -81,7 +81,7 @@ func TestInitCommandPreflightsPathConflictsBeforeWriting(t *testing.T) { func TestInitCommandPreservesExistingRegularMain(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := os.Mkdir(peeper.SourceDirName, 0o755); err != nil { t.Fatal(err) } @@ -112,7 +112,7 @@ func TestInitCommandPreservesExistingRegularMain(t *testing.T) { func TestInitCommandNormalizesWhitespace(t *testing.T) { root := t.TempDir() - t.Chdir(root) + chdirForTest(t, root) if err := InitCommand([]string{"Hello Peeper"}); err != nil { t.Fatalf("InitCommand: %v", err) } diff --git a/cmd/cli/list_test.go b/cmd/cli/list_test.go index ba93770f..33fc4216 100644 --- a/cmd/cli/list_test.go +++ b/cmd/cli/list_test.go @@ -17,7 +17,7 @@ func TestListCommandPropagatesMalformedLockfile(t *testing.T) { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) if err := ListCommand(nil); err == nil { t.Fatal("ListCommand ignored malformed lockfile") @@ -29,7 +29,7 @@ func TestListCommandAllowsMissingLockfile(t *testing.T) { if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte("name = \"app\"\nbuild = \"program\"\n"), 0o644); err != nil { t.Fatal(err) } - t.Chdir(root) + chdirForTest(t, root) if err := ListCommand(nil); err != nil { t.Fatalf("ListCommand with no lockfile: %v", err) diff --git a/cmd/cli/test_helpers_test.go b/cmd/cli/test_helpers_test.go new file mode 100644 index 00000000..9fb39382 --- /dev/null +++ b/cmd/cli/test_helpers_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "os" + "testing" +) + +// chdirForTest is the Go 1.23 equivalent of testing.T.Chdir. Keep working +// directory mutation behind one helper so tests restore process state even +// when they fail. +func chdirForTest(t *testing.T, dir string) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir %s: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Errorf("restore working directory %s: %v", previous, err) + } + }) +} diff --git a/cmd/dispatch.go b/cmd/dispatch.go index beafe62e..b70b5999 100644 --- a/cmd/dispatch.go +++ b/cmd/dispatch.go @@ -36,7 +36,8 @@ func exitOnCommandError(err error) { if errors.Is(err, errAlreadyReported) { os.Exit(exitCodeError) } - if status, ok := errors.AsType[programExitStatus](err); ok { + var status programExitStatus + if errors.As(err, &status) { os.Exit(int(status)) } colors.RED.Fprintln(os.Stderr, err) diff --git a/go.mod b/go.mod index d10de473..e2897679 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module compiler -go 1.26.2 \ No newline at end of file +go 1.23.2 \ No newline at end of file diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 81b12abd..13ce600a 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -2908,7 +2908,7 @@ func TestGenerateLLVMIRUsesWidenedUnsignedDynamicArrayIndexForGEP(t *testing.T) t.Fatalf("expected narrow unsigned index widening, got:\n%s", irText) } gep := "" - for line := range strings.SplitSeq(irText, "\n") { + for _, line := range strings.Split(irText, "\n") { if strings.Contains(line, "getelementptr i32") { gep = line break diff --git a/internal/frontend/lexer/lexer_test.go b/internal/frontend/lexer/lexer_test.go index 75661d86..b14bc723 100644 --- a/internal/frontend/lexer/lexer_test.go +++ b/internal/frontend/lexer/lexer_test.go @@ -148,7 +148,7 @@ fn add(a: i32, b: i32): i32 { return a + b; }` - for b.Loop() { + for i := 0; i < b.N; i++ { New("benchmark"+peeper.SourceExt, src, nil).Tokenize() } } diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 89880c00..edac1dd2 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -246,7 +246,9 @@ func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Dura version := s.diagVersion[filePath] s.mu.Unlock() - s.diagWG.Go(func() { + s.diagWG.Add(1) + go func() { + defer s.diagWG.Done() // Full-sync edits arrive as whole-file snapshots. Delay diagnostics so a // burst of keystrokes collapses into one recompile instead of one per edit. time.Sleep(delay) @@ -263,7 +265,7 @@ func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Dura } s.mu.Unlock() } - }) + }() } func (s *ServerState) waitForScheduledDiagnostics() error { diff --git a/internal/project/imports.go b/internal/project/imports.go index 8ec076d9..bb7186bb 100644 --- a/internal/project/imports.go +++ b/internal/project/imports.go @@ -129,7 +129,7 @@ func (ctx *CompilerContext) importCandidateRoot(prefix string) (root, sourcePref } func hasHiddenImportSegment(path string) bool { - for segment := range strings.SplitSeq(path, "/") { + for _, segment := range strings.Split(path, "/") { if strings.HasPrefix(segment, ".") { return true } @@ -340,8 +340,7 @@ func validateImportPath(importPath string) error { if filepath.IsAbs(importPath) || strings.HasPrefix(importPath, "./") || strings.HasPrefix(importPath, "../") { return fmt.Errorf("import path must be root-relative") } - parts := strings.SplitSeq(importPath, "/") - for part := range parts { + for _, part := range strings.Split(importPath, "/") { if part == "" || part == "." || part == ".." { return fmt.Errorf("import path must be root-relative") } diff --git a/pkg/registry/download.go b/pkg/registry/download.go index 54a13572..dbe256b9 100644 --- a/pkg/registry/download.go +++ b/pkg/registry/download.go @@ -315,7 +315,7 @@ func fetchGitHubVersions(ctx context.Context, httpClient *http.Client, repoName, } func githubNextPage(linkHeader string) (string, error) { - for part := range strings.SplitSeq(linkHeader, ",") { + for _, part := range strings.Split(linkHeader, ",") { part = strings.TrimSpace(part) if !strings.Contains(part, `rel="next"`) { continue diff --git a/x_test/fixtures_test.go b/x_test/fixtures_test.go index ca67546d..4538cf1b 100644 --- a/x_test/fixtures_test.go +++ b/x_test/fixtures_test.go @@ -152,7 +152,8 @@ func executeFixtureProcess(t *testing.T, ctx context.Context, command string, ar if err == nil { return stdout.String(), stderr.String(), 0 } - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { return stdout.String(), stderr.String(), exitErr.ExitCode() } t.Fatalf("execute %s: %v", command, err) From 43f71cb2cc9b6bf18bf5f92535409185be0fc349 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 08:35:02 +0000 Subject: [PATCH 71/80] Document final compiler architecture --- COMPILER_FRAMEWORK_REPORT.md | 777 +++--------------------- COMPILER_GUIDELINES.md | 16 +- CONTRIBUTING.md | 7 +- Code-tour.md | 23 +- README.md | 4 + docs/compiler-architecture.md | 354 +++++++++++ docs/compiler-framework/README.md | 709 ++------------------- docs/compiler-framework/change-paths.md | 468 +++++--------- 8 files changed, 660 insertions(+), 1698 deletions(-) create mode 100644 docs/compiler-architecture.md diff --git a/COMPILER_FRAMEWORK_REPORT.md b/COMPILER_FRAMEWORK_REPORT.md index 77cf7608..4d2079e9 100644 --- a/COMPILER_FRAMEWORK_REPORT.md +++ b/COMPILER_FRAMEWORK_REPORT.md @@ -1,741 +1,122 @@ -# Compiler Framework Direction +# Compiler Framework Final Report -## Why this work was requested +## Problem -Framework discussion started during `for` loop implementation. Loop itself was not only concern. Work exposed broader development risk: adding one language construct required finding and coordinating many scattered types, functions, maps, switches, and phase-specific assumptions. +Peeper previously repeated the same structural and semantic work in several +phases. A feature could compile while one resolver/type/flow/ownership/cleanup +path was forgotten, then fail much later. Fixing one missed path often created a +new mismatch because phases independently walked the same syntax or type shape. -A feature could appear complete in parser or typechecker while one downstream phase was silently missed. Missing work might only surface later as: +`ast.Inspect` already demonstrated the preferred solution: one package owns +structure; many consumers reuse it. -- incorrect control flow; -- missing ownership cleanup; -- malformed HIR or MIR; -- backend failure; -- editor/LSP crash on incomplete source; -- stale incremental state; -- production-only behavior discovered long after implementation. +## Final decision -Peeper already had one strong framework-like pattern: structural traversal through APIs such as `Inspect` and node-owned child walking. Central traversal reduced repeated recursion and made child handling easier to audit. It did not yet make a newly added child field impossible to omit from manual `forEachChild` code; framework must close that remaining gap through generated traversal or an equivalent mechanical check. +Framework is **not** a compiler-wide visitor pattern and **not** an effect stream +used for everything. Final design combines: -Request was to apply same principle to rest of compiler: +- canonical structural kernels; +- phase-owned semantic evidence; +- generic graph/worklist mechanics; +- explicit exhaustive decisions only at genuine semantic extension points. -> Make required work mechanically visible. When syntax or semantics changes, compiler should immediately reveal every phase, invariant, artifact, and test that must also change. +Detailed contract: [`docs/compiler-architecture.md`](docs/compiler-architecture.md). -Primary goal is **code as executable safety guard**. Documentation explains architecture, but codebase must enforce it. Adding a node should break compilation until participating phases make explicit decisions. Adding a child field should fail generated-code or static consistency checks until traversal includes it. Publishing incomplete ownership/type/CFG evidence should fail at phase boundary before HIR, MIR, backend, or production use. +## Shipped architecture changes -Goal was not a generic language-generator product. Goal was a clean, logically safe compiler architecture that teaches its own flow through package ownership, function signatures, interface requirements, generated traversal, typed results, and validators. Someone should be able to replace syntax or selected language rules while retaining these safety checks. +### Semantic type structure -## Problems that motivated proposal +`typeinfo.Type` is sealed by canonical child and ownership-composition methods. +`typeinfo.ForEachChild` exposes immediate semantic children with relation metadata. +Recursive containment and copy/drop capability queries reuse this structure. -### 1. Semantic state was scattered +Result: adding a container around an existing subtype declares its relation once; +nested reference/ownership/drop behavior is derived instead of added to several +walks. -Related facts could live in broad project-level structures, while producers and consumers were spread across collector, binder, resolver, typechecker, CFG, ownership, HIR, MIR, backend, and LSP code. +### Shared graph topology -This obscured basic questions: +`graph.Directed` owns ordered outgoing/incoming indexes. Existing graph users and +CFG use this kernel while CFG retains typed branch/case semantics. -- Which phase owns this fact? -- When is it complete? -- Which later phases may consume it? -- When must incremental compilation discard it? -- Is missing evidence valid recovery behavior or compiler bug? +Result: no separate CFG successor/predecessor store can drift from reverse edges. -### 2. Structural traversal was safer than semantic dispatch +### Shared fixed-point scheduling -`Inspect`/walk functions helped every consumer reach nested nodes. They did not guarantee that every semantic phase had consciously handled a newly introduced node kind. +`graph.Worklist` owns queueing, pending deduplication, and rescheduling. Flow, +definite-init, ownership, and liveness keep their own lattices and transfers. -A new statement could be traversed structurally while resolver, typechecker, CFG, ownership, or lowering silently lacked required semantics. +Result: repeated scheduler mechanics are centralized without creating a generic +pass framework that hides semantics. -### 3. Invalid artifacts failed too late +### Canonical places -Many cross-phase invariants existed only as assumptions. Bad semantic evidence could survive until HIR, MIR, or backend code, where resulting failure was far from actual producer. +`place.Project` and `place.Decompose` own selector/index place grammar. Effect and +ownership code no longer maintain private selector/index peeling logic. -Desired behavior: +### Semantic effects as downstream boundary -```text -producer creates invalid result - ↓ -producer boundary rejects it immediately -``` - -Not: - -```text -producer creates invalid result - ↓ -several phases accept it - ↓ -backend crashes on unrelated-looking operation -``` - -### 4. Naming and generated artifact construction were mixed with orchestration +Effects now carry enough identity for generic consumers: -Functions such as generated binding/identifier construction looked small enough to resemble wrappers, while module lowering also owned source lowering, hidden loop state, symbol naming, callable mangling, and generated assignments. +- definitions carry initializer identity; +- writes carry source owner/value identity; +- borrows carry exact borrowed operand identity; +- sequence loops publish `Iterate` with loop/place/carrier identity. -Concern was valid: small functions are useful only when they own a real invariant. A helper that merely forwards data is noise. A constructor that consistently couples symbol ID, lowered type, name, and source location protects a real lowering invariant—but it should live in a purposeful subsystem and be named accordingly. +Ownership consumes `Define` and `Write` directly instead of separate `let`, +`const`, and assignment statement handlers. `effect.Visitor` is the compile-time +consumer contract: introducing a genuinely new semantic operation breaks every +exhaustive consumer until it explicitly implements that operation. Sequence-loop borrow lifetime is +published as an effect instead of rediscovered from `ForStmt` and typechecker plan +shape. Definite initialization and ownership/liveness consume the same ordered +effect stream. -### 5. Loop metadata exposed unclear ownership +### Contract cleanup -Types such as iteration evidence and loop target stacks raised two different questions: +Contracts that only verified duplicated downstream AST switches were removed. +Remaining contracts guard real closed extension points such as semantic type +identity/lowering/fingerprinting. Artifact validators remain primary guards for +cross-phase evidence. -- Is this temporary builder state, semantic evidence, CFG topology, or project-wide state? -- Does its file/package location match its owner? +### Go baseline -Proposal was to place each type beside phase that produces and validates it, instead of using broad files such as `project/modules.go` as a general storage location. +Repository now targets Go 1.23.2. Post-1.23 convenience APIs used by tests/runtime +were replaced with equivalent 1.23 code; no compiler semantics required Go 1.26. -### 6. Multiple identities and rediscovery increased drift risk +## Extension result -Compiler concepts could be identified differently by filesystem path, import key, symbol owner, graph node, or mangled name. Multiple representations invite scans, conversion helpers, stale aliases, collisions, and mismatched incremental invalidation. - -Framework direction therefore included one canonical typed identity per concept, with string serialization only at boundaries that require strings. - -## Proposed framework - -Framework is a set of explicit subsystem contracts, not one giant abstraction. +For a new syntax construct expressed using existing semantic actions, expected +work is concentrated in syntax-aware owners: ```text -source - ↓ -parser-owned AST - ↓ validated phase boundary -binding/resolution result - ↓ validated phase boundary -typechecker result - ↓ validated phase boundary -CFG + flow result - ↓ validated phase boundary -ownership result - ↓ validated phase boundary -HIR - ↓ validated phase boundary -MIR - ↓ validated phase boundary -backend IR -``` - -Each phase should answer: - -| Contract | Required answer | -| --- | --- | -| Owner | Which package owns decision and result? | -| Inputs | Which exact earlier artifacts are valid inputs? | -| Output | Which explicit result or artifact is published? | -| Invariants | What is guaranteed after successful completion? | -| Diagnostics | Which invalid source conditions are reported here? | -| Consumers | Which later phases may read result? | -| Invalidation | Which edit/reset discards result? | -| Mutation | Which shared state may phase mutate? | -| Concurrency | Can modules execute phase in parallel? | -| Failure policy | User diagnostic, recoverable invalid artifact, or compiler bug? | -| Verification | Which validator and tests enforce contract? | - -## Primary guarantee: codebase guides and rejects incomplete work - -Framework succeeds only when compiler source itself leads developer through required integration points. - -Desired failure chain for new syntax: - -```text -add node kind - ↓ -Go interface satisfaction identifies phases missing node decision - ↓ - generated traversal/static check identifies unclassified child fields - ↓ -typed phase APIs show required inputs and result owner - ↓ -artifact validator rejects incomplete or inconsistent evidence - ↓ -source/property tests reject logically wrong semantics -``` - -This distinguishes omissions from semantic mistakes: - -| Developer mistake | Earliest intended guard | -| --- | --- | -| New node omitted by resolver/typechecker/ownership/HIR | Go compile error through exhaustive phase interface | -| New child field omitted from structural walk | generated traversal consistency or custom static-analysis failure | -| Node intentionally irrelevant to phase | explicit `ignore` implementation with reviewed reason | -| Required ownership/type evidence not published | phase-result validator failure | -| Evidence points to wrong CFG site, symbol, type, or scope | cross-artifact validator failure | -| Implementation compiles but language semantics are wrong | invariant, property, and source-fixture failure | - -Go compiler can prove interface satisfaction and type compatibility. It cannot prove arbitrary ownership semantics or detect a field omitted inside an otherwise valid handwritten method. Framework therefore combines Go interfaces with generated code/static checks and executable validators. Calling all of this “compile-time safety” would be imprecise; target is earliest mechanical failure available for each mistake class. - -### Exhaustive phase interfaces - -Canonical AST family defines complete phase-facing visitor contract: - -```go -type StmtVisitor interface { - VisitBlock(*BlockStmt) - VisitIf(*IfStmt) - VisitWhile(*WhileStmt) - VisitFor(*ForStmt) - VisitReturn(*ReturnStmt) -} -``` - -Every participating phase proves satisfaction: - -```go -var _ ast.StmtVisitor = (*ownershipChecker)(nil) -var _ ast.StmtVisitor = (*hirLowerer)(nil) -``` - -Adding `VisitDefer(*DeferStmt)` then produces direct Go errors in every incomplete phase. No default/no-op visitor implementation may hide missing methods. - -Separate interfaces should cover real AST families—statements, expressions, declarations, and type syntax—rather than one universal visitor that forces meaningless methods on every phase. - -### Generated child traversal - -Interface satisfaction cannot catch this handwritten omission: - -```go -type ForStmt struct { - Iterable Expr - Body *BlockStmt - Else *BlockStmt // new field -} - -func (s *ForStmt) forEachChild(yield func(Node) bool) { - yield(s.Iterable) - yield(s.Body) - // Else forgotten; Go still compiles. -} -``` - -Preferred framework derives traversal from AST struct definitions: - -```go -func (s *ForStmt) forEachChild(yield func(Node) bool) bool { - return yield(s.Iterable) && - yield(s.Body) && - yield(s.Else) -} -``` - -Generator classifies node-compatible fields and lists. Metadata fields such as IDs, tokens, positions, and primitive values are known non-children. Any unknown composite field must require explicit classification instead of silently defaulting to ignored. - -Normal repository validation should run generator in check mode: - -```bash -go run ./scripts/generate-ast --check -``` - -Adding or changing AST field then fails until generated traversal and node-kind contracts are current. Equivalent custom `go vet` analyzer is acceptable if it provides same deterministic guarantee with less complexity. - -### Ownership completeness accounting - -Ownership phase should not merely expose `VisitFor`; it should prove every ownership-relevant typed expression received decision. - -Conceptual result: - -```go -type ValueOwnership uint8 - -const ( - OwnershipInvalid ValueOwnership = iota - OwnershipCopy - OwnershipMove - OwnershipBorrow - OwnershipOwnedTemporary -) - -type Result struct { - Values map[ast.NodeID]ValueOwnership -} +AST/parser -> resolver/typechecker as needed -> CFG/effects as needed -> HIR + | + v + existing definite-init/ownership/liveness/cleanup ``` -Boundary validator walks typed expressions and requires non-invalid ownership classification whenever expression type needs ownership handling: - -```go -if typeinfo.RequiresOwnershipDecision(typ) && result.Values[expr.ID()] == OwnershipInvalid { - return fmt.Errorf("expression %d has no ownership decision", expr.ID()) -} -``` - -Exact representation should follow existing ownership model rather than this conceptual map. Required invariant remains: compiler can mechanically account for every ownership-relevant element, and missing analysis cannot silently reach lowering. - -### Types and signatures explain compiler flow - -Phase dependencies should be visible from result ownership and APIs. Functions that accept broad mutable `Module` access should be narrowed where practical, without creating decorative parameter structs. - -Conceptual ownership boundary: - -```go -type ownership.Input struct { - AST *ast.Module - Bindings *bindingresult.Result - Types *typecheckresult.Result - CFG *cfg.Module -} - -func Analyze(input Input) ownershipresult.Result -``` - -This input type earns its place only if it is actual ownership phase contract. Reading signature should tell developer what ownership consumes, what it publishes, and which earlier phase must change when evidence changes. - -## Main workstreams - -### 1. Phase-owned semantic results - -Every meaningful phase output should have one owner and one storage location. - -Examples: +For a new composite semantic type: ```text -bindingresult.Result -constantresult.Result -typecheckresult.Result -flowresult.Result -ownershipresult.Result +declare type -> child relations + ownership shape + | + v + generic containment/copy/drop propagation ``` -These are justified boundaries because they represent real compiler phases or distinct lifetimes. They are not decorative wrappers. - -Rules: - -- one fact has one producer; -- one fact has one canonical storage location; -- later phases consume published evidence instead of rediscovering it; -- result lifetime matches incremental reset boundary; -- no compatibility maps or forwarding accessors remain after migration; -- result packages contain phase data, not scheduler orchestration. +Representation-specific decisions such as equality, ABI lowering, exported +fingerprint, or genuinely special sizing remain explicit by design. -### 2. Exhaustive node-handling contracts +## Verification standard -Structural walking solves recursion. Separate phase contracts should solve omitted semantics. - -For every relevant node kind, each participating phase must explicitly choose one: +Final handoff requires: ```text -handle — phase owns distinct semantics -traverse — canonical child walk is sufficient -ignore — intentionally irrelevant, with reason -reject — invalid at this phase boundary -``` - -Important constraint: no visitor base type with default no-op methods. Defaults would recreate omission bug by silently accepting new nodes. - -Preferred implementation starts with compile-time visitor interfaces requiring every node-kind method and compile-time satisfaction assertions for participating phases. Generated node-kind registries and completeness tests may supplement interfaces where Go cannot express closed sets directly. - -Child-field completeness is a separate problem. Generate `forEachChild` implementations from AST structs or enforce them with a custom analyzer; do not assume visitor interfaces can detect a forgotten field inside a valid method. - -Choose least boilerplate mechanism that makes omissions fail Go compilation, generated-code checks, static analysis, or normal tests—before feature can reach production. - -### 3. Canonical artifact validators - -Each phase result should have one validator at real boundary. - -Validators check artifact shape and published invariants. They do not repeat semantic analysis. - -Examples: - -- AST recovery invariants; -- symbol/type identity validity; -- required typechecker evidence; -- CFG edge/predecessor symmetry and terminators; -- ownership cleanup-site validity; -- HIR symbol/type/location consistency; -- MIR operand and block validity; -- backend physical type compatibility. - -Invalid source remains source diagnostics. Validator failure indicates compiler implementation bug. - -### 4. Canonical CFG queries and descriptors - -CFG remains owner of control-flow topology. Consumers should not infer loops or structured control flow from incidental block IDs and shapes. - -Before adding metadata, inspect consumers and prove existing typed blocks/sites/edges are insufficient. If shared construct metadata is needed, publish validated descriptors once from CFG instead of rediscovering loops in ownership or MIR. - -Example conceptual result: - -```go -type LoopDescriptor struct { - Header BlockID - Body BlockID - Latch BlockID - Exit BlockID -} -``` - -Builder-only state such as active `break` target, `continue` target, and lexical scope depth should remain private CFG construction context. It is not semantic evidence and should not live in project-wide module state. - -### 5. Separate mangling from artifact construction - -Two different responsibilities must not share misleading names. - -**Mangler owns names:** - -- callable/linkage names; -- module identity components; -- receiver identity; -- generic/symbol instance suffixes; -- collision-safe framing. - -**Phase-local artifact construction owns generated nodes:** - -- hidden symbol name and ID; -- lowered type ID; -- source location; -- generated binding/identifier/place/expression shape. - -Do not create compiler-wide generic builder. AST, HIR, MIR, and backend artifacts have different invariants and lifetimes. Builder earns its place only inside phase that owns generated representation. - -### 6. Validated semantic variants - -Semantic plans should make invalid combinations impossible or immediately rejectable. - -A tag plus many nullable fields is difficult to audit: - -```go -type ForIteration struct { - Kind ForIterationKind - Carrier *Symbol - Cursor *Symbol - End *Symbol - Ordinal *Symbol -} -``` - -Range and sequence iteration do not require same hidden state. Cleaner design gives each variant its required fields and validates it before publication. - -```go -type RangeIteration struct { - Cursor *Symbol - End *Symbol - Ordinal *Symbol -} - -type SequenceIteration struct { - Carrier *Symbol - Cursor *Symbol -} +GOTOOLCHAIN=local go test ./... +GOTOOLCHAIN=local go vet ./... +GOTOOLCHAIN=local go test -race ``` -Exact Go representation can vary. Core requirement is stable: downstream phases should not repeatedly reconstruct which nullable combinations are legal. - -### 7. Canonical identities - -Use one comparable typed identity for module registry, imports, symbol ownership, semantic fingerprints, invalidation, and graph membership. - -```go -type ID struct { - Origin string - Namespace string - Dependency string - ImportPath string -} -``` - -Filesystem path remains secondary lookup because logical module identity should survive relocation. String encoding exists only for string-only boundaries such as generic graph or diagnostic grouping APIs, and must be collision-safe. - -No duplicate `Module.Key`, symbol-owner key type, scan-based owner lookup, or compatibility accessor should survive migration. - -### 8. Recovered-AST and editor safety contracts - -Interactive compiler receives incomplete source continuously. Parser recovery output therefore needs deliberate invariants. - -Preferred model: - -- parser defines which fields may be absent; -- required recovered fields use explicit missing/synthetic nodes where practical; -- downstream phases consume documented recovered shape; -- LSP/compiler boundary contains ordinary frontend panics; -- failed analysis snapshots are discarded, never published; -- stale document revisions cannot overwrite newer results. - -`recover()` is panic containment, not process isolation. It protects server from ordinary compiler panics but not OOM, deadlock, `os.Exit`, or corrupted shared state. Subprocess isolation remains possible future escalation, not present requirement. - -### 9. Workflow enforcement - -Architecture only helps when normal workflow enforces it. - -For every new language construct, contributor should be able to mechanically check: - -```text -parser -AST node + child traversal -binding/resolution -base typechecking evidence -constant evaluation, when relevant -CFG -flow typing -definite initialization -ownership -HIR -MIR -backend -LSP/editor recovery -artifact validators -positive and negative source fixtures -``` - -CI should include: - -- phase coverage/completeness tests; -- artifact validator tests; -- source fixtures; -- malformed-source regression tests; -- progressive typing/prefix tests; -- frontend fuzzing with invariant “arbitrary editor source must not panic.” - -## Before and after examples - -Examples are intentionally short and conceptual. Exact production names may differ. - -### Before: broad shared semantic storage - -```go -type Module struct { - ExprTypes map[NodeID]Type - CaseTests map[NodeID]CaseTest - MatchInfo map[NodeID]Match - ConstValues map[SymbolID]Value -} -``` - -### After: explicit phase ownership - -```go -type Module struct { - Bindings *bindingresult.Result - Constants *constantresult.Result - Typechecking *typecheckresult.Result - Flow *flowresult.Result - Ownership ownershipresult.Result -} -``` - -Result tells reader who produced data and when it is valid. - ---- - -### Before: later phase rediscovers semantic meaning - -```go -func lowerCall(call *ast.CallExpr) hir.Expr { - sym := lookup(call.Callee) - args := expandDefaults(sym, call.Args) - return lowerResolvedCall(sym, args) -} -``` - -### After: consume typechecker evidence - -```go -func lowerCall(call *ast.CallExpr) hir.Expr { - plan := module.Typechecking.Calls[call.ID()] - return lowerResolvedCall(plan.Symbol, plan.Arguments) -} -``` - -HIR lowers a decision; it does not repeat typechecking. - ---- - -### Before: new node silently falls through - -```go -switch stmt := stmt.(type) { -case *ast.IfStmt: - checkIf(stmt) -case *ast.WhileStmt: - checkWhile(stmt) -} -``` - -### After: phase contract must classify every kind - -```go -var statementContract = map[ast.StmtKind]Decision{ - ast.StmtIf: Handle, - ast.StmtWhile: Handle, - ast.StmtFor: Handle, - ast.StmtBad: Ignore, -} -``` - -Completeness test compares this table with canonical statement-kind registry. Adding node without decision fails immediately. - ---- - -### Before: nullable evidence requires scattered checks - -```go -if plan.Kind == Range && plan.End != nil { - // lower range -} -if plan.Kind == Sequence && plan.Carrier != nil { - // lower sequence -} -``` - -### After: explicit validated variants - -```go -switch plan := plan.(type) { -case RangeIteration: - lowerRange(plan) -case SequenceIteration: - lowerSequence(plan) -default: - panic("invalid iteration plan") -} -``` - -Required state travels with variant that needs it. - ---- - -### Before: multiple module identities and scan lookup - -```go -type Module struct { - Key string - ImportPath string -} - -for _, module := range ctx.modules { - if module.DefiningModuleKey() == owner { - return module - } -} -``` - -### After: one typed identity and direct lookup - -```go -type Module struct { - ID moduleid.ID - FilePath string -} - -module := ctx.modules[symbol.DefiningModule] -``` - -Logical lookup becomes direct and identity conversion disappears. - ---- - -### Before: generated node helpers look like wrappers - -```go -func generatedIdent(ctx *Context, mod *Module, sym *Symbol, loc *Location) *ir.Ident { - return &ir.Ident{ - Name: symbolName(mod, sym), - Type: loweredTypeID(ctx, mod, sym.Type), - } -} -``` - -### After: phase-local artifact constructor owns invariant - -```go -type artifactBuilder struct { - ctx *Context - module *Module -} - -func (b artifactBuilder) ident(sym *Symbol, loc *Location) *ir.Ident { - return &ir.Ident{ - Name: b.mangle(sym), - Type: b.lowerType(sym.Type), - SymbolID: sym.ID, - SourceInfo: ir.SourceInfo{Location: loc}, - } -} -``` - -This boundary is justified only if several generated artifacts must preserve same name/type/symbol/location invariant. If used once or only forwarding, inline it instead. - ---- - -### Before: invalid result fails downstream - -```go -hir := Lower(module) -mir := LowerMIR(hir) // panic here -``` - -### After: fail at producing boundary - -```go -result := typechecker.Check(module) -if err := result.Validate(); err != nil { - return internalError("typecheck result", err) -} -module.Typechecking = result -``` - -Failure points at producer that violated contract. - -## What “cleaner” means - -Framework does not promise fewer named types in every package. Some types are necessary because phases represent genuinely different facts. Cleanliness means fewer ambiguous and duplicated concepts. - -Desired reduction: - -- fewer broad “miscellaneous semantic info” structs; -- fewer compatibility accessors; -- fewer pass-through wrappers; -- fewer repeated lookups and semantic rediscovery; -- fewer nullable combinations; -- fewer identity conversions; -- fewer files that act as unrelated storage bins; -- fewer production bugs discovered only after downstream failure. - -Useful types remain when they make ownership and invariants explicit. Decorative types disappear. - -A clean compiler should let contributor answer quickly: - -```text -Where is this decision made? -Where is its result stored? -Who may consume it? -How is it validated? -When is it invalidated? -Which test fails if I forget a phase? -``` - -## Anti-goals and guardrails - -Do not turn framework into abstraction tax. - -Avoid: - -- generic pass manager hiding real scheduler barriers; -- one universal visitor with no-op defaults; -- one universal artifact builder; -- wrappers added only to rename existing calls; -- old and new semantic maps kept together; -- validators that rerun compiler semantics; -- project package becoming dumping ground for phase-owned types; -- backend naming moved into source semantics when physical ABI layout matters; -- subprocess compiler split before actual isolation need exists. - -Every new boundary must own at least one real phase, lifetime, invariant, policy, or independently reused operation. - -## Expected development experience - -Ideal feature workflow: - -1. Add syntax node. -2. Canonical child traversal test identifies missing structural registration. -3. Phase contract tests list every semantic phase needing explicit decision. -4. Typechecker publishes validated evidence. -5. CFG, ownership, and lowering consume evidence directly. -6. Artifact validators catch malformed handoff at producer boundary. -7. Source fixtures prove accepted and rejected behavior end to end. -8. Prefix/fuzz tests prove incomplete editor source does not crash frontend. - -Result should be compiler that is not only correct today, but difficult to extend incorrectly tomorrow. - -## Final objective - -Peeper framework goal is executable omission safety through explicit ownership: - -> One phase owns each decision. One artifact carries each result. One validator protects each boundary. One canonical identity names each concept. Go interfaces expose missing node handling. Generated traversal exposes missing child fields. Normal tests expose invalid semantics. - -Compiler codebase—not contributor memory—should be primary implementation guide. Adding syntax or semantic behavior should cause compiler, generator, analyzer, validators, and fixtures to enumerate unfinished work immediately. - -That architecture keeps codebase readable, makes compiler journey safer, and gives future contributors a clear map for extending language without relying on accidental production discovery. +plus source fixtures and repository-specific build validation. Passing tests alone +is not enough; architecture audit must also confirm canonical kernels have not +been bypassed. diff --git a/COMPILER_GUIDELINES.md b/COMPILER_GUIDELINES.md index 4e5ae073..b478d67a 100644 --- a/COMPILER_GUIDELINES.md +++ b/COMPILER_GUIDELINES.md @@ -19,10 +19,12 @@ design, do not follow it silently. Report conflict and evidence so maintainer ca decide whether guideline or design must change. Current ownership, pointer, copy, and optional design lives in -[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). Planned work for -mechanically enforced phase contracts, traversal completeness, artifact validation, -and contributor change points lives in -[docs/compiler-framework/README.md](docs/compiler-framework/README.md). +[docs/ownership-pointer-model.md](docs/ownership-pointer-model.md). The implemented +compiler architecture, canonical structural mechanisms, semantic evidence boundaries, +and contributor extension rules live in +[docs/compiler-architecture.md](docs/compiler-architecture.md). Migration history and +supporting analysis live under +[docs/compiler-framework/](docs/compiler-framework/README.md). ## 1. Priorities @@ -143,6 +145,12 @@ fail clearly rather than be skipped silently. Semantic switches remain appropriate when each node kind requires distinct behavior. Centralize structural recursion, not semantic decisions. +Current canonical mechanisms are `ast.Inspect` for AST recursion, +`typeinfo.ForEachChild` for semantic-type structure, `place.Project`/`Decompose` +for storage projections, `graph.Directed` for topology, and `graph.Worklist` for +fixed-point scheduling. Analyses should extend these owners instead of creating a +parallel walk, adjacency store, or queue/queued-set implementation. + Before adding a walker or lookup: 1. search all existing implementations; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06348b96..b086c575 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,10 +38,11 @@ These files are canonical; do not copy their rules into new documents: - [`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md): compiler phase, representation, traversal, and incremental-analysis guidance. -For a change that touches the pipeline, read +For a change that touches the compiler pipeline or semantic model, read +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) first. It defines +the canonical mechanisms, representation boundaries, and extension paths. [`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) -first. It walks the files a new syntax construct, an internal lowering change, or a -new type must visit, and names the test that fails at each stop you skip. +is the concrete file-by-file companion for common changes. `AGENTS.md` contains automation workflow, not additional human-facing code policy. diff --git a/Code-tour.md b/Code-tour.md index 3c826a05..524b0989 100644 --- a/Code-tour.md +++ b/Code-tour.md @@ -8,9 +8,10 @@ Every code sample here is **simplified** — real signatures carry more paramete error handling. Each one names the file it came from so you can read the real thing. Related reading: [`RULES.md`](RULES.md) for what code is acceptable, -[`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md) for phase discipline, and -[`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) for -the file-by-file walk when you are *changing* something rather than learning it. +[`COMPILER_GUIDELINES.md`](COMPILER_GUIDELINES.md) for phase discipline, +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) for the canonical +architecture, and [`docs/compiler-framework/change-paths.md`](docs/compiler-framework/change-paths.md) +for the file-by-file walk when you are *changing* something rather than learning it. --- @@ -562,8 +563,8 @@ flowchart TD | Guard | Where | Catches | | --- | --- | --- | | Child traversal contract | `internal/contracts` | a node field missing from `forEachChild` | -| Statement/expression contract | `internal/contracts` | a node kind no phase decides about | -| Semantic type contract | `internal/contracts` | a `typeinfo.Type` with no capability, identity or lowering | +| Syntax-boundary dispatch contract | `internal/contracts` | a new node kind omitted by a true syntax-aware owner | +| Semantic type contract | Go type system + `internal/contracts` | missing child/ownership structure or a required representation decision | | Lowered node contract | `internal/contracts` | an HIR/MIR kind nothing lowers or emits | | `cfg.Validate` | `internal/ir/cfg` | malformed topology | | `effect.Validate` | `internal/semantics/effect` | operations with no symbol, unbalanced calls | @@ -575,8 +576,9 @@ A contract failure reads like this: publishStmt makes no decision about ast.YieldStmt; add a case or declare why the kind is inert ``` -Every omission must be either handled or **classified** — `traverse`, `ignore`, `reject` -or `contextual` — with a written reason that is itself checked for staleness. +At true closed extension points, every omission must be handled or deliberately +classified. Generic downstream analyses should not grow AST classifications at all; +they consume canonical CFG/type/place/effect evidence instead. --- @@ -597,8 +599,9 @@ Steps 1–6 are unavoidable: where a name lives and what types are legal *is* th Step 7 is what buys you definite initialization, ownership, liveness, drops and usage **for free** — they consume operations and never learn your construct exists. -`docs/compiler-framework/change-paths.md` walks this in full, including the stops where -nothing catches you. +`docs/compiler-architecture.md` defines why these boundaries exist; +`docs/compiler-framework/change-paths.md` gives the concrete edit path and the guard +at each true extension point. --- @@ -626,4 +629,4 @@ nothing catches you. | What does the typechecker publish? | `internal/semantics/typecheckresult/result.go` | | Why is a value moved/dropped here? | `internal/semantics/effect`, then `internal/semantics/ownership` | | What does the backend emit for X? | `internal/backend/llvm/emitter.go` | -| How do I add a node kind safely? | `internal/contracts`, and run the suite | +| How do I add a node kind safely? | `docs/compiler-architecture.md`, then the owning syntax boundary | diff --git a/README.md b/README.md index aaa5f1c8..e1b94293 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ unfinished language and runtime work. [`Code-tour.md`](Code-tour.md) walks one `.peep` file through every phase to a native binary, with diagrams and the real entry points at each stop. +[`docs/compiler-architecture.md`](docs/compiler-architecture.md) explains the canonical +compiler mechanisms and how to extend syntax, semantic types, graphs, and analyses +without reintroducing duplicated walks or phase logic. + ## Binary installation Linux and macOS: diff --git a/docs/compiler-architecture.md b/docs/compiler-architecture.md new file mode 100644 index 00000000..4fecdb06 --- /dev/null +++ b/docs/compiler-architecture.md @@ -0,0 +1,354 @@ +# Peeper Compiler Architecture + +This document describes the compiler architecture that exists in this repository. +Language behavior is defined by [`language-spec.md`](language-spec.md) and +[`ownership-pointer-model.md`](ownership-pointer-model.md); this document defines +where that behavior is implemented and how new work should compose with existing +mechanisms. + +## Design goal + +Peeper's compiler is organized around one rule: + +> Define structure once. Define unique semantics once. Derive everything else. + +The architecture is intended to prevent the failure mode where a feature looks +complete in parsing or typechecking but one forgotten ownership, flow, cleanup, or +lowering walk survives until production. + +Three invariants drive the design: + +1. **One canonical mechanism per concern.** Tree structure, semantic type + structure, place projections, graph adjacency, worklist scheduling, and value + effects each have one owner. +2. **Compositional behavior.** A new container/type/syntax construct that is built + from existing semantic operations inherits existing ownership, definite-init, + liveness, and cleanup behavior instead of reimplementing it. +3. **Loud true extension points.** If a new construct introduces genuinely new + semantics, syntax-aware owners must make an explicit decision. Unknown sealed + node/effect kinds panic or fail validation instead of being silently skipped. + +This is not a "visitor everywhere" architecture and not an "effects replace every +phase" architecture. Visitors/switches are appropriate at syntax interpretation +boundaries. Generic downstream analyses consume canonical semantic evidence. + +## Pipeline + +`internal/pipeline/pipeline.go` owns scheduling. Current per-module order is: + +```text +parse + -> collect + -> bind + -> resolve + -> constants + -> typecheck + -> CFG + -> flow typing + -> semantic effects + -> definite initialization + -> ownership + cleanup + -> usage + -> HIR + -> MIR + -> backend +``` + +Project/module readiness and incremental checkpoints remain explicit. Do not hide +this scheduler behind a uniform pass interface: phases have different dependency, +barrier, and invalidation rules. + +## Representation boundary + +Syntax remains source shape. Semantic facts remain side tables/results keyed by +stable identity. + +```text + source + | + AST / parser + | + +------------------+------------------+ + | syntax-aware semantic owners | + | resolver, typechecker, CFG builder, | + | effect publisher, HIR lowering | + +------------------+------------------+ + | + canonical semantic evidence + symbols / types / places / CFG / effects + | + +------------------+------------------+ + | syntax-agnostic generic analyses | + | definite-init, most ownership value | + | flow/liveness/worklist mechanics | + +------------------+------------------+ + | + cleanup / IR +``` + +A later phase must not rediscover a decision already published by an earlier +owner. If ownership needs to know that a call argument is borrowed, typechecking +publishes that fact and effects publish `Borrow`; ownership must not inspect call +syntax and infer it again. + +## Canonical structural mechanisms + +### AST: `ast.Inspect` + +AST nodes own child shape through `forEachChild`. Generic consumers use +`ast.Inspect` rather than maintaining private recursive switches. + +A new AST node must declare its children once. Semantic phases may still dispatch +on node kind when behavior genuinely differs. + +### Semantic types: `typeinfo.ForEachChild` + +`typeinfo.Type` is sealed inside `typeinfo` and requires two structural contracts: + +- `forEachChild` — immediate semantic children and their `TypeChildRelation`; +- `ownershipShape` — how copy/drop semantics compose over those children. + +`typeinfo.ForEachChild` is the semantic-type equivalent of `ast.Inspect`. +Containment and ownership capability queries consume this structure. Sizing and +lowerability intentionally keep separate recursion policies because recursive +cycles mean different things to those queries. + +Consequence: adding a new composite semantic type cannot satisfy `typeinfo.Type` +until it declares child structure and ownership composition. Nested ownership/drop +then propagates through generic machinery. + +### Places: `place.Project` / `place.Decompose` + +Selector/index place grammar is owned by `internal/semantics/place`. +Consumers must not peel `SelectorExpr`/`IndexExpr` independently to answer which +storage is touched. + +Canonical place facts carry: + +- root symbol when storage is named; +- ordered field/index projections; +- temporary base identity when expression projects from a temporary. + +Addressability, mutability, effect publication, ownership, and flow origin logic +build on this contract. + +### Graphs: `graph.Directed` + +`internal/graph.Directed` owns directed adjacency and reverse adjacency once. +Domain graphs keep semantic edge data on top: + +- import/type dependency graphs use `graph.Graph`; +- CFG owns `cfg.Edge` kinds/case metadata while storing site/block topology in + `graph.Directed`. + +Do not add another successors/predecessors store to a domain graph. Add domain +metadata to its edge/node type and reuse the topology kernel. + +### Fixed-point scheduling: `graph.Worklist` + +`graph.Worklist` owns FIFO scheduling, pending-node deduplication, and +rescheduling. Flow, definite-init, ownership, and liveness retain their own state, +join, direction, edge transfer, diagnostics, and convergence rules. + +This is deliberately smaller than a generic dataflow framework. Shared mechanics +are centralized; semantic lattices remain visible in their owning packages. + +## Canonical semantic effects + +`internal/semantics/effect` publishes value/storage behavior in source evaluation +order. Current operations are: + +- `Define` — storage becomes a binding, optionally initialized from a value; +- `Write` — existing place is replaced/mutated; +- `Use` — read/copy/move of a place; +- `Borrow` — shared/mutable/raw borrow with exact operand identity; +- `Iterate` — long-lived sequence-loop access and hidden carrier identity; +- `Discard` — produced value is thrown away; +- `CallBegin` / `CallEnd` — call-lifetime brackets for argument loans. + +`effect.Visitor` is the exhaustive consumer boundary. An `effect.Op` is sealed and +must dispatch through that visitor; adding a new semantic operation therefore makes +every exhaustive consumer fail compilation until it implements the new visitor method. +This is where Peeper deliberately uses the visitor pattern: new **semantics** are +introduced to every consumer, while new syntax that reuses existing effects is not. + +The effect publisher is a syntax-aware boundary because evaluation order and use +kind are language semantics. Downstream analyses do not repeat that AST walk. + +Examples of behavior now derived from effects: + +- definite initialization consumes `Define`, `Write`, `Use`, `Borrow`; +- ownership consumes `Define`/`Write` generically rather than special-casing + `let`, `const`, and assignment statements; +- ownership/liveness use the same published `Use`/`Borrow` sequence; +- sequence-loop borrow lifetime arrives as `Iterate`; ownership no longer asks + `ForStmt` or `SequenceIteration` what kind of loop it is. + +A new syntax construct that can be expressed with existing operations normally +requires no changes to these downstream analyses. + +A new **semantic operation** is different. It is a real extension point: add the +operation, validate it, and make each consumer explicitly decide what it means. +Unknown effects must not be silently ignored. + +## Phase ownership + +| Concern | Canonical owner | Published evidence / artifact | +| --- | --- | --- | +| Source structure | parser / `frontend/ast` | AST + stable node IDs | +| Declaration catalog | collector | module symbols | +| Type binding | binder | symbol type state / binding result | +| Lexical/import resolution | resolver | `Bindings.NodeSymbols`, scopes | +| Type rules and adaptation | typechecker | `typecheckresult.Result` | +| Control topology | `ir/cfg` | typed blocks/sites/edges | +| Variant/optional path facts | flow typechecker | `flowresult.Result` | +| Evaluation/storage actions | `semantics/effect` | ordered `effect.Result` | +| Definite initialization | `semantics/definiteinit` | diagnostics | +| Move/borrow/drop analysis | `semantics/ownership` | `ownershipresult.Result` | +| High-level lowering | `ir/hir/lower` | HIR | +| Mid-level lowering | `ir/mir` | MIR | +| Physical layout/codegen | backend | backend IR | + +When a phase needs a fact owned above it, extend the owner's result/query. Do not +re-detect the fact from AST shape below the owner. + +## What remains syntax-aware, intentionally + +Some phases must understand syntax because syntax introduces semantics: + +- resolver: names/scopes/import/variant paths; +- typechecker: type rules, conversions, calls, loop/match semantics; +- CFG builder: source control constructs -> topology; +- effect publisher: evaluation order and value/storage action; +- HIR lowering: source construct -> executable high-level IR. + +Those switches are not architectural duplication by themselves. The smell is two +phases independently deriving the **same fact**. + +Ownership still has return-specific policy because return-origin/pointer-escape +checks straddle returned-value evaluation: provenance must be checked before a +move can erase it, while cleanup runs after evaluation. This is a deliberate +language policy, not a generic child walk. If another control transfer needs the +same semantic policy, publish a control effect rather than adding another parallel +AST reconstruction. + +## Adding a new expression or statement + +Classify the feature before editing downstream code. + +### A. Pure syntax / sugar over existing semantics + +Expected work: + +1. AST + parser; +2. AST child declaration (`forEachChild`); +3. translate at the syntax-aware semantic boundary into existing decisions/effects. + +Ownership, definite-init, liveness, graph scheduling, and cleanup should require no +new node case. + +### B. New typechecking or control semantics, existing value effects + +Expected work: + +1. AST + parser; +2. resolver only if name/scope behavior differs; +3. typechecker decision/evidence; +4. CFG only if topology differs; +5. effect publisher maps construct to existing operations; +6. HIR lowering. + +Generic analyses stay unchanged. + +### C. New semantic action + +Only when existing effects cannot represent behavior: + +1. add sealed `effect.Op` and its `effect.Visitor` method; +2. add producer + validator; +3. implement the new visitor method in every semantic consumer; compile failures + provide the checklist; +4. add focused tests proving evaluation order and analysis behavior; +5. add/update Peeper fixtures when language behavior changes. + +Do not introduce a new effect merely because syntax is new. + +## Adding a semantic type + +A new type must first satisfy `typeinfo.Type`: + +1. `TypeNode` and `Text`; +2. `forEachChild` with correct `TypeChildRelation` for every contained type; +3. `ownershipShape` describing leaf/container ownership policy. + +Once this is done, recursive containment and copy/drop propagation compose through +the canonical structure. + +Then make explicit decisions only where representation semantics genuinely differ: + +- `SameType` / compatibility; +- sizing/lowerability when shape has special rules; +- exported semantic fingerprint; +- HIR/backend type lowering; +- syntax conversion if source has new type syntax. + +`internal/contracts/type_dispatch_test.go` guards these true type-kind extension +points. It should not grow entries for generic containment/ownership traversal. + +## Adding a graph-backed analysis + +- Reuse `graph.Directed` for topology. +- Keep semantic edge metadata in domain edge type. +- Reuse `graph.Worklist` when analysis needs rescheduling. +- Keep state/join/transfer in analysis package. +- Do not infer true/false/case/loop meaning from successor position; use `cfg.Edge` + kind/case and CFG block/site metadata. + +## Validation model + +Different mistakes are caught at different boundaries: + +| Mistake | Guard | +| --- | --- | +| AST child omitted | AST child completeness contracts/tests | +| semantic type child/ownership relation omitted | sealed `typeinfo.Type` compile-time contract | +| semantic type missing representation decision | focused type dispatch contract | +| malformed graph topology | CFG/graph validators and tests | +| malformed effect evidence | `effect.Result.Validate` | +| new effect ignored by an exhaustive consumer | `effect.Visitor` compile-time contract | +| malformed cleanup evidence | `ownershipresult.Validate` | +| malformed HIR/MIR | IR validators | +| wrong language behavior | package tests + `x_test` source fixtures | + +Source-parsing contract tests are retained only where Go's type system cannot +express a closed extension boundary more directly. They are not the primary +architecture. + +## Forbidden patterns + +Do not add: + +- a second recursive AST walk for a structural query already served by + `ast.Inspect`; +- a second semantic-type child enumeration for generic recursive behavior; +- private selector/index peeling when `place.Project`/`Decompose` answers it; +- private graph adjacency stores; +- private queue + queued-set fixed-point schedulers; +- downstream syntax checks for a fact already published by typechecking/CFG/effects; +- default/no-op handling that makes an unknown sealed node/effect silently succeed; +- pass-through compatibility wrappers around canonical APIs. + +## Verification commands + +Repository currently targets Go 1.23.2. + +```bash +GOTOOLCHAIN=local go test ./... +GOTOOLCHAIN=local go vet ./... +GOTOOLCHAIN=local go test -race ./internal/project ./internal/pipeline ./internal/lsp ./internal/semantics/... +``` + +For language behavior, use `x_test` and the bundled compiler according to +[`RULES.md`](../RULES.md). Architecture reviews should also search for new private +adjacency stores, queue/queued loops, repeated type-child enumeration, and AST +switches appearing in previously syntax-agnostic analyses. diff --git a/docs/compiler-framework/README.md b/docs/compiler-framework/README.md index 7e0169e6..c753da09 100644 --- a/docs/compiler-framework/README.md +++ b/docs/compiler-framework/README.md @@ -1,667 +1,66 @@ # Compiler Framework -This document defines planned framework work for making Peeper compiler changes -mechanical, auditable, and hard to complete incorrectly. Goal is not generic -compiler infrastructure. Goal is explicit contracts around Peeper's real phases so -adding syntax, semantics, analyses, or backends immediately exposes every required -change point. +Framework migration is now implemented as a set of canonical compiler kernels, +not a universal pass/visitor framework. -This is a roadmap. Sections marked **Current** describe code that exists on `main`. -Sections marked **Target** describe work still requiring implementation and review. -Mandatory policy remains in [`RULES.md`](../../RULES.md). Durable engineering -principles remain in [`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). -This document applies those rules to concrete compiler subsystems; it does not -replace them. +Read [`../compiler-architecture.md`](../compiler-architecture.md) first. It is the +current architecture contract and extension guide. This directory retains deeper +migration notes, artifact ownership analysis, validation design, and historical +change-path evidence. -## Objectives +## Final architecture decision -Framework work must establish these guarantees: +Peeper uses four complementary mechanisms: -1. Every phase has one named owner, explicit inputs, explicit output, documented - invariants, diagnostics, consumers, and invalidation rules. -2. Adding a node or changing child structure cannot silently omit traversal. -3. Every semantic phase makes an explicit handle, traverse, ignore, or reject - decision for every relevant node kind. -4. Invalid phase artifacts fail at their producing boundary, not in an unrelated - downstream phase or backend. -5. Later phases consume established semantic evidence instead of rediscovering it. -6. Control-flow consumers use canonical CFG topology and typed edges/sites instead - of inferring meaning from incidental block shape. Construct metadata is added - only when inspected consumers prove a missing shared invariant. -7. Naming, mangling, generated artifacts, and backend ABI naming each have one - purposeful owner. -8. Contributor tests fail when a required phase decision is missing. +1. **Canonical structure** — `ast.Inspect`, `typeinfo.ForEachChild`, + `place.Project`/`Decompose`, and `graph.Directed` ensure structural knowledge is + written once. +2. **Canonical semantic evidence** — typechecker results, typed CFG edges/sites, + flow evidence, and ordered semantic effects prevent downstream rediscovery; + `effect.Visitor` makes the semantic operation set exhaustive for consumers. +3. **Generic mechanics** — `graph.Worklist` and shared graph topology remove + repeated scheduling/adjacency code without hiding phase-specific lattices. +4. **Explicit true extension points** — resolver/typechecker/CFG/effect/HIR and + semantic type representation decisions remain exhaustive where behavior really + differs. -A fork should be able to replace syntax, selected semantic rules, runtime policy, -or backend details while retaining these safety contracts. Copyability is a useful -design pressure, not promise of a language-generator product. +Goal is not "every phase visits every AST node". Goal is stronger: -## Non-goals +> phases that only care what syntax **does** should consume canonical semantic +> evidence and never need to know that syntax kind exists. -Framework work must not introduce: +A new ordinary expression can therefore reuse `Use`/`Borrow`/`Write`/`Define` and +inherit definite-init, ownership, liveness, and cleanup behavior. A new composite +semantic type declares child structure and ownership composition once and nested +copy/drop behavior follows automatically. -- generic pass manager hiding current scheduler or phase dependencies; -- compiler-wide artifact builder mixing AST, HIR, MIR, and backend policy; -- default visitor methods that silently ignore new node kinds; -- pass-through wrappers around existing canonical functions; -- duplicate result maps kept during migration; -- validators that repeat semantic analysis; -- source-shape rediscovery in HIR, MIR, ownership, or backend lowering; -- fake HIR, MIR, or backend artifacts created only for tests or examples; -- stable extension APIs before real independent consumers exist. +## Current canonical owners -A boundary earns its place only when it owns a phase result, lifetime, invariant, -policy, or independently reused operation. - -## Current framework kernel - -### Pipeline - -**Current.** Phase identity lives in `internal/phase/phase.go`. Project orchestration -lives in `internal/pipeline/pipeline.go`. - -```mermaid -flowchart TD - Setup[Setup compiler context] - Load[Load graph and parse modules] - Parse[Parsed module checkpoint] - Collect[Collect declarations] - Bind[Bind declaration types] - Resolve[Resolve names and scopes] - Const[Evaluate constants] - Type[Typecheck] - CFG[Build CFG] - Flow[Flow typing] - Init[Definite initialization] - Own[Ownership and cleanup] - Usage[Usage barrier] - HIR[Lower HIR and fold] - MIR[Lower MIR] - Backend[Emit backend IR] - Finalize[Project finalization] - - Setup --> Load --> Parse --> Collect --> Bind --> Resolve --> Const --> Type - Type --> CFG --> Flow --> Init --> Own --> Usage --> HIR --> MIR - MIR --> Backend --> Finalize -``` - -Canonical orchestration points: - -| Responsibility | Current owner | -| --- | --- | -| Run one project | `pipeline.Run` | -| Schedule ready modules | `advanceModulesThrough` | -| Choose next phase | `nextModulePhase` | -| Check import prerequisites | `moduleReadyForNextPhase` and `importPrerequisitePhase` | -| Execute one module phase | `advanceModulePhase` | -| Detect scheduler stalls | `requireScheduledModulesAtLeast` | -| Invalidate semantic dependents | `invalidateSemanticDependents` | -| Store one source unit and retained artifacts | `project.Module` | -| Store shared compilation state | `project.CompilerContext` | - -`Setup`, `Load`, and `Finalize` are project checkpoints. `Parsed` through `Backend` -are retained per-module checkpoints. `Usage` is a project barrier after all -scheduled modules reach ownership. Framework work must preserve distinction -between per-module transitions, dependency readiness, and project barriers. Do not -add a uniform `Pass.Run` abstraction that hides these differences. - -### Retained artifacts - -**Current.** `project.Module` retains artifacts across incremental compilation and -`resetToPhase` defines their lifetimes. - -| Artifact | Current field or owner | Producer | Main consumers | -| --- | --- | --- | --- | -| Parsed syntax | `Module.AST` | parser | semantic phases, CFG, HIR | -| Module symbols | `Module.ModuleScope` | collector and binder | resolver onward | -| Staged binding graph | `Module.Bindings` / `bindingresult.Result` | collector through typechecker | CFG, flow, ownership, HIR, LSP | -| Constant evaluation | `Module.Constants` / `constantresult.Result` | const evaluation and later constant queries | fingerprinting, CFG, flow, HIR, MIR | -| Base typechecker evidence | `Module.Typechecking` / `typecheckresult.Result` | base typechecker | CFG, consteval, flow, definite-init, ownership, HIR | -| Typed AST index | `Module.TypedASTNodes` | pipeline after typecheck | CFG-sensitive phases | -| Control-flow graph | `Module.CFG` | CFG builder | flow, definite-init, ownership, MIR | -| Flow evidence | `Module.Flow` / `flowresult.Result` | flow typechecker | ownership, HIR, tooling | -| Ownership evidence | `Module.Ownership` / `ownershipresult.Result` | ownership | MIR | -| High-level IR | `Module.HIR` | HIR lowering plus typed expression folding | MIR, dumps/tooling | -| Mid-level IR | `Module.MIR` | MIR lowering | backend | -| Backend text | `Module.LLVMIR` | LLVM backend | build/link and dumps | - -`project.Module` is a source-unit aggregate. It is not itself a phase result. -Framework work should split mixed result ownership where useful without wrapping the -aggregate or forcing every artifact into a generic result interface. - -### Module identity - -**Current.** `moduleid.ID` is the one canonical module identity. It is a comparable -value of `Origin`, `Namespace`, `Dependency`, and `ImportPath`, and it is the key for -`ctx.modules`, `ctx.fileIndex`, `ctx.semanticExportBaselines`, import resolution, -`symbols.Symbol.DefiningModule`, and type-declaration identity. - -Identity is logical, not positional: it survives filesystem relocation, and file path -is a secondary index only. Path-based `Module.Key`, `symbols.DefiningModuleKey`, -`ModuleKeyFor`, `ModuleByKey`, and loader-side identity backfill no longer exist. - -`ID.Valid()` is the single identity predicate and requires both `Origin` and -`ImportPath`. That is what keeps map keys distinct: an identity carrying only an -origin would collapse every local module onto one entry. Do not add an `IsZero()` -counterpart; a partially populated identity is invalid, not empty. - -String-only boundaries take `ID.String()`, which length-frames each component in hex -so no delimiter collision is possible. Graph node IDs and diagnostics module scoping -are such boundaries and consume the encoding rather than the struct. `internal/diagnostics` -still names these parameters `moduleKey`; it is deliberately identity-agnostic and -that rename is outstanding terminology debt. - -Module construction derives identity once, in `CompilerContext.NewModuleForFile` or -`prelude.ModuleID(ctx)`. `NewModuleForFile` returns nil when no import path can be -derived, so callers must establish project root containment first; `cmd/build.go` -and both LSP entry paths do this through `manifest.ResolveSourceFileProject` and -`manifest.PathWithinSourceDir`, and report a source-root diagnostic rather than an -identity failure. - -Every identity must be derivable from the file it names. `prelude.ModuleID` resolves -the prelude path and runs it back through `ImportPathForFile`, so the auto-loaded -prelude registers under exactly the identity `ResolveImportPath` produces for an -explicit `core:global` import. A hardcoded import path here registers the file under -an identity no import can reproduce, and the same file then arrives twice under two -identities. - -`AddModule` enforces one identity per file. That conflict is reachable from user -source and from library-root configuration, so it emits an `ErrAmbiguousImport` -diagnostic and keeps the first registration rather than panicking; identity -conflicts are user-facing errors, not impossible states. - -### Structural traversal - -**Current.** Reuse these APIs: - -| Representation | Canonical traversal | -| --- | --- | -| AST declarations | `ast.ForEachDecl` | -| AST nodes | `ast.Inspect` with node-owned `forEachChild` | -| HIR statements | `hir.InspectStmt` with statement-owned `forEachChild` | -| Shared expressions | `ir.InspectExpr` | -| Places and projection expressions | `ir.InspectPlace` | - -These APIs solve structural recursion. They do not solve exhaustive semantic -handling. A resolver or typechecker still needs a phase-specific decision for each -node kind because handling behavior differs by phase. - -MIR and CFG currently use explicit graph/block/instruction loops. Add canonical -walkers only after concrete consumers need identical traversal semantics. - -### Existing phase-owned results - -**Current.** Five semantic results have purposeful packages or direct owners: - -- `bindingresult.Result` owns block scopes, node-to-symbol bindings, method receiver/declaration indexes, and operation-function catalog over one staged symbol graph. Collector initializes it; collector, binder, resolver, and typechecker complete it; reset to `Parsed` discards it. -- `constantresult.Result` physically separates authoritative post-typecheck `ModuleValues` from mutable pretypecheck/local `QueryCache` entries. `FinalizeValues` republishes top-level constants without retaining duplicate cache entries; fingerprints and MIR consume only `ModuleValues`. Foreign constant queries resolve the defining module and read its published values without copying into consumer cache. -- `typecheckresult.Result` owns base expression types, effective call arguments, generated-default binding markers, implicit conversions, implicit call arguments, interface implementation slots, intrinsic dispatch, string concatenation classification, variant construction, base case tests, match evidence, and for-iteration evidence for one base-typecheck generation. It also owns `CaseTest`, match, and iteration evidence models. `typechecker.Check` publishes a fresh result; reset below `Typechecked` discards it. -- `flowresult.Result` owns flow-refined types, origins, payload access, flow-sensitive case tests, and variant-field evidence. Its case-test entries use the earlier `typecheckresult.CaseTest` model while remaining a distinct flow result map. -- `internal/semantics/ownershipresult` owns cleanup plans consumed by MIR. - -`project.SemanticInfo` and mixed `Module.ConstValues` storage have been removed. All semantic evidence and constant-evaluation artifacts now have explicit owners and reset contracts. - -### Existing validation - -**Current.** Validation is mostly embedded in producing or consuming phases: - -- parser and semantic phases emit source diagnostics; -- `cfg.Analyze` checks unreachable sites, constant non-loop conditions, and - return completeness without mutating finalized topology; -- pipeline validates program entrypoint and scheduler completion; -- `llvm.ValidateRuntimeSymbols` validates reserved runtime symbols and extern - ownership constraints; -- backend layout and typed emission helpers reject physical type mismatches. - -No canonical structural verifier currently exists for complete CFG, HIR, or MIR -artifacts. - -## Phase contract - -**Target.** Every phase must publish or document this contract: - -| Contract field | Required meaning | +| Concern | API | | --- | --- | -| Owner | One package responsible for decision and output | -| Inputs | Exact artifacts and prerequisite guarantees consumed | -| Output | Explicit result, artifact mutation, or diagnostic-only effect | -| Invariants | Facts guaranteed when phase completes without internal error | -| Diagnostics | Codes, text, spans, ordering, deduplication, and source identity owned by phase | -| Consumers | Later phases allowed to depend on output | -| Invalidation | Earliest edit/checkpoint that discards output | -| Mutation and concurrency | State mutated, synchronization owner, and whether modules may run in parallel | -| Determinism | Output, diagnostics, fingerprints, and names that must not depend on scheduler order | -| Failure policy | User diagnostic, recoverable invalid artifact, or internal error | -| Verification | Focused tests and boundary validator proving contract | - -A phase may mutate a purposeful artifact when identity continuity requires it, such -as binding collected symbol objects in place. Contract must state that mutation; -it must not be hidden behind generic `Run` methods. - -### Proposed migration constraints - -These constraints require confirmation from Workstream 1 inventory before becoming -code or repository policy: - -1. Phase result models should contain inert phase data rather than orchestration. -2. Candidate dependency direction is from `project.Module` to result models, with - result models avoiding imports of scheduler/orchestration state. -3. One fact needs one producer and one canonical storage location. -4. Each migrated field should move with all consumers in one reviewable step when - feasible; if a larger migration cannot do that safely, approved plan must state - temporary state and removal gate explicitly. -5. No compatibility map, stale alias, or forwarding accessor may remain after a - migration step closes. -6. Existing flow and ownership results stay separate unless inspected ownership, - lifetime, and consumer evidence supports another boundary. -7. Backend physical layout remains backend-owned and never mutates semantic order. - -## Workstream 1: Separate phase-owned semantic results - -**Complete.** Field inventory and approved ownership/lifetime decisions are tracked in [`semantic-results.md`](semantic-results.md). Base-typechecker evidence lives in `typecheckresult.Result`; staged collection/binding/resolution state lives in `bindingresult.Result`; authoritative constants and mutable evaluator cache live in separate maps inside `constantresult.Result`. Module bindings carry defining identity, and dependencies reach `Typechecked` before consumer constant evaluation so foreign reads are authoritative and race-free. `SemanticInfo`, mixed `Module.ConstValues`, compatibility maps, and forwarding accessors no longer exist. - -For each field record: - -- producing phase and exact write sites; -- consuming phases and exact read sites; -- key identity (`NodeID`, `SymbolID`, CFG site, or declaration identity); -- reset checkpoint; -- incremental fingerprint dependency; -- whether field is base semantics, flow semantics, lowering evidence, or shared - symbol state. - -Then migrate one real owner at a time. Likely boundaries include resolver result and -typechecker result, but package names and shapes must follow inventory rather than -this document's guess. - -Acceptance criteria: - -- every migrated field has one producer and one storage location; -- `ResetModule` and `resetToPhase` discard result at correct checkpoint; -- semantic export fingerprints remain stable or intentionally change with tests; -- diagnostic codes, text, primary/secondary spans, module/source identity, - observable ordering, and deduplication remain unchanged unless an intentional - change has focused regression coverage; -- shared state remains race-free under parallel module scheduling; -- repeated runs preserve applicable fingerprints, diagnostics, generated names, - and HIR/MIR output; -- all call sites use new canonical result directly; -- no wrapper or duplicate compatibility map survives migration. - -## Workstream 2: Exhaustive node-handling contracts - -**Target.** Structural inspectors continue owning child traversal. Separate -phase-handling contracts make node-kind omissions visible. - -First experiment covers all production `ast.Stmt` implementations and an inspected -set of phases that dispatch on statements. Exact phase list and canonical kind -registry must be recorded before implementation. If experiment works without -excess boilerplate, extend same contract separately to declarations, expressions, -and type syntax. - -Each participating phase explicitly classifies each statement kind as: - -- **handle**: phase owns distinct semantics for node; -- **traverse**: phase only needs canonical child walk; -- **ignore**: node is intentionally irrelevant, with reason; -- **reject**: node is invalid at this phase boundary. - -Do not implement this with visitor base classes containing no-op defaults. Candidate -mechanisms must be evaluated against real AST statement family first: - -1. compile-time visitor interfaces requiring every method; -2. mechanically checked dispatch tables keyed by declared node kind; -3. focused completeness tests comparing declared kinds to phase decisions. - -Choose least boilerplate mechanism that makes omission fail compilation or normal -tests. - -Acceptance criteria: - -- adding one production-registered statement kind fails until every participating - phase updates; -- completeness source is same registry or sealed dispatch mechanism used by - production nodes, not test-only parallel list; -- adding child field fails traversal completeness test until `forEachChild` updates; -- recovery statements have explicit phase policy; -- intentional ignores are named and reviewable; -- structural recursion remains centralized in node-owning package. - -## Workstream 3: Canonical artifact validators - -**Target.** Add validators at real representation boundaries. Validator checks -published shape and evidence; it does not rerun semantic decisions. - -### AST boundary - -Validate only invariants parser promises under valid or recovered syntax, such as -stable node identity, source locations, and explicitly documented missing nodes. -Invalid user syntax remains parser diagnostics, not internal errors. - -### CFG boundary - -Verify: - -- entry, exit, block, and target ownership; -- one terminator per finalized block; -- successor/predecessor symmetry; -- valid edge kinds for terminator kind; -- valid `SiteID` block/index pairs; -- site predecessor/successor symmetry; -- lexical scope-exit chains; -- reachable flags consistent with entry traversal; -- construct descriptors reference blocks in same graph. - -### HIR boundary - -Verify: - -- source-backed nodes retain valid source identity; -- generated nodes obey generated identity contract; -- symbols and types exist in shared tables; -- places and expressions have compatible types; -- structured control has valid bodies and targets. - -Lowering conformance tests, not HIR structural validation, prove semantic evidence -maps into expected HIR shape. - -### MIR boundary - -Verify: - -- block IDs and branch targets exist in function; -- every block has one valid terminator; -- operand and result types match instruction contract; -- referenced symbols, static data, and type IDs exist; -- emitted cleanup instructions satisfy MIR-local instruction and control-flow shape; -- target-sized carriers are normalized before backend. - -Validate `ownershipresult.CleanupPlan` against CFG and HIR immediately before MIR -lowering. Post-lowering MIR validation cannot reconstruct consumed cleanup-plan -references and must not attempt to repeat ownership analysis. - -### Backend boundary - -Keep ABI/layout checks backend-owned. Shared validators must not duplicate LLVM -layout decisions. Backend validates physical type, pointee, alignment, calling -convention, and runtime symbol policy. - -Failure policy and acceptance: - -- invalid source produces user diagnostics; -- validator failure follows `RULES.md` section 12: return `error` when caller is - expected to handle validation failure; panic for violated internal invariants or - impossible IR states; -- internal artifact failures never become user diagnostics; -- package tests invoke validators directly; -- pipeline tests invoke validators immediately after production through an - explicit test/configured hook; -- production invocation policy is decided separately after cost is measured. - -## Workstream 4: Verify CFG structure before adding construct metadata - -**Target.** CFG remains canonical control-flow topology. Existing blocks, typed -edges, semantic sites, and exact `SiteID` values are current authority. Do not add -construct descriptors until an inspected consumer demonstrably infers semantic -roles from incidental `NodeID`, block order, or `BlockOrigin` shape. - -First step is an ownership table for every current topology query in flow typing, -definite initialization, ownership, and MIR. For each query record whether existing -edge/site APIs express required fact directly. If they do, keep them. If two or more -consumers need same missing structured-control fact, propose smallest immutable -descriptor owned by CFG construction. - -Condition, infinite, range, and sequence loops plus `break`/`continue` are current -verification corpus. CFG construction consumes typechecker-owned guaranteed-entry -evidence through `cfg.BuildQueries`; any public construct metadata must be justified -against these merged topology and query contracts. - -Builder-local active target state remains construction state, not public CFG -evidence. Rename or restructure it only when touched by concrete behavior change; -do not create a public descriptor merely to mirror builder fields. - -Acceptance criteria: - -- every current CFG role query names exact source field/API it uses; -- CFG validator proves block, edge, site, and scope-exit consistency; -- current condition/infinite loops have valid, malformed, nested, and terminating - coverage; -- any new descriptor has at least two concrete consumers or protects one - non-obvious cross-phase invariant; -- consumers stop topology inference only where descriptor replaces inspected - duplicated logic; -- no descriptor is added when existing typed edges and sites already suffice. - -### As investigated - -Every topology query outside `internal/ir/cfg` was inspected. **No loop descriptor -is warranted.** What the audit found: - -| Query | Consumer | Verdict | -| --- | --- | --- | -| Block role within a loop | `mir/module_lower.go` maps `BlockLoopInit`/`Body`/`Latch` to the matching `hir.For` segment | Direct. `BlockOrigin` answers exactly the question asked; keep. | -| Terminator kind | MIR lowering, definite-init | Direct typed switch on `cfg.Jump`/`Branch`/`Return`/`SwitchVariant`. No gap. | -| Edge meaning | flow typing, ownership, definite-init | Direct. `EdgeTrue`/`EdgeFalse`/`EdgeVariantCase` carry branch meaning independently of adjacency order. No gap. | -| Site adjacency | flow, definite-init, ownership | Ordinary dataflow over `Site.Successors`/`Predecessors`. Legitimate use, not inference. | -| "Am I leaving a sequence loop?" | ownership | **Was inferred** from `BlockNormal` plus a `NodeID` naming a loop — true only because the exit was the one loop block left unlabelled. Fixed by adding `BlockLoopExit`, the smallest change that removes the inference. | -| "Where do these match arms converge?" | ownership | **Still inferred**, by walking single successors past scope-exit sites until a non-scope-exit site is reached. Recorded, not fixed — see below. | - -The match-join walk in `semantics/ownership/ownership.go` is a real inference: CFG -construction creates the join block and then discards that knowledge. It is left in -place deliberately. Ownership needs the join *site* at which carrier liveness is -checked, not the block, so a block-origin label would not remove the walk; the fix -would be a published join site, which is a descriptor rather than a label. With one -consumer and no second phase needing the same fact, it does not meet the bar above. -Revisit if a second consumer appears. - -`BlockLoopExit` also made explicit something previously implicit: missing-return -reporting distinguished structured control from plain continuations by comparing -against `BlockNormal`. That comparison is now `structuredControl`, so adding an origin -requires classifying it rather than silently changing which span a user sees. - -## Workstream 5: Naming and generated artifact subsystems - -**Target.** Separate naming policy from typed artifact construction. - -### Naming and mangling - -Language/linkage mangling owns: - -- extern link names; -- canonical entry `main`; -- module and dependency identity; -- callable kind and receiver identity; -- symbol instance suffixes; -- collision resistance and deterministic output. - -Current authorities include HIR lowering's callable/symbol naming, -`ir.SanitizeSymbolName`, `ir.StripSymbolInstance`, nominal module identity, and -backend-owned interface/type ABI symbols. Before moving anything, audit exact output -strings and all call sites. Backend symbols involving physical layout stay -backend-owned. - -### Generated artifacts - -Generated binding, identifier, assignment, projection, or control nodes belong to -phase-local artifact construction. A phase-local builder is allowed only when it -owns real lowering state and centralizes repeated invariants such as: - -- module and compiler context; -- symbol identity and canonical generated name; -- lowered type ID; -- source/generated location policy; -- generated node identity; -- target-sized carrier rules. - -Do not call artifact constructors manglers. Do not create compiler-wide builder. -Do not move one-use composite literals into decorative helpers. - -Acceptance criteria: - -- every linkage name has one canonical naming owner; -- every repeated generated HIR shape has one purposeful construction path; -- extern, entrypoint, generic instance, receiver, and collision tests preserve - exact naming behavior; -- generated control-flow and interface artifacts retain symbol/type/location identity; -- old names and constructors are deleted, not wrapped. - -## Workstream 6: Validate compound semantic evidence - -**Target.** Compound semantic evidence must not permit contradictory states to -travel silently into lowering. Start with inventory, not assumed variant shape. - -For each evidence type with a kind/tag plus nullable or optional fields, record: - -- producer and publication point; -- fields required by each semantic state; -- consumers and their current nil/kind checks; -- invalid combinations representable by current type; -- whether constructor, validator, separate variants, or simpler flat shape best - protects actual invariant. - -For-iteration evidence now lives in `typecheckresult.Result`. It records range or -sequence kind, guaranteed-entry proof, target-sized cursor state, hidden carrier -symbols, and source binding symbols. CFG, ownership, and HIR consume this published -evidence directly. Remaining work must validate its nullable kind-dependent fields -or replace them with smaller validated variants without duplicating semantic proof. - -Candidate evidence includes conversions, compiler calls, interface conformance, -variant construction, and merged for-iteration evidence. Typechecker remains owner -of semantic decisions; CFG, ownership, HIR, MIR, and backend consume published -evidence without rediscovery. - -Acceptance criteria: - -- inventory identifies concrete contradictory states and affected consumers; -- chosen representation is smallest shape that protects proved invariant; -- rejected source does not publish valid-looking evidence; -- validator or constructor reports missing symbols, mismatched types, or wrong - syntax association at producer boundary; -- consumers remove duplicated defensive checks only after producer guarantee exists; -- target-width tests apply when evidence contains target-sized values; -- positive and negative source fixtures cover changed language behavior. - -## Workstream 7: Enforcement in normal development - -**Target.** Framework contracts must run under ordinary `go test ./...` and normal -review, not optional audits. - -Required enforcement: - -1. traversal completeness tests; -2. per-phase node handling completeness tests; -3. negative tests for every artifact validator invariant; -4. reset/invalidation tests for every phase result; -5. source prefix and malformed-input corpus after recovered-AST contract is - defined; -6. whole-pipeline regressions for every fixed panic or malformed artifact; -7. target-width tests for target-sized lengths, indexes, pointers, and carriers; -8. generated artifact tests through real HIR, MIR, and backend lowering; -9. contributor checklist linked from `CONTRIBUTING.md` after framework APIs exist. - -A simulated new construct must fail expected traversal, dispatch, evidence, -validation, and fixture checks until contributor updates all required owners. - -## Adding or changing a language construct - -Use this matrix before implementation. Mark each row **changed**, **verified -unchanged**, or **not applicable with reason**. - -The matrix states the question each area asks. [`change-paths.md`](change-paths.md) -answers where to go: it walks the real file sequence for three change shapes, traced -from commits already in the repository, and names what catches you when a stop is -missed — including the stops where nothing does. - -Four of those stops were pure mechanism: they rediscovered read and write meaning -the typechecker had already decided. -[`effect-stream-migration.md`](effect-stream-migration.md) records how that meaning -became a published artifact instead, and what the migration cost. -[`type-capabilities.md`](type-capabilities.md) does the same for derived type -questions: what became one walker, and which lookalikes must stay separate. - -| Area | Required question | -| --- | --- | -| Lexer/token model | Does syntax require token or lexical-state change? | -| Parser | What valid and recovered syntax shapes are produced? | -| AST model | Which node owns each child and source location? | -| AST traversal | Does canonical `forEachChild` expose every new child? | -| Collection | Which declarations/symbol shells become visible? | -| Binding | Which declaration types or cycles must be bound? | -| Resolution | Which names, scopes, paths, and shadowing rules apply? | -| Constant evaluation | Does construct produce or consume constant evidence? | -| Typechecking | Which semantic rule and explicit evidence are established? | -| Target validation | Are lengths, indexes, pointers, or carriers representable? | -| CFG | Which blocks, sites, edges, and construct roles are required? | -| Flow typing | Which facts differ by branch, case, or iteration? | -| Definite initialization | Which paths initialize or consume storage? | -| Ownership | Which loans, moves, drops, and cleanup sites occur? | -| Usage | Which bindings/imports count as used? | -| HIR | How is established evidence represented without rediscovery? | -| MIR | How does normalized CFG and ownership evidence lower? | -| Backend | Which physical layout/instruction/ABI rules apply? | -| LSP | Can partial source and stale revisions be handled safely? | -| Diagnostics | Which phase owns each error and source span? | -| Incremental reset | Which edit invalidates which result? | -| Fixtures | Which positive runtime/type and negative semantics cases are needed? | -| Width/backend matrix | Which supported targets/backends need explicit coverage? | - -Feature is incomplete while any applicable row is unanswered. - -## Copy and adaptation boundaries - -A language fork should normally customize: - -- tokens, parser grammar, and source AST; -- collector/binder/resolver rules; -- type system and semantic evidence; -- runtime intrinsics and bundled library; -- target policy and backend implementation; -- diagnostics and language-server presentation. - -It should normally retain or deliberately replace: - -- explicit phase contracts; -- artifact handoff discipline; -- canonical structural traversal; -- exhaustive semantic handling checks; -- validator boundaries; -- dependency-aware scheduling; -- incremental invalidation contracts; -- generated/source identity distinction; -- source fixtures and whole-pipeline tests. - -This separation makes experimentation safer without pretending Peeper semantics are -configuration data. - -## Delivery order - -Workstreams are ordered to minimize migration risk: - -1. Inventory and split phase-owned semantic results. -2. Prove exhaustive handling on one AST statement family. -3. Define validator contracts and failure policy. -4. Verify CFG queries and add construct metadata only where concrete consumers require it. -5. Separate canonical mangling from phase-local artifact construction. -6. Inventory compound evidence and protect concrete invalid states. -7. Make all contracts mandatory in normal tests and contributor workflow. - -Each workstream should land independently with focused and broad validation required -by `RULES.md`. Review every touched owner before starting next migration. - -## Completion criteria - -Framework work is complete when: - -- every retained phase artifact has documented producer, consumers, and reset rule; -- mixed semantic state has explicit phase ownership; -- adding node kind or child causes immediate compile/test failure at every required - handling point; -- CFG, HIR, and MIR boundaries have canonical validators; -- structured-control consumers use canonical typed edges/sites or justified - descriptors rather than topology guesses; -- mangling and generated artifact construction have distinct canonical owners; -- compound semantic evidence has producer-enforced invariants using smallest - representation justified by inspected states and consumers; -- contributor checklist and CI enforce full pipeline coverage; -- no pass-through wrappers, stale aliases, ignored parameters, duplicate maps, or - semantic rediscovery paths remain from migrations. +| AST recursion | `ast.Inspect` / node `forEachChild` | +| semantic type structure | `typeinfo.ForEachChild` / `TypeChildRelation` | +| type ownership composition | sealed `typeinfo.Type.ownershipShape` | +| place/projection grammar | `place.Project`, `place.Decompose` | +| graph adjacency | `graph.Directed` | +| fixed-point scheduling | `graph.Worklist` | +| control topology | `cfg.Graph` + typed `cfg.Edge` | +| value/storage behavior | `effect.Result` + exhaustive `effect.Visitor` | +| cleanup evidence | `ownershipresult.Result` | + +## Contract philosophy + +Old framework experiments tried to make every phase acknowledge every AST kind. +That catches omissions but preserves distributed work. Final architecture narrows +those contracts to actual semantic boundaries. + +- Structural consumers reuse canonical traversal. +- Generic analyses consume semantic operations. +- A new semantic type is sealed until it declares structure + ownership policy. +- Remaining source-inspection contracts guard only closed sets that Go cannot make + exhaustive directly. +- Artifact validators reject malformed evidence at producer boundaries. + +See [`change-paths.md`](change-paths.md) for historical evidence that motivated +migration. Its old change counts are baseline measurements, not the current +recommended extension path. diff --git a/docs/compiler-framework/change-paths.md b/docs/compiler-framework/change-paths.md index 3952c976..555e0528 100644 --- a/docs/compiler-framework/change-paths.md +++ b/docs/compiler-framework/change-paths.md @@ -1,351 +1,163 @@ # Change paths through the compiler -Mandatory policy is [`RULES.md`](../../RULES.md). Durable principles are -[`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). The framework roadmap is -[`README.md`](README.md), whose *Adding or changing a language construct* matrix -lists the questions you must answer. +This is the concrete companion to [`../compiler-architecture.md`](../compiler-architecture.md). +Read that document first: the goal is **not** to make every phase acknowledge every +syntax node. The goal is to edit the few owners of unique semantics and let canonical +structure/evidence drive the rest. -**This document answers a different question: where do I actually go?** The matrix -tells you that CFG needs a decision. It does not tell you that the decision lives in -`buildStmt`, that a missing one fails -`TestEveryStatementKindHasAPhaseDecision/buildStmt`, or that you can skip the backend -entirely. That is what follows. +Mandatory repository policy remains [`RULES.md`](../../RULES.md); durable compiler +principles remain [`COMPILER_GUIDELINES.md`](../../COMPILER_GUIDELINES.md). -Each walk is traced from a change already in git, so every stop is a real file that a -real commit touched — not a plausible guess. Line numbers drift; function names and -test names are the durable part. +## The rule for every change -## How to read a walk +Before adding a switch or recursive walk, ask what fact you need and who already owns it. -Every stop names the **owner** (file and function), the **decision** made there, and -**what catches you** if you skip it. That last column ranks the same three ways the -framework does: - -| Rank | Meaning | +| Need | Canonical owner/API | | --- | --- | -| **Automatic** | The compiler will not build. You cannot forget. | -| **Visible** | A named test fails under plain `go test ./...`. | -| **Loud** | A validator or panic fires at runtime, naming the phase. | -| **— nothing** | Nothing catches you. Read this as a warning, not as permission. | - -The `— nothing` rows are deliberate. A map with the gaps drawn in is more useful than -one that pretends the coast is clear. - ---- - -## Walk 1 — adding a syntax construct - -**Traced from `2302c08` "Implement and harden for loops"**: 19 production files, 11 -test files, 14 `x_test` fixtures. `git show --stat 2302c08` is the ground truth for this walk. - -`for` was a good stress test because it is not one construct but two — `for i in 0..n` -(range) and `for v in array` (sequence) — plus `break`/`continue`, which are transfers -rather than statements with an effect. - -### The stops, in pipeline order - -**1. Token** — `frontend/token/kinds.go`, `frontend/token/keywords.go` -Does the syntax need a new keyword or token kind? `for` already existed; this change -added only `in` — one entry in `keywords.go`, one `Kind` in `kinds.go`, one help line. -*Catches you:* — nothing. A missing token surfaces as a parse error in your own test. - -**2. AST node** — `frontend/ast/stmt.go` -Declare the node, give it `NodeIDHolder` and a `Location`, and implement the family -marker (`stmtNode()`, `exprNode()`, or `typeNode()`). The marker is what enrolls your -node in every contract below — there is no registry to update, and no list to forget. -`ForStmt` holds `Index`, `Value`, `Iterable`, `Cond`, `Body`. - -**3. AST traversal** — same file, `forEachChild` -Every field that holds a node must be visited. `ForStmt.forEachChild` visits all five. -*Catches you:* **Visible** — `contracts.TestEveryNodeBearingFieldIsTraversed` parses -this package with `go/ast` and fails naming the field you left out. -`TestEverySubStructureFieldIsExpanded` covers fields that hold sub-structures rather -than nodes directly. - -**4. Parser** — `frontend/parser/parse_stmt.go` -Produce the node for valid syntax *and* decide what a malformed header produces. The -for-loop change added recovery paths deliberately; `x_test/negative_for_malformed_header` -pins the result. -*Catches you:* — nothing structural. Your own parser tests are the only guard. - -**5. Resolver** — `semantics/resolver/resolver.go`, `resolveStmt` -Which names does the construct introduce, and in which scope? For `for`, the loop -variables get a body scope. - -**6. Typechecker** — `semantics/typechecker/check_stmt.go`, `checkStmt` -The semantic rule, and — the important part — **the evidence you publish**. `checkStmt` -delegates to `checkForInStmt`, which publishes -`typecheckresult.Result.ForIterations[node.ID()]`: element type, the generated cursor -and value symbols, guaranteed-entry proof, and an `IterationPlan` holding the kind -together with that kind's own state. - -> Publish evidence that cannot be malformed. `IterationPlan` is a closed interface, so -> a loop cannot claim one iteration kind while carrying another's state, and consumers -> need no defensive checks. Prefer this over a kind tag beside optional fields. - - -> **This is the load-bearing step.** Everything downstream consumes this evidence -> rather than re-reading the source. If you publish nothing here, every later phase -> either re-derives the fact — which the framework forbids — or silently does nothing. - -**7. CFG** — `ir/cfg/build.go`, `buildStmt` -Which blocks, edges, and sites does the construct create? `ForStmt` builds -`BlockLoopInit`, `BlockLoop`, `BlockLoopBody`, `BlockLoopLatch` and wires -`break`/`continue` as edges. -If CFG construction needs a semantic fact, it takes it as a **query**, not by importing -the typechecker: `cfg.BuildQueries{MatchCases, LoopGuaranteedEntry}`. -`LoopGuaranteedEntry` exists exactly because a loop with a proven-nonempty range must -not report its body as conditionally skipped. -*Catches you:* **Loud** — `cfg.Module.Validate` reports `ICE0003` when the topology you -build is malformed: an unterminated reachable block, an edge kind disagreeing with its -terminator, an adjacency recorded from only one side, or a stale reachable flag. - -**8. Flow typing** — `semantics/typechecker/flow.go`, `applyConditionEdge` -Which facts differ on the true and false edges? A `for` header carries no narrowing -condition, so it is classified `ignore` with that reason — an explicit decision, not -an omission. - -**9. Semantic effects** — `semantics/effect/build.go`, `publishStmt` -What does the construct define, write, and read, and in what order? This is the only -stop that reads meaning out of a statement. Publish it once here and definite -initialization needs nothing: it consumes `Define`, `Write` and `Use` and no longer -switches on an AST kind at all. -*Catches you:* **Visible** — `contracts.TestEveryStatementKindHasAPhaseDecision` fails -with `publishStmt makes no decision about ast.YourStmt`. The published artifact is then -shape-checked by `effect.Result.Validate`, which raises `ICE0002` if an operation names -no symbol, names a node absent from the typed AST, or lands at a site the graph does not -contain. See [`effect-stream-migration.md`](effect-stream-migration.md). - -**10. Ownership** — `semantics/ownership/ownership.go` (`applyStmt`) and -`semantics/ownership/reference.go` (`symbolUseSequence`) -Which loans, moves, and drops occur? `applyStmt` reads the published `ForIterations` -evidence to establish the sequence carrier's borrow. Cleanup lands in -`ownershipresult.CleanupPlan`, keyed to exact CFG sites. - -**11. HIR lowering** — `ir/hir/lower/module_lower.go`, `appendStmt` → `lowerForStmt` -Represent the established evidence without rediscovering it. `hir.For` has explicit -`Init`, `Cond`, `Bindings`, `Body`, `Next` blocks; `lowerForStmt` fills them from the -published symbols. Note what it does **not** do: it never re-inspects the iterable's -type to decide range-vs-sequence — it switches on the published kind. -If you add a HIR node, it needs `forEachChild` and `appendText` there too. - -**12. HIR folding** — `ir/hir/fold/fold.go`, `foldStmt` -Constant folding over typed HIR. `hir.For` is handled so folding descends into all -five blocks. -*Catches you:* **Visible** — `contracts.TestEveryLoweredNodeKindHasAPhaseDecision` -fails with `foldStmt makes no decision about hir.YourStmt`. HIR still has no structural -validator, so a malformed HIR artifact is caught only when a later phase trips over it. - -**13. MIR lowering** — `ir/mir/module_lower.go` -Lower normalized control flow and consume the cleanup plan. `hir.For` is read in -`lowerCFGFunction` (to find the loop a CFG block belongs to) and in -`lowerCFGTerminator` (to emit the header and latch). -*Catches you:* **Automatic**, partly — `mir.Instr` and `mir.Terminator` are sealed by -unexported markers, so the set is closed to the `mir` package and an instruction can no -longer be used where a terminator belongs. Coverage is **Visible**: -`TestEveryLoweredNodeKindHasAPhaseDecision` holds `lowerCFGStmt`, `appendInstr` and -`setBlockTerm`, so a new node that nothing lowers or stamps fails by name. - -**14. Backend** — `backend/llvm/` -**The for-loop change touched no backend file at all.** This is the single most -useful fact in this walk: a construct that lowers to existing MIR shapes needs zero -backend work, because MIR is the backend's only input. You owe the backend a change -only when you introduce a new MIR instruction or terminator. -*Catches you if you do:* **Loud** — `GenerateLLVMIR` panics with -`LLVM emission: unhandled MIR instruction …` / `… unhandled MIR terminator …`, pinned -by `TestGenerateLLVMIRPanicsForUnknownMIRNodes`. A block with no terminator panics too. - -**15. LSP** — `internal/lsp/` -The for-loop change touched no LSP file either. Revisit only if the construct -introduces a new completion or hover surface. -*Catches you:* — nothing. - -**16. Fixtures** — `x_test/` -The for-loop change added 14: four runtime (`for_range_loop`, `for_array_loop`, -`for_nested_loops`, `for_break_continue`) and ten negative. A fixture is a directory -with `peeper.toml` plus `src/`, discovered automatically. -*Catches you:* — nothing forces you to add one; the suite passes without. RULES §14 -requires end-to-end regressions for behavior changes, but that requirement is enforced -by review, not by a test. This is the largest honest gap in the pipeline. - -### What Walk 1 forced automatically - -Adding one `stmtNode` implementation enrolls the kind in -`TestEveryStatementKindHasAPhaseDecision`, which then fails at **all eight** statement -dispatch sites until each one either handles the kind or declares why it is inert: +| AST children | node `forEachChild` + `ast.Inspect` | +| semantic type children | `typeinfo.ForEachChild` | +| copy/drop composition | sealed `typeinfo.Type.ownershipShape` | +| storage projection | `place.Project` / `place.Decompose` | +| graph adjacency | `graph.Directed` | +| fixed-point scheduling | `graph.Worklist` | +| name identity | resolver/binding results | +| type/use/adaptation decisions | `typecheckresult.Result` | +| control topology | `cfg.Module` / typed `cfg.Edge` | +| evaluation/storage actions | `effect.Result` | +| cleanup/drop evidence | `ownershipresult.Result` | -``` -resolveStmt · checkStmt · buildStmt · appendStmt · lowerElse -applyStmt · publishStmt · applyConditionEdge -``` +If a later phase needs a fact from an earlier owner, extend the owner's published result. +Do not reconstruct the fact from AST shape downstream. + +## Path 1 — add an expression or statement + +First classify the feature. + +### Syntax using existing semantics + +Expected edits: + +1. **AST** — define the node and its stable identity/location. +2. **AST children** — implement `forEachChild` once. Generic `ast.Inspect` users then + see the new children automatically. +3. **Parser** — parse/recover the source syntax. +4. **Resolver/typechecker** — only where name, scope, type, call, or adaptation semantics + differ. +5. **CFG** — only when control topology differs. +6. **Effect publisher** — map evaluation to existing `Define`/`Write`/`Use`/`Borrow`/ + `Iterate`/`Discard`/call-boundary operations. +7. **HIR** — lower the source construct when no existing source lowering covers it. + +Do **not** add a corresponding AST case to definite initialization, ordinary ownership +state transitions, liveness, or cleanup. If one of those needs syntax to understand the +new feature, the semantic boundary is probably missing evidence. + +### Syntax with a genuinely new semantic action + +Only add a new `effect.Op` when the existing operations cannot express the behavior. +Then the effect is a true closed extension point: + +1. add the sealed operation in `internal/semantics/effect`; +2. publish it in evaluation order; +3. validate its required identity/evidence; +4. extend `effect.Visitor`; every exhaustive consumer then fails compilation until it + explicitly decides what the operation means; +5. add focused Go tests and, for language behavior, `x_test` source fixtures. + +A new effect is therefore a compile-time introduction to semantic consumers, not a +search-and-remember exercise. It must never fall through as an accidental no-op. -An `exprNode` enrolls in `TestEveryExpressionKindHasAPhaseDecision` across four sites: -`resolveExpr`, `typeExprBase`, `checkExpr`, `lowerASTExpr`. +## Path 2 — add a semantic type -The families as they stand: **19** statement kinds, **23** expression kinds, **12** -type kinds. Type kinds have no dispatch contract yet. +A semantic type is not complete until it satisfies the sealed `typeinfo.Type` contract. +The first edits are therefore local to `internal/semantics/typeinfo`: -"Declare why it is inert" means an entry in `internal/contracts/node_dispatch_test.go` -with one of four decisions — `traverse`, `ignore`, `reject`, `contextual` — and a -**reason string**. Reasons are checked: `TestOmissionReasonsNameRealNodeKinds` fails on -an empty reason, an invalid decision, or a reason naming a kind that no longer exists. -Claiming a kind is inert while also handling it fails too, so the classification cannot -rot in either direction. +1. add the type and its `TypeNode`/`Text` behavior; +2. enumerate immediate contained types in `forEachChild` with correct + `TypeChildRelation` values; +3. declare `ownershipShape` — whether it is a leaf/container and how copy/drop composes. ---- +That is the structural extension point. After it is correct, recursive containment and +copy/drop propagation use the canonical traversal automatically. -## Walk 2 — an internal lowering or optimization change +Then add only representation-specific decisions that truly differ, for example: -This is the short path, and it is short because of a rule rather than a coincidence. +- equality/compatibility; +- sizing or lowerability with special cycle/ABI rules; +- exported semantic fingerprinting; +- HIR/backend lowering; +- source-type conversion if new syntax is involved. -**Traced from the shape of `ir/hir/fold/fold.go`.** +`internal/contracts/type_dispatch_test.go` guards the remaining closed type-kind sites. +Do not add new private recursive type-child walkers to satisfy one query. -| Stop | Owner | Decision | -| --- | --- | --- | -| HIR folding | `ir/hir/fold/fold.go`, `ApplyTypedExpressionFolding` → `foldStmt`, `foldBlock` | Constant propagation over typed HIR; must descend into every block a statement owns | -| MIR lowering | `ir/mir/module_lower.go` | Normalized control flow, temporaries, cleanup emission | -| Backend | `backend/llvm/` | Physical layout, instruction selection, ABI | +## Path 3 — add a graph-backed analysis -### The rule that makes this path short +1. Store topology in `graph.Directed`; keep domain semantics in typed node/edge metadata. +2. Reuse `graph.Worklist` if the analysis is a rescheduling fixed point. +3. Keep the analysis's state, join, transfer, direction, diagnostics, and edge semantics + in its own package. +4. On CFG, use edge kinds/case metadata. Never infer true/false/case/loop meaning from + successor position. -> **Below HIR, no phase may re-derive a source-level fact.** Consume published -> evidence or fail. +A domain graph may wrap the graph kernel; it should not own a second adjacency index. -Concretely: MIR lowering does not decide whether an assignment drops its target. It -reads `CleanupPlan.BeforeAssign`. It does not decide which match fields to destroy. It -reads `MatchFieldDrops`. When you find yourself reaching back toward the AST from MIR, -that is the signal you are in the wrong phase — the fact belongs in the typechecker's -or ownership's published result, and the change belongs in Walk 1. +## Path 4 — add an ownership/flow rule -Three drop channels have been deleted for breaking this rule: `hir.Return.Cleanup`, -`hir.Assign.DropTarget`, and `CleanupPlan.MatchCarrierMoves`. The first two let -lowering carry an opinion of its own; the third recorded an opinion nothing consumed. -`ownershipresult.CleanupPlan` is now the single source of planned drops. +Start from semantic evidence, not syntax. -### What catches you +- Value is read/copied/moved? Extend the producer of `effect.Use` or its typechecker + decision, not an ownership expression switch. +- Place is borrowed? Publish `effect.Borrow` with exact operand/place identity. +- Storage is introduced/replaced? Use `effect.Define` / `effect.Write`. +- A long-lived sequence iteration access is needed? Use `effect.Iterate` and CFG loop + identity. +- A branch/case fact is needed? Put it on CFG/flow evidence. +- A type recursively contains ownership/reference behavior? Put the relationship on the + semantic type structure. -| Concern | Guard | +Return pointer/reference provenance is currently a deliberate ownership policy that +straddles returned-value evaluation, so ownership retains a return-specific control +hook. If another construct needs the same control semantic, publish a shared control +operation rather than adding parallel syntax reconstruction. + +## What should break when a new thing is added + +The architecture intentionally distinguishes automatic composition from true extension +points: + +| Change | Expected guard | | --- | --- | -| New MIR instruction or terminator | **Loud** — `GenerateLLVMIR` panics on an unclassified node | -| Block emitted with no terminator | **Loud** — panics: `LLVM emission: block bN has no terminator` | -| Operand/type mismatch in emission | **Visible** — `TestTypedLLVMBuilderRejectsOperandMismatches` | -| Ownership evidence inconsistent with CFG or types | **Loud** — `ICE0002` from `ownershipresult.Validate` at the phase boundary | -| Folding that drops a block | — nothing | -| Wrong MIR lowering that still type-checks | — nothing but fixtures | - ---- - -## Walk 3 — adding a type - -A new `typeinfo.Type` is the semantic type model — a different family from AST type -*syntax*, and it has its own contract. `contracts.TestEverySemanticTypeKindHasAPhaseDecision` -holds you to the three sites where a missing case is silently wrong rather than loudly -rejected. The rest of this walk is still a manual checklist. - -If your type also needs new syntax to write it, that syntax node joins the `typeNode` -family and `TestEveryTypeKindHasAPhaseDecision` will hold you to `TypeFromSyntax` and -the binder's `addTypeDeclEdges`. - -**1. Declare it** — `semantics/typeinfo/types.go` -Implement `Type`: `TypeNode()` and `Text() string`. -*Catches you:* **Automatic** — the interface will not be satisfied otherwise. This is -the only automatic guard in the entire walk. - -**2. Ownership capability** — `semantics/typeinfo/capabilities.go` -This is the step that decides whether your type is safe by default. The governing rule: - -> Ownership capability is baked into the type itself. Scalar → copyable → copy. -> Contains a reference, pointer, or allocation inside → move. Check the type, apply -> the rule. No per-type policy tables. - -Answer it in one place: `ownershipCapability` in `capability_walk.go`, which decides -copy class and drop obligation in a single traversal. `OwnershipCapabilityOf` is the -public query over it. `IsImplicitCopyType`, `noCopyType` and `NeedsDrop` no longer exist. -Also consider `IsSizedType`, `IsLowerableType`, `IsEquatable`, `IsOrderable`, -`IsArithmetic`, `IsIntegral`, `IsCondition`. -Get this right and ownership, cleanup, and drop emission follow with no further work — -that is the whole point of the capability model. -*Catches you:* **Visible** — `TestEverySemanticTypeKindHasAPhaseDecision/ownershipCapability` -fails naming your kind and what the site decides. Its `default:` still answers move-on-use -with no drop, so the contract is what stops that silent answer standing in for a decision. - -**3. HIR type lowering** — `ir/hir/lower/lower_types.go` -The largest type switch in the compiler (~31 cases). Map your type to an `ir.TypeID`. -*Catches you:* **Visible** — `TestEverySemanticTypeKindHasAPhaseDecision/intern` fails -naming your kind. Unmapped types still fall to `ir.InvalidType`, so the contract is the -only thing between you and a silently invalid runtime type. - -**4. Export fingerprint** — `project/export_fingerprint.go`, `semanticTypeKey` -Incremental correctness. If your type is not keyed distinctly, a dependent module can -fail to rebuild when your type changes. -*Catches you:* — nothing, and the failure is a stale-build bug that looks like -something else entirely. Treat this stop as high-risk. - -**5. Backend layout and ABI** — `backend/llvm/` -Physical size, alignment, pointee, calling convention. Backend-owned by design; do not -push layout decisions into `typeinfo`. - -**6. Hover and completion** — `lsp/hover.go` (~7 type cases) -*Catches you:* — nothing; the type renders with a fallback. - -**7. Fixtures** — `x_test/` -Positive runtime plus negative semantics. For anything with a target-sized -representation, cover both 32- and 64-bit. - ---- - -## Consolidated: what the compiler actually enforces - -| Contract | Guards | Location | -| --- | --- | --- | -| `TestEveryNodeBearingFieldIsTraversed` | A new child field is traversed | `internal/contracts` | -| `TestEverySubStructureFieldIsExpanded` | Sub-structure fields are expanded | `internal/contracts` | -| `TestEveryStatementKindHasAPhaseDecision` | 19 statement kinds × 9 phase sites | `internal/contracts` | -| `TestEveryExpressionKindHasAPhaseDecision` | 23 expression kinds × 4 phase sites | `internal/contracts` | -| `TestEveryTypeKindHasAPhaseDecision` | 12 type-syntax kinds × 2 phase sites | `internal/contracts` | -| `TestOmissionReasonsNameRealNodeKinds` | Inert-kind reasons stay true | `internal/contracts` | -| `ownershipresult.Validate` → `ICE0002` | Published ownership evidence matches CFG and types | pipeline, after ownership | -| `llvm.ValidateRuntimeSymbols` | Reserved runtime symbols, extern ownership | pipeline, after backend emission | -| `GenerateLLVMIR` panics | Unhandled MIR node; block with no terminator | backend emission | -| `cfg.Module.Validate` → `ICE0003` | Block and site identity, termination, edge kind vs terminator, adjacency in both directions, reachability | pipeline, after CFG construction | -| `cfg.Analyze` | Unreachable code, constant conditions, missing return — **user diagnostics, not structure** | after CFG | - -## Where nothing catches you - -Stated plainly, because a contributor deserves to know which parts of the walk are on -the honor system: - -- **Semantic type kinds are covered at three sites, not everywhere.** - `TestEverySemanticTypeKindHasAPhaseDecision` holds capability, type identity and HIR - lowering. `IsSizedType`, `IsLowerableType` and the LSP hover formatters are deliberately - outside it, because their defaults reject or degrade rather than answer wrongly. - Fingerprinting needs no contract: `Text()` is an interface method, so the compiler - enforces it. -- **No structural validator exists for HIR or MIR.** Every node kind now has to be - classified at lowering and in the backend, but nothing checks the *shape* of a - lowered artifact: CFG topology and ownership evidence have boundary validators, the - two lowered representations do not, so a malformed HIR or MIR is still caught only - when the backend trips over it. -- **Nothing requires a fixture.** A construct can reach the backend with no end-to-end - coverage at all. -- **Nothing requires an LSP update**, so a new construct can be invisible to hover and - completion without any signal. - -These are tracked as framework workstreams 3, 4, and 7 in [`README.md`](README.md). If -you close one, delete its bullet here — a stale gap list is worse than none. - ---- - -## Before you call it done - -1. Walk the matrix in [`README.md`](README.md) and mark every row **changed**, - **verified unchanged**, or **not applicable with reason**. -2. Run the RULES §14 minimum validation, plus what this repo has settled into: - `gofmt`, `go test -count=1 ./...`, race on touched packages, - `go run ./scripts/bundle.go`, `PEEPER_BIN="$PWD/build/bin/peeper" go test ./x_test`, - `git diff --check`. §14 also requires every supported target width when the change - touches target-sized integers, lengths, indexes, pointers, or ABI carriers. -3. **Prove each new test is not vacuous.** Revert your fix, confirm the test fails, and - confirm the failure message is the one you expect. A test that passes for a reason - other than its own check is a false guarantee — which is precisely what this - framework exists to eliminate. +| new AST field/node child | AST traversal completeness test | +| new syntax kind at a syntax-aware closed site | dispatch contract / compiler failure | +| new semantic type | sealed `typeinfo.Type` compile failure until structure/ownership declared | +| new semantic type missing representation decision | type dispatch contract | +| new effect operation | `effect.Visitor` compile failure + artifact validator | +| malformed CFG/effects/ownership/IR | artifact validator | +| changed Peeper behavior | focused tests + `x_test` fixture | + +The ideal result is that a new ordinary syntax node causes **fewer** downstream edit +requirements than before, while genuinely new semantics become **more** explicit. + +## Pre-review audit + +Before considering a compiler architecture change complete, search for accidental +parallel machinery: + +```bash +# private fixed-point schedulers in semantic analyses +rg -n 'queue|queued' internal/semantics + +# syntax knowledge leaking back into generic consumers +rg -n 'case \*ast\.' internal/semantics/definiteinit internal/semantics/ownership + +# selector/index projection reimplementation +rg -n 'SelectorExpr|IndexExpr' internal/semantics/ownership internal/semantics/effect +``` + +Interpret results semantically rather than mechanically. A return-specific ownership +policy or the syntax-aware effect publisher is valid; another generic child/projection +walk is not. + +Then run the verification commands documented in +[`../compiler-architecture.md`](../compiler-architecture.md). From 33ce2a5253d23595761d8f98b98b332d2b7d5a46 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 08:36:48 +0000 Subject: [PATCH 72/80] Use module symbols for ownership policy --- internal/semantics/ownership/ownership.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index c5205b86..e6004252 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -77,17 +77,17 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult MatchWholePayloadDrops: make(map[ir.NodeID]struct{}), } } + for _, sym := range module.ModuleScope.Symbols() { + if sym == nil || (sym.Kind != symbols.SymbolVar && sym.Kind != symbols.SymbolConst) { + continue + } + if ownershipTrackedSymbol(sym) { + ctx.Diagnostics.AddError(diagnostics.ErrInvalidAssignment, + "ownership-tracked module bindings are not supported", ast.LocOf(sym.ASTNode), "") + } + } for _, stmt := range module.AST.Stmts { switch node := stmt.(type) { - case *ast.LetDecl, *ast.ConstDecl: - sym, found := module.ModuleScope.LookupNode(node) - if !found || sym == nil { - continue - } - if ownershipTrackedSymbol(sym) { - ctx.Diagnostics.AddError(diagnostics.ErrInvalidAssignment, - "ownership-tracked module bindings are not supported", ast.LocOf(node), "") - } case *ast.FnDecl: var sym *symbols.Symbol if node.Receiver != nil { From e52be1469f6905cc69db0ea5193779180228be6d Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 22:12:45 +0600 Subject: [PATCH 73/80] Fix projection operand evaluation in semantic effects Evaluate nested indexes and slice operands once in source order across reads, writes, and borrows. Preserve assignment RHS ordering and temporary projection identities. Share placeOperands to centralize evaluation-order invariants across consumers, as permitted by the shared domain logic helper rule. Add producer and executable source regressions. --- internal/semantics/effect/build.go | 46 +++++---- internal/semantics/effect/build_test.go | 96 +++++++++++++++++++ .../peeper.toml | 8 ++ .../src/main.peep | 9 ++ .../peeper.toml | 7 ++ .../src/main.peep | 26 +++++ .../peeper.toml | 6 ++ .../src/main.peep | 47 +++++++++ 8 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 x_test/negative_projection_borrow_conflict/peeper.toml create mode 100644 x_test/negative_projection_borrow_conflict/src/main.peep create mode 100644 x_test/negative_projection_uninitialized/peeper.toml create mode 100644 x_test/negative_projection_uninitialized/src/main.peep create mode 100644 x_test/runtime_projection_operand_order/peeper.toml create mode 100644 x_test/runtime_projection_operand_order/src/main.peep diff --git a/internal/semantics/effect/build.go b/internal/semantics/effect/build.go index e32d455d..e4ea65be 100644 --- a/internal/semantics/effect/build.go +++ b/internal/semantics/effect/build.go @@ -271,7 +271,7 @@ func (b *builder) value(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr, ki b.emit(site, Use{Place: Place{Root: sym}, Node: node.ID(), Location: ast.LocOf(node), Kind: kind}) } case *ast.AddressExpr: - b.borrow(site, scope, node, node.Expr, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) + b.borrow(site, scope, node, node.Expr, nil, node.Mode == ast.AddressMutable, node.Mode == ast.AddressRaw) case *ast.SelectorExpr: // A field of a place is itself a place, so the use lands on the // projection rather than on the whole aggregate. Structural projection @@ -288,8 +288,7 @@ func (b *builder) value(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr, ki // rather than an empty range. _, ranged := node.Index.(*ast.RangeExpr) if ranged || node.Index == nil { - b.value(site, scope, node.Index, typeinfo.UseRead) - b.borrow(site, scope, node, node.Expr, b.mutableReference(node.ID()), false) + b.borrow(site, scope, node, node.Expr, node.Index, b.mutableReference(node.ID()), false) return } projection, ok := place.Project(node) @@ -297,8 +296,6 @@ func (b *builder) value(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr, ki return } b.projection(site, scope, node, projection.Base, projection.Step, kind) - // The index is a separate value, not part of the place. - b.value(site, scope, projection.Index, typeinfo.UseRead) case *ast.RangeExpr: b.value(site, scope, node.Start, typeinfo.UseRead) b.value(site, scope, node.End, typeinfo.UseRead) @@ -392,16 +389,36 @@ func (b *builder) placeOf(scope *symbols.Scope, expr ast.Expr) (Place, bool) { return Place{Root: sym, Projections: append([]place.OriginProjection(nil), projections...)}, true } +// placeOperands evaluates what is needed to reach storage, without charging a +// second use of the root binding. Decomposing a place alone loses the index +// expressions; they must run from the innermost base outward before the access. +func (b *builder) placeOperands(site cfg.SiteID, scope *symbols.Scope, expr ast.Expr) { + if projection, projected := place.Project(expr); projected { + if place.IsPlaceExpr(projection.Base) { + b.placeOperands(site, scope, projection.Base) + } else { + // Preserve uses of intermediate temporary projections as well as the + // evaluation that produces their base. + b.value(site, scope, projection.Base, typeinfo.UseRead) + } + b.value(site, scope, projection.Index, typeinfo.UseRead) + return + } + if _, binding := expr.(*ast.Ident); !binding { + b.value(site, scope, expr, typeinfo.UseRead) + } +} + // projection publishes a use of one projected place. When the base names // storage the use roots at that binding; otherwise the base is a temporary, // which still has its own effects and is walked before the projection is // published. func (b *builder) projection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, kind typeinfo.UseKind) { + b.placeOperands(site, scope, whole) if rooted, ok := b.placeOf(scope, whole); ok { b.emit(site, Use{Place: rooted, Node: whole.ID(), Location: ast.LocOf(whole), Kind: kind}) return } - b.value(site, scope, base, typeinfo.UseRead) b.emit(site, Use{ Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, Node: whole.ID(), @@ -453,18 +470,15 @@ func (b *builder) writeTarget(site cfg.SiteID, scope *symbols.Scope, target ast. b.value(site, scope, target, typeinfo.UseRead) return } - if projection.Index != nil { - b.value(site, scope, projection.Index, typeinfo.UseRead) - } b.writeProjection(site, scope, target, projection.Base, projection.Step, owner, valueID) } func (b *builder) writeProjection(site cfg.SiteID, scope *symbols.Scope, whole, base ast.Expr, step place.OriginProjection, owner, value ast.NodeID) { + b.placeOperands(site, scope, whole) if rooted, ok := b.placeOf(scope, whole); ok { b.emit(site, Write{Place: rooted, Node: whole.ID(), Owner: owner, Value: value, Location: ast.LocOf(whole)}) return } - b.value(site, scope, base, typeinfo.UseRead) b.emit(site, Write{ Place: Place{Temporary: base.ID(), Projections: []place.OriginProjection{step}}, Node: whole.ID(), @@ -505,10 +519,7 @@ func (b *builder) argument(site cfg.SiteID, scope *symbols.Scope, argument ast.E if address, explicit := argument.(*ast.AddressExpr); explicit { operand = address.Expr } - // Values evaluated to reach the place, such as an index, still happen. - if projection, projected := place.Project(operand); projected && projection.Index != nil { - b.value(site, scope, projection.Index, typeinfo.UseRead) - } + b.placeOperands(site, scope, operand) b.emit(site, Borrow{ Place: b.placeOrTemporary(scope, operand), Node: argument.ID(), @@ -521,10 +532,9 @@ func (b *builder) argument(site cfg.SiteID, scope *symbols.Scope, argument ast.E // borrow publishes a reference taken to a place. Values inside the operand that // are evaluated to reach it, such as an index, are published first. -func (b *builder) borrow(site cfg.SiteID, scope *symbols.Scope, whole, operand ast.Expr, mutable, raw bool) { - if projection, projected := place.Project(operand); projected && projection.Index != nil { - b.value(site, scope, projection.Index, typeinfo.UseRead) - } +func (b *builder) borrow(site cfg.SiteID, scope *symbols.Scope, whole, operand, bounds ast.Expr, mutable, raw bool) { + b.placeOperands(site, scope, operand) + b.value(site, scope, bounds, typeinfo.UseRead) b.emit(site, Borrow{ Place: b.placeOrTemporary(scope, operand), Node: whole.ID(), diff --git a/internal/semantics/effect/build_test.go b/internal/semantics/effect/build_test.go index 8b74e270..f066e45a 100644 --- a/internal/semantics/effect/build_test.go +++ b/internal/semantics/effect/build_test.go @@ -60,6 +60,12 @@ func buildEffects(t *testing.T, source string) (effect.Result, *project.Module) if result == nil { t.Fatal("Build published no result") } + if err := module.CFG.Validate(); err != nil { + t.Fatalf("constructed CFG rejected: %v", err) + } + if err := result.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("published effects rejected: %v", err) + } return result, module } @@ -106,7 +112,21 @@ func describe(op effect.Op) string { case effect.Write: return "write " + op.Place.Root.Name case effect.Use: + if op.Place.Root == nil { + return "use temporary" + } return "use " + op.Place.Root.Name + case effect.Borrow: + if op.Place.Root == nil { + return "borrow temporary" + } + return "borrow " + op.Place.Root.Name + case effect.CallBegin: + return "call" + case effect.CallEnd: + return "end" + case effect.Discard: + return "discard" case effect.Iterate: if op.Place.Root == nil { return "iterate temporary" @@ -116,6 +136,82 @@ func describe(op effect.Op) string { return "unknown" } +func TestBuildPublishesProjectionOperands(t *testing.T) { + for _, test := range []struct { + name string + source string + want []string + }{ + { + name: "nested read", + source: `fn probe(matrix: [2][2]i32, i: i32, j: i32) -> i32 { return matrix[i][j]; }`, + want: []string{"define matrix", "define i", "define j", "use i", "use j", "use matrix"}, + }, + { + name: "field after index", + source: `struct Row { field: i32 } +fn probe(rows: [2]Row, i: i32) -> i32 { return rows[i].field; }`, + want: []string{"define rows", "define i", "use i", "use rows"}, + }, + { + name: "write after RHS", + source: `fn probe(mut matrix: [2][2]i32, i: i32, j: i32, value: i32) { matrix[i][j] = value; }`, + want: []string{"define matrix", "define i", "define j", "define value", "use value", "use i", "use j", "write matrix"}, + }, + { + name: "explicit borrow", + source: `fn probe(matrix: [2][2]i32, i: i32, j: i32) { let view = &matrix[i][j]; }`, + want: []string{"define matrix", "define i", "define j", "use i", "use j", "borrow matrix", "define view"}, + }, + { + name: "implicit borrow", + source: `struct Cell { value: i32 } +fn (cell: &Cell) take() -> i32 { return cell.value; } +fn probe(matrix: [2][2]Cell, i: i32, j: i32) -> i32 { return matrix[i][j].take(); }`, + want: []string{"define matrix", "define i", "define j", "call", "use i", "use j", "borrow matrix", "end"}, + }, + { + name: "slice bounds after base", + source: `fn probe(matrix: [2][2]i32, i: i32, start: i32, end: i32) { let view = matrix[i][start..end]; }`, + want: []string{"define matrix", "define i", "define start", "define end", "use i", "use start", "use end", "borrow matrix", "define view"}, + }, + { + name: "full slice", + source: `fn probe(matrix: [2][2]i32, i: i32) { let view = matrix[i][..]; }`, + want: []string{"define matrix", "define i", "use i", "borrow matrix", "define view"}, + }, + { + name: "calls in indexes", + source: `fn first() -> i32 { return 0; } +fn second() -> i32 { return 1; } +fn probe(matrix: [2][2]i32) -> i32 { return matrix[first()][second()]; }`, + want: []string{"define matrix", "call", "use first", "end", "call", "use second", "end", "use matrix"}, + }, + { + name: "temporary receiver", + source: `struct Cell { value: i32 } +fn (cell: &Cell) take() -> i32 { return cell.value; } +fn make() -> Cell { return .Cell{value = 1}; } +fn probe() -> i32 { return make().take(); }`, + want: []string{"call", "call", "use make", "end", "borrow temporary", "end"}, + }, + { + name: "temporary base", + source: `fn make() -> [2]i32 { return [2]i32{1, 2}; } +fn probe(i: i32) -> i32 { return make()[i]; }`, + want: []string{"define i", "call", "use make", "end", "use i", "use temporary"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + result, module := buildEffects(t, test.source) + got := publishedOps(t, result, module, "probe") + if !sameOps(got, test.want) { + t.Fatalf("published %v, want %v", got, test.want) + } + }) + } +} + func TestBuildPublishesSequenceIterationLifetime(t *testing.T) { result, module := buildEffects(t, `fn walk(values: [2]i32) { for value in values {} diff --git a/x_test/negative_projection_borrow_conflict/peeper.toml b/x_test/negative_projection_borrow_conflict/peeper.toml new file mode 100644 index 00000000..b571814f --- /dev/null +++ b/x_test/negative_projection_borrow_conflict/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_projection_borrow_conflict" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0037", "cannot activate mutable borrow while storage is borrowed"] +stderr_excludes = ["T0039"] diff --git a/x_test/negative_projection_borrow_conflict/src/main.peep b/x_test/negative_projection_borrow_conflict/src/main.peep new file mode 100644 index 00000000..06b06687 --- /dev/null +++ b/x_test/negative_projection_borrow_conflict/src/main.peep @@ -0,0 +1,9 @@ +fn Write(_: &mut i32) -> i32 { return 0; } +fn Read(_: &i32) {} + +fn Conflict(matrix: [2][2]i32, mut value: i32) -> i32 { + let reference = &value; + let selected = matrix[Write(&mut value)][0]; + Read(reference); + return selected; +} diff --git a/x_test/negative_projection_uninitialized/peeper.toml b/x_test/negative_projection_uninitialized/peeper.toml new file mode 100644 index 00000000..4e271319 --- /dev/null +++ b/x_test/negative_projection_uninitialized/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_projection_uninitialized" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0039", "symbol `read_index` used before it's initialized", "symbol `write_index` used before it's initialized", "symbol `borrow_index` used before it's initialized", "symbol `field_index` used before it's initialized", "symbol `slice_index` used before it's initialized"] diff --git a/x_test/negative_projection_uninitialized/src/main.peep b/x_test/negative_projection_uninitialized/src/main.peep new file mode 100644 index 00000000..1d5e9d44 --- /dev/null +++ b/x_test/negative_projection_uninitialized/src/main.peep @@ -0,0 +1,26 @@ +struct Cell { value: i32 } + +fn Read(matrix: [2][2]i32) -> i32 { + let mut read_index: i32; + return matrix[read_index][0]; +} + +fn Write(mut matrix: [2][2]i32) { + let mut write_index: i32; + matrix[write_index][0] = 1; +} + +fn Borrow(matrix: [2][2]i32) { + let mut borrow_index: i32; + let view = &matrix[borrow_index][0]; +} + +fn Field(cells: [2]Cell) -> i32 { + let mut field_index: i32; + return cells[field_index].value; +} + +fn Slice(matrix: [2][2][2]i32) { + let mut slice_index: i32; + let view = matrix[slice_index][0][..]; +} diff --git a/x_test/runtime_projection_operand_order/peeper.toml b/x_test/runtime_projection_operand_order/peeper.toml new file mode 100644 index 00000000..df88fc20 --- /dev/null +++ b/x_test/runtime_projection_operand_order/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_projection_operand_order" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_projection_operand_order/src/main.peep b/x_test/runtime_projection_operand_order/src/main.peep new file mode 100644 index 00000000..c325ba01 --- /dev/null +++ b/x_test/runtime_projection_operand_order/src/main.peep @@ -0,0 +1,47 @@ +struct Trace { order: i32 } +struct Cell { value: i32 } + +fn mark(trace: &mut Trace, digit: i32) -> i32 { + trace.order = trace.order * 10 + digit; + return 0; +} + +fn make(trace: &mut Trace) -> [2]i32 { + trace.order = trace.order * 10 + 1; + return [2]i32{7, 8}; +} + +fn (cell: &Cell) read() -> i32 { + return cell.value; +} + +fn main() -> i32 { + let mut trace = .Trace{order = 0}; + let mut matrix = [2][2]i32{[2]i32{7, 8}, [2]i32{9, 10}}; + let value = matrix[mark(&mut trace, 1)][mark(&mut trace, 2)]; + if value != 7 || trace.order != 12 { return 1; } + + trace.order = 0; + matrix[mark(&mut trace, 2)][mark(&mut trace, 3)] = mark(&mut trace, 1); + if matrix[0][0] != 0 || trace.order != 123 { return 2; } + + trace.order = 0; + let cells = [2][2]Cell{[2]Cell{.{value = 3}, .{value = 4}}, [2]Cell{.{value = 5}, .{value = 6}}}; + let selected = cells[mark(&mut trace, 1)][mark(&mut trace, 2)].read(); + if selected != 3 || trace.order != 12 { return 3; } + + trace.order = 0; + { + let view = matrix[mark(&mut trace, 1)][mark(&mut trace, 2)..mark(&mut trace, 3) + 1]; + if view[0] != 0 || trace.order != 123 { return 4; } + } + + trace.order = 0; + let temporary = make(&mut trace)[mark(&mut trace, 2)]; + if temporary != 7 || trace.order != 12 { return 5; } + + trace.order = 0; + let pointer: rawptr = @matrix[mark(&mut trace, 1)][mark(&mut trace, 2)]; + if pointer != @matrix[0][0] || trace.order != 12 { return 6; } + return 0; +} From 2de5d990e1692b9540c637f0f40334b7660f8eac Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 22:12:46 +0600 Subject: [PATCH 74/80] Strengthen graph and semantic artifact contracts Validate complete CFG adjacency and exact site edges, reject incorrect expression identities, and preserve invalid-source recovery. Expose a detached complete edge snapshot for validator consumers. Restore facade-only empty-ID filtering without rejecting generic zero nodes. Share normalization between graph algorithms to preserve caller input. Benchmark insertion and retain current storage pending production profiling. --- internal/graph/directed.go | 14 +++ internal/graph/directed_test.go | 87 +++++++++++++++++ internal/graph/graph.go | 25 ++++- internal/graph/graph_test.go | 41 ++++++++ internal/ir/cfg/model.go | 4 + internal/ir/cfg/validate.go | 96 +++++++++---------- internal/ir/cfg/validate_test.go | 43 ++++++++- internal/semantics/effect/validate.go | 55 ++++++----- internal/semantics/effect/validate_test.go | 50 ++++++++++ .../peeper.toml | 1 + 10 files changed, 334 insertions(+), 82 deletions(-) diff --git a/internal/graph/directed.go b/internal/graph/directed.go index a290b71e..f3917f31 100644 --- a/internal/graph/directed.go +++ b/internal/graph/directed.go @@ -42,6 +42,20 @@ func (g *Directed[Node, Edge]) AddEdge(edge Edge) bool { return true } +// Edges returns a snapshot of every stored edge, including disconnected edges +// whose endpoints a domain validator may not recognize. Source order is +// unspecified; within a source, insertion order is preserved. +func (g *Directed[Node, Edge]) Edges() []Edge { + if g == nil { + return nil + } + var edges []Edge + for _, outgoing := range g.out { + edges = append(edges, outgoing...) + } + return edges +} + // OutEdges returns outgoing edges in insertion order. The returned slice is a // snapshot so consumers cannot corrupt the graph's reverse index accidentally. func (g *Directed[Node, Edge]) OutEdges(id Node) []Edge { diff --git a/internal/graph/directed_test.go b/internal/graph/directed_test.go index 7a0fa58e..8da53b1e 100644 --- a/internal/graph/directed_test.go +++ b/internal/graph/directed_test.go @@ -1,6 +1,7 @@ package graph import ( + "fmt" "reflect" "testing" ) @@ -11,6 +12,53 @@ type testDirectedEdge struct { kind int } +func TestDirectedPreservesZeroNode(t *testing.T) { + g := NewDirected(func(edge [2]int) (int, int) { return edge[0], edge[1] }) + g.AddEdge([2]int{1, 0}) + order, cycles := g.TopoSort([]int{1, 0}, nil) + if !reflect.DeepEqual(order, []int{0, 1}) || len(cycles) != 0 { + t.Fatalf("topology = %v, %v; want [0 1], no cycles", order, cycles) + } + if got := g.WeaklyConnectedComponents([]int{1, 0}, nil); !reflect.DeepEqual(got, [][]int{{1, 0}}) { + t.Fatalf("components = %v; want [[1 0]]", got) + } + if got := g.InEdges(0); !reflect.DeepEqual(got, [][2]int{{1, 0}}) { + t.Fatalf("zero node incoming edges = %v", got) + } +} + +func BenchmarkDirectedConstruction(b *testing.B) { + for _, shape := range []struct { + name string + sources int + degree int + }{ + {name: "cfg", sources: 1024, degree: 2}, + {name: "dependencies", sources: 256, degree: 8}, + {name: "fanout", sources: 1, degree: 128}, + {name: "fanout", sources: 1, degree: 1024}, + {name: "fanout", sources: 1, degree: 4096}, + {name: "fanout", sources: 1, degree: 16384}, + } { + b.Run(fmt.Sprintf("%s/%d", shape.name, shape.degree), func(b *testing.B) { + edges := make([][2]int, 0, shape.sources*shape.degree) + for from := 0; from < shape.sources; from++ { + for offset := 1; offset <= shape.degree; offset++ { + edges = append(edges, [2]int{from, from + offset}) + } + } + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + g := NewDirected(func(edge [2]int) (int, int) { return edge[0], edge[1] }) + for _, edge := range edges { + g.AddEdge(edge) + } + } + }) + } +} + func TestDirectedOwnsBothAdjacencyDirections(t *testing.T) { g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) first := testDirectedEdge{from: "a", to: "b", kind: 1} @@ -26,6 +74,45 @@ func TestDirectedOwnsBothAdjacencyDirections(t *testing.T) { } } +func TestDirectedEdgesSnapshot(t *testing.T) { + var missing *Directed[string, testDirectedEdge] + if len(missing.Edges()) != 0 { + t.Fatal("nil graph has edges") + } + g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) + want := map[testDirectedEdge]bool{ + {from: "a", to: "b", kind: 1}: true, + {from: "a", to: "b", kind: 2}: true, + {from: "foreign", to: "disconnected", kind: 1}: true, + } + for edge := range want { + g.AddEdge(edge) + } + snapshot := g.Edges() + if len(snapshot) != len(want) { + t.Fatalf("snapshot = %v, want %v", snapshot, want) + } + for _, edge := range snapshot { + if !want[edge] { + t.Fatalf("unexpected or repeated edge %v", edge) + } + delete(want, edge) + } + original := snapshot[0] + snapshot[0] = testDirectedEdge{} + found := false + for _, edge := range g.OutEdges(original.from) { + found = found || edge == original + } + if !found { + t.Fatal("snapshot mutation changed canonical adjacency") + } + g.AddEdge(testDirectedEdge{from: "new", to: "node"}) + if len(snapshot) != 3 { + t.Fatal("later graph mutation changed snapshot") + } +} + func TestDirectedAlgorithmsShareCanonicalAdjacency(t *testing.T) { g := NewDirected(func(edge testDirectedEdge) (string, string) { return edge.from, edge.to }) g.AddEdge(testDirectedEdge{from: "a", to: "b", kind: 1}) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 53d02609..f0d735e8 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -1,6 +1,9 @@ package graph -import "sync" +import ( + "slices" + "sync" +) type NodeID string @@ -83,7 +86,7 @@ func (g *Graph) TopoSort(ids []NodeID, kinds ...EdgeKind) ([]NodeID, [][]NodeID) } g.mu.RLock() defer g.mu.RUnlock() - return g.directed.TopoSort(ids, g.edgeFilter(kinds)) + return g.directed.TopoSort(nonEmptyNodeIDs(ids), g.edgeFilter(kinds)) } func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]NodeID { @@ -92,7 +95,23 @@ func (g *Graph) WeaklyConnectedComponents(ids []NodeID, kinds ...EdgeKind) [][]N } g.mu.RLock() defer g.mu.RUnlock() - return g.directed.WeaklyConnectedComponents(ids, g.edgeFilter(kinds)) + return g.directed.WeaklyConnectedComponents(nonEmptyNodeIDs(ids), g.edgeFilter(kinds)) +} + +// Empty IDs are invalid only in the domain facade, not in Directed. +func nonEmptyNodeIDs(ids []NodeID) []NodeID { + first := slices.Index(ids, NodeID("")) + if first < 0 { + return ids + } + filtered := make([]NodeID, first, len(ids)-1) + copy(filtered, ids[:first]) + for _, id := range ids[first+1:] { + if id != "" { + filtered = append(filtered, id) + } + } + return filtered } func (g *Graph) edgeFilter(kinds []EdgeKind) func(edge) bool { diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 884fdd71..7f316411 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -1,6 +1,7 @@ package graph import ( + "reflect" "slices" "testing" ) @@ -10,6 +11,46 @@ const ( testEdgeMetadata EdgeKind = "metadata" ) +func TestAlgorithmsIgnoreEmptyNodeIDs(t *testing.T) { + g := New(testEdgeImport) + g.AddEdge("a", "b") + g.AddEdge("b", "a", testEdgeMetadata) + for _, tc := range []struct { + name string + ids []NodeID + kinds []EdgeKind + }{ + {name: "nil"}, + {name: "empty", ids: []NodeID{}}, + {name: "all empty", ids: []NodeID{"", ""}}, + {name: "mixed duplicates isolated", ids: []NodeID{"", "a", "", "b", "a", "isolated", ""}}, + {name: "metadata", ids: []NodeID{"", "a", "b", "isolated", ""}, kinds: []EdgeKind{testEdgeMetadata}}, + {name: "cycle", ids: []NodeID{"", "a", "b", "a", ""}, kinds: []EdgeKind{testEdgeImport, testEdgeMetadata}}, + } { + t.Run(tc.name, func(t *testing.T) { + original := slices.Clone(tc.ids) + var valid []NodeID + for _, id := range tc.ids { + if id != "" { + valid = append(valid, id) + } + } + wantOrder, wantCycles := g.TopoSort(valid, tc.kinds...) + order, cycles := g.TopoSort(tc.ids, tc.kinds...) + if !reflect.DeepEqual(order, wantOrder) || !reflect.DeepEqual(cycles, wantCycles) { + t.Errorf("topology = %v, %v; want %v, %v", order, cycles, wantOrder, wantCycles) + } + wantComponents := g.WeaklyConnectedComponents(valid, tc.kinds...) + if got := g.WeaklyConnectedComponents(tc.ids, tc.kinds...); !reflect.DeepEqual(got, wantComponents) { + t.Errorf("components = %v; want %v", got, wantComponents) + } + if !reflect.DeepEqual(tc.ids, original) { + t.Errorf("caller IDs changed: %v; want %v", tc.ids, original) + } + }) + } +} + func TestTopoSortOrdersImportDependencies(t *testing.T) { g := New(testEdgeImport) g.AddEdge("a", "b") diff --git a/internal/ir/cfg/model.go b/internal/ir/cfg/model.go index c5b19880..f564e210 100644 --- a/internal/ir/cfg/model.go +++ b/internal/ir/cfg/model.go @@ -20,6 +20,10 @@ func (m *Module) Function(id ir.NodeID) *Graph { return m.byNodeID[id] } +// Graph is finalized by BuildModule. Terminators and ordered block sites define +// control flow; BlockEdges and SiteEdges are derived traversal indexes. Consumers +// must not mutate topology after publication: rebuild the CFG before publishing +// a new generation, since site IDs and downstream evidence depend on it. type Graph struct { NodeID ir.NodeID Name string diff --git a/internal/ir/cfg/validate.go b/internal/ir/cfg/validate.go index 80e9e0ed..be608533 100644 --- a/internal/ir/cfg/validate.go +++ b/internal/ir/cfg/validate.go @@ -163,17 +163,12 @@ func validateBlockAdjacency(fn *Graph) []string { } } } - for _, block := range fn.Blocks { - for _, edge := range fn.BlockEdges.OutEdges(block.ID) { - pair := [2]int{edge.From, edge.To} - if edge.From != block.ID { - problems = append(problems, fmt.Sprintf("function %d block b%d owns an edge leaving b%d", fn.NodeID, block.ID, edge.From)) - } - if !forward[pair] { - problems = append(problems, fmt.Sprintf("function %d block topology records b%d -> b%d, but the terminator does not", fn.NodeID, edge.From, edge.To)) - } - delete(forward, pair) + for _, edge := range fn.BlockEdges.Edges() { + pair := [2]int{edge.From, edge.To} + if !forward[pair] { + problems = append(problems, fmt.Sprintf("function %d block topology records b%d -> b%d, but the terminator does not", fn.NodeID, edge.From, edge.To)) } + delete(forward, pair) } for pair := range forward { problems = append(problems, fmt.Sprintf("function %d block b%d transfers to b%d, which is absent from block topology", fn.NodeID, pair[0], pair[1])) @@ -217,58 +212,53 @@ func validateSiteEdges(fn *Graph) []string { if fn.SiteEdges == nil { return append(problems, fmt.Sprintf("function %d has no site topology", fn.NodeID)) } + // Check the derived index against source topology, not against another + // adjacency index. A return has no block successor, but does have a site + // edge to the function exit. + expected := make(map[Edge]bool) for _, block := range fn.Blocks { last := len(block.Sites) - 1 - for index, site := range block.Sites { - for _, edge := range fn.SiteEdges.OutEdges(site.ID) { - if edge.From != site.ID { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] owns an edge leaving %v", fn.NodeID, block.ID, index, edge.From)) - } - if siteAt(fn, edge.To) == nil { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] transfers to %v, which is not a site", fn.NodeID, block.ID, index, edge.To)) - continue - } - if kind, ok := expectedEdgeKind(block, index == last, edge.Kind); !ok { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] leaves on a %s edge, but %s", fn.NodeID, block.ID, index, edgeKindName(edge.Kind), kind)) - } + for index := range last { + expected[Edge{From: block.Sites[index].ID, To: block.Sites[index+1].ID, Kind: EdgeNormal}] = true + } + expect := func(target *Block, kind EdgeKind, caseIndex int) { + if target != nil && ownsBlock(fn, target) { + expected[Edge{From: block.Sites[last].ID, To: target.Sites[0].ID, Kind: kind, Case: caseIndex}] = true } - for _, edge := range fn.SiteEdges.InEdges(site.ID) { - if edge.To != site.ID { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] records an edge arriving at %v", fn.NodeID, block.ID, index, edge.To)) - } - if siteAt(fn, edge.From) == nil { - problems = append(problems, fmt.Sprintf("function %d site b%d[%d] arrives from %v, which is not a site", fn.NodeID, block.ID, index, edge.From)) - } + } + switch term := block.Terminator.(type) { + case *Jump: + expect(term.Target, EdgeNormal, 0) + case *Branch: + expect(term.TrueTarget, EdgeTrue, 0) + expect(term.FalseTarget, EdgeFalse, 0) + case *Return: + expect(fn.Exit, EdgeReturn, 0) + case *SwitchVariant: + for _, target := range term.Targets { + expect(target.Target, EdgeVariantCase, target.Case) } + case nil: + default: + problems = append(problems, fmt.Sprintf("function %d block b%d has unknown terminator %T", fn.NodeID, block.ID, term)) } } - return problems -} - -// expectedEdgeKind reports whether one outgoing edge kind is legal at a site. -// Edges between sites within a block are plain sequence; only the block's last -// site leaves on the terminator, and then the kind must name that terminator's -// meaning. It returns the expectation to quote when the kind is wrong. -func expectedEdgeKind(block *Block, last bool, kind EdgeKind) (string, bool) { - if !last { - if kind == EdgeNormal { - return "", true + for _, edge := range fn.SiteEdges.Edges() { + if siteAt(fn, edge.From) == nil { + problems = append(problems, fmt.Sprintf("function %d edge leaves %v, which is not a site", fn.NodeID, edge.From)) } - return "a site inside a block leaves only on a normal edge", false + if siteAt(fn, edge.To) == nil { + problems = append(problems, fmt.Sprintf("function %d edge transfers to %v, which is not a site", fn.NodeID, edge.To)) + } + if !expected[edge] { + problems = append(problems, fmt.Sprintf("function %d site edge %v -> %v (%s, case %d) is not described by its block sites or terminator", fn.NodeID, edge.From, edge.To, edgeKindName(edge.Kind), edge.Case)) + } + delete(expected, edge) } - switch block.Terminator.(type) { - case *Jump: - return "a jump leaves only on a normal edge", kind == EdgeNormal - case *Branch: - return "a branch leaves only on a true or false edge", kind == EdgeTrue || kind == EdgeFalse - case *Return: - return "a return leaves only on a return edge", kind == EdgeReturn - case *SwitchVariant: - return "a variant switch leaves only on a variant-case edge", kind == EdgeVariantCase - case nil: - return "a block with no terminator leaves on no edge", false + for edge := range expected { + problems = append(problems, fmt.Sprintf("function %d site edge %v -> %v (%s, case %d) is absent from site topology", fn.NodeID, edge.From, edge.To, edgeKindName(edge.Kind), edge.Case)) } - return "", true + return problems } // validateReachability checks the flag consumers trust against the traversal it diff --git a/internal/ir/cfg/validate_test.go b/internal/ir/cfg/validate_test.go index 64a1f99c..04237d9b 100644 --- a/internal/ir/cfg/validate_test.go +++ b/internal/ir/cfg/validate_test.go @@ -101,6 +101,47 @@ func TestValidateRejectsTopologyDefects(t *testing.T) { }, want: "but the terminator does not", }, + { + name: "block edge with foreign source", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: 99, To: fn.Exit.ID}) }, + want: "but the terminator does not", + }, + { + name: "block edge with foreign target", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: fn.Entry.ID, To: 99}) }, + want: "but the terminator does not", + }, + { + name: "entirely foreign block edge", + damage: func(fn *Graph) { fn.BlockEdges.AddEdge(BlockEdge{From: 98, To: 99}) }, + want: "but the terminator does not", + }, + { + name: "entirely foreign site edge", + damage: func(fn *Graph) { fn.SiteEdges.AddEdge(Edge{From: SiteID{Block: 98}, To: SiteID{Block: 99}}) }, + want: "which is not a site", + }, + { + name: "missing site edges", + damage: func(fn *Graph) { + fn.SiteEdges = graphcore.NewDirected(func(edge Edge) (SiteID, SiteID) { return edge.From, edge.To }) + }, + want: "absent from site topology", + }, + { + name: "branch edge names wrong valid target", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { edge.To = fn.Entry.Sites[0].ID; return edge }) + }, + want: "not described by its block sites or terminator", + }, + { + name: "branch edge carries spurious case metadata", + damage: func(fn *Graph) { + rewriteFirstSiteEdge(fn, func(edge Edge) Edge { edge.Case = 99; return edge }) + }, + want: "not described by its block sites or terminator", + }, { name: "a transfer goes unrecorded by its target", damage: func(fn *Graph) { @@ -148,7 +189,7 @@ func TestValidateRejectsTopologyDefects(t *testing.T) { return edge }) }, - want: "a branch leaves only on a true or false edge", + want: "not described by its block sites or terminator", }, { name: "reachability disagrees with entry traversal", diff --git a/internal/semantics/effect/validate.go b/internal/semantics/effect/validate.go index 40d7cd5c..bf8cb396 100644 --- a/internal/semantics/effect/validate.go +++ b/internal/semantics/effect/validate.go @@ -13,14 +13,14 @@ import ( const maxReportedProblems = 10 -// Validate checks the shape of published effects: that every operation names a -// symbol, that a read can be reported against a source span, and that every key -// refers to a site that exists in the graph it claims. +// Validate checks operation identities, expression categories, storage roots, +// source locations, call brackets, and membership in the supplied CFG. // // It deliberately does not re-derive meaning. Whether a read should have been // published for some expression is the producer's decision, and re-deciding it -// here would be a second implementation of the thing being validated. A missing -// operation is caught by the dispatch contract in internal/contracts, not here. +// here would be a second implementation of the thing being validated. Dispatch +// contracts check node-kind coverage, not what each case publishes. Required +// operations and their order are covered by producer tests and source fixtures. func (r Result) Validate(graphs *cfg.Module, nodes map[ast.NodeID]ast.Node) error { if len(r) == 0 { return nil @@ -88,26 +88,26 @@ func (v *validationVisitor) where() string { func (v *validationVisitor) VisitDefine(op Define) { where := v.where() - v.problems = append(v.problems, validateNode(where, "define", op.Symbol == nil, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Node](where, "define", op.Symbol == nil, op.Node, v.nodes)...) if op.Value != 0 { - v.problems = append(v.problems, validateNode(where, "define value", false, op.Value, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "define value", false, op.Value, v.nodes)...) } } func (v *validationVisitor) VisitWrite(op Write) { where := v.where() - v.problems = append(v.problems, validatePlace(where, "write", op.Place)...) - v.problems = append(v.problems, validateNode(where, "write", false, op.Node, v.nodes)...) - v.problems = append(v.problems, validateNode(where, "write owner", false, op.Owner, v.nodes)...) + v.problems = append(v.problems, validatePlace(where, "write", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "write", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Node](where, "write owner", false, op.Owner, v.nodes)...) if op.Value != 0 { - v.problems = append(v.problems, validateNode(where, "write value", false, op.Value, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "write value", false, op.Value, v.nodes)...) } } func (v *validationVisitor) VisitUse(op Use) { where := v.where() - v.problems = append(v.problems, validatePlace(where, "use", op.Place)...) - v.problems = append(v.problems, validateNode(where, "use", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validatePlace(where, "use", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "use", false, op.Node, v.nodes)...) if op.Location == nil { v.problems = append(v.problems, where+" is a use with no source location to report against") } @@ -115,9 +115,9 @@ func (v *validationVisitor) VisitUse(op Use) { func (v *validationVisitor) VisitBorrow(op Borrow) { where := v.where() - v.problems = append(v.problems, validatePlace(where, "borrow", op.Place)...) - v.problems = append(v.problems, validateNode(where, "borrow", false, op.Node, v.nodes)...) - v.problems = append(v.problems, validateNode(where, "borrow operand", false, op.Operand, v.nodes)...) + v.problems = append(v.problems, validatePlace(where, "borrow", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "borrow", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "borrow operand", false, op.Operand, v.nodes)...) if op.Location == nil { v.problems = append(v.problems, where+" is a borrow with no source location to report against") } @@ -125,9 +125,9 @@ func (v *validationVisitor) VisitBorrow(op Borrow) { func (v *validationVisitor) VisitIterate(op Iterate) { where := v.where() - v.problems = append(v.problems, validatePlace(where, "iteration", op.Place)...) - v.problems = append(v.problems, validateNode(where, "iteration", op.Carrier == nil, op.Node, v.nodes)...) - v.problems = append(v.problems, validateNode(where, "iteration owner", false, op.Loop, v.nodes)...) + v.problems = append(v.problems, validatePlace(where, "iteration", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "iteration", op.Carrier == nil, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Node](where, "iteration owner", false, op.Loop, v.nodes)...) if op.Location == nil { v.problems = append(v.problems, where+" is an iteration with no source location to report against") } @@ -135,8 +135,8 @@ func (v *validationVisitor) VisitIterate(op Iterate) { func (v *validationVisitor) VisitDiscard(op Discard) { where := v.where() - v.problems = append(v.problems, validatePlace(where, "discard", op.Place)...) - v.problems = append(v.problems, validateNode(where, "discard", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validatePlace(where, "discard", op.Place, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "discard", false, op.Node, v.nodes)...) if op.Location == nil { v.problems = append(v.problems, where+" is a discard with no source location to report against") } @@ -144,7 +144,7 @@ func (v *validationVisitor) VisitDiscard(op Discard) { func (v *validationVisitor) VisitCallBegin(op CallBegin) { where := v.where() - v.problems = append(v.problems, validateNode(where, "call start", false, op.Node, v.nodes)...) + v.problems = append(v.problems, validateNode[ast.Expr](where, "call start", false, op.Node, v.nodes)...) v.open = append(v.open, op.Node) } @@ -163,18 +163,20 @@ func (v *validationVisitor) VisitCallEnd(op CallEnd) { // validatePlace enforces that a place names exactly one root. A place with // neither names nothing; one with both would let a consumer reach two different // answers depending on which field it read. -func validatePlace(where, kind string, at Place) []string { +func validatePlace(where, kind string, at Place, nodes map[ast.NodeID]ast.Node) []string { switch { case at.Root == nil && at.Temporary == 0: return []string{fmt.Sprintf("%s is a %s whose place names neither a binding nor a temporary", where, kind)} case at.Root != nil && at.Temporary != 0: return []string{fmt.Sprintf("%s is a %s whose place names both binding %s and temporary %d", where, kind, at.Root.Name, at.Temporary)} + case at.Temporary != 0: + return validateNode[ast.Expr](where, kind+" temporary", false, at.Temporary, nodes) } return nil } -func validateNode(where, kind string, missingSymbol bool, node ast.NodeID, nodes map[ast.NodeID]ast.Node) []string { +func validateNode[T ast.Node](where, kind string, missingSymbol bool, node ast.NodeID, nodes map[ast.NodeID]ast.Node) []string { problems := make([]string, 0, 2) if missingSymbol { problems = append(problems, fmt.Sprintf("%s is a %s with no symbol", where, kind)) @@ -182,8 +184,11 @@ func validateNode(where, kind string, missingSymbol bool, node ast.NodeID, nodes if nodes == nil { return problems } - if _, exists := nodes[node]; !exists { + syntax, exists := nodes[node] + if !exists { problems = append(problems, fmt.Sprintf("%s is a %s naming node %d, which is not in the typed AST", where, kind, node)) + } else if _, ok := syntax.(T); !ok { + problems = append(problems, fmt.Sprintf("%s is a %s naming node %d with unexpected node type %T", where, kind, node, syntax)) } return problems } diff --git a/internal/semantics/effect/validate_test.go b/internal/semantics/effect/validate_test.go index a3637100..a6e03eb2 100644 --- a/internal/semantics/effect/validate_test.go +++ b/internal/semantics/effect/validate_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "compiler/internal/frontend/ast" "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/semantics/effect" @@ -93,6 +94,55 @@ func TestValidateReportsDefects(t *testing.T) { } } +func TestValidateRejectsWrongExpressionIdentity(t *testing.T) { + result, module := buildEffects(t, validationSource) + fn, site := anySite(t, result) + function := module.TypedASTNodes[ast.NodeID(fn)].(*ast.FnDecl) + expr := function.Params[0].Name + target := function.Body.Stmts[1].(*ast.AssignStmt).Target + root := effect.Place{Root: &symbols.Symbol{Name: "count"}} + for _, test := range []struct { + name string + ops []effect.Op + }{ + {"define value", []effect.Op{effect.Define{Symbol: root.Root, Node: function.ID(), Value: expr.ID(), Initialized: true}}}, + {"write target", []effect.Op{effect.Write{Place: root, Node: expr.ID(), Owner: function.Body.ID()}}}, + {"write value", []effect.Op{effect.Write{Place: root, Node: target.ID(), Owner: function.Body.ID(), Value: expr.ID()}}}, + {"use", []effect.Op{effect.Use{Place: root, Node: expr.ID(), Location: ast.LocOf(expr)}}}, + {"borrow", []effect.Op{effect.Borrow{Place: root, Node: expr.ID(), Operand: target.ID(), Location: ast.LocOf(expr)}}}, + {"borrow operand", []effect.Op{effect.Borrow{Place: root, Node: target.ID(), Operand: expr.ID(), Location: ast.LocOf(target)}}}, + {"iteration", []effect.Op{effect.Iterate{Place: root, Node: expr.ID(), Loop: function.Body.ID(), Carrier: root.Root, Location: ast.LocOf(expr)}}}, + {"discard", []effect.Op{effect.Discard{Place: root, Node: expr.ID(), Location: ast.LocOf(expr)}}}, + {"call start", []effect.Op{effect.CallBegin{Node: expr.ID(), Location: ast.LocOf(expr)}, effect.CallEnd{Node: expr.ID()}}}, + {"temporary", []effect.Op{effect.Use{Place: effect.Place{Temporary: expr.ID()}, Node: target.ID(), Location: ast.LocOf(target)}}}, + } { + t.Run(test.name, func(t *testing.T) { + published := effect.Result{fn: effect.SiteOps{site: test.ops}} + if err := published.Validate(module.CFG, module.TypedASTNodes); err != nil { + t.Fatalf("well-shaped operation rejected: %v", err) + } + nodes := make(map[ast.NodeID]ast.Node, len(module.TypedASTNodes)) + for id, node := range module.TypedASTNodes { + nodes[id] = node + } + nodes[expr.ID()] = &ast.BlockStmt{NodeIDHolder: ast.NodeIDHolder{NodeID: expr.ID()}} + if err := published.Validate(module.CFG, nodes); err == nil || !strings.Contains(err.Error(), "unexpected node type") { + t.Fatalf("wrong expression identity: got %v", err) + } + }) + } +} + +func TestValidateRejectsNilIndexedNode(t *testing.T) { + result, module := buildEffects(t, validationSource) + for id := range module.TypedASTNodes { + module.TypedASTNodes[id] = nil + } + if err := result.Validate(module.CFG, module.TypedASTNodes); err == nil { + t.Fatal("nil indexed nodes accepted") + } +} + func TestValidateAcceptsEmptyResult(t *testing.T) { if err := effect.Result(nil).Validate(nil, nil); err != nil { t.Fatalf("Validate() = %v, want nil for an empty artifact", err) diff --git a/x_test/negative_default_parameter_arity/peeper.toml b/x_test/negative_default_parameter_arity/peeper.toml index 95355756..df4e1b4d 100644 --- a/x_test/negative_default_parameter_arity/peeper.toml +++ b/x_test/negative_default_parameter_arity/peeper.toml @@ -5,3 +5,4 @@ build = "program" mode = "check" outcome = "failure" stderr_contains = ["Compilation failed"] +stderr_excludes = ["published semantic effects are malformed", "control-flow topology is malformed", "panic:"] From f6a2d505e426a983b7b40908f05d1f7bef7805a7 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 22:12:47 +0600 Subject: [PATCH 75/80] Preserve enum reference-field provenance across replacement Reuse recorded operand types so assignment checks retain flow payload evidence and correct HIR projections. Centralize existing type lookup instead of replaying expression typing. Track holder-relative loan paths and replace only overwritten field loans, preserving siblings, copied holders, and partial-write liveness. The field replacement helper protects slot identity and reuses resolved storage evidence. Check mutation against carrier storage while preserving typed backend invariants. Add state, semantic, HIR, and executable positive/negative regressions for replacement, copies, optional clearing, mutable references, joins, and loops. --- internal/ir/hir/lower/variant_rebind_test.go | 52 +++++ internal/semantics/ownership/effects.go | 3 + internal/semantics/ownership/reference.go | 80 +++++++- .../ownership/variant_rebind_test.go | 194 ++++++++++++++++++ internal/semantics/typechecker/check_stmt.go | 4 +- internal/semantics/typechecker/flow.go | 31 +-- .../peeper.toml | 8 + .../src/main.peep | 58 ++++++ .../peeper.toml | 6 + .../src/main.peep | 54 +++++ 10 files changed, 466 insertions(+), 24 deletions(-) create mode 100644 internal/ir/hir/lower/variant_rebind_test.go create mode 100644 internal/semantics/ownership/variant_rebind_test.go create mode 100644 x_test/negative_variant_reference_rebind/peeper.toml create mode 100644 x_test/negative_variant_reference_rebind/src/main.peep create mode 100644 x_test/runtime_variant_reference_rebind/peeper.toml create mode 100644 x_test/runtime_variant_reference_rebind/src/main.peep diff --git a/internal/ir/hir/lower/variant_rebind_test.go b/internal/ir/hir/lower/variant_rebind_test.go new file mode 100644 index 00000000..b7a28d7b --- /dev/null +++ b/internal/ir/hir/lower/variant_rebind_test.go @@ -0,0 +1,52 @@ +package lower + +import ( + "testing" + + "compiler/internal/ir" + "compiler/internal/ir/hir" + "compiler/pkg/peeper" +) + +func TestGenerateHIRLowersIndexAssignment(t *testing.T) { + out := generateTestHIR(t, "hir_index_assignment"+peeper.SourceExt, "hir_index_assignment", `fn main() { + let mut values = [2]i32{1, 2}; + values[0] = 7; +}`) + assign := out.Funcs[0].Body.Stmts[1].(*hir.Assign) + projections := assign.Target.Projections + if len(projections) != 1 || projections[0].Kind != ir.PlaceProjectionIndex { + t.Fatalf("index assignment projections = %#v, want index", projections) + } + if out.Types.Text(assign.Target.Type) != "i32" || assign.Value.TypeID() != assign.Target.Type { + t.Fatal("index assignment lost element type") + } +} + +func TestGenerateHIRLowersVariantReferenceFieldRebind(t *testing.T) { + out := generateTestHIR(t, "hir_variant_rebind_test"+peeper.SourceExt, "hir_variant_rebind_test", `enum Resource { Borrowed: { value: &i32 }, Empty } +fn Read(_: &i32) {} +fn probe(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first }; + if resource is Resource::Borrowed { + resource.value = &second; + match resource { + Resource::Borrowed with { value = ref } => { Read(ref); } + Resource::Empty => {} + } + } +}`) + branch := out.Funcs[1].Body.Stmts[1].(*hir.If) + assign := branch.Then.Stmts[0].(*hir.Assign) + projections := assign.Target.Projections + if len(projections) != 2 { + t.Fatalf("rebind projections = %#v, want payload then field", projections) + } + if projections[0].Kind != ir.PlaceProjectionVariantPayload || projections[0].Case != 0 || + projections[1].Kind != ir.PlaceProjectionField || projections[1].FieldIndex != 0 { + t.Fatalf("rebind projections = %#v, want Borrowed payload then value field", projections) + } + if out.Types.Text(assign.Target.Type) != "&i32" || assign.Value.TypeID() != assign.Target.Type { + t.Fatalf("rebind types = %s <- %s, want &i32", out.Types.Text(assign.Target.Type), out.Types.Text(assign.Value.TypeID())) + } +} diff --git a/internal/semantics/ownership/effects.go b/internal/semantics/ownership/effects.go index 3a2e74c8..660e1b06 100644 --- a/internal/semantics/ownership/effects.go +++ b/internal/semantics/ownership/effects.go @@ -195,6 +195,9 @@ func (a *analyzer) applyWriteEffect( if op.Owner != 0 && typeinfo.OwnershipCapabilityOf(a.exprType(target)).Drop { a.cleanup.BeforeAssign[ir.NodeID(op.Owner)] = struct{}{} } + if op.Value != 0 { + a.replaceReferenceField(target, references[op.Value], st) + } return } diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 7d706d5a..dbe1018f 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -21,6 +21,9 @@ type loanID struct { } type referenceLoan struct { + // path identifies the slot within the holder, not the borrowed storage. + // One loan can occupy multiple enum fields and survive replacement of one. + path []place.OriginProjection id loanID origins []place.Origin mutable bool @@ -147,8 +150,13 @@ func (a *analyzer) checkStorageAccess( if a == nil || expr == nil || loans == nil { return } + origins := a.originsForExpr(expr) + if access == storageMutate && a.module != nil && a.module.Flow != nil { + // Replacing a reference slot mutates the carrier, not its old referent. + origins = a.module.Flow.ResolvedStorageOrigins[expr.ID()] + } a.reportLoanConflict( - a.originsForExpr(expr), + origins, a.referenceHolder(expr), access, expr, @@ -328,7 +336,7 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo } if ident, ok := expr.(*ast.Ident); ok { sym := a.module.Bindings.NodeSymbols[ident.ID()] - if typ, typed := symbols.GetSymbolType(sym); typed && typeinfo.ContainsStoredReference(typ) { + if referenceHoldingSymbol(sym) { if value, found := st.references[sym]; found { return copyReferenceLoans(value), true } @@ -336,6 +344,20 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo } _, mutable, ok := typeinfo.ReferenceValueTarget(a.exprType(expr)) if ok { + if _, projected := expr.(*ast.SelectorExpr); projected && a.module.Flow != nil { + var value []referenceLoan + for _, storage := range a.module.Flow.ResolvedStorageOrigins[expr.ID()] { + for _, loan := range st.references[storage.Root] { + if slices.Equal(loan.path, storage.Projections) { + loan.path = nil + value = append(value, loan) + } + } + } + if len(value) > 0 { + return copyReferenceLoans(value), true + } + } origins := a.originsForExpr(expr) if len(origins) == 0 { return []referenceLoan{}, false @@ -350,8 +372,14 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo if literal, ok := expr.(*ast.StructLit); ok { var loans []referenceLoan for _, field := range literal.Fields { + if field.Name == nil { + continue + } fieldLoans, found := a.referenceValueForExpr(field.Value, st) if found { + for i := range fieldLoans { + fieldLoans[i].path = append([]place.OriginProjection{{Kind: place.OriginField, Field: field.Name.Name}}, fieldLoans[i].path...) + } loans = append(loans, fieldLoans...) } } @@ -361,7 +389,39 @@ func (a *analyzer) referenceValueForExpr(expr ast.Expr, st state) ([]referenceLo if !constructed || construction.Payload == nil { return []referenceLoan{}, false } - return a.referenceValueForExpr(construction.Value, st) + loans, found := a.referenceValueForExpr(construction.Value, st) + for i := range loans { + loans[i].path = append([]place.OriginProjection{{Kind: place.OriginVariantPayload, Case: construction.Case}}, loans[i].path...) + } + return loans, found +} + +// replaceReferenceField consumes flow's exact storage identity. Accepted local +// enum reference fields are direct/optional; nested reference aggregates remain +// rejected by typechecking. Other holders and sibling slots retain their loans. +func (a *analyzer) replaceReferenceField(target ast.Expr, value storedReference, st state) { + if _, _, reference := typeinfo.ReferenceValueTarget(a.exprType(target)); !reference || a.module.Flow == nil { + return + } + storage := a.module.Flow.ResolvedStorageOrigins[target.ID()] + if len(storage) != 1 || len(storage[0].Projections) == 0 { + return + } + destination := storage[0] + if !referenceHoldingSymbol(destination.Root) { + return + } + var kept []referenceLoan + for _, loan := range st.references[destination.Root] { + if !slices.Equal(loan.path, destination.Projections) { + kept = append(kept, loan) + } + } + for _, loan := range copyReferenceLoans(value.loans) { + loan.path = slices.Clone(destination.Projections) + kept = append(kept, loan) + } + a.updateReferenceSymbol(destination.Root, kept, len(kept) > 0, st) } func (a *analyzer) originsForExpr(expr ast.Expr) []place.Origin { @@ -453,6 +513,7 @@ func copyReferenceLoans(value []referenceLoan) []referenceLoan { copy(copyValue, value) for i := range copyValue { copyValue[i].origins = place.CloneOrigins(copyValue[i].origins) + copyValue[i].path = slices.Clone(copyValue[i].path) } return copyValue } @@ -480,9 +541,10 @@ func mergeReferenceValues(dst, src map[*symbols.Symbol][]referenceLoan) bool { continue } for _, srcLoan := range srcValue { - index := referenceLoanIndex(dstValue, srcLoan.id) + index := referenceLoanIndex(dstValue, srcLoan) if index < 0 { srcLoan.origins = place.CloneOrigins(srcLoan.origins) + srcLoan.path = slices.Clone(srcLoan.path) dstValue = append(dstValue, srcLoan) changed = true continue @@ -511,7 +573,7 @@ func sameReferenceLoans(left, right []referenceLoan) bool { return false } for _, leftLoan := range left { - index := referenceLoanIndex(right, leftLoan.id) + index := referenceLoanIndex(right, leftLoan) if index < 0 { return false } @@ -524,9 +586,9 @@ func sameReferenceLoans(left, right []referenceLoan) bool { return true } -func referenceLoanIndex(loans []referenceLoan, id loanID) int { +func referenceLoanIndex(loans []referenceLoan, candidate referenceLoan) int { for i := range loans { - if loans[i].id == id { + if loans[i].id == candidate.id && slices.Equal(loans[i].path, candidate.path) { return i } } @@ -629,6 +691,10 @@ func (v *livenessEffectVisitor) VisitWrite(op effect.Write) { if op.Place.Root == nil || !trackedLiveSymbol(op.Place.Root) { return } + if len(op.Place.Projections) > 0 { + v.recordUse(op.Place.Root, op.Node) + return + } v.definitions[op.Place.Root] = struct{}{} if typ, typed := symbols.GetSymbolType(op.Place.Root); typed && typeinfo.OwnershipCapabilityOf(typ).Drop { v.recordUse(op.Place.Root, op.Node) diff --git a/internal/semantics/ownership/variant_rebind_test.go b/internal/semantics/ownership/variant_rebind_test.go new file mode 100644 index 00000000..39bf2a92 --- /dev/null +++ b/internal/semantics/ownership/variant_rebind_test.go @@ -0,0 +1,194 @@ +package ownership + +import ( + "fmt" + "slices" + "testing" + + "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" + "compiler/internal/semantics/place" +) + +func TestProjectedEnumReferenceState(t *testing.T) { + result := checkOwnershipSource(t, `enum Resource { Borrowed: { value: &i32, sibling: &i32 }, Empty } +fn Read(_: &i32) {} +fn probe(mut first: i32, mut second: i32) { + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + resource.value = &second; + resource.value = resource.value; + match resource { + Resource::Borrowed with { value = value, sibling = sibling } => { Read(value); Read(sibling); } + Resource::Empty => {} + } + } +}`) + if result.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) + } + analysis := inspectFunctionAnalysis(t, result, "probe") + fn := result.module.AST.Stmts[2].(*ast.FnDecl) + branch := fn.Body.Stmts[2].(*ast.IfStmt) + assign := branch.Then.Stmts[0].(*ast.AssignStmt) + match := branch.Then.Stmts[2].(*ast.MatchStmt) + node := analysisNodeForStmt(t, analysis, match) + resource, _ := analysis.functionScope.Lookup("resource") + first, _ := analysis.functionScope.Lookup("first") + second, _ := analysis.functionScope.Lookup("second") + value := analysis.inStates[node.cfgSite.ID].references[resource] + if len(value) != 2 { + t.Fatalf("carrier loans = %#v, want two distinct slots", value) + } + for _, loan := range value { + if len(loan.path) != 2 || loan.path[0].Kind != place.OriginVariantPayload || loan.path[0].Case != 0 { + t.Fatalf("invalid carrier path: %#v", loan.path) + } + want := first + if loan.path[1].Field == "value" { + want = second + if loan.id.node != assign.Value { + t.Fatal("field self-assignment changed loan identity") + } + } else if loan.path[1].Field != "sibling" { + t.Fatalf("unexpected field path: %#v", loan.path) + } + if !place.SameOrigins(loan.origins, []place.Origin{{Root: want}}) { + t.Fatalf("%s loan origins = %#v, want %s", loan.path[1].Field, loan.origins, want.Name) + } + } + storage := result.module.Flow.ResolvedStorageOrigins[assign.Target.ID()] + if len(storage) != 1 || storage[0].Root != resource || !slices.Equal(storage[0].Projections, []place.OriginProjection{ + {Kind: place.OriginVariantPayload, Case: 0}, {Kind: place.OriginField, Field: "value"}, + }) { + t.Fatalf("assignment storage = %#v", storage) + } + binding := match.Arms[0].Fields[0].Binding + if got := result.module.Flow.ResolvedValueOrigins[binding.ID()]; !place.SameOrigins(got, []place.Origin{{Root: second}}) { + t.Fatalf("match value origins = %#v, want second", got) + } +} + +func TestProjectedEnumReferenceRebind(t *testing.T) { + tests := []struct { + name, fields, setup, body, code string + }{ + {name: "new referent stays borrowed", body: `resource.value = &second; +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "old referent released", body: `resource.value = &second; +first = 3; +Read(resource.value);`}, + {name: "sibling retains old loan", fields: "value: &i32, sibling: &i32", setup: `let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first };`, body: `resource.value = &second; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "same loan in two fields survives one replacement", fields: "value: &i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = &second; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "same loan released after both replacements", fields: "value: &i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = &second; +resource.sibling = &second; +first = 3; +Read(resource.value); +Read(resource.sibling);`}, + {name: "carrier copy retains old loan", body: `let duplicate = resource; +resource.value = &second; +first = 3; +match duplicate { +Resource::Borrowed with { value = reference } => { Read(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "field copy retains old loan", body: `let duplicate = resource.value; +resource.value = &second; +first = 3; +Read(duplicate);`, code: diagnostics.ErrBorrowConflict}, + {name: "independent copies release independently", body: `let mut duplicate = resource; +resource.value = &second; +if duplicate is Resource::Borrowed { +duplicate.value = &second; +first = 3; +Read(duplicate.value); +Read(resource.value); +}`}, + {name: "self assignment retains loan", body: `resource.value = resource.value; +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "self assignment then replacement releases loan", body: `resource.value = resource.value; +resource.value = &second; +first = 3; +Read(resource.value);`}, + {name: "optional clear releases loan", fields: "value: ?&i32", body: `resource.value = none; +first = 3; +ReadOptional(resource.value);`}, + {name: "optional clear preserves sibling loan", fields: "value: ?&i32, sibling: &i32", setup: `let reference = &first; +let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference };`, body: `resource.value = none; +first = 3; +Read(resource.sibling);`, code: diagnostics.ErrBorrowConflict}, + {name: "mutable replacement releases old loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +first = 3; +Write(resource.value);`}, + {name: "mutable replacement protects new loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +second = 3; +match resource { +Resource::Borrowed with { value = reference } => { Write(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "mutable moved carrier retains new loan", fields: "value: &mut i32", setup: `let mut resource = Resource::Borrowed with .{ value = &mut first };`, body: `resource.value = &mut second; +let moved = resource; +second = 3; +match moved { +Resource::Borrowed with { value = reference } => { Write(reference); } +Resource::Empty => {} +}`, code: diagnostics.ErrBorrowConflict}, + {name: "both branches release old loan", body: `if flag { resource.value = &second; } else { resource.value = &third; } +first = 3; +Read(resource.value);`}, + {name: "one branch retains old loan", body: `if flag { resource.value = &second; } +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "branch replacement protects new loan", body: `if flag { resource.value = &second; } +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "zero iteration loop retains old loan", body: `for flag { resource.value = &second; } +first = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "loop replacement protects new loan", body: `for flag { resource.value = &second; } +second = 3; +Read(resource.value);`, code: diagnostics.ErrBorrowConflict}, + {name: "post loop replacement releases old loan", body: `for flag { resource.value = &second; } +resource.value = &third; +first = 3; +second = 4; +Read(resource.value);`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.fields == "" { + test.fields = "value: &i32" + } + if test.setup == "" { + test.setup = "let mut resource = Resource::Borrowed with .{ value = &first };" + } + src := fmt.Sprintf(`enum Resource { Borrowed: { %s }, Empty } +fn Read(_: &i32) {} +fn ReadOptional(_: ?&i32) {} +fn Write(_: &mut i32) {} +fn probe(mut first: i32, mut second: i32, mut third: i32, flag: bool) { +%s +if resource is Resource::Borrowed { +%s +} +}`, test.fields, test.setup, test.body) + result := checkOwnershipSource(t, src) + if test.code == "" { + if result.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", result.EmitAllToString()) + } + } else if !hasOwnershipCode(result, test.code) { + t.Fatalf("expected %s:\n%s", test.code, result.EmitAllToString()) + } + }) + } +} diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 5807449a..9fcb477d 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -326,7 +326,7 @@ func (c *checker) checkAssign(scope *symbols.Scope, node *ast.AssignStmt) { return } case *ast.SelectorExpr: - baseType := c.typeExpr(scope, target.Expr, nil) + baseType := c.recordedExprType(target.Expr) if _, ok := typeinfo.PointerTarget(typeinfo.Underlying(baseType)); ok { return } @@ -376,7 +376,7 @@ func (c *checker) checkIndexAssignmentTarget(scope *symbols.Scope, target *ast.I if typeinfo.IsInvalidOrUnknown(targetType) { return true } - baseType := c.typeExpr(scope, target.Expr, nil) + baseType := c.recordedExprType(target.Expr) if typeinfo.IsInvalidOrUnknown(baseType) { return true } diff --git a/internal/semantics/typechecker/flow.go b/internal/semantics/typechecker/flow.go index 1753b3e8..961947f3 100644 --- a/internal/semantics/typechecker/flow.go +++ b/internal/semantics/typechecker/flow.go @@ -285,22 +285,26 @@ func (c *checker) recordFlowResolution(expr ast.Expr, resolution place.Resolutio c.flow.result.ResolvedValueOrigins[id] = place.CloneOrigins(resolution.ValueOrigins) } +// recordedExprType reads an already typed operand without replaying calls or +// erasing the payload evidence established while typing its enclosing place. +func (c *checker) recordedExprType(expr ast.Expr) typeinfo.Type { + if expr == nil { + return nil + } + if c.flow != nil { + if typ := c.flow.result.ExprTypes[expr.ID()]; typ != nil { + return typ + } + } + return c.module.BaseExprType(expr.ID()) +} + func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowState) place.Resolution { if c == nil || c.module == nil { return place.Resolution{} } return place.Resolve(scope, expr, place.ResolveOptions{ - ExprType: func(node ast.Expr) typeinfo.Type { - if node == nil { - return nil - } - if c.flow != nil { - if typ := c.flow.result.ExprTypes[node.ID()]; typ != nil { - return typ - } - } - return c.module.BaseExprType(node.ID()) - }, + ExprType: c.recordedExprType, ResolveBinding: c.module.ExpandedDefaultBinding, ReferenceOrigins: func(storage []place.Origin) []place.Origin { return originValues(st.references, storage) @@ -312,10 +316,7 @@ func (c *checker) resolveFlowPlace(scope *symbols.Scope, expr ast.Expr, st flowS if call == nil || call.Callee == nil { return nil } - calleeType := c.module.BaseExprType(call.Callee.ID()) - if c.flow != nil && c.flow.result.ExprTypes[call.Callee.ID()] != nil { - calleeType = c.flow.result.ExprTypes[call.Callee.ID()] - } + calleeType := c.recordedExprType(call.Callee) fn, _ := typeinfo.Underlying(calleeType).(*typeinfo.FuncType) var origins []place.Origin args := c.module.Typechecking.CallArgumentsOrSource(call) diff --git a/x_test/negative_variant_reference_rebind/peeper.toml b/x_test/negative_variant_reference_rebind/peeper.toml new file mode 100644 index 00000000..bf8a5553 --- /dev/null +++ b/x_test/negative_variant_reference_rebind/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_variant_reference_rebind" +build = "lib" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["T0037", "cannot mutate storage while it is borrowed"] +stderr_excludes = ["T0010", "T0007", "panic", "llvm invariant"] diff --git a/x_test/negative_variant_reference_rebind/src/main.peep b/x_test/negative_variant_reference_rebind/src/main.peep new file mode 100644 index 00000000..6b3b137c --- /dev/null +++ b/x_test/negative_variant_reference_rebind/src/main.peep @@ -0,0 +1,58 @@ +enum Resource { Borrowed: { value: &i32, sibling: &i32 }, Empty } +enum Optional { Borrowed: { value: ?&i32 }, Empty } +enum Mutable { Borrowed: { value: &mut i32 }, Empty } +fn Read(_: &i32) {} +fn ReadOptional(_: ?&i32) {} +fn Write(_: &mut i32) {} + +fn NewLoan(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + resource.value = &second; + second = 3; + Read(resource.value); + } +} +fn SiblingLoan(mut first: i32, mut second: i32) { + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + resource.value = &second; + first = 3; + Read(resource.sibling); + } +} +fn CopiedLoan(mut first: i32, mut second: i32) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + let copy = resource.value; + resource.value = &second; + resource.sibling = &second; + first = 3; + Read(copy); + } +} +fn OptionalLoan(mut first: i32, mut second: i32) { + let mut resource = Optional::Borrowed with .{ value = &first }; + if resource is Optional::Borrowed { + resource.value = &second; + second = 3; + ReadOptional(resource.value); + } +} +fn MutableLoan(mut first: i32, mut second: i32) { + let mut resource = Mutable::Borrowed with .{ value = &mut first }; + if resource is Mutable::Borrowed { + resource.value = &mut second; + second = 3; + Write(resource.value); + } +} +fn LoopLoan(mut first: i32, mut second: i32, flag: bool) { + let mut resource = Resource::Borrowed with .{ value = &first, sibling = &first }; + if resource is Resource::Borrowed { + for flag { resource.value = &second; } + second = 3; + Read(resource.value); + } +} diff --git a/x_test/runtime_variant_reference_rebind/peeper.toml b/x_test/runtime_variant_reference_rebind/peeper.toml new file mode 100644 index 00000000..965ef462 --- /dev/null +++ b/x_test/runtime_variant_reference_rebind/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_variant_reference_rebind" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_variant_reference_rebind/src/main.peep b/x_test/runtime_variant_reference_rebind/src/main.peep new file mode 100644 index 00000000..d9f5e017 --- /dev/null +++ b/x_test/runtime_variant_reference_rebind/src/main.peep @@ -0,0 +1,54 @@ +struct Box { value: i32 } +enum Resource { Borrowed: { value: &Box, sibling: &Box }, Empty } +enum Optional { Borrowed: { value: ?&Box }, Empty } +enum Mutable { Borrowed: { value: &mut Box }, Empty } + +fn Read(reference: &Box) -> i32 { return reference.value; } +fn Write(reference: &mut Box) { reference.value = 30; } + +fn main() -> i32 { + let mut first: Box = .{ value = 10 }; + let mut second: Box = .{ value = 20 }; + let reference = &first; + let mut resource = Resource::Borrowed with .{ value = reference, sibling = reference }; + if resource is Resource::Borrowed { + let duplicate = resource; + resource.value = resource.value; + resource.value = &second; + if Read(resource.value) != 20 || Read(resource.sibling) != 10 { return 1; } + match duplicate { + Resource::Borrowed with { value = old } => { if Read(old) != 10 { return 2; } } + Resource::Empty => { return 3; } + } + resource.sibling = &second; + first.value = 11; + for index in 0..2 { + if index == 0 { resource.value = &first; } else { resource.value = &second; } + } + resource.value = &second; + first.value = 12; + match resource { + Resource::Borrowed with { value = current, sibling = sibling } => { + if Read(current) != 20 || Read(sibling) != 20 { return 4; } + } + Resource::Empty => { return 5; } + } + } + let mut optional = Optional::Borrowed with .{ value = &first }; + if optional is Optional::Borrowed { + optional.value = none; + first.value = 13; + if optional.value != none { return 6; } + } + let mut mutable = Mutable::Borrowed with .{ value = &mut first }; + if mutable is Mutable::Borrowed { + mutable.value = &mut second; + first.value = 14; + Write(mutable.value); + } + if first.value != 14 || second.value != 30 { return 7; } + let mut values = [2]i32{1, 2}; + values[0] = 7; + if values[0] != 7 { return 8; } + return 0; +} From 62613b80967d43e82fdec82876430cac960e8e01 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 22:12:47 +0600 Subject: [PATCH 76/80] Remove redundant type traversal and fingerprint indirection Return directly from required leaf traversal methods and remove the redundant semantic Type assertion. Retain typed-nil reflection behavior with explicit regression coverage; preserve recursive fingerprints and ownership capabilities. --- internal/project/export_fingerprint.go | 17 ++++----- internal/semantics/typeinfo/structure.go | 30 +++++++-------- internal/semantics/typeinfo/structure_test.go | 37 +++++++++++++++++++ 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/internal/project/export_fingerprint.go b/internal/project/export_fingerprint.go index 9648b1d9..5c0106ce 100644 --- a/internal/project/export_fingerprint.go +++ b/internal/project/export_fingerprint.go @@ -98,17 +98,16 @@ func semanticExportMetadata(ctx *CompilerContext, module *Module, sym *symbols.S } func semanticTypeKey(typ typeinfo.Type, visiting map[typeinfo.Type]bool) string { - semantic, ok := typ.(typeinfo.Type) - if !ok || semantic == nil { + if typ == nil { return "" } - if visiting[semantic] { - return "recursive(" + typeinfo.TypeText(semantic) + ")" + if visiting[typ] { + return "recursive(" + typeinfo.TypeText(typ) + ")" } - visiting[semantic] = true - defer delete(visiting, semantic) + visiting[typ] = true + defer delete(visiting, typ) - switch node := semantic.(type) { + switch node := typ.(type) { case *typeinfo.DefinedType: parameters := make([]string, len(node.TypeParameters)) for index, parameter := range node.TypeParameters { @@ -166,9 +165,9 @@ func semanticTypeKey(typ typeinfo.Type, visiting map[typeinfo.Type]bool) string *typeinfo.ByteType, *typeinfo.CharType, *typeinfo.FloatType, *typeinfo.BoolType, *typeinfo.CStrType, *typeinfo.StringType, *typeinfo.NoneType, *typeinfo.AllocatorType, *typeinfo.NamedType, *typeinfo.RawPtrType: - return typeinfo.TypeText(semantic) + return typeinfo.TypeText(typ) default: - panic(fmt.Sprintf("export fingerprint: unhandled semantic type %T", semantic)) + panic(fmt.Sprintf("export fingerprint: unhandled semantic type %T", typ)) } } diff --git a/internal/semantics/typeinfo/structure.go b/internal/semantics/typeinfo/structure.go index 8bb72570..b8eb08b4 100644 --- a/internal/semantics/typeinfo/structure.go +++ b/internal/semantics/typeinfo/structure.go @@ -55,22 +55,20 @@ func isNilType(typ Type) bool { return value.Kind() == reflect.Pointer && value.IsNil() } -func noTypeChildren(func(TypeChild) bool) bool { return true } - -func (*InvalidType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*UnknownType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*IntegerType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*ByteType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*CharType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*FloatType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*BoolType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*CStrType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*StringType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*NoneType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*AllocatorType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*NamedType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*TypeParameterType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } -func (*RawPtrType) forEachChild(yield func(TypeChild) bool) bool { return noTypeChildren(yield) } +func (*InvalidType) forEachChild(func(TypeChild) bool) bool { return true } +func (*UnknownType) forEachChild(func(TypeChild) bool) bool { return true } +func (*IntegerType) forEachChild(func(TypeChild) bool) bool { return true } +func (*ByteType) forEachChild(func(TypeChild) bool) bool { return true } +func (*CharType) forEachChild(func(TypeChild) bool) bool { return true } +func (*FloatType) forEachChild(func(TypeChild) bool) bool { return true } +func (*BoolType) forEachChild(func(TypeChild) bool) bool { return true } +func (*CStrType) forEachChild(func(TypeChild) bool) bool { return true } +func (*StringType) forEachChild(func(TypeChild) bool) bool { return true } +func (*NoneType) forEachChild(func(TypeChild) bool) bool { return true } +func (*AllocatorType) forEachChild(func(TypeChild) bool) bool { return true } +func (*NamedType) forEachChild(func(TypeChild) bool) bool { return true } +func (*TypeParameterType) forEachChild(func(TypeChild) bool) bool { return true } +func (*RawPtrType) forEachChild(func(TypeChild) bool) bool { return true } func (t *DefinedType) forEachChild(yield func(TypeChild) bool) bool { if t == nil { diff --git a/internal/semantics/typeinfo/structure_test.go b/internal/semantics/typeinfo/structure_test.go index 31a35c46..2dfa7e68 100644 --- a/internal/semantics/typeinfo/structure_test.go +++ b/internal/semantics/typeinfo/structure_test.go @@ -91,6 +91,43 @@ func TestTypeStructureDrivesRecursiveContainment(t *testing.T) { } } +func TestLeafTypeTraversalCompletesWithoutYield(t *testing.T) { + for _, typ := range []Type{ + &InvalidType{}, &UnknownType{}, &IntegerType{}, &ByteType{}, &CharType{}, + &FloatType{}, &BoolType{}, &CStrType{}, &StringType{}, &NoneType{}, + &AllocatorType{}, &NamedType{}, &TypeParameterType{}, &RawPtrType{}, + } { + if !typ.forEachChild(func(TypeChild) bool { + t.Errorf("leaf %T yielded a child", typ) + return false + }) || !typ.forEachChild(nil) { + t.Errorf("leaf %T traversal did not complete", typ) + } + } +} + +func TestNilTypeTraversalAndOwnership(t *testing.T) { + for _, typ := range []Type{ + nil, (*InvalidType)(nil), (*UnknownType)(nil), (*IntegerType)(nil), + (*ByteType)(nil), (*CharType)(nil), (*FloatType)(nil), (*BoolType)(nil), + (*CStrType)(nil), (*StringType)(nil), (*NoneType)(nil), (*AllocatorType)(nil), + (*NamedType)(nil), (*TypeParameterType)(nil), (*RawPtrType)(nil), + (*DefinedType)(nil), (*OwnedPtrType)(nil), (*RefType)(nil), + (*OptionalType)(nil), (*ArrayType)(nil), (*FuncType)(nil), + (*StructType)(nil), (*InterfaceType)(nil), (*EnumType)(nil), + } { + if !ForEachChild(typ, func(TypeChild) bool { + t.Errorf("nil %T yielded a child", typ) + return false + }) { + t.Errorf("nil %T traversal did not complete", typ) + } + if got := ownershipCapability(typ); got != (OwnershipCapability{Copy: CopyExplicit}) { + t.Errorf("nil %T capability = %+v; want explicit copy, no drop", typ, got) + } + } +} + func TestForEachChildAcceptsTypedNilTypes(t *testing.T) { var optional *OptionalType var typ Type = optional From 018295d1c6fe17ebf117a663c094d71e60103a97 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 5 Sep 2026 22:12:48 +0600 Subject: [PATCH 77/80] Reconcile compiler architecture guarantees and review outcomes Document bounded ownership provenance, lexical usage, effect validation limits, CFG index lifecycle, and measured performance tradeoffs. Mark the original task historical without erasing its contents. Validation: full Go tests, vet, focused race tests, bundle, and full executable fixtures passed sequentially. Go baseline remains 1.23.2; validation used installed go1.26.7-X:nodwarf5. --- COMPILER_FRAMEWORK_REPORT.md | 44 ++++++++++++++-- docs/compiler-architecture.md | 98 ++++++++++++++++++++++++++++++----- task.md | 8 +++ 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/COMPILER_FRAMEWORK_REPORT.md b/COMPILER_FRAMEWORK_REPORT.md index 4d2079e9..740f9408 100644 --- a/COMPILER_FRAMEWORK_REPORT.md +++ b/COMPILER_FRAMEWORK_REPORT.md @@ -22,7 +22,10 @@ used for everything. Final design combines: Detailed contract: [`docs/compiler-architecture.md`](docs/compiler-architecture.md). -## Shipped architecture changes +## Implemented architecture + +This describes current source, including local correctness follow-up; it does not +assert that uncommitted work is released or that roadmap milestones are complete. ### Semantic type structure @@ -39,7 +42,10 @@ walks. `graph.Directed` owns ordered outgoing/incoming indexes. Existing graph users and CFG use this kernel while CFG retains typed branch/case semantics. -Result: no separate CFG successor/predecessor store can drift from reverse edges. +Result: adjacency/reverse-adjacency mechanics have one owner. CFG terminators and +ordered sites remain canonical topology; block/site indexes are derived, frozen by +consumer convention, and rebuilt together for topology changes. Validators inspect +all stored edges, including foreign components, and exact site-edge metadata. ### Shared fixed-point scheduling @@ -71,12 +77,34 @@ published as an effect instead of rediscovered from `ForStmt` and typechecker pl shape. Definite initialization and ownership/liveness consume the same ordered effect stream. +### Bounded ownership provenance and lexical usage + +Ownership still captures accepted reference-bearing value shapes before effects +can move them, using existing loans, flow origins, reference types and semantic +variant constructions. Holder-relative loan paths distinguish stored slots from +borrowed origins and loan IDs. Exact projected direct/optional enum reference-field +replacement preserves siblings and copied holders; carrier-level liveness remains +conservative. This is not a general aggregate provenance framework, nor support for +nested stored-reference aggregates. New reference-bearing shapes require an audit. + +Usage warnings remain lexical: `semantics/usage` consumes symbol usage/mutability +flags from resolution, type/import lookup and typechecking, not reachable runtime +effects. Migrating them would require a separate warning-policy decision. + ### Contract cleanup Contracts that only verified duplicated downstream AST switches were removed. Remaining contracts guard real closed extension points such as semantic type identity/lowering/fingerprinting. Artifact validators remain primary guards for -cross-phase evidence. +cross-phase evidence shape/identity. They do not prove that a handled syntax case +published every required operation: producer ordering tests and executable source +fixtures cover semantic publication. Sealed type methods enforce presence, not +correct child enumeration. Empty effect artifacts can be valid. + +Leaf type traversal methods return directly; exported fingerprints use their +existing `Type` parameter without a redundant assertion. Bounded pointer reflection +in `isNilType` remains to preserve typed-nil capability answers. No nil-only +interface or exhaustive replacement dispatcher is introduced. ### Go baseline @@ -86,7 +114,8 @@ were replaced with equivalent 1.23 code; no compiler semantics required Go 1.26. ## Extension result For a new syntax construct expressed using existing semantic actions, expected -work is concentrated in syntax-aware owners: +work is concentrated in syntax-aware owners, provided existing provenance shapes +also suffice: ```text AST/parser -> resolver/typechecker as needed -> CFG/effects as needed -> HIR @@ -117,6 +146,11 @@ GOTOOLCHAIN=local go vet ./... GOTOOLCHAIN=local go test -race ``` -plus source fixtures and repository-specific build validation. Passing tests alone +plus a fresh `go run ./scripts/bundle.go` followed by full `x_test` with explicit +absolute `PEEPER_BIN`. Run full tests, bundle and executable fixtures sequentially: +they share build artifacts. Without `PEEPER_BIN`, fixture execution is skipped. +See architecture verification commands for focused graph/project/pipeline races. + +Passing tests alone is not enough; architecture audit must also confirm canonical kernels have not been bypassed. diff --git a/docs/compiler-architecture.md b/docs/compiler-architecture.md index 4fecdb06..c44646e3 100644 --- a/docs/compiler-architecture.md +++ b/docs/compiler-architecture.md @@ -22,8 +22,9 @@ Three invariants drive the design: structure, place projections, graph adjacency, worklist scheduling, and value effects each have one owner. 2. **Compositional behavior.** A new container/type/syntax construct that is built - from existing semantic operations inherits existing ownership, definite-init, - liveness, and cleanup behavior instead of reimplementing it. + from existing semantic operations and provenance evidence can reuse ownership, + definite-init, liveness, and cleanup behavior. New reference-bearing value shapes + still need an explicit provenance audit; operation coverage alone is insufficient. 3. **Loud true extension points.** If a new construct introduces genuinely new semantics, syntax-aware owners must make an explicit decision. Unknown sealed node/effect kinds panic or fail validation instead of being silently skipped. @@ -115,7 +116,14 @@ cycles mean different things to those queries. Consequence: adding a new composite semantic type cannot satisfy `typeinfo.Type` until it declares child structure and ownership composition. Nested ownership/drop -then propagates through generic machinery. +then propagates through generic machinery. This enforces method presence, not +correct child enumeration or ownership policy; behavioral tests remain necessary. + +Typed-nil capability inputs retain explicit-copy/no-drop answers. `isNilType` uses +bounded pointer reflection before ownership dispatch: nil scalar and owned-pointer +receivers otherwise return ordinary non-nil facts. Keep this guard rather than add +an exhaustive type-kind switch or a nil-only interface. Traversal methods separately +handle nil receivers; this is not a compiler-wide typed-nil validation guarantee. ### Places: `place.Project` / `place.Decompose` @@ -144,6 +152,13 @@ Domain graphs keep semantic edge data on top: Do not add another successors/predecessors store to a domain graph. Add domain metadata to its edge/node type and reuse the topology kernel. +CFG terminators and ordered block sites define control flow. Block/site edge +indexes are derived at construction and immutable by consumer convention after +publication. Rebuild the CFG for a new topology generation; do not independently +mutate terminators or indexes while downstream evidence still names its sites. +Validators inspect all stored edges, including disconnected foreign endpoints, +and compare site targets, kinds, and case labels against the block topology. + ### Fixed-point scheduling: `graph.Worklist` `graph.Worklist` owns FIFO scheduling, pending-node deduplication, and @@ -184,8 +199,10 @@ Examples of behavior now derived from effects: - sequence-loop borrow lifetime arrives as `Iterate`; ownership no longer asks `ForStmt` or `SequenceIteration` what kind of loop it is. -A new syntax construct that can be expressed with existing operations normally -requires no changes to these downstream analyses. +A new syntax construct that reuses existing operations and reference-provenance +shapes normally requires no changes to these downstream analyses. The publisher +must still evaluate every base/index/bound exactly once in semantic order; a place +path describes storage, not all evaluated operands. A new **semantic operation** is different. It is a real extension point: add the operation, validate it, and make each consumer explicitly decide what it means. @@ -205,6 +222,7 @@ Unknown effects must not be silently ignored. | Evaluation/storage actions | `semantics/effect` | ordered `effect.Result` | | Definite initialization | `semantics/definiteinit` | diagnostics | | Move/borrow/drop analysis | `semantics/ownership` | `ownershipresult.Result` | +| Lexical usage warnings | `semantics/usage` | diagnostics from symbol `Used` / `RequiresMutable` flags | | High-level lowering | `ir/hir/lower` | HIR | | Mid-level lowering | `ir/mir` | MIR | | Physical layout/codegen | backend | backend IR | @@ -220,7 +238,9 @@ Some phases must understand syntax because syntax introduces semantics: - typechecker: type rules, conversions, calls, loop/match semantics; - CFG builder: source control constructs -> topology; - effect publisher: evaluation order and value/storage action; -- HIR lowering: source construct -> executable high-level IR. +- HIR lowering: source construct -> executable high-level IR; +- ownership reference capture: bounded value-shape interpretation plus published + type/flow evidence, preserving live loans before effects can move source values. Those switches are not architectural duplication by themselves. The smell is two phases independently deriving the **same fact**. @@ -232,6 +252,40 @@ language policy, not a generic child walk. If another control transfer needs the same semantic policy, publish a control effect rather than adding another parallel AST reconstruction. +### Reference provenance and holder-relative loans + +`ownership.referenceValueForExpr` is not a generic aggregate interpreter. It uses +existing holder loans, `Flow.ResolvedValueOrigins`, reference types, struct payload +syntax and `Typechecking.VariantConstructions` for currently accepted carriers. +Pre-evaluation capture preserves loan identity before a move clears source state. +Flow origin sets describe referents; they do not replace ownership's dynamic loan +IDs, mutability, reservations/activation, liveness, joins or cleanup policy. + +Each `referenceLoan.path` locates a slot relative to its holder, independently of +`origins` (borrowed storage) and `id` (loan identity). Copies clone paths; equality +and joins distinguish the same loan in different slots. Projected writes consume +an exact `Flow.ResolvedStorageOrigins` destination and captured RHS loans to replace +one direct/optional enum reference field, including clearing it, while retaining +sibling and copied-holder loans. Partial writes keep the carrier live. This is not +full field-sensitive last-use analysis or support for nested stored-reference +aggregates/arrays. Those storage restrictions remain typechecker-owned. + +Flow typing must retain recorded assignment-operand types and variant payload +proofs for HIR; retyping an already checked operand can erase that evidence. HIR +consumes published payload/projection facts; backend typed-store invariants remain +strict. Single-case enum selectors and optional-array index assignment have known +separate typing limitations, not resolved by this reference-field repair. + +### Lexical usage, not runtime liveness + +`semantics/usage.Analyze` emits unused/import/private/local/parameter and unnecessary +`mut` warnings from symbol flags. Resolver and project type/import lookup mark +`Used`; typechecking marks `RequiresMutable`. Type-only/import references and source +uses outside reachable runtime paths are not equivalent to effect-stream uses. +Ownership liveness remains a separate CFG/effect analysis. Moving usage to reachable +CFG effects would change warning policy and needs explicit design/approval; it is +not an unfinished mechanical migration required by this architecture. + ## Adding a new expression or statement Classify the feature before editing downstream code. @@ -244,8 +298,8 @@ Expected work: 2. AST child declaration (`forEachChild`); 3. translate at the syntax-aware semantic boundary into existing decisions/effects. -Ownership, definite-init, liveness, graph scheduling, and cleanup should require no -new node case. +For already supported value/provenance shapes, ownership, definite-init, liveness, +graph scheduling, and cleanup should require no new node case. ### B. New typechecking or control semantics, existing value effects @@ -258,7 +312,8 @@ Expected work: 5. effect publisher maps construct to existing operations; 6. HIR lowering. -Generic analyses stay unchanged. +Generic mechanics stay unchanged; audit reference capture if the feature introduces +a new accepted reference-bearing value shape. ### C. New semantic action @@ -311,7 +366,8 @@ Different mistakes are caught at different boundaries: | Mistake | Guard | | --- | --- | | AST child omitted | AST child completeness contracts/tests | -| semantic type child/ownership relation omitted | sealed `typeinfo.Type` compile-time contract | +| semantic type child/ownership method omitted | sealed `typeinfo.Type` compile-time contract | +| incorrect child relation or capability composition | structural tests + capability golden/cycle tests | | semantic type missing representation decision | focused type dispatch contract | | malformed graph topology | CFG/graph validators and tests | | malformed effect evidence | `effect.Result.Validate` | @@ -320,6 +376,13 @@ Different mistakes are caught at different boundaries: | malformed HIR/MIR | IR validators | | wrong language behavior | package tests + `x_test` source fixtures | +Effect validation checks node membership and expression categories, not whether +an existing syntax case emitted every required operation. Definition, write-owner, +and iteration-owner IDs remain generic source identities; consumers do not need +a particular declaration syntax. Dispatch contracts catch missing kind decisions; +producer ordering tests and source fixtures catch missing or reordered operations. +Empty artifacts remain valid when no operations are needed. + Source-parsing contract tests are retained only where Go's type system cannot express a closed extension boundary more directly. They are not the primary architecture. @@ -343,11 +406,20 @@ Do not add: Repository currently targets Go 1.23.2. ```bash -GOTOOLCHAIN=local go test ./... -GOTOOLCHAIN=local go vet ./... -GOTOOLCHAIN=local go test -race ./internal/project ./internal/pipeline ./internal/lsp ./internal/semantics/... +go test -count=1 ./internal/semantics/typeinfo ./internal/project ./internal/contracts +go test -count=1 ./... +go vet ./... +go test -race -count=1 ./internal/graph ./internal/project ./internal/pipeline +go run ./scripts/bundle.go +PEEPER_BIN="$PWD/build/bin/peeper" go test -count=1 ./x_test +git diff --check ``` +Run commands sequentially: full Go tests and executable fixtures can touch shared +`build/` artifacts. Without `PEEPER_BIN`, fixture tests skip compiler execution; +manifest-only success is not language validation. Race coverage above is focused, +not a claim that every package or target was race-tested. + For language behavior, use `x_test` and the bundled compiler according to [`RULES.md`](../RULES.md). Architecture reviews should also search for new private adjacency stores, queue/queued loops, repeated type-child enumeration, and AST diff --git a/task.md b/task.md index be4d7b3e..8c35a6d0 100644 --- a/task.md +++ b/task.md @@ -1,5 +1,13 @@ # Compiler Maintainability Migration Handoff +> Historical migration proposal, retained below without rewriting its original scope. +> Current implementation contract: [`docs/compiler-architecture.md`](docs/compiler-architecture.md) +> and [`COMPILER_FRAMEWORK_REPORT.md`](COMPILER_FRAMEWORK_REPORT.md). The old branch/workflow, +> effects-based usage target and blanket reflection ban below are not current delivery +> requirements: usage remains lexical; bounded typed-nil capability reflection is retained. +> New reference-bearing shapes still require provenance audits even when effects exist. +> Go baseline is 1.23.2. This history authorizes no branch changes, commits or shipping. + ## Objective Make Peeper compiler clean, readable, and difficult to extend incorrectly. From 7c0b3977ee059f5f749d6e468e4be8a09680a779 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 7 Sep 2026 00:51:29 +0600 Subject: [PATCH 78/80] Harden IR validators against typed-nil interface slots Typed nils pass plain nil comparisons because the interface value itself is non-nil, so the HIR statement validator and the MIR terminator/instruction validators dereferenced nil concrete pointers and panicked instead of reporting the malformed artifact. Guard every interface slot through one typed-nil probe, fix the HIR validator comment that claimed traversal-based coverage the switch does not provide, and cover the panic paths plus the optional-slot case with regression tests. --- internal/ir/hir/validate.go | 17 ++++++++++------- internal/ir/hir/validate_test.go | 20 ++++++++++++++++++++ internal/ir/mir/validate.go | 6 ++++-- internal/ir/mir/validate_test.go | 10 ++++++++++ internal/ir/nodes.go | 8 ++------ 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/internal/ir/hir/validate.go b/internal/ir/hir/validate.go index ae677ca3..cb4614ac 100644 --- a/internal/ir/hir/validate.go +++ b/internal/ir/hir/validate.go @@ -5,6 +5,8 @@ import ( "fmt" "sort" "strings" + + "compiler/pkg/typednil" ) const maxReportedProblems = 10 @@ -46,19 +48,20 @@ func (m *Module) Validate() error { return errors.New(strings.Join(problems, "; ")) } -// validateStmt walks through the canonical child traversal rather than a switch -// of its own, so a new statement kind is covered here the moment it declares its -// children. What it adds is the checks a traversal cannot make: an empty slot is -// invisible to a walk that skips nils. +// validateStmt checks what a child traversal cannot see: an empty slot is +// invisible to a walk that skips nils. It enumerates statement kinds with an +// explicit switch rather than forEachChild so nil slots and required bodies are +// reported with their role; a new statement kind with children must extend this +// switch to keep that reporting. func validateStmt(fn string, stmt Stmt) []string { problems := make([]string, 0) - if stmt == nil { + if typednil.IsNil(stmt) { return append(problems, fmt.Sprintf("function %s holds a nil statement", fn)) } switch node := stmt.(type) { case *Block: for index, child := range node.Stmts { - if child == nil { + if typednil.IsNil(child) { problems = append(problems, fmt.Sprintf("function %s holds a nil statement at block index %d", fn, index)) continue } @@ -66,7 +69,7 @@ func validateStmt(fn string, stmt Stmt) []string { } case *If: problems = append(problems, validateBody(fn, "if", node.Then)...) - if node.Else != nil { + if !typednil.IsNil(node.Else) { problems = append(problems, validateStmt(fn, node.Else)...) } case *For: diff --git a/internal/ir/hir/validate_test.go b/internal/ir/hir/validate_test.go index f4a99b00..ebfdae2b 100644 --- a/internal/ir/hir/validate_test.go +++ b/internal/ir/hir/validate_test.go @@ -28,6 +28,16 @@ func TestValidateAcceptsWellFormedModule(t *testing.T) { } } +// A typed nil wraps a nil pointer in a non-nil interface, so it must be treated +// like a plain nil optional slot rather than dereferenced as a real statement. +func TestValidateAcceptsTypedNilOptionalSlot(t *testing.T) { + module := wellFormedHIR() + module.Funcs[0].Body.Stmts[0].(*If).Else = (*Block)(nil) + if err := module.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a typed-nil optional else", err) + } +} + func TestValidateReportsDefects(t *testing.T) { tests := []struct { name string @@ -61,6 +71,16 @@ func TestValidateReportsDefects(t *testing.T) { }, want: "has a case 0 with no body", }, + { + name: "typed-nil block statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = (*Block)(nil) }, + want: "holds a nil statement at block index 3", + }, + { + name: "typed-nil return statement in a block", + damage: func(m *Module) { m.Funcs[0].Body.Stmts[3] = (*Return)(nil) }, + want: "holds a nil statement at block index 3", + }, { name: "nil function", damage: func(m *Module) { m.Funcs = append(m.Funcs, nil) }, diff --git a/internal/ir/mir/validate.go b/internal/ir/mir/validate.go index 3c8dcbce..329738a2 100644 --- a/internal/ir/mir/validate.go +++ b/internal/ir/mir/validate.go @@ -5,6 +5,8 @@ import ( "fmt" "sort" "strings" + + "compiler/pkg/typednil" ) const maxReportedProblems = 10 @@ -68,11 +70,11 @@ func validateFunction(fn *Function) []string { } for _, block := range fn.Blocks { for index, instr := range block.Instrs { - if instr == nil { + if typednil.IsNil(instr) { problems = append(problems, fmt.Sprintf("function %s block b%d holds a nil instruction at %d", fn.Name, block.ID, index)) } } - if block.Term == nil { + if typednil.IsNil(block.Term) { // Emission would otherwise fall off the end of a block. problems = append(problems, fmt.Sprintf("function %s block b%d has no terminator", fn.Name, block.ID)) continue diff --git a/internal/ir/mir/validate_test.go b/internal/ir/mir/validate_test.go index f73a8de8..85826da5 100644 --- a/internal/ir/mir/validate_test.go +++ b/internal/ir/mir/validate_test.go @@ -67,6 +67,16 @@ func TestValidateReportsDefects(t *testing.T) { damage: func(m *Module) { m.Funcs[0].Blocks[0].Instrs = []Instr{nil} }, want: "holds a nil instruction at 0", }, + { + name: "typed-nil terminator", + damage: func(m *Module) { m.Funcs[0].Blocks[1].Term = (*Jump)(nil) }, + want: "block b1 has no terminator", + }, + { + name: "typed-nil instruction", + damage: func(m *Module) { m.Funcs[0].Blocks[0].Instrs = []Instr{(*Store)(nil)} }, + want: "holds a nil instruction at 0", + }, { name: "one case selected twice", damage: func(m *Module) { diff --git a/internal/ir/nodes.go b/internal/ir/nodes.go index dcb4b3a0..51ccfbd1 100644 --- a/internal/ir/nodes.go +++ b/internal/ir/nodes.go @@ -2,12 +2,12 @@ package ir import ( "fmt" - "reflect" "strings" "compiler/internal/semantics/symbols" "compiler/internal/source" "compiler/pkg/ascii" + "compiler/pkg/typednil" ) // NodeID identifies source syntax without retaining an AST object in IR. @@ -433,11 +433,7 @@ func (p *Place) forEachChild(visit func(Expr)) { // WithOrigin applies provenance at compiler phase boundaries, including // synthetic expressions returned by helper lowerers. func WithOrigin(expr Expr, info SourceInfo) Expr { - if expr == nil { - return nil - } - value := reflect.ValueOf(expr) - if value.Kind() == reflect.Pointer && value.IsNil() { + if typednil.IsNil(expr) { return expr } expr.setOrigin(info) From 58a737e7cbb0c905c6ffb8d5e690d54f270e6d20 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 7 Sep 2026 00:51:41 +0600 Subject: [PATCH 79/80] Centralize typed-nil detection in pkg/typednil The interface nil trap was probed by three private copies: ast.IsNilNode, typeinfo.isNilType, and the ir.WithOrigin guard added with the validator hardening. Detection policy now lives once in pkg/typednil.IsNil, every call site uses it directly, the duplicated helpers are deleted without wrappers, and reflect imports drop out of the ir, ast, and typeinfo packages. A policy change is now a one-function edit. --- internal/frontend/ast/inspect.go | 4 ++- internal/frontend/ast/location.go | 14 ++-------- internal/frontend/parser/parser.go | 3 +- .../semantics/typeinfo/capability_walk.go | 4 ++- internal/semantics/typeinfo/structure.go | 14 ---------- pkg/typednil/typednil.go | 24 ++++++++++++++++ pkg/typednil/typednil_test.go | 28 +++++++++++++++++++ 7 files changed, 62 insertions(+), 29 deletions(-) create mode 100644 pkg/typednil/typednil.go create mode 100644 pkg/typednil/typednil_test.go diff --git a/internal/frontend/ast/inspect.go b/internal/frontend/ast/inspect.go index 181d8566..0be8acaa 100644 --- a/internal/frontend/ast/inspect.go +++ b/internal/frontend/ast/inspect.go @@ -1,10 +1,12 @@ package ast +import "compiler/pkg/typednil" + // Inspect traverses the AST in depth-first order: it starts by calling f(node); // if f returns true, Inspect invokes f recursively for each of the non-nil children of node, // followed by a call to f(nil). func Inspect(node Node, f func(Node) bool) { - if node == nil || IsNilNode(node) { + if typednil.IsNil(node) { return } if !f(node) { diff --git a/internal/frontend/ast/location.go b/internal/frontend/ast/location.go index eab1ad1c..cdd8e0b6 100644 --- a/internal/frontend/ast/location.go +++ b/internal/frontend/ast/location.go @@ -2,23 +2,13 @@ package ast import ( "compiler/internal/source" - "reflect" + "compiler/pkg/typednil" ) -// IsNilNode handles the Go interface nil trap: a non-nil interface holding a -// nil pointer is not equal to nil, so a plain `n == nil` check is insufficient. -func IsNilNode(n Node) bool { - if n == nil { - return true - } - v := reflect.ValueOf(n) - return v.Kind() == reflect.Pointer && v.IsNil() -} - // LocOf safely returns the location of a node, handling nil interfaces // and nil pointer receivers without panicking. func LocOf(n Node) *source.Location { - if IsNilNode(n) { + if typednil.IsNil(n) { return nil } return n.loc() diff --git a/internal/frontend/parser/parser.go b/internal/frontend/parser/parser.go index 6241aa2e..2fa4d6fe 100644 --- a/internal/frontend/parser/parser.go +++ b/internal/frontend/parser/parser.go @@ -17,6 +17,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/token" "compiler/internal/source" + "compiler/pkg/typednil" ) type Parser struct { @@ -772,7 +773,7 @@ func (p *Parser) nextID() ast.NodeID { } func reg[T ast.Node](p *Parser, n T) T { - if !ast.IsNilNode(n) { + if !typednil.IsNil(n) { n.SetID(p.nextID()) } return n diff --git a/internal/semantics/typeinfo/capability_walk.go b/internal/semantics/typeinfo/capability_walk.go index cb135a96..c7f2a196 100644 --- a/internal/semantics/typeinfo/capability_walk.go +++ b/internal/semantics/typeinfo/capability_walk.go @@ -1,5 +1,7 @@ package typeinfo +import "compiler/pkg/typednil" + // ownershipShapeKind describes how a semantic type contributes copy/drop // behavior. It is part of the sealed Type contract: a new type must declare // both its structural children and how ownership composes across them. @@ -91,7 +93,7 @@ func ownershipCapability(t Type) OwnershipCapability { var walk func(Type, bool) capabilityFacts walk = func(current Type, enumPayload bool) capabilityFacts { - if current == nil || isNilType(current) || visiting[current] { + if current == nil || typednil.IsNil(current) || visiting[current] { return capabilityFacts{} } shape := current.ownershipShape() diff --git a/internal/semantics/typeinfo/structure.go b/internal/semantics/typeinfo/structure.go index b8eb08b4..9b606661 100644 --- a/internal/semantics/typeinfo/structure.go +++ b/internal/semantics/typeinfo/structure.go @@ -1,7 +1,5 @@ package typeinfo -import "reflect" - // TypeChildRelation describes why one semantic type contains another. The // relation is structural evidence, not an analysis result: ownership, sizing, // lowerability, substitution, and future queries may interpret the same child @@ -43,18 +41,6 @@ func ForEachChild(typ Type, yield func(TypeChild) bool) bool { return typ.forEachChild(yield) } -// isNilType handles a typed-nil pointer stored in the Type interface without -// enumerating concrete type kinds. Type is sealed to this package and all -// current implementations are pointer types, but the kind guard keeps this -// helper correct if a value implementation is ever introduced. -func isNilType(typ Type) bool { - if typ == nil { - return true - } - value := reflect.ValueOf(typ) - return value.Kind() == reflect.Pointer && value.IsNil() -} - func (*InvalidType) forEachChild(func(TypeChild) bool) bool { return true } func (*UnknownType) forEachChild(func(TypeChild) bool) bool { return true } func (*IntegerType) forEachChild(func(TypeChild) bool) bool { return true } diff --git a/pkg/typednil/typednil.go b/pkg/typednil/typednil.go new file mode 100644 index 00000000..fb10e146 --- /dev/null +++ b/pkg/typednil/typednil.go @@ -0,0 +1,24 @@ +// Package typednil provides one canonical check for the Go interface nil +// trap: a non-nil interface value wrapping a nil pointer is not equal to nil. +// Every compiler slot that accepts an interface kind and every validator that +// dereferences one must probe through this package, so a change to the +// detection policy lands in exactly one place. +package typednil + +import "reflect" + +// IsNil reports whether value is nil, including the typed-nil case where a +// non-nil interface holds a nil pointer. Plain equality checks miss that case +// because the interface itself carries type information. +// +// Policy, enforced here for every caller: pointer-kind values are probed for +// nil-ness; nil maps, slices, channels, and functions report false because the +// compiler's interface slots hold pointer-backed kinds. Changing that policy +// means changing this one function. +func IsNil(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + return reflected.Kind() == reflect.Pointer && reflected.IsNil() +} diff --git a/pkg/typednil/typednil_test.go b/pkg/typednil/typednil_test.go new file mode 100644 index 00000000..22e5790c --- /dev/null +++ b/pkg/typednil/typednil_test.go @@ -0,0 +1,28 @@ +package typednil + +import "testing" + +func TestIsNil(t *testing.T) { + type probe struct{ field int } + var typedNil *probe + tests := []struct { + name string + value any + want bool + }{ + {"untyped nil", nil, true}, + {"typed-nil pointer in interface", typedNil, true}, + {"nil map", map[string]int(nil), false}, + {"nil slice", []int(nil), false}, + {"live pointer", &probe{}, false}, + {"non-pointer value", probe{}, false}, + {"string", "peeper", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsNil(test.value); got != test.want { + t.Fatalf("IsNil(%v) = %v, want %v", test.value, got, test.want) + } + }) + } +} From 564e694dc090b1fee352f416cd6ebf0da1117a79 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Mon, 7 Sep 2026 01:06:07 +0600 Subject: [PATCH 80/80] Drop unused loop-part role map in HIR validator Validation never labels loop init, bindings, or next in diagnostics, so iterating a role-keyed map and discarding the key suggested reporting that does not exist. Iterate the optional blocks directly. --- internal/ir/hir/validate.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ir/hir/validate.go b/internal/ir/hir/validate.go index cb4614ac..b89ba24e 100644 --- a/internal/ir/hir/validate.go +++ b/internal/ir/hir/validate.go @@ -75,10 +75,9 @@ func validateStmt(fn string, stmt Stmt) []string { case *For: // Init, Bindings and Next are optional; a loop with no body is not. problems = append(problems, validateBody(fn, "loop", node.Body)...) - for role, part := range map[string]*Block{"loop init": node.Init, "loop bindings": node.Bindings, "loop next": node.Next} { + for _, part := range []*Block{node.Init, node.Bindings, node.Next} { if part != nil { problems = append(problems, validateStmt(fn, part)...) - _ = role } } case *SwitchVariant: