diff --git a/INTERPROC_FACTS_DOMAIN_JOURNAL.md b/INTERPROC_FACTS_DOMAIN_JOURNAL.md new file mode 100644 index 00000000..f00a0b1f --- /dev/null +++ b/INTERPROC_FACTS_DOMAIN_JOURNAL.md @@ -0,0 +1,11431 @@ +# Interproc Facts And Checker Domain Design Journal + +## 2026-05-19 Design Consolidation Checkpoint + +This document records the design model before the next implementation pass. It is +not an implementation plan for incremental bridges. The intended correction is a +flash migration: design the final shape, migrate directly to it, delete the old +helper clusters, and do not leave compatibility wrappers or fallback layers in +the production checker. + +## 2026-05-19 Implementation Checkpoint + +First flash-migration slice landed for parameter evidence ownership. + +Changed production shape: + +- `api.FunctionFact` now owns canonical parameter evidence in `Params`. +- `api.Facts.ParamHints` was removed. +- `api.SnapshotStore.GetParamHintsSnapshot` was removed. +- same-iteration merge and interproc widening now combine parameter evidence + through `FunctionFacts`. +- post-flow call observation publication now emits `FunctionFacts` deltas with + `Params` instead of writing a side channel. +- return inference seeds local function parameter evidence from canonical + `FunctionFacts`. +- Salsa snapshot facts now track one canonical fact product for parameters, + returns, narrow returns, and function type projection. + +This is intentionally not a bridge. No production code reads a legacy +`ParamHints` fact channel and no compatibility writer reconstructs it from +`FunctionFacts`. + +Second cleanup slice in the same migration: + +- the local inference package was renamed from `infer/paramhints` to + `infer/paramevidence`, then the domain was moved to + `domain/paramevidence` when its lattice laws were consolidated; +- `LocalFuncInfo.ParamHints` became `LocalFuncInfo.ParameterEvidence`; +- phase input `ParamHintSignatures` became `ParameterEvidenceSignatures`; +- local call-graph propagation now exposes `PropagateParameterEvidence`; +- helper files and regression fixtures were renamed to parameter-evidence + terminology; +- production checker code no longer contains `ParamHint` or `paramhints` + identifiers. +- parameter-use projection now treats builtin `type(param)` checks and + `param = param or {}` self-default assignments as shape-neutral guard/default + operations instead of whole-parameter escapes. Those operations must not turn + a call-site record observation into a closed public contract. + +Verification notes: + +- `go test ./...` passes. +- `git diff --check` passes. +- `../scripts/verify-suite.sh` passes go-lua checker tests and builds the Wippy + binary, then exits non-zero in external lint targets while building Wippy + against `github.com/wippyai/go-lua v1.5.16`. +- A temp local-replace replay under `/tmp/wippy-golua-local-replace` builds + Wippy against this checkout without editing external code. It reduced the + projection-related false positives, but the full external sweep is still not + clean: tests/app 2 errors/4 warnings, session 20, actor/test 3, agent/src 12, + docker-demo 72, llm/src 10, llm/test 9, migration 1, views 1. + +Remaining cleanup after this parameter-evidence slice: + +- return/narrow/type projections still need the same treatment: read-only views + over the canonical function summary product, not separate authorities. +- Remaining local-replace external diagnostics must be classified in the next + engine slice. Some are soundness-preserving real-code issues (`any` flowing + into concrete contracts); some still expose missing checker power, especially + public functions that validate invalid input with `type(...)` guards and + should infer a wider accepted input domain without weakening the guarded body. + +## 2026-05-19 Domain Rectification Checkpoint + +The next flash-migration slice moved parameter evidence out of inference/return +orchestration and into a domain owner: + +- `compiler/check/infer/paramevidence` was moved to + `compiler/check/domain/paramevidence`; +- shared value-shape predicates that were duplicated during the first move were + factored into `compiler/check/domain/value`; +- parameter-evidence vector/map normalization, join, widening, table-top + absorption, nilability splitting, soft/concrete selection, and truthy-key + refinement now live under domain packages; +- `returns` no longer owns parameter evidence merge helpers. Function-fact + parameter slots delegate to `paramevidence.JoinVectors`, + `paramevidence.FilterEmptyVector`, and `paramevidence.RefinesFunctionParam`; +- return-summary and parameter-evidence code both call `domain/value` for + optional elision, truthy refinements, soft/concrete preference, recursive + structural scanning, and record-extension checks; +- parameter-evidence law tests moved with the domain, so the tests describe the + owner instead of the old return package. + +This is not a compatibility bridge. The old package path and old +`WidenParameterEvidence` API were deleted. Call sites moved directly to the +domain package. + +Verification for this slice so far: + +- `go test ./compiler/check/domain/value` passes. +- `go test ./compiler/check/domain/paramevidence` passes. +- `go test ./compiler/check/returns` passes. +- `go test ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- Standard `../scripts/verify-suite.sh` passes the go-lua checker tests and + Wippy binary build, then exits non-zero on external lint targets while the + Wippy checkout is still using its pinned go-lua module: session 8 errors, + agent/src 8 errors, docker-demo 21 errors and 2 warnings. +- Local-replace replay with + `WIPPY_DIR=/tmp/wippy-golua-local-replace GOFLAGS=-buildvcs=false` also + passes the go-lua checker tests and Wippy binary build, then exits non-zero + on known external diagnostics: tests/app 2 errors/4 warnings, session 20, + actor/test 3, agent/src 11, docker-demo 72, llm/src 9, llm/test 9, + migration 1, views 1. + +Design result: + +- orchestration still decides when evidence is collected from calls, body use, + post-flow observations, or signatures; +- the parameter-evidence domain now decides how evidence combines; +- the value domain owns shared structural predicates instead of duplicating them + under returns and parameter evidence; +- helper names that encode parameter-specific lattice laws are no longer local + return-package predicates. + +## 2026-05-19 Value Shape Domain Checkpoint + +The follow-up rectification removed another cluster of domain laws from +`returns` and moved it into `compiler/check/domain/value`. + +Moved value-shape laws: + +- soft container refinement; +- stale falsy map-key refinement; +- nested nil-only regression detection; +- recursive structural-growth detection; +- structural-shape unwrapping and shallow shape equality; +- union member extraction after structural unwrapping. + +`returns` now keeps return-vector orchestration, but it asks `domain/value` for +value-shape facts. This preserves the current behavior while making the mental +model cleaner: + +```text +returns = return-vector policy and function-summary alignment +domain/value = reusable structural value relations +domain/paramevidence = parameter evidence lattice and parameter-slot refinement +``` + +This is a direct ownership move, not a bridge. The old local helpers were +deleted from `returns`. + +Verification for this slice so far: + +- `go test ./compiler/check/domain/value ./compiler/check/returns` passes. +- `go test ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- Standard `../scripts/verify-suite.sh` passes the go-lua checker tests and + Wippy binary build, then exits non-zero on the existing external lint targets: + session 8 errors, agent/src 8 errors, docker-demo 21 errors and 2 warnings. + +## 2026-05-19 Return Summary Domain Checkpoint + +The next rectification slice moved return-vector policy and function-signature +return alignment out of `compiler/check/returns` and into +`compiler/check/domain/returnsummary`. + +Moved domain laws: + +- return-vector equality and nil-slot canonicalization; +- return-vector normalization with soft-union pruning; +- directional refinement, optional elision, record extension, and nil-slot fill; +- concrete-over-soft summary preference and stale falsy map-key refinement; +- nested nil-only regression protection; +- recursive structural-growth stopping for table builders; +- nested `never` artifact repair; +- higher-order monotone summary merge for function-returning-function and + self-recursive method shapes; +- summary-to-function-return alignment and conservative unknown return + attachment for otherwise returnless callable values. + +Production callers now import `domain/returnsummary` directly. The old +`returns.ReturnTypes*`, `returns.MergeReturnSummary`, +`returns.NormalizeReturnVector*`, `returns.AlignFunctionTypeWithSummary`, +`returns.WithSummaryOrUnknown`, `canonicalReturnVector`, and +`normalizeAndPruneReturnVector` names were deleted instead of wrapped. + +Current package ownership: + +```text +domain/value = reusable structural value relations +domain/paramevidence = parameter evidence lattice, equality, and parameter-slot refinement +domain/returnsummary = return-vector lattice and function-return alignment +returns = function-fact product orchestration and interproc widening +``` + +This keeps one clear abstract-interpreter data flow: + +1. flow and return inference produce candidate return evidence; +2. `domain/returnsummary` decides how return vectors normalize, compare, merge, + and align to callable types; +3. `returns` only decides when function-fact products are joined or widened; +4. Salsa snapshots continue to observe the canonical fact product rather than a + compatibility mirror. + +This is a flash migration, not a bridge. Production code no longer calls the old +return-summary helpers through `returns`. + +Verification for this slice so far: + +- `go test ./compiler/check/domain/returnsummary ./compiler/check/returns` + passes. +- `go test ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- `go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction + -benchmem -count=3` reports about 1.15 ms/op, 882 KB/op, and 9390 + allocs/op on this machine. +- Standard `../scripts/verify-suite.sh` passes go-lua checker tests and builds + the Wippy binary, then exits non-zero on the known external pinned lint + targets: session 8 errors, agent/src 9 errors, docker-demo 21 errors and + 2 warnings. + +## 2026-05-19 Function Fact Domain Checkpoint + +The next rectification slice moved the per-function fact laws out of +`compiler/check/returns` and into `compiler/check/domain/functionfact`. + +Moved domain laws: + +- canonicalization and emptiness for one `api.FunctionFact`; +- same-iteration join for one function fact; +- merge policy for function-type fact projections; +- compatible function-variant collapse inside unions while preserving residual + non-function union members; +- same-shape function merging across params, variadic params, returns, effects, + error-return specs, and refinements; +- parameter-slot fact merge policy that delegates to `domain/paramevidence`; +- return-slot fact merge policy that delegates to `domain/returnsummary`. + +Production callers now import `domain/functionfact` directly for individual +function facts. The old `returns.JoinFunctionFact`, +`returns.MergeFunctionFactType`, `returns.NormalizeFunctionFact`, and +`returns.NormalizeFunctionFacts` names were deleted instead of wrapped. + +Current package ownership: + +```text +domain/value = reusable structural value relations +domain/paramevidence = parameter evidence lattice, equality, and parameter-slot refinement +domain/returnsummary = return-vector lattice and function-return alignment +domain/functionfact = one-function fact normalization, join, and type projection +returns = function-fact maps, captured effects, local SCC orchestration, and interproc widening +``` + +The resulting data flow is now narrower: + +1. inference and post-flow code produce one-function deltas through + `functionfact.Join`; +2. `returns.JoinFacts` and `returns.WidenFacts` decide how those deltas combine + across symbol maps and fixpoint iterations; +3. convergence-specific widening remains in `returns` because it depends on the + whole interprocedural product and iteration boundary; +4. no production code calls legacy per-function fact helpers through `returns`. + +Verification for this slice so far: + +- `go test ./compiler/check/domain/functionfact ./compiler/check/returns + ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- `go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction + -benchmem -count=3` reports about 1.15-1.17 ms/op, 882 KB/op, and 9390 + allocs/op on this machine. +- Standard `../scripts/verify-suite.sh` passes go-lua checker tests and builds + the Wippy binary, then exits non-zero on the known external pinned lint + targets: session 8 errors, agent/src 10 errors, docker-demo 21 errors and + 2 warnings. + +## 2026-05-19 Fact Product Domain Checkpoint + +The next rectification slice moved the whole interprocedural fact product out of +`compiler/check/returns` and into `compiler/check/domain/factproduct`. + +Moved product laws: + +- `api.Facts` equality; +- same-iteration product join; +- recursive-boundary product widening; +- function-fact map canonicalization and deterministic symbol enumeration; +- literal signature join/widen; +- captured type join/widen; +- captured field assignment join/widen; +- captured container mutation join/widen; +- constructor-field join/widen; +- deterministic captured field/container equality and merge helpers. + +Production callers now import `domain/factproduct` directly. The old +`returns.WidenFacts`, `returns.JoinFacts`, `returns.FactsEqual`, +`returns.ConstructorFieldsEqual`, `returns.WidenLiteralSigs`, +`returns.JoinLiteralSigs`, captured-fact join/widen/equality helpers, and +captured merge helpers were deleted from `returns` instead of wrapped. + +Test ownership was rectified at the same time: + +- return-vector and return-summary law tests moved to `domain/returnsummary`; +- one-function fact join/type-merge tests moved to `domain/functionfact`; +- whole-product tests moved to `domain/factproduct`; +- `returns` keeps only local return orchestration tests. + +Current package ownership: + +```text +domain/value = reusable structural value relations +domain/paramevidence = parameter evidence lattice, equality, and parameter-slot refinement +domain/returnsummary = return-vector lattice and function-return alignment +domain/functionfact = one-function fact normalization, join, and type projection +domain/factproduct = whole api.Facts product join, widening, equality, and map domains +returns = local return SCC orchestration, call graph, overlays, signature seeding +store = snapshot/Salsa wiring and fixpoint publication +``` + +This separates the abstract interpreter more cleanly: + +1. local inference produces function and mutation deltas; +2. `domain/functionfact` and the other slot domains define one-slot meaning; +3. `domain/factproduct` defines how the whole interprocedural product combines; +4. the store decides when to apply join or widening and when to publish Salsa + snapshot inputs; +5. `returns` no longer owns cross-graph product laws. + +Verification for this slice so far: + +- `go test ./compiler/check/domain/factproduct ./compiler/check/store + ./compiler/check/returns ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- `go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction + -benchmem -count=3` reports 1.17-1.19 ms/op, 881 KB/op, and 9390 allocs/op + on this machine. +- Standard `../scripts/verify-suite.sh` passes go-lua checker tests and builds + the Wippy binary, then exits non-zero on the known external pinned lint + targets: session 8 errors, agent/src 10 errors, docker-demo 21 errors and + 2 warnings. + +## 2026-05-19 Convergence Law Ownership Checkpoint + +The next rectification slice removed convergence and structural value laws from +`domain/factproduct`. The fact-product domain now composes slot domains instead +of carrying private copies of their logic. + +Moved laws: + +- higher-order recursive-growth detection moved to `domain/value`; +- convergence widening for one `typ.Type` moved to `domain/value`; +- unsafe precision-drop detection moved to `domain/value`; +- return-vector convergence widening moved to `domain/returnsummary`; +- one-function fact convergence widening moved to `domain/functionfact`; +- same-signature return-slot merging for function literals moved to + `domain/functionfact`; +- related tests moved to the packages that own the laws. + +The old local helper names are gone from production code: + +```text +mergeFunctionReturnsIfSameShape +widenFunctionFactTypeForConvergence +widenReturnSummaryForConvergence +maybeWidenTypeForConvergence +widenValueTypeForConvergence +typeUnsafePrecisionDrop +returnsummary.HasHigherOrderGrowthRisk +``` + +Current convergence flow: + +1. `domain/value` defines structural type relations and finite-height + convergence approximations. +2. `domain/returnsummary` widens return vectors using the value domain. +3. `domain/functionfact` widens one `api.FunctionFact` using parameter evidence, + return summaries, and value relations. +4. `domain/factproduct` widens maps and fact slots only by delegating to those + owners. + +Verification for this slice so far: + +- `go test ./...` passes. +- `git diff --check` passes. +- `go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction + -benchmem -count=3` reports 1.14-1.16 ms/op, 881 KB/op, and 9390 allocs/op + on this machine. +- Standard `../scripts/verify-suite.sh` passes go-lua checker tests and builds + the Wippy binary, then exits non-zero on the known external pinned lint + targets: session 8 errors, agent/src 10 errors, docker-demo 21 errors and + 2 warnings. One first run printed agent/src 12 errors; direct replay of that + target and a full rerun both returned 10. + +## 2026-05-19 False-Positive Replay And Domain Refinement Checkpoint + +The next pass classified remaining local-replace lint failures and fixed the +ones that were checker false positives without weakening `any` soundness. + +Direct engine fixes: + +- the call pipeline now re-synthesizes every expected-sensitive argument form + that can change meaning under a concrete callee parameter expectation: + function literals, table literals, identifiers, attribute reads, explicit + casts, logical operators, call expressions, and non-nil assertions; +- intersection callees now publish contextual expected-argument vectors during + phase one, using the same merge law as union callees while still requiring + `FinishCall` to validate every intersection member; +- positive field-literal narrowing is now a domain meet for top/open table + shapes instead of only a union filter. A guard such as `part.type == "image"` + materializes the proven field on `any`, table top, maps, and open records with + row-tail evidence. Existing closed broad fields keep the previous "may match" + policy, so `field: string` does not collapse to a literal singleton merely + because one branch compared it. + +The key false-positive class was: + +```lua +for _, part in ipairs(content) do + if part.type == "text" and part.text and part.text ~= "" then + table.insert(content_blocks, { text = part.text }) + elseif part.type == "image" then + convert_image_to_converse(part) + end +end +``` + +When `content` came from `any`, the negative side of the text branch could +create an open `{text: ""}` shape. The later `part.type == "image"` check kept +that open shape because the open tail could contain `type`, but it failed to +record the hard proof that this branch's `type` field is present and equal to +`"image"`. The result was a false error when passing `part` to a helper that +requires a `type: string` field. + +Correct abstract interpretation: + +```text +Observation: part.type == "image" +Location: Location(part).field("type") +Evidence: hard runtime proof, field-literal equality +Domain: value/shape meet +State: open row-tail shape plus explicit type = "image" +Query: helper parameter assignability sees required type field +``` + +Wrong interpretation: + +```text +open row-tail may contain type -> keep the old shape unchanged +``` + +That wrong interpretation lost proof. It was not a reason to let `any` flow +into concrete contracts generally. + +Regression coverage added: + +- imported optional response-body fallback into an imported string call; +- explicit cast of an imported unknown field into an imported method call; +- intersection callee expected-argument publication; +- logical/cast/call/non-nil expected-sensitive argument re-synthesis; +- discriminated array elements from typed and untyped sources; +- open-record field-literal meet commutativity and union refinement laws. + +Local-replace Wippy replay after this fix: + +- `wippy.llm.bedrock:mapper` line 240 is clean; the reproduced checker false + positive is gone. +- `wippy.llm.bedrock:mapper` still reports line 503 (`parse_text_tool_call(text, + tool_names)` with `text` from `text_blocks`). This is not fixed in go-lua + because `text_blocks` is populated from `block.text` on an untyped external + payload. `if block.text then` proves truthiness, not stringness. Treating that + as string would be an `any`-to-concrete unsoundness unless the engine grows an + explicit successful-operator refinement model for `..` and string methods. +- session dependency diagnostics such as `expected string, got string?` remain + tied to pinned/locked external source shapes without a local fallback or cast. +- larger local-replace sweeps still contain true strictness diagnostics where + `any`, `unknown`, optional values, or intentionally invalid test inputs flow + into concrete contracts. Those must not be hidden by changing go-lua + assignability. + +Verification for this pass: + +- `go test ./types/constraint ./types/flow ./types/narrow` passes. +- `go test ./compiler/check/synth/phase/extract ./compiler/check/synth/ops + ./compiler/check/tests/regression` passes. +- `go test ./compiler/check/...` passes. +- `go test ./...` passes. +- `git diff --check` passes. +- `go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction + -benchmem -count=3` reports about 1.13-1.15 ms/op, 881 KB/op, and 9390 + allocs/op on this machine. +- `../scripts/verify-suite.sh` passes the go-lua checker tests and Wippy binary + build, then exits non-zero on the external pinned lint targets: + session 8 errors, agent/src 8 errors, docker-demo 21 errors and 2 warnings. + The rest of the verify-suite lint targets report zero diagnostics. + +## 2026-05-19 Typed Write Boundary Reactualization + +The current validation pass found a real soundness gap while classifying +external diagnostics: + +```lua +local CONFIG = { chars_per_token = 4 } + +local function configure(new_config) + for key, value in pairs(new_config) do + if CONFIG[key] ~= nil then + CONFIG[key] = value + end + end +end + +configure({ chars_per_token = "bad" }) +return 10 * CONFIG.chars_per_token +``` + +This is not a false positive. The write `CONFIG[key] = value` is a typed write +boundary. The guard proves that the key names an existing slot; it does not +prove that the incoming value is compatible with that slot. Accepting the write +and only complaining later at arithmetic is weaker and can miss the real source +of unsoundness. + +Correct abstract interpretation: + +```text +Observation: CONFIG[key] = value +Location: dynamic index location under CONFIG with key evidence +Evidence: assignment value evidence plus key-domain evidence +Domain: memory/write projection asks the value domain for the target slot +State: write accepted only if value <= writable slot type +Query: later reads can trust unchanged numeric slots +Diagnostic: failed write compatibility, not arithmetic fallout +``` + +Wrong interpretation: + +```text +dynamic key exists -> mutate CONFIG with value and hope later reads catch it +``` + +The final ownership must not be a local assignment-hook helper. A checker hook +may format the diagnostic, but the semantic operation is a pure domain/query +law: + +```text +WriteProjection(containerType, keyType) -> writable value type +``` + +For the current codebase shape, the flash migration target is: + +- `types/query/core` owns pure read/write projection over structural types; +- assignment checking asks that query for computed-index write targets; +- ordinary field enrichment remains a memory/evidence operation, not a subtype + check against the current read projection; +- numeric literal fields widen to their primitive numeric type for writes so + mutable defaults are not frozen as singleton slots; +- `any` and `unknown` remain unresolved, not concrete proof. + +Implementation ownership was corrected accordingly: + +- `types/query/core.IndexWrite` is the pure write-side projection. +- exact finite key domains use a write-side meet: a value must satisfy every + slot the key may denote. +- broad dynamic keys only produce a projection when all possible slots have one + uniform writable type. Heterogeneous dynamic keys require memory/key-value + relation evidence; a type-only projection must not invent that relation. +- mixed direct-field plus row-tail writes are projected only when the direct + slot and row-tail slot agree. Otherwise the write belongs to the memory + relation domain, not to a single structural slot. +- mutable structural slots may widen singleton defaults for write projection, + but closed finite literal domains remain closed. A field declared as + `"queued" | "started"` must not become `string` just because the destination + is mutable. +- the assignment hook now only asks the query for a target slot and checks the + source against it. It does not own the structural write law. + +This keeps the rule aligned with the abstract machine: + +```text +Transfer observes a write. +Type query projects the writable slot type. +Assignability checks value evidence against that slot. +Diagnostics project failure evidence. +``` + +Regression requirements for this class: + +- negative: untyped URL/resource/config data flowing into concrete contracts is + rejected; +- positive: explicit `type(...) == "string"` guards feed string contracts; +- positive: typed config updates preserve numeric reads; +- negative: bad config updates fail at the write boundary; +- law: write projection does not overwrite unrelated named fields for dynamic + keys. + +This also reclassifies the clean external replay: + +- `tests/app` overlay URL errors are source/contract issues because `args.url` + is untyped and may be truthy non-string. +- `views` resource ID error is source/contract unless the source validates each + `entry.data.resources` element as string. +- `llm` provider model and Bedrock text-block errors are source/contract unless + the source or manifest proves stringness. +- `llm.util:compress` config mutation is a checker-design stressor: reads must + stay precise for valid typed config updates, and invalid dynamic writes must + be rejected at the write boundary. + +## 2026-05-19 Partial-Interpreter Architecture Diagnosis + +The recurring bug shape is not "one more missed helper". It is a split semantic +authority problem. The checker still has several partial interpreters: + +- synthesis decides some expression meaning and some contextual typing; +- flow transfer decides some path and mutation meaning; +- narrowing queries decide some refined read meaning; +- assignment/call/return hooks decide some compatibility and diagnostic meaning; +- interprocedural inference decides some function-summary meaning; +- query/subtype/value packages decide some structural laws. + +This split is why a local fix can look correct and still expose another nearby +failure. The same evidence can pass through different semantic owners depending +on whether it appears as a table literal, call argument, returned closure, +dynamic write, field read, or interprocedural fact. That is the architectural +issue two steps back. + +The current event-bus projector failure is in that class: + +```lua +function Builder:build(): protocol.Projector + return function(state: protocol.BusState, event: protocol.Event, at) + state.projections[event.id] = { updated_at = at } + end +end +``` + +The return annotation provides an expected function type +`(BusState, Event, time.Time) -> ()`. That expected type must become contextual +parameter evidence for the returned closure before the closure body is checked. +If `at` remains `unknown` inside the closure, the abstract interpreter lost +evidence at a phase boundary. The typed write check is then correctly strict: +`unknown` cannot be assigned to `time.Time?`. The bug is not the write +projection; the bug is missing canonical expected-function transfer into the +nested function's environment. + +Correct abstract interpretation: + +```text +Observation: return function(...) ... end +Context: enclosing function has expected return slot protocol.Projector +Evidence: returned expression is checked against that expected slot +Transfer: expected function params seed the closure parameter locations +State: closure body sees at: time.Time +Query: dynamic write projection accepts updated_at: time.Time? +``` + +Wrong interpretation: + +```text +return expression type is checked after closure body synthesis +closure parameter at defaults to unknown +write boundary rejects table field using that unknown +``` + +The final architecture must make expected type context part of the same +abstract-machine input as flow and facts. It cannot be a hook-local retry. The +flash-migration rule for this class is: + +- expected types are contextual evidence at expression boundaries; +- function-literal contextual evidence owns parameter seeding for the nested + function body; +- table-literal contextual evidence owns field/value synthesis; +- call-argument contextual evidence owns argument synthesis; +- assignment/write contextual evidence owns source synthesis; +- diagnostics are emitted after the canonical transfer/query has answered. + +No production code should grow another "if returned closure, then synth again" +bridge. The implementation must route the expected function type into the +existing function-literal type construction and nested-function analysis path so +all returned callbacks, assigned callbacks, table-held callbacks, and call +arguments use the same rule. + +## 2026-05-19 Remaining Architecture Tasks After Split-Authority Diagnosis + +The work is not complete until these items are true at the engine level: + +- `subtype` is a pure structural relation. It must not be the owner of + provenance-sensitive rules such as "this mutable record can widen because it + is fresh". +- write-slot projection is a pure destination query. It may widen singleton + defaults for mutable local ergonomics, but it must preserve closed finite + literal domains such as `"queued" | "started"`; those domains are semantic + contracts, not incidental singleton defaults. +- assignability is the single owner for checking a value against an expected + destination under a mode: call argument, return slot, local declaration, + structured write, and contextual literal checking. +- freshness and escape state are represented by the abstract interpreter or by a + conservative provenance query over the solved graph. Syntax alone is not + proof; a direct table literal and an unescaped local whose current value is + that literal should be accepted for the same reason. +- mutable record singleton-to-union widening is allowed only at a proven fresh + contextual boundary. A narrower alias must not be allowed to observe later + writes through a wider mutable slot. +- callback contextual typing must seed nested function parameter locations + before body/write diagnostics are produced. +- convergence must remain domain-owned: no iteration caps, no equality-time + repairs, no producer-specific fallback channels. +- Salsa should cache immutable graph summaries, function results, type queries, + and eventually provenance/use summaries. It must not hide incomplete semantic + inputs. + +Immediate implementation checklist: + +- keep the `ApplyParamList` nil-annotation-slot fix, because it is the canonical + parameter-list law for contextual function parameters; +- add the missing fresh-literal escape rule at the assignability/provenance + boundary, not by weakening global mutable record subtyping; +- add a positive regression where a returned callback builds a local projection + literal and writes that local into a typed map; +- add a negative regression where a narrower mutable alias is written through a + wider destination and then observed through the narrow alias; +- do not enable full static field-path write diagnostics until class/metatable + self-reference assignments (`T.__index = T`) have a canonical self-type model; +- replay the real event-bus fixture and local external lint cases before + claiming there are no false positives; +- update this journal with the final owner names and verification output. + +## Goal + +The checker should read as one abstract interpreter over a product domain. + +The current implementation is already powerful: + +- it tracks flow-sensitive path facts, +- it narrows through guards and assertions, +- it propagates table and container mutations, +- it infers local and interprocedural function facts, +- it correlates value/error return slots, +- it handles soft annotation evidence, +- it uses Salsa-style query inputs for function-result invalidation. + +The design problem is that these capabilities are encoded by many local helper +clusters. That makes the system hard to reason about even when the behavior is +mostly correct. Helpers such as `typeRefinesTableKeyByTruthiness` are not just +helpers; they are domain laws living in the wrong place. + +The target is a smaller, clearer checker where each law has exactly one owner. + +## Non-Negotiable Constraints + +- No production transition layer. +- No legacy mirror fact channels. +- No raising iteration caps to hide non-convergence. +- No external application-code edits as part of go-lua design correction. +- No weakening soundness by making `any` assignable to concrete contracts. +- No helper-specific exceptions for external lint targets. +- No pools as the first answer to performance; use structural ownership and + caching first. +- Every final abstraction must have law tests and paired positive/negative + behavioral tests. + +## Current Mental Model + +The checker is a multi-phase abstract interpreter: + +1. Scope and CFG construction establish symbols, lexical parents, control-flow + points, and function graph identity. +2. Declared-phase synthesis extracts initial types, table literal shapes, + function literal signatures, and call/effect evidence. +3. `flowbuild` lowers AST and synthesis facts into flow inputs: + declarations, assignments, table/index mutations, call effects, branch + predicates, return constraints, numeric constraints, aliases, and termination + facts. +4. `types/flow` solves a forward dataflow problem over canonical SSA path keys. + The persistent solved state is currently split across value maps, conditions, + numeric states, alias maps, field overlays, and local caches. +5. Narrowing queries are demand-side interpretation: read solved facts at a + point, apply propagated constraints, and answer refined path/type questions. +6. Return inference and local function SCC solving use the flow result plus + interprocedural snapshots to infer return vectors, parameter evidence, function + facts, captured fields, and captured container mutations. +7. The interprocedural store combines same-iteration deltas with a precise join + and combines recursive fixpoint boundaries with widening. +8. Salsa-style snapshot inputs connect function-result queries to exact + interproc facts, refinements, and constructor-field snapshots. + +This is the right high-level shape. The weakness is that the product domain is +not first-class enough in code. + +## Clean Abstract Interpreter Target + +The final checker should be explainable as: + +```text +AbstractInterpreter = CFG + AbstractState + Transfer + Join + Widen + Query +``` + +Where: + +- `CFG` owns control-flow order and dominance. +- `AbstractState` owns the full product of memory, value, numeric, relation, + effect, and termination facts. +- `Transfer` is the only way statements and expressions change state. +- `Join` is the only way same-phase branch/predecessor evidence combines. +- `Widen` is the only way recursive or interprocedural cycles are forced to + converge. +- `Query` reads solved state without inventing another analysis path. + +This is the mental model the code should expose. If a rule cannot be explained +as one of these operations, it is either orchestration or a design smell. + +The current checker has the right ingredients but not the right ownership. It +has preflow inference, flow solving, narrowing queries, return SCC inference, +overlay refresh, mutation replay, and interproc widening. Those should become +clients of the same abstract-state and domain APIs. They should not remain +separate places where local helpers decide what refinement means. + +## State-Of-The-Art Bar + +The target is not just cleaner Go packages. The target is a modern static +analysis engine with explicit theory: + +- monotone abstract domains with named `Normalize`, `Leq`, `Join`, `Meet`, and + `Widen` operations; +- transfer functions over a product state instead of helper-specific rewrites; +- a first-class memory model for paths, fields, indexes, aliases, mutations, + row tails, and dominance; +- relational facts for tuple slots and path correlations instead of hardcoded + error-return branches; +- principled distinction between `unknown`, `any`, `nil`, absent fields, soft + evidence, hard evidence, table top, and open row tails; +- explicit widening at recursive boundaries and optional narrowing only after a + post-fixpoint is reached; +- deterministic canonicalization and equality, never equality-time repair; +- cache keys derived from immutable inputs and domain snapshots, not incidental + phase call order; +- paired positive/negative law tests so the implementation cannot get faster by + becoming less sound. + +Anything less will keep producing local helper patches. The migration should +make the checker look like the theory it is implementing. + +## Core Moral Model + +The checker should be taught and reasoned about with one sentence: + +```text +Evidence is produced by transfer, combined by domains, stabilized by widening, +and observed by queries. +``` + +That sentence is the guardrail. + +- Extraction does not decide lattice policy. It only converts source syntax into + typed evidence and transfer instructions. +- Transfer does not decide cross-iteration convergence. It only updates the + current abstract state. +- Domains do not inspect AST. They only combine abstract values and facts. +- Widening does not recover precision. It only guarantees convergence. +- Queries do not produce new facts. They only read the solved state and apply + already-recorded constraints. +- Interprocedural producers do not mutate old state. They emit deltas. + +If a function violates one of these rules, it is a design smell even if the +behavioral test passes. + +## One-Page Doctrine + +The final checker should fit in this operational doctrine. + +```text +1. Source syntax is lowered once into graph-indexed transfer IR. +2. Transfer IR is interpreted over one product AbstractState. +3. AbstractState owns every persistent intraprocedural fact. +4. Domain objects own every combine/refine/widen law. +5. Queries are read-only views over solved AbstractState. +6. Function inference publishes immutable InterprocDelta values. +7. FactsDomain is the only interprocedural merge/widen authority. +8. Salsa tracks immutable inputs and query dependencies. +``` + +Everything else is implementation detail. + +The doctrine gives a direct review test: + +- If code lowers syntax, it belongs in graph/IR/extract. +- If code changes state, it is transfer. +- If code combines facts, it is a domain operation. +- If code forces convergence, it is widening. +- If code answers a question, it is a query. +- If code crosses function/module boundaries, it emits or consumes a delta. +- If code caches, it must name immutable inputs and invalidation. + +No rule should need to be implemented twice under different helper names. + +## Abstract Machine Specification + +The final checker should be specified as a small abstract machine. This gives the +code a single target shape and gives reviews a way to reject scattered helper +logic. + +```text +Machine = + Inputs + + Program + + State + + Domains + + Worklist + + QueryView + + Publisher +``` + +### Inputs + +Inputs are immutable during one function analysis query: + +- graph identity, +- parent scope identity, +- manifest/module environment, +- declared type environment, +- canonical interproc snapshot, +- constructor snapshot, +- effect/refinement snapshot, +- graph summaries, +- pure type-query engine. + +Inputs are the only values allowed to affect the answer besides the transfer +program. If an answer depends on something not listed here, the dependency model +is incomplete. + +### Program + +The program is normalized checker IR: + +- no source AST policy decisions, +- no hidden synthesis callbacks, +- no direct store mutation, +- no cache-dependent control flow. + +Each instruction has one meaning as a transfer over `AbstractState`. + +### State + +The state is the product: + +```text +State = + Memory + x Values + x Shapes + x NumericFacts + x Relations + x Effects + x Termination + x DiagnosticsEvidence +``` + +`DiagnosticsEvidence` is not user diagnostics. It is proof metadata such as +"this constraint failed here" or "this widening lost precision here". User +diagnostics are emitted after solving by querying this evidence. This keeps +diagnostic formatting out of domain semantics. + +### Domains + +Domains define the algebra: + +```text +Normalize, Leq, Join, Meet, Refine, Widen, Equal +``` + +Every operation must be local to its owned component or explicitly part of a +product operation. For example, relation transfer can ask value and memory +domains to interpret a path predicate, but it cannot create a private value +merge law. + +### Worklist + +The worklist owns traversal, not meaning. + +Allowed: + +- schedule CFG points, +- schedule SCC members, +- detect local stabilization, +- invoke loop/SCC widening at declared boundaries. + +Forbidden: + +- prefer one fact over another, +- normalize facts, +- publish interproc state, +- recover precision after widening. + +If the worklist needs semantic information to decide convergence, that +information must be exposed through `Leq` or `Equal` on the relevant domain. + +### QueryView + +The query view is a read-only projection over solved state. + +It answers: + +- type at location/point, +- relation at location/point, +- effect summary at call/function boundary, +- return tuple summary, +- parameter obligation summary, +- diagnostic projection. + +It must not write facts, widen, repair state, or backfill caches that later act +as analysis state. + +### Publisher + +The publisher converts solved state into immutable deltas: + +```text +State -> FunctionResult -> InterprocDelta +``` + +The publisher does not merge with previous results. It does not reconstruct +legacy channels. It emits the final product-domain representation expected by +`FactsDomain`. + +### Machine Transition Rules + +The core machine transitions are: + +```text +step(instruction, state) = Transfer.Apply(instruction, state, domains) +join(predStates) = AbstractState.Join(predStates, domains) +widen(prev, next) = AbstractState.Widen(prev, next, domains) +query(state, question) = QueryView.Answer(state, question) +publish(state) = InterprocDelta +``` + +Every specialized feature should reduce to these transitions: + +- branch narrowing is transfer plus join; +- field writes are memory transfer; +- table mutators are effect transfer plus memory transfer; +- assertions are effect transfer plus relation/value refinement; +- error-return behavior is relation transfer over tuple slots; +- callback behavior is higher-order effect transfer; +- local function inference is an SCC over function-state summaries; +- interproc inference is a fixpoint over `InterprocDelta` values. + +If a feature cannot be expressed this way, either the machine is missing a +domain or the feature is implemented at the wrong layer. + +### Machine Laws + +The implementation should preserve these laws: + +- Transfer is monotone with respect to domain `Leq`. +- Join is least-upper-bound or a documented approximation. +- Meet/refine never invents evidence without provenance. +- Widen is only applied at explicit recursive boundaries. +- Normalize is idempotent and is not hidden in equality. +- Query is pure over solved state. +- Publication is deterministic. +- Cache hits do not change semantics. +- Diagnostics are projections of evidence, not sources of evidence. + +These laws should become test names. A regression that violates one of them is a +design regression, not a local bug. + +## Ownership Ledger + +Every semantic object should have one home. This table is the fastest review +tool for the future flash migration. + +| Object | Born In | Canonical State | Transformed By | Queried By | Published As | Cache Boundary | +|---|---|---|---|---|---|---| +| symbol identity | graph build | graph bundle | never semantically transformed | location resolver | graph key/symbol key | graph input | +| parent scope | scope build | immutable scope state | never semantically transformed | analysis key lookup | parent hash | `FuncKey`/`GraphKey` | +| field/index path | IR/path lowering | `Location` / `MemoryState` | memory transfer | query view | captured path/mutation delta | location interning | +| local value fact | transfer | `AbstractState.Values` | value domain | type-at query | return/param/capture delta when exported | per-function state | +| table shape fact | literal/assignment transfer | value + memory domains | value/memory domains | field/index query | function/captured/container delta | type query + local state | +| branch truthiness | condition transfer | relation/value constraints | relation/value domains | query view | relation summary if it crosses boundary | per-function state | +| nil/absent evidence | assignment/field transfer | memory + value domains | memory/value domains | field query | return/param/capture delta | per-function state | +| parameter observation | call transfer | parameter evidence domain | parameter domain | function summary query | function fact delta | interproc facts input | +| body obligation | body transfer | parameter evidence domain | parameter domain | function summary query | function fact delta | graph summary + state | +| return tuple | return transfer | return summary domain | return domain | return query | function fact delta | interproc facts input | +| tuple/path relation | predicate/effect/return transfer | relation domain | relation domain | relation query | relation summary delta | local state / interproc facts | +| table mutation | assignment/effect transfer | memory domain | memory domain | iteration/field query | captured container delta | local state / interproc facts | +| call effect | effect resolution | effect domain | effect domain | transfer/query view | refinement/effect delta | effect snapshot input | +| termination fact | transfer/effect transfer | termination domain | termination domain | reachability query | function effect delta | per-function state | +| diagnostic evidence | failed constraint transfer/query | diagnostics evidence state | diagnostic projection only | diagnostics pass | no semantic delta | result only | +| constructor field | constructor transfer/publication | constructor field domain | memory/value domains | constructor query | constructor snapshot | constructor input | +| external dynamic value | manifest/effect transfer | value evidence with provenance | value/domain checks | assignability query | only if exported with provenance | manifest/type input | + +Design rule: + +```text +If a row needs two canonical states, the model is split incorrectly. +If a row has no cache boundary, the implementation will invent one locally. +If a row has two publishers, legacy mirror channels are coming back. +``` + +## Dataflow Moral Rules + +The checker should be easy to explain because the direction of information never +reverses. + +### Syntax To Evidence + +Syntax can create observations. It cannot create authority by itself. + +Examples: + +- a table literal observes fields; +- a call observes arguments; +- a guard observes a branch condition; +- a return observes tuple slots. + +These observations become evidence only through transfer and domain +qualification. + +### Evidence To Fact + +Evidence becomes a fact when the owning domain accepts it into state. + +Examples: + +- a field observation becomes a memory fact at a canonical location; +- a truthy guard becomes a relation/value constraint; +- a body use becomes a parameter obligation; +- a call argument becomes a parameter observation. + +No producer decides global precedence. The evidence order belongs to the domain. + +### Fact To Answer + +Answers are read-only projections. + +Examples: + +- "what is the type here?", +- "does this path exclude nil?", +- "what does this function return?", +- "does this call terminate?", +- "which diagnostic should be emitted?". + +An answer cannot become a fact unless a later transfer explicitly observes it +and routes it through the owning domain. This prevents query-time analysis. + +### Fact To Delta + +Only solved facts that cross a function or module boundary become deltas. + +Examples: + +- local temporary narrowing does not publish; +- body obligation publishes as parameter evidence; +- return tuple publishes as return summary and relation summary; +- captured mutation publishes as memory/effect summary; +- external contract application does not rewrite the contract. + +The publisher emits a delta; `FactsDomain` combines it. + +### Delta To Snapshot + +Snapshots are cache inputs, not semantic repair points. + +Examples: + +- changed canonical facts update snapshot inputs; +- unchanged canonical facts do not invalidate queries; +- empty canonical facts clear stale inputs; +- compatibility projections are not written. + +This keeps incremental revalidation honest: Salsa tracks dependencies, domains +track meaning. + +## Boundary Invariants + +Every boundary in the dataflow should have a small invariant that can be tested +or reviewed directly. + +### Graph Boundary + +Invariant: + +```text +Graph identity changes only when syntax/binding identity changes. +``` + +This boundary may cache syntax summaries. It may not depend on interproc facts, +solved flow state, or expected call types. + +### IR Boundary + +Invariant: + +```text +Checker IR contains operations, not answers. +``` + +The IR may say "apply this call effect" or "assign this value to this +location". It may not pre-decide the result type of an operation whose answer +depends on flow/interproc state. + +### Transfer Boundary + +Invariant: + +```text +Transfer is the only state-writing semantics inside a function. +``` + +All writes to memory, value, relation, effect, and termination state must be +visible as transfer operations. A helper that writes state outside transfer is a +hidden interpreter. + +### Join Boundary + +Invariant: + +```text +Branch merge uses domain Join and nothing else. +``` + +A branch-specific merge helper is allowed only if it is the domain's exported +join/meet/refine operation. If it knows about AST shape, it is in the wrong +layer. + +### Widen Boundary + +Invariant: + +```text +Widen happens only at named recursive boundaries. +``` + +Loop widening, local function SCC widening, and interproc widening may have +different schedules, but they must call the same domain-level widening laws for +the same fact family. + +### Query Boundary + +Invariant: + +```text +Query answers cannot become stored evidence. +``` + +Query caches are permitted only for answers. They must not publish facts or +change future convergence. + +### Publication Boundary + +Invariant: + +```text +Publication emits immutable deltas and never merges them. +``` + +The same solved state must always produce the same delta. If publication reads +previous facts to decide how to shape the delta, it is doing merge work in the +wrong layer. + +### Snapshot Boundary + +Invariant: + +```text +Snapshot updates are semantic no-ops except for dependency invalidation. +``` + +Setting a snapshot input can make queries rerun. It cannot normalize, widen, +infer, or delete evidence except by reflecting the already-canonical facts. + +### Diagnostic Boundary + +Invariant: + +```text +Diagnostics observe proof failure; they do not define type behavior. +``` + +A diagnostic pass may ask why a check failed. It may not make the check pass or +fail by changing evidence. + +## Evidence Authority Model + +The checker should be precise because it carries proof, not because it guesses. +Authority is therefore part of evidence. It is not a global total order; it is a +domain-specific partial order over a specific question. + +Canonical evidence shape: + +```text +Evidence = + Location + + Value/Predicate/Effect + + Provenance + + Authority + + Scope + + Phase + + SourceSpan +``` + +`SourceSpan` may be absent for synthetic or imported evidence, but provenance +must not be absent. + +### Authority Classes + +The final design should name these authority classes explicitly. + +| Authority | Meaning | Can Prove Concrete Contract? | Can Be Weakened By Join? | Can Publish? | +|---|---|---|---|---| +| explicit contract | user/API annotation or manifest contract | yes | only through declared variance/summary abstraction | yes | +| hard runtime proof | guard, assertion, dominance-proven assignment | yes | yes at control-flow join | if it crosses boundary | +| relation proof | fact derived from tuple/path relation | yes for related locations | yes when relation path is lost | if relation crosses boundary | +| effect proof | applied call/effect summary | yes if effect declares it | yes at join/widen | yes as effect/summary | +| body obligation | function body requires a shape | yes for parameter contract inference | yes at recursive widen | yes | +| call observation | caller passed a shape | no by itself | yes | yes as weak evidence | +| contextual literal evidence | expected type applied at literal boundary | yes for that literal | yes | yes if literal escapes | +| soft annotation | low-authority annotation hint | no without compatible proof | yes | only as soft evidence | +| unresolved observation | `unknown` | no | yes but not erased silently | yes as unknown | +| dynamic top | `any` | no without explicit cast/contract | yes as dynamic top | yes as any | + +This table prevents the common mistake of treating all useful evidence as the +same. A call observation is useful for inference, but it is not proof that the +callee accepts that shape. An explicit `any` is useful information, but it is +not proof of a concrete field. + +### Conflict Resolution + +Conflicts should be resolved by the owning domain, not by producer preference. + +| Conflict | Owner | Correct Resolution | +|---|---|---| +| hard proof vs soft annotation | evidence/value domain | hard proof wins for the proven path | +| explicit `any` vs expected concrete param | assignability/value domain | reject unless cast/contract proves concrete | +| unknown return vs concrete return | return domain | preserve unresolved behavior unless domain law proves refinement | +| call observation vs body obligation | parameter domain | body obligation is stronger contract evidence | +| parent table shape vs child-path write | memory domain | child-path fact wins for that path | +| closed missing field vs open row tail | value/memory domain | closed absence and open unknown tail stay distinct | +| relation proof vs unrelated assignment | relation/memory domain | relation survives only if location identity is preserved | +| widening precision loss vs later query | owning domain | query observes widened state; no post-widen repair | + +Conflict policy must be testable as a domain law. If the test has to construct a +whole checker to decide the conflict, the domain boundary is still too implicit. + +### Proof-Carrying Facts + +Every persistent fact should be explainable as: + +```text +fact = domain.accept(observation, provenance, authority, location) +``` + +Queries should be able to answer both: + +- the abstract answer, such as "this value is string"; +- the proof route, such as "truthy guard on this location removed nil". + +The proof route does not need to be exposed in normal diagnostics, but it must +exist in the design. Without it, the checker cannot distinguish real precision +from accidental broadening. + +### Precision And Soundness Contract + +Precision can increase only by proof. + +Allowed precision gains: + +- guard removes nil/false from the exact guarded location; +- assertion effect narrows the declared target relation; +- body obligation records a parameter shape the body actually reads; +- table literal contextual typing applies at the literal boundary; +- relation summary narrows linked tuple slots after a predicate. + +Forbidden precision gains: + +- callee expected type rewrites caller evidence; +- repeated callers vote a parameter into a concrete contract; +- `any` becomes a concrete record because a later field is used; +- closed missing field becomes open unknown tail to avoid an error; +- cached answer is reused after an untracked dependency changed. + +Precision can decrease only at named abstraction boundaries: + +- branch join, +- loop widening, +- local function SCC widening, +- interproc widening, +- published summary abstraction. + +Precision must not decrease at: + +- equality, +- snapshot update, +- diagnostics, +- compatibility projection, +- query cache lookup. + +This is the soundness/performance contract. Faster analysis is valid only if it +computes the same evidence or a documented domain approximation at a named +boundary. + +### Absence Of Evidence + +Absence is not a proof. + +Rules: + +- no field evidence does not mean field is nil; +- no relation evidence does not mean slots are independent if a relation was + dropped by a bug; +- no return evidence does not mean zero returns unless arity is known; +- no effect evidence does not mean pure call unless the effect row is closed; +- no param evidence does not mean `any`; it means unresolved until declared or + inferred evidence exists. + +This is where many false positives and false negatives start. The final domains +should model absence explicitly instead of using nil maps as semantic answers. + +## Dataflow Proof Traces + +Every important inference should have a trace format. This is not a logging +requirement for the first implementation. It is the mental model for proving the +checker did the right thing. + +Trace skeleton: + +```text +Observation + -> Location + -> Evidence + -> Domain acceptance + -> State fact + -> Join/Widen if any + -> Query answer + -> Publication if any +``` + +### Guarded Field Trace + +```text +Observation: if options.model then +Location: Location(options).field("model") +Evidence: truthy predicate, hard runtime proof +Domain: RelationDomain + ValueDomain +State: path excludes nil/false on true branch +Query: provider.open argument reads non-nil field type +Publish: none unless the relation escapes through a summary +``` + +Wrong trace: + +```text +provider.open expects string -> options.model becomes string +``` + +The wrong trace reverses dataflow. + +### Error Return Trace + +```text +Observation: local value, err = f() +Location: return tuple slots assigned to local locations +Evidence: f publishes tuple relation +Domain: RelationDomain accepts slot correlation +State: err nil branch relates value slot to success case +Query: value.field sees success-side value evidence +Publish: wrapper republishes tuple relation only if slot identity is preserved +``` + +Wrong trace: + +```text +function has two returns -> assume value/error convention +``` + +The wrong trace invents relation evidence from arity. + +### Dynamic Payload Trace + +```text +Observation: payload = json.decode(raw) +Location: payload +Evidence: imported dynamic value +Domain: ValueDomain records any/unknown with provenance +State: payload.name remains dynamic/unresolved +Query: needs_string(payload.name) requires proof +Publish: dynamic evidence only if exported +``` + +Wrong trace: + +```text +needs_string expects string -> payload.name becomes string +``` + +The wrong trace treats expected type as evidence. + +### Captured Mutation Trace + +```text +Observation: nested function inserts into state.items +Location: canonical location for state.items +Evidence: mutation effect with captured provenance +Domain: EffectDomain applies MemoryDomain mutation +State: array element fact at state.items +Query: ipairs reads element fact if dominance/escape permits it +Publish: captured container mutation delta if it crosses function boundary +``` + +Wrong trace: + +```text +captured mutation replay builds a new parent table shape +``` + +The wrong trace loses operator kind and child-path authority. + +### Trace Review Rule + +For any new inference, a reviewer should be able to ask: + +- What was observed? +- What is the canonical location? +- What authority does the evidence have? +- Which domain accepted it? +- Where can it lose precision? +- Which query read it? +- Does it publish, and if so as which delta? +- Which cache boundary owns reuse? + +If the answer starts with "this helper checks whether...", the design likely +needs another domain operation instead of another helper. + +## Semantic Atoms + +The final design should use a small shared vocabulary. These words should have +one meaning everywhere in the checker. + +### Value + +A `Value` is an abstract runtime Lua value. + +It can be concrete, literal, structural, function-like, `nil`, `unknown`, or +`any`. It is not a source annotation and not a location. A value domain may say +how values combine; it may not decide where a value came from. + +### Location + +A `Location` is an abstract program place where evidence can attach. + +Examples: + +- symbol at SSA version, +- field path, +- index path, +- tuple slot, +- receiver slot, +- captured variable, +- return slot, +- graph/function identity. + +Locations are canonical before transfer. AST paths and SSA paths cannot both be +authoritative. + +## Location And Memory Calculus + +The final checker needs one answer to the question: + +```text +Are these two pieces of evidence about the same runtime place? +``` + +If that answer is local to each helper, precision will stay fragile. Guarded +fields, captured mutations, alias replay, tuple relations, and table-key +refinements all depend on the same location calculus. + +### Location Shape + +A location should be a canonical structured value, not a string path and not an +AST node. + +```text +Location = + Root + + Version + + PathSegments + + ScopeIdentity + + ProvenanceClass +``` + +Roots: + +- local symbol root, +- parameter root, +- receiver `self` root, +- upvalue/captured root, +- return tuple root, +- temporary tuple result root, +- module export root, +- constructor instance root, +- external/imported value root. + +Segments: + +- named field, +- literal index, +- dynamic index with key evidence, +- array element, +- map value, +- tuple slot, +- metatable/member access when modeled, +- synthetic effect target. + +`Version` belongs to the root or to a versioned location identity. It should not +be smuggled into a string suffix. `ScopeIdentity` is required for parent-scoped +facts so two equal-looking symbols in different parent scopes do not collide. + +### Canonicalization Laws + +Location canonicalization should obey these laws: + +- resolving the same symbol/path at the same CFG point returns the same + canonical location; +- resolving different lexical symbols never collides, even when names match; +- aliases are explicit equivalence/forwarding facts, not path rewrites; +- field and index segments are interned/normalized before storage; +- dynamic index evidence is preserved and not collapsed to `string` unless a + proof refines it; +- tuple slots remain tuple slots until assignment or forwarding gives them a + concrete destination; +- captured locations retain lexical owner identity; +- module/export locations retain module identity; +- open row-tail access and closed missing-field access produce different + locations/evidence. + +These laws should be tested without a whole checker. A location unit test should +be able to prove whether two references alias, differ, or are unknown. + +### Memory State Shape + +Memory state should be the product of several maps with one owner: + +```text +MemoryState = + ValueAt(Location) + + PresenceAt(Location) + + Children(Location) + + AliasFacts + + MutationLog + + DominanceFacts + + EscapeFacts +``` + +`ValueAt` says what value evidence is known at a location. +`PresenceAt` distinguishes present, absent, nil value, unknown presence, and +open row-tail unknown. +`Children` records known child facts without forcing a parent table rewrite. +`AliasFacts` records location identity relations and their dominance. +`MutationLog` records effectful writes with operator kind. +`DominanceFacts` tells whether a write/guard reaches a query point. +`EscapeFacts` tells whether a local fact can publish across a boundary. + +None of these should be represented by "map missing means nil". Absence of a map +entry means no stored fact for that component. + +### Read Law + +A memory read answers by ordered evidence, not by helper preference. + +Read order for a path should be: + +1. exact dominated location fact; +2. exact relation-refined fact for the same location; +3. exact child-path mutation fact; +4. alias-forwarded fact whose alias is valid at the query point; +5. declared/constructed parent shape projected through the path; +6. open row-tail evidence; +7. unresolved evidence. + +Forbidden read behavior: + +- expected callee type becomes read evidence; +- parent table shape overwrites explicit child mutation; +- closed missing field becomes open row-tail unknown; +- dynamic index write broadens every named field without proof; +- stale query cache answers for a different location version. + +This read law is where many current helper clusters should collapse. + +### Write And Mutation Law + +A write is not just "join this type into a table". + +Write shape: + +```text +Write = + Target Location + + OperatorKind + + ValueEvidence + + Dominance + + Provenance +``` + +Operator kinds: + +- assignment, +- field write, +- nil overwrite, +- deletion/absence write if Lua semantics or API effect establishes deletion, +- dynamic index write, +- array element insert, +- map value update, +- container send/receive, +- captured mutation replay. + +The operator kind is semantic. `table.insert(x, v)`, `x[k] = v`, and +`x.field = v` may all affect a table, but they do not have the same path law. +Captured replay must preserve the original operator kind. + +### Alias And Dominance Law + +Alias facts are valid only over a control-flow region. + +Rules: + +- alias created by assignment is valid until reassignment or invalidating + mutation; +- field alias preserves the exact field path it came from; +- dynamic index alias preserves key evidence; +- branch-local alias facts do not leak unless dominance proves they reach the + query point; +- loop-carried aliases widen at the loop boundary; +- captured aliases include lexical owner and escape information. + +Relation facts must reference canonical locations, not syntactic expressions. +If assignment preserves location identity, relations can transfer. If it copies +only a value and loses tuple/path identity, relation facts must not silently +survive. + +### Tuple Slot Law + +Tuple slots are locations, not just positions in a slice. + +Rules: + +- return arity is part of tuple identity; +- nil padding is explicit; +- wrapper forwarding preserves tuple-slot relation only when forwarding is + identity-preserving; +- assignment from tuple slot to local location records a relation edge from slot + to local; +- swapped or reordered returns update relation mapping explicitly; +- vararg expansion has its own location/evidence policy and cannot be treated + as fixed tuple identity without proof. + +This prevents the `(value, err)` convention from becoming an arity heuristic. + +### Presence Law + +Presence is separate from value type. + +States: + +- present with value evidence, +- present with nil value, +- absent from closed structure, +- optional in declared structure, +- unknown via open row tail, +- unknown via dynamic table top. + +Important distinctions: + +- `field = nil` is not automatically the same as absent unless the domain rule + for that context says so; +- optional declared field is not the same as proven absence; +- open record tail gives unknown evidence, not nil evidence; +- map value may be nil even when key presence is unknown; +- table top preserves that a value is table-like without proving named fields. + +Presence should be tested as its own domain law. It is too important to hide in +record subtyping or field lookup helpers. + +### Publication Law + +Only memory facts that escape the local function become interproc deltas. + +Publishable memory evidence: + +- captured variable type, +- captured field assignment, +- captured container mutation, +- constructor field, +- return value/tuple slot, +- parameter obligation/effect, +- module export field. + +Non-publishable memory evidence: + +- branch-local narrowing, +- local alias that does not escape, +- temporary tuple slot after assignment unless relation summary requires it, +- diagnostic-only failure evidence, +- query cache answer. + +Publication should project from memory state. It should not reconstruct memory +facts by rescanning AST or replaying helper-specific summaries. + +### Performance Consequences + +The location calculus is also a performance boundary. + +Expected wins: + +- interned locations make map keys cheap and stable; +- path parsing disappears from hot query paths; +- child-path facts avoid rebuilding whole parent tables; +- alias and dominance checks become graph-indexed facts; +- relation queries compare location IDs instead of syntactic paths; +- captured mutation replay reuses the same mutation operator. + +Rejected performance shapes: + +- stringifying paths to compare them in hot loops; +- reparsing path suffixes during every narrowed query; +- rebuilding parent records for each child write; +- using object pools before ownership of locations and memory facts is proven; +- caching read answers without a solved-state/location-version key. + +### Location Law Tests + +The flash migration should add focused tests for: + +- same expression at same point resolves to same location; +- same name in different scopes resolves to different locations; +- alias validity ends at reassignment; +- branch-local alias does not leak; +- dynamic index write does not overwrite unrelated named field; +- child field write outranks parent shape at that child; +- closed missing field differs from open row-tail field; +- tuple relation survives identity forwarding; +- tuple relation dies on reorder unless remapped; +- captured mutation preserves operator kind and target location; +- nil value and absence remain distinguishable. + +These tests are foundational. If they pass, many higher-level inference tests +become much simpler because they no longer need to encode location policy. + +### Evidence + +`Evidence` is a value plus provenance and authority. + +Examples: + +- explicit annotation, +- hard runtime proof, +- body obligation, +- call observation, +- soft annotation, +- unresolved observation, +- imported dynamic value. + +Evidence is not automatically truth. Domains decide how evidence combines. + +### Fact + +A `Fact` is evidence that has been accepted into a domain state. + +Facts are persistent inside `AbstractState` or inside an immutable +`InterprocDelta`. Raw observations are not facts until transfer/domain logic +accepts them. + +### Constraint + +A `Constraint` restricts possible facts along a control-flow path. + +Examples: + +- truthy/falsy, +- type test, +- nil/non-nil, +- has-field, +- numeric bound, +- relation branch. + +Constraints do not mutate storage by themselves. Transfer applies them to +`AbstractState`; queries read the result. + +### Relation + +A `Relation` connects multiple locations. + +Examples: + +- return slot 1 being nil implies return slot 0 is non-nil, +- assertion on one symbol narrows a sibling path, +- method receiver relation to `self`, +- callback argument relation to caller state. + +Relations are not encoded as special value types. They are first-class domain +facts. + +### Effect + +An `Effect` describes what execution of a call or instruction can do. + +Examples: + +- mutate memory, +- terminate, +- refine an argument, +- produce a tuple relation, +- call a callback, +- collect keys. + +Effects are applied by transfer. They do not rewrite types directly. + +## Relation And Effect Calculus + +Relations and effects are the bridge between local flow precision and +interprocedural power. They must be first-class domain facts, not names of known +functions. + +Core rule: + +```text +Relations describe conditional truth between locations. +Effects describe state transitions caused by execution. +``` + +An assertion, predicate, table mutator, callback, error-return convention, and +terminating function all fit this rule. + +### Relation Shape + +A relation should be represented as a structured fact: + +```text +Relation = + RelationID + + Participants + + Arms + + Directionality + + Validity + + Provenance +``` + +Participants are canonical locations: + +- tuple slots, +- locals, +- fields, +- indexes, +- receiver/self, +- callback arguments, +- captured paths. + +Arms describe conditional cases: + +- success/failure branch, +- true/false predicate branch, +- nil/non-nil branch, +- type-test branch, +- discriminant branch, +- custom effect branch. + +Directionality matters. Some relations are bidirectional; many are not. For +example, `err == nil` may imply success-side value evidence, but using a value +does not necessarily prove `err == nil` unless the relation declares that +reverse implication. + +Validity records when the relation is safe to apply: + +- CFG region, +- dominance/post-dominance requirement, +- location identity requirement, +- alias validity, +- tuple-slot identity, +- function summary boundary, +- effect precondition. + +### Relation Operations + +The relation domain should own these operations: + +```text +Attach(relation, state) +Assume(location predicate, state) +Remap(relation, location mapping) +Project(location, state) +Join(a, b) +Widen(prev, next) +Publish(relation, boundary) +``` + +`Attach` stores a relation after validating participants. +`Assume` applies a branch predicate and derives consequences. +`Remap` preserves a relation through assignment, wrapper forwarding, or tuple +reordering only when identity mapping is explicit. +`Project` answers what a relation proves about a queried location. +`Join` keeps only facts valid on all incoming paths or marks path-conditional +arms explicitly. +`Widen` bounds recursive relation growth. +`Publish` emits only relations that remain meaningful across the boundary. + +Forbidden relation operations: + +- infer relation from return arity alone; +- preserve relation after assignment without location mapping; +- treat a predicate function name as proof outside effect transfer; +- erase relation provenance during join; +- encode relation as a special `typ.Type`. + +### Tuple Relation Law + +The `(value, err)` convention is one tuple relation instance: + +```text +SuccessArm: err is nil -> value is success value +FailureArm: err is non-nil -> value is nil/unknown failure value +``` + +It is not: + +- any two-return function, +- any call followed by `test.is_nil`, +- a special return-summary vector, +- a call-checking hack. + +Custom error records, boolean-success APIs, result objects, and status-code +APIs should be expressible by defining different relation arms over locations. + +### Predicate Relation Law + +Predicate/assertion functions apply relations through effects. + +Examples: + +- `is_nil(x)` proves nil/non-nil branches for `x`; +- `is_string(x)` proves string/non-string branches for `x`; +- `assert_type(x, "string")` refines `x` or terminates; +- `has_field(x, "name")` proves presence for `x.name`; +- custom manifest predicate proves declared relation arms. + +The function name is only a lookup key for an effect summary. The effect summary +is the semantic object. + +Wrong shape: + +```text +if call name == "test.is_nil" then patch value type +``` + +Correct shape: + +```text +call -> effect summary -> relation transfer -> query +``` + +### Effect Shape + +An effect summary should be a structured transition: + +```text +Effect = + EffectID + + Preconditions + + MemoryEffects + + RelationEffects + + ValueEffects + + TerminationEffect + + CallbackEffects + + PublicationPolicy + + Provenance +``` + +Preconditions decide when the effect is valid. +Memory effects mutate locations through `MemoryDomain`. +Relation effects attach or assume relations through `RelationDomain`. +Value effects refine or produce value evidence through `ValueDomain`. +Termination effects update reachability through `TerminationDomain`. +Callback effects describe higher-order execution. +Publication policy decides whether the summary can cross a function/module +boundary. + +### Effect Application Law + +Applying an effect is transfer: + +```text +Call instruction + -> resolve callee/effect summary + -> instantiate summary with actual argument/receiver/return locations + -> validate preconditions + -> apply memory effects + -> apply relation effects + -> apply value effects + -> apply termination effects + -> schedule callback effects if invoked +``` + +Every sub-step calls the owning domain. The effect domain coordinates; it does +not own memory, value, relation, or termination laws. + +### Callback Effect Law + +Callbacks are effectful calls whose callee is a parameter or field. + +Rules: + +- callback invocation has its own call site and locations; +- callback argument evidence flows as call observations; +- callback return/effect evidence flows back only through declared callback + summary; +- captured caller memory can be mutated only through explicit captured location + effects; +- unknown callback effects are not pure unless the effect row is closed. + +This prevents higher-order code from becoming a blind spot or a source of +unsound broadening. + +### Termination Law + +Termination is an effect, not a diagnostic side channel. + +Examples: + +- `error()` terminates the current path; +- assertion failure terminates one branch; +- `return` terminates the current function path; +- infinite loop may terminate analysis reachability differently from runtime + non-return depending on proof. + +Reachability must update before value queries observe post-call state. Otherwise +the checker can report false positives from impossible paths or accept values +from dead branches. + +### Open And Closed Effect Rows + +Effects need the same open/closed discipline as structural types. + +Closed effect row: + +```text +This call has exactly these modeled effects. +``` + +Open effect row: + +```text +This call has at least these effects; unknown effects may remain. +``` + +Rules: + +- no effect summary does not mean pure call; +- closed pure summary can prove no mutation/termination/refinement; +- open summary cannot prove absence of unknown mutation; +- unknown external call must not refine values without a declared effect; +- manifest effects are typed inputs, not hardcoded behavior. + +### Relation/Effect Join And Widen + +Join: + +- keeps relations/effects valid on all joined paths; +- preserves path-conditional arms when the domain represents them explicitly; +- drops or weakens facts whose participant locations are no longer identical; +- never converts absence of relation into proof of independence. + +Widen: + +- bounds recursive relation chains; +- bounds callback/effect expansion; +- bounds recursive captured mutation growth; +- preserves sound top/unknown effects when precision is lost. + +Precision loss here must be visible as domain widening, not hidden in query or +publication. + +### Publication Law + +Publishable relations/effects: + +- function return tuple relation, +- predicate/assertion function relation summary, +- captured memory mutation effect, +- callback invocation effect, +- termination/non-returning effect, +- external manifest effect, +- constructor/receiver mutation effect. + +Non-publishable relations/effects: + +- branch-local guard that does not escape; +- local assertion proof after the checked value dies; +- relation over temporary tuple slots unless remapped to exported locations; +- query-only refinement; +- diagnostic-only proof. + +Publication should remap local locations to boundary locations. If a relation or +effect cannot be remapped, it does not publish. + +### Performance Consequences + +The relation/effect calculus should improve performance by making reuse +structural. + +Expected wins: + +- relation queries index by participant location; +- effect summaries are cached by callee identity and manifest/source version; +- effect instantiation is local and cheap because locations are canonical; +- callback expansion is bounded by summary widening; +- wrapper forwarding remaps relation IDs instead of resynthesizing return + behavior; +- predicate handling uses one transfer path. + +Rejected shapes: + +- scanning all relations for every type query; +- recomputing effect summaries inside every call check; +- using string function names in hot semantic paths; +- replaying captured mutations by rebuilding table types; +- preserving all recursive callback effects without widening; +- clearing false positives by treating unknown effects as pure. + +### Relation And Effect Law Tests + +The flash migration should add focused tests for: + +- tuple relation attaches only from declared summary, not arity; +- tuple relation survives identity wrapper forwarding; +- tuple relation remaps through swapped returns only with explicit mapping; +- predicate effect narrows only declared participants; +- assertion termination removes impossible paths before value query; +- unknown external call does not refine argument; +- closed pure effect proves no mutation; +- open effect row does not prove no mutation; +- callback call observation reaches callback parameter evidence; +- callback unknown effects do not mutate closed state without declaration; +- captured mutation effect preserves operator kind and target location; +- relation join does not invent independence; +- recursive relation/effect widening converges without erasing all useful proof. + +## Function Boundary Summary Calculus + +A function boundary is where local abstract state becomes reusable evidence for +callers. This boundary must have one product-domain object. It should not be +spread across parameter evidence, return summaries, narrow summaries, function +types, captured fields, captured containers, literal signatures, and effect +maps as independent authorities. + +Core rule: + +```text +FunctionSummary = abstraction(QueryView(SolvedState), BoundaryMap) +``` + +The summary is not a second analysis. It is a deterministic abstraction of the +solved state through the function boundary. + +### Boundary Map + +The boundary map explains how local locations become external locations. + +```text +BoundaryMap = + Parameters + + Receiver + + Returns + + Captures + + Exports + + Constructors + + CallbackSlots +``` + +Examples: + +- parameter location maps to parameter slot; +- receiver `self` maps to receiver slot; +- local return tuple slots map to return slots; +- captured upvalue paths map to captured locations; +- module fields map to export locations; +- constructor writes map to constructor instance fields; +- callback parameters map to callback function slots. + +Any summary fact that cannot be expressed through the boundary map is not +publishable. It remains local evidence. + +### Summary Product + +The canonical function summary should be a product: + +```text +FunctionSummary = + SignatureSurface + x ParameterEvidence + x ReturnTupleSummary + x RelationSummary + x EffectSummary + x CaptureSummary + x ConstructorSummary + x ExportSummary +``` + +`SignatureSurface` is the user-facing callable type projection. It is derived +from the product. It is not the stored authority. + +`ParameterEvidence` records annotations, body obligations, call observations, +soft evidence, contextual literal evidence, and recursive widening state. + +`ReturnTupleSummary` records explicit arity, nil padding, unknown slots, any +slots, multivalue expansion policy, and per-slot provenance. + +`RelationSummary` records tuple/path relations that survive the boundary map. + +`EffectSummary` records memory, relation, value, termination, and callback +effects that callers must apply through transfer. + +`CaptureSummary` records captured value/path/mutation evidence that escaped the +function body. + +`ConstructorSummary` records constructor field facts only when construction +semantics prove them. + +`ExportSummary` records module-visible fields and functions. + +### Parameter Summary Law + +Parameters have several evidence sources, but one domain. + +Evidence sources: + +- explicit parameter annotation, +- manifest/API contract, +- body obligation, +- call observation, +- function literal expected type, +- soft annotation, +- recursive SCC seed, +- interproc snapshot. + +Merge policy: + +- explicit contracts define the checked surface; +- body obligations can infer required structure; +- call observations are weak evidence and cannot create a hard contract alone; +- soft evidence refines only when compatible proof exists; +- recursive evidence widens only at SCC/interproc boundaries; +- optionality and nilability are separate axes; +- `any` remains dynamic top unless explicit cast/contract changes the question; +- absence of parameter evidence is unresolved, not `any`. + +Wrong shape: + +```text +ParamHints merge differently from FunctionFacts.Params +``` + +Correct shape: + +```text +ParameterEvidenceDomain.Join(existing, candidate) +``` + +### Return Summary Law + +Returns are tuples with attached relations and effects. + +Rules: + +- arity is explicit; +- nil padding is explicit; +- zero returns differ from one nil return; +- unknown return evidence is not bottom; +- any return evidence remains dynamic top; +- recursive return growth widens at the return domain boundary; +- narrow/success returns are derived views over tuple relation state; +- wrapper forwarding preserves return relations only through explicit location + remapping; +- vararg return expansion has a distinct summary policy. + +Wrong shape: + +```text +ReturnSummaries and NarrowReturns are stored as separate truths +``` + +Correct shape: + +```text +ReturnTupleSummary + RelationSummary -> projected narrow/success view +``` + +### Function Type Projection Law + +A function type is a projection, not an authority. + +Projection: + +```text +FunctionType = + params(ParameterEvidence) + + returns(ReturnTupleSummary) + + effects(EffectSummary) + + relation metadata if the surface type can carry it +``` + +Rules: + +- projection is deterministic and cacheable; +- projection does not write facts; +- projection does not reconcile legacy channels; +- projection must be invalidated by changes to the canonical summary product; +- two projections of the same summary must be equal. + +This removes the need for bridge shapes such as "function types from facts" as a +semantic layer. A projection function may exist as a read-only view, but it is +not a merge or fallback path. + +### Capture Summary Law + +Captures are memory/effect facts remapped through lexical ownership. + +Publishable capture facts: + +- captured variable value evidence; +- captured field write; +- captured nil overwrite/deletion when modeled; +- captured table/container mutation; +- captured relation over exported/captured locations; +- captured callback effect. + +Rules: + +- captured paths use canonical locations with lexical owner identity; +- mutation operator kind is preserved; +- dominance/escape controls whether the mutation publishes; +- parent-derived table shape cannot overwrite child captured mutation; +- captured facts are applied by transfer in the receiving context, not by + rebuilding parent table types. + +### Constructor And Export Summary Law + +Constructor and export facts are boundary memory facts. + +Rules: + +- constructor fields are published only from construction evidence; +- module export fields are published only from export locations; +- local helper facts do not publish just because the name is visible; +- exported functions publish their function summary product; +- imports read snapshots and apply summaries through transfer/query, not through + local special cases. + +### Call Application Law + +Calling a function applies its summary to actual locations. + +```text +CallSite + + FunctionSummary + + ActualArgumentLocations + + ReturnDestinationLocations + -> Transfer over AbstractState +``` + +Application steps: + +1. check actuals against projected parameter contracts; +2. record call observations as weak parameter evidence; +3. instantiate effect summary over actual locations; +4. instantiate relation summary over return and argument locations; +5. bind return tuple summary to destination locations; +6. update termination/reachability; +7. publish caller-side deltas only after the caller solves. + +Forbidden: + +- expected parameter type rewrites actual evidence; +- callee summary mutates interproc store during call checking; +- caller synthesizes a new callee summary from local expectations; +- return arity heuristic creates relation summary; +- call application bypasses transfer. + +### Summary Join And Widen + +Function summaries combine through their domains. + +Join: + +- combines independent observations within one iteration; +- preserves provenance and authority; +- keeps tuple arity explicit; +- joins relations/effects only when participant remapping is compatible; +- avoids rebuilding equivalent maps or slices on no-op joins. + +Widen: + +- applies at local function SCC and interproc boundaries; +- bounds recursive parameter, return, capture, relation, and effect growth; +- preserves sound unknown/any distinction; +- emits precision-loss evidence for diagnostics/profiling; +- never hides convergence by equality-time normalization. + +Leq/Equal: + +- compare canonical summary state only; +- do not rebuild projections; +- do not normalize as repair; +- are the basis for fixpoint convergence and snapshot invalidation. + +### Summary Storage Law + +The stored authority should be one canonical product. + +Allowed stored authority: + +```text +FunctionSummary product +``` + +Allowed derived views: + +- callable `typ.Function` surface; +- display signature; +- backward-compatible API response if needed outside production semantics; +- narrow/success return projection; +- parameter hint projection for UI/debugging. + +Forbidden stored authority: + +- parameter evidence as separate merge truth; +- return summaries as separate merge truth; +- narrow returns as separate merge truth; +- function type cache as separate merge truth; +- captured mutation helper summaries with custom merge; +- legacy compatibility view written back into facts. + +The final flash migration should delete duplicate stored channels in the same +change that introduces the canonical product. + +### Performance Consequences + +The boundary summary calculus should make interproc faster because summaries +become smaller and more stable. + +Expected wins: + +- one summary hash/equality path instead of multiple channel comparisons; +- no function-type projection during convergence unless a caller asks for it; +- no return narrow projection during convergence unless a query asks for it; +- no-op joins can reuse previous summary components; +- snapshot inputs update only changed canonical summaries; +- wrapper forwarding remaps summaries instead of resynthesizing them; +- parameter-use graph summaries feed parameter evidence without AST rescans. + +Rejected shapes: + +- rebuilding all derived views on every merge; +- writing projections back into canonical facts; +- comparing function summaries by formatting types; +- widening by dropping entire summary families; +- adding iteration caps instead of domain widening; +- clearing caches manually to repair stale summary dependencies. + +### Function Boundary Law Tests + +The flash migration should add focused tests for: + +- function type projection is deterministic from the same summary; +- parameter body obligation outranks call observation; +- call observation alone does not prove concrete callee contract; +- explicit `any` parameter does not become concrete from calls; +- zero returns differ from one nil return; +- unknown return survives merge with concrete return when unresolved; +- narrow/success return is derived from relation summary; +- wrapper forwarding preserves relation through explicit remap; +- captured field write and captured container mutation use same memory law; +- constructor field publishes only from constructor evidence; +- export summary does not include non-escaping locals; +- no-op summary join preserves equality and avoids snapshot rewrite; +- recursive function summary widens and converges without erasing all relation + proof. + +### Delta + +A `Delta` is a completed analysis contribution to another scope or iteration. + +Examples: + +- function fact delta, +- parameter evidence delta, +- captured mutation delta, +- constructor field delta, +- relation summary delta. + +Deltas are immutable. The store never lets a producer mutate canonical state in +place. + +### Snapshot + +A `Snapshot` is the immutable state observed by a query. + +Snapshots are cache inputs. If a snapshot changes, dependent queries must +revalidate through Salsa or an explicitly documented cache invalidation rule. + +## Canonical Dataflow Contract + +The final dataflow should have explicit boundary objects. + +```text +Source + -> GraphBundle + -> CheckerIR + -> TransferProgram + -> AbstractState + -> QueryView + -> FunctionResult + -> InterprocDelta + -> FactsDomain + -> SnapshotInputs +``` + +### GraphBundle + +Owns: + +- AST function body, +- CFG, +- symbol table, +- parent scope identity, +- dominance/post-dominance indexes, +- local function indexes, +- parameter-use summaries. + +It is immutable after construction. Anything expensive and graph-derived should +be cached here or through a Salsa query keyed by graph identity. + +### CheckerIR + +Owns the normalized checker program: + +- declarations, +- assignments, +- branch predicates, +- calls, +- returns, +- table constructors, +- field/index writes, +- mutation effects, +- termination effects. + +It should be AST-free except for source spans and stable graph references. This +is where the checker stops being syntax-driven and becomes analysis-driven. + +### TransferProgram + +Owns executable transfer instructions over `AbstractState`. + +Examples: + +```text +Assign(Location, ValueExpr) +Assume(Condition) +Mutate(Mutation) +Call(CallSite) +Return(ReturnTuple) +Terminate(Reason) +``` + +Every statement-level fact should enter the solver through an instruction like +this. A table insert, captured mutation replay, field assignment, and dynamic +index write should not each invent their own path rules. + +### AbstractState + +Owns the whole product: + +```text +AbstractState = + MemoryState + x ValueFacts + x NumericFacts + x ShapeFacts + x RelationFacts + x EffectFacts + x TerminationFacts +``` + +This must be the persistent state of the intraprocedural solver. Query-time +`ProductDomain` construction should be replaced by reading this product, or by +creating a cheap view over it. The state product is the source of truth. + +### QueryView + +Owns read-only answers: + +- type at point, +- narrowed path type, +- field/index presence, +- tuple relation at call site, +- constant/numeric facts, +- reachability. + +It cannot write facts. It cannot perform fresh synthesis that changes the +answer independently from `AbstractState`. + +### InterprocDelta + +Owns facts emitted by a completed function analysis: + +- function fact, +- parameter evidence, +- literal signatures, +- captured field mutations, +- captured container/table mutations, +- constructor fields, +- relation summaries. + +The delta is immutable. The store combines it through `FactsDomain` only. + +## Evidence Lifecycle + +Every fact in the checker should have a visible lifecycle: + +```text +Observed -> Located -> Qualified -> Transferred -> Joined -> Widened -> Queried -> Published +``` + +### Observed + +Evidence starts from one of a small number of sources: + +- source annotation, +- literal syntax, +- assignment, +- guard/predicate/assertion, +- call argument, +- call return, +- effect spec, +- table/container mutation, +- imported manifest, +- previous interproc snapshot. + +Observation records provenance. It does not decide final authority. + +### Located + +Every observation must attach to a location: + +- symbol, +- field path, +- index path, +- tuple slot, +- function graph, +- parent scope, +- call site, +- return site. + +Location must be canonical before the evidence enters transfer. This prevents +one helper using AST paths while another uses SSA path keys. + +### Qualified + +The evidence is tagged with its authority: + +```text +explicit annotation > hard proof > body obligation > call observation > +soft annotation > unresolved evidence +``` + +`any` is not "very strong evidence." It is dynamic top. `unknown` is not "safe +to ignore." It is unresolved evidence. These two facts must remain distinct in +every domain. + +The authority order is partial, not a simple global priority. For example: + +- explicit annotation dominates inferred shape for assignment checking; +- hard branch proof dominates soft annotation for narrowing; +- body obligation dominates call observation for parameter contracts; +- explicit `any` remains dynamic top and does not become concrete because a + later call expects concrete; +- unresolved `unknown` can be refined by proof, but cannot be silently replaced + by unrelated precision. + +This should become an explicit `EvidenceOrder`, not a set of local `if` +statements. + +### Transferred + +Transfer applies evidence to the current `AbstractState`. + +Examples: + +- assignment writes memory/value facts, +- guard writes relation and shape facts, +- call writes return tuple and effect facts, +- table insert writes a mutation fact, +- error-return check reads a tuple relation and narrows linked slots. + +Transfer does not call interproc merge functions. Transfer does not widen. + +### Joined + +Control-flow joins combine same-phase predecessor states through domain `Join`. +This is where branch evidence meets. + +Branch joins must preserve runtime alternatives. For Lua, `x or y` and `x and y` +return actual operand values, so the value domain cannot prune a live branch just +because the other branch is more precise. + +### Widened + +Widening is allowed only at named recursive boundaries: + +- loop fixpoint, +- local function SCC, +- interprocedural fixpoint, +- recursive type/shape growth boundary. + +Widening must be visible in code. If a helper "prefers" one side to force +stability, it is a widening rule and belongs to the domain that owns that +cycle. + +### Queried + +Queries produce read-only views: + +- type at point, +- narrowed path, +- field/index evidence, +- relation state, +- effect summary. + +Queries cannot publish facts. If a query has to synthesize new evidence to +answer correctly, that evidence belongs in transfer or in a cached derived input +computed before solving. + +### Published + +Only completed function analysis publishes interproc deltas. Publication is a +data move: + +```text +FunctionResult -> InterprocDelta -> FactsDomain.Join/Widen -> SnapshotInputs +``` + +Publication is not another inference pass. + +## Required Domain API Shape + +Every domain should expose the same conceptual operations even if Go uses +concrete types instead of generics everywhere. + +```go +type Domain[T any] interface { + Normalize(T) T + Leq(a, b T) bool + Join(a, b T) T + Meet(a, b T) T + Widen(prev, next T) T +} +``` + +Transfer is separate: + +```go +type Transfer[I any, S any] interface { + Apply(input I, state S) S +} +``` + +Query is separate: + +```go +type Query[S any, Q any, A any] interface { + Answer(state S, question Q) A +} +``` + +This separation is important: + +- `Join` and `Widen` do not inspect AST. +- `Transfer` does not know interproc storage. +- `Query` does not mutate state. +- `Normalize` is explicit and not hidden in equality. + +## Domain Invariant Ledger + +Each domain needs invariants that can be tested independently from the full +checker. These are the invariants that should guide the flash migration. + +### Value Domain Invariants + +- `unknown` means unresolved evidence and must not be silently dropped at + return, branch, table, or relation joins. +- `any` means dynamic top and must not satisfy concrete contracts without an + explicit proof, guard, schema, or cast. +- `nil` is a Lua value; absent field is structural absence; optional field is a + type-level allowance for absence/nil depending on context. +- soft evidence is lower authority than hard evidence, but `nil` alone does not + erase a soft structured shape. +- open row-tail field access produces row-tail evidence; closed missing field + does not. +- table top absorbs table-like precision only in domains where table-likeness is + the intended upper bound, not as a general precision eraser. + +### Memory Domain Invariants + +- every fact has exactly one canonical location; +- child-path facts outrank parent-derived fallback evidence for the same path; +- alias replay preserves identity and dominance; +- mutation replay preserves operator kind; +- nil overwrite and field deletion are represented explicitly; +- branch-local mutation does not leak unless control-flow dominance proves it. + +### Relation Domain Invariants + +- tuple/path relations are first-class facts; +- relation facts survive assignment, wrapper forwarding, and module export only + when slot/path identity is preserved; +- relation narrowing is bidirectional only when the relation declares it; +- a guard helper such as `is_nil` can apply a relation but cannot invent one. + +### Effect Domain Invariants + +- effects are summaries, not post-hoc type rewrites; +- effect application goes through transfer; +- captured effects preserve location, operator kind, and provenance; +- termination effects affect reachability before value queries; +- external contract effects are typed inputs, not hardcoded checker behavior. + +### Parameter Evidence Invariants + +- call observations are weaker than body obligations; +- body obligations are inferred only from actual body demand; +- source annotations remain authoritative; +- soft annotations can refine but not override hard proof; +- recursive parameter evidence widens at SCC/interproc boundaries only; +- function-fact params and parameter evidence use the same evidence order. + +### Return Summary Invariants + +- tuple arity is explicit; +- nil padding is explicit; +- unknown return evidence is not bottom; +- relation summaries travel with tuple summaries; +- recursive container growth has one widening policy; +- narrow summary is derived from solved flow facts, not a second stored truth. + +### Interproc Facts Invariants + +- producers emit immutable deltas; +- store merge uses `FactsDomain.Join`; +- fixpoint boundary uses `FactsDomain.Widen`; +- equality compares canonical state only; +- derived views are not write targets; +- snapshot inputs mirror canonical read state exactly. + +## Domain Interaction Protocol + +The product-domain design is only useful if packages interact through a small +set of verbs. These verbs are the mental model for the future implementation. + +```text +Syntax/Graph -> Instruction -> Transfer -> Domain operation -> AbstractState +AbstractState -> Query -> Answer +FunctionResult -> InterprocDelta -> FactsDomain -> Snapshot +``` + +### Transfer + +Transfer applies one semantic instruction to one abstract state. + +Allowed: + +- read the instruction payload, +- ask a domain for local semantic operations, +- produce a new abstract state. + +Forbidden: + +- scan unrelated AST, +- publish interproc facts, +- mutate Salsa inputs, +- call compatibility projections, +- repair old facts. + +If a transfer needs a type meaning question, it asks `ValueDomain`. If it needs +a location question, it asks `MemoryDomain`. If it needs a correlation question, +it asks `RelationDomain`. It does not inline those laws. + +### Domain Operation + +A domain operation defines what a fact means and how it combines. + +Allowed: + +- normalize owned values, +- compare owned values, +- join owned values, +- meet or refine owned values, +- widen owned values, +- answer pure owned-domain predicates. + +Forbidden: + +- depend on source syntax, +- depend on checker phase order, +- allocate hidden facts in another domain, +- read mutable store state, +- perform query invalidation. + +Domain operations must be deterministic and law-testable without constructing a +whole checker. + +### Abstract State + +`AbstractState` is the one mutable semantic product during analysis. + +Allowed: + +- hold domain components, +- combine components through their domains, +- expose read-only query views after solving. + +Forbidden: + +- keep shadow facts that duplicate domain-owned facts, +- hide a second mini solver, +- let equality normalize, +- let queries write analysis evidence. + +### Query + +Queries answer questions against solved state. + +Allowed: + +- read state, +- memoize performance-only answers keyed by immutable input, +- project final user-facing answers. + +Forbidden: + +- create new evidence, +- change convergence, +- backfill facts into the store, +- call `Join` or `Widen`. + +If a query discovers that useful information is missing, the correct response is +to add a transfer/effect/domain fact that produces it before solving. The query +must not become a hidden analysis phase. + +### Publication + +Publication converts a solved function result into an immutable interproc delta. + +Allowed: + +- summarize returns, +- summarize parameter obligations, +- summarize captured effects, +- summarize relations, +- emit deltas. + +Forbidden: + +- merge deltas directly, +- reconcile legacy channels, +- mutate existing facts, +- apply caller-specific preferences. + +The only writer of canonical interproc state is `FactsDomain`. + +### Snapshot + +Snapshotting wires canonical facts into Salsa inputs. + +Allowed: + +- copy canonical facts into inputs, +- invalidate dependent queries through Salsa dependency tracking. + +Forbidden: + +- normalize, +- widen, +- infer, +- drop fields for compatibility, +- reconstruct projections that were not canonical facts. + +Snapshotting is cache plumbing. It is not part of type semantics. + +## Layering And Import Rules + +The final code should make illegal designs difficult to express. Package +dependencies should encode the semantic architecture. + +### Domain Packages + +Domain packages may import: + +- low-level type structures, +- subtype/query primitives, +- small immutable domain-local helper packages. + +Domain packages must not import: + +- AST packages, +- flow builders, +- checker store, +- Salsa database handles, +- diagnostics emitters, +- compatibility view builders. + +Reason: a domain is a pure algebra over facts. If it can see syntax or mutable +store state, local helper logic will grow back. + +### Memory And Location Packages + +Memory/location packages may import: + +- symbol/location identity, +- type values needed to represent field and container facts, +- relation keys where tuple/path identity must be preserved. + +They must not import: + +- call checking, +- interproc store, +- return inference, +- diagnostics formatting. + +Reason: every producer must use the same path identity rules. No producer should +construct its own equivalent of "field path", "tuple slot", or "receiver self". + +### Transfer Packages + +Transfer packages may import: + +- normalized checker IR, +- abstract state, +- domain set, +- memory/location model. + +They must not import: + +- old fact bridges, +- compatibility projections, +- checker diagnostics as control flow, +- global interproc store mutation. + +Reason: transfer is the executable abstract semantics for one instruction. It +can create deltas inside state, but publication happens later. + +### Store And Pipeline Packages + +Store/pipeline packages may import: + +- domain interfaces, +- abstract interpreter engine, +- Salsa database handles, +- diagnostics/reporting. + +They must not implement: + +- truthiness laws, +- soft/hard evidence ordering, +- return tuple relation semantics, +- path dominance rules, +- recursive type widening. + +Reason: orchestration controls when analysis runs. Domains control what analysis +means. + +### Query Packages + +Query packages may import: + +- read-only solved state, +- Salsa query APIs, +- pure domain predicates used for answering. + +They must not import: + +- mutable transfer state, +- publication writers, +- domain normalization writers. + +Reason: a query can be cached aggressively only when it is pure. + +### Test Packages + +Tests should mirror these boundaries: + +- domain law tests construct only domain values, +- transfer tests build small IR fragments and inspect abstract state, +- solver tests check convergence and widening, +- replay tests validate production programs, +- negative tests prove that convenience broadening did not happen. + +Tests that require a whole checker to prove a simple domain law are a signal +that the domain boundary is still too implicit. + +## Dataflow Walkthroughs + +### Guarded Field To Call Argument + +Pattern: + +```lua +if options.model then + provider.open(options.model) +end +``` + +Correct dataflow: + +1. `options.model` is observed as a field read. +2. The guard transfers a truthy relation for `Location(options, "model")`. +3. The call argument query reads that relation and answers `NonNil(modelType)`. +4. Parameter evidence records a call observation for the callee. +5. If the callee body requires `string`, body obligation and call observation + combine in `ParameterEvidenceDomain`. + +Wrong shape: + +- special-case `options.model` in call checking, +- make all truthy fields strings, +- accept `any` as string. + +### Table Insert To Later Iteration + +Pattern: + +```lua +table.insert(state.items, value) +for _, item in ipairs(state.items) do ... end +``` + +Correct dataflow: + +1. `state.items` resolves to one memory location. +2. `table.insert` transfers a `MutationTableElement` to that location. +3. Memory join preserves the element fact at the exact child path. +4. `ipairs` queries the array element evidence from memory. + +Wrong shape: + +- replay captured table insert through generic container mutation, +- let parent table literal shape override explicit child-path evidence, +- infer element type from the loop variable without memory provenance. + +### Error Return Correlation + +Pattern: + +```lua +local value, err = f() +test.is_nil(err) +value.field +``` + +Correct dataflow: + +1. `f()` returns a tuple with a relation summary. +2. Assignment binds tuple slots to locations. +3. `test.is_nil(err)` transfers a relation constraint on the error slot. +4. Relation query narrows the linked value slot. +5. Field access reads the narrowed value slot. + +Wrong shape: + +- hardcode `test.is_nil` as a value-slot refinement, +- assume every two-return function is `(value, err)`, +- drop tuple relation when a wrapper forwards returns. + +### Unknown External Payload + +Pattern: + +```lua +local payload = json.decode(raw) +needs_string(payload.name) +``` + +Correct dataflow: + +1. `json.decode` returns dynamic/unresolved data. +2. `payload.name` is unresolved or `any` depending on API contract. +3. Passing it to `string` must fail unless a guard, schema, cast, or contract + proves it. + +Wrong shape: + +- treat unknown external fields as strings because most callers expect strings, +- let table shape contextualization silently rewrite explicit `any`, +- clear global lint by broadening assignability. + +## Inference Model + +Inference is not a separate magical subsystem. It is the process of solving for +unknown slots in the product domain under the evidence produced by transfer. + +The final model should distinguish these inference layers: + +### Local Value Inference + +Scope: + +- local variables, +- field/index reads, +- table literals, +- expression results, +- branch-local values, +- loop-carried values. + +Authority: + +```text +AbstractState.ValueFacts + MemoryState + RelationFacts +``` + +Rules: + +- local value inference reads declared types, transfer assignments, and + constraints; +- it never writes interprocedural facts directly; +- it must preserve the distinction between `unknown` and `any`; +- table literal contextualization is a transfer/type-domain operation, not a + one-off hook; +- logical `and`/`or` inference must preserve actual Lua branch values. + +### Parameter Inference + +Scope: + +- call-site argument observations, +- body-derived obligations, +- source annotations, +- soft annotations, +- current function facts, +- function literal expectations. + +Authority: + +```text +ParameterEvidenceDomain +``` + +Rules: + +- call-site observations are evidence, not contracts; +- body obligations are contracts only when the function body proves it requires + that shape; +- explicit source annotations dominate inferred hints; +- soft annotations refine only when hard evidence proves the refinement; +- recursive parameter evidence must join/widen through the parameter domain. + +There should be no separate ad hoc policy for "parameter evidence" versus "function +fact params". Both are parameter evidence with different provenance and merge +mode. + +### Return Inference + +Scope: + +- return statements, +- tuple/multivalue expansion, +- nil padding, +- recursive return vectors, +- summary and narrow summary slots, +- wrapper forwarding. + +Authority: + +```text +ReturnSummaryDomain +RelationDomain +``` + +Rules: + +- return arity is part of the tuple domain; +- `unknown` return evidence is unresolved runtime behavior, not bottom; +- recursive return vectors widen only at the SCC/fixpoint boundary; +- relation facts such as `(value, err)` attach to return tuples explicitly; +- wrapper forwarding propagates tuple and relation facts together. + +### Function Type Inference + +Scope: + +- local function literals, +- method receiver `self`, +- higher-order callbacks, +- literal signatures, +- exported functions, +- imported module functions. + +Authority: + +```text +FunctionFactDomain +ParameterEvidenceDomain +ReturnSummaryDomain +RelationDomain +``` + +Rules: + +- function type inference is a product of parameter evidence, return summary, + and relation/effect summaries; +- a same-body function fact may seed analysis only through non-narrowing domain + merge; +- higher-order signatures must use variance-aware merge rules; +- literal signatures are facts in the interproc product, not a second function + authority. + +### Effect Inference + +Scope: + +- built-in and manifest call effects, +- assertion/predicate refinements, +- table and container mutations, +- callback invocation effects, +- termination and non-returning calls, +- return tuple relation attachment, +- captured mutation summaries, +- external contract effects. + +Authority: + +```text +EffectDomain +MemoryDomain +RelationDomain +TerminationDomain +``` + +Rules: + +- an effect is an abstract transfer summary, not a postflow patch; +- applying an effect must produce the same state change as inlining its + corresponding transfer instructions would produce, up to the abstraction; +- effect summaries preserve target locations, tuple slots, operator kind, + dominance, and provenance; +- effects that refine values must emit relation/value constraints through the + owning domains; +- effects that mutate memory must emit memory mutations through the memory + domain; +- effects that terminate execution must update reachability before any value + query observes the post-call state; +- callback effects are higher-order summaries and must be applied at the call + edge that invokes the callback, not at publication time; +- external effects are typed inputs to the domain, not hardcoded names in call + checking. + +Wrong effect inference shapes: + +- "after this call, rewrite argument type" in call checking; +- "after this function, patch captured fields" in interproc merge; +- "if function name is `test.is_nil`, narrow slot" outside relation/effect + transfer; +- "if table mutator is seen later, replay as generic container mutation"; +- "if global harness fails, add a special accepted shape". + +Correct effect inference shape: + +```text +call instruction + -> resolve effect summary + -> EffectDomain.Apply(summary, state) + -> MemoryDomain/RelationDomain/ValueDomain/TerminationDomain operations + -> new AbstractState +``` + +Effect inference must be compositional. A user-defined wrapper around an effect +should publish the same kind of summary that the built-in effect uses, so callers +do not need wrapper-specific logic. + +### Inference Soundness Boundary + +The checker should infer every property that is proven by: + +- source annotations, +- reachable transfer facts, +- memory/path identity, +- relation facts, +- effect summaries, +- interproc summaries, +- declared external contracts. + +The checker must not infer a property from: + +- the type expected by a later failing call, +- most callers preferring a shape, +- `any`, +- absent evidence, +- a compatibility projection, +- a cache hit whose input identity is incomplete. + +This boundary is the core soundness rule: + +```text +Expected type is a constraint to check against evidence. +It is not evidence unless a declared contract explicitly says so. +``` + +Contextual typing is still valid, but it must be represented as evidence with +provenance. For example, a table literal checked against an expected record can +receive contextual field types at the literal boundary. A dynamic payload flowing +through `any` cannot acquire those field types because a callee wanted them. + +## Phase Responsibility Table + +| Phase | May Create | May Combine | May Widen | May Query | Forbidden | +|---|---|---|---|---|---| +| Scope/CFG | graph identity, symbols | no type facts | no | no | type merge policy | +| Extract/IR | transfer instructions | no domain joins | no | declared-only queries | fixpoint repair | +| Flow solve | abstract state updates | domain joins at CFG joins | loop-local widening only if owned by flow domain | internal state reads | interproc fact writes | +| Narrow/query | read-only answers | no persistent joins | no | yes | producing facts | +| Return SCC | local return/param deltas | local domain joins | SCC widening through domain only | solved flow state | AST-specific merge laws | +| Interproc store | immutable deltas | `FactsDomain.Join` | `FactsDomain.Widen` | snapshot reads | producer-specific callbacks | +| Salsa | dependencies/cache | no semantic joins | no | query execution | hidden state mutation | + +## Foundational Diagnosis + +The checker has accumulated strong behavior before it acquired the right +vocabulary. + +Current scattered concepts: + +- value-type joins, +- return-slot joins, +- function-param fact joins, +- parameter-evidence joins, +- table-top absorption, +- soft-placeholder replacement, +- open-record row-tail merging, +- recursive structural-growth cutoffs, +- truthiness refinements, +- error-return tuple correlations, +- captured table/container mutation replay, +- path identity and alias identity, +- body-derived parameter contracts, +- call-site observations, +- signature projection to body use. + +These concepts are real. The problem is not that they exist. The problem is that +they appear as local helpers in `returns`, `paramevidence`, `flow`, `synth`, and +`typ`, with overlapping responsibilities. + +That creates the "guacamole" feeling: behavior is strong, but the mental model +is not visible at the package boundary. + +## Canonical Product Domain + +The final checker should have these explicit products. + +### Value Domain + +Owns pure type operations that are independent of checker phase: + +- `NormalizeType` +- `JoinValue` +- `JoinReturnSlot` +- `Meet` +- `WidenShape` +- `Refines` +- `TruthinessRefinement` +- `Nilability` +- `SoftEvidence` +- open/closed record row-tail policy +- map/array/table-top classification + +Candidate home: + +```text +types/typ/domain +``` + +or, if it needs checker-only evidence policy: + +```text +compiler/check/domain/value +``` + +Rule: domain-level predicates such as "candidate refines baseline by removing a +falsy table key" cannot live in `compiler/check/returns/join.go`. + +### Memory And Path Domain + +Owns the question "what program location does this fact describe?" + +It must unify: + +- `constraint.Path` +- CFG symbol/version identity +- SSA path keys +- field/index segments +- aliases +- dynamic index writes +- table mutator paths +- captured mutation paths +- field overlays + +Candidate home: + +```text +compiler/check/memory +``` + +The public model should be: + +```go +type Location struct { ... } +type MemoryState struct { ... } +type Mutation struct { Kind, Target, Key, Value, Dominance } +``` + +Current scattered path helpers should collapse into this package. The solver +should not need to know whether a fact came from a table literal, field write, +alias replay, or captured mutation to apply the same path-law rules. + +### Flow State Domain + +Owns the persistent state of intraprocedural analysis. + +The final `AbstractState` should be a product: + +- memory facts, +- numeric facts, +- shape/presence facts, +- relation facts, +- termination facts, +- effect facts. + +Candidate home: + +```text +compiler/check/flowstate +``` + +or inside `types/flow` if it remains independent of checker-specific APIs. + +Current weakness: + +`types/flow.ProductDomain` is the closest modern abstraction, but it is mostly +used transiently during narrowing queries. The main solver still stores raw +maps and side caches. That split should disappear. Query-time narrowing should +read from the same abstract state product that transfer functions update. + +### Relation Domain + +Owns facts that connect multiple paths or tuple slots: + +- error-return `(value, err)` correlation, +- sibling return-slot narrowing, +- predicate links, +- assertion links, +- type-test links, +- tuple-slot relation facts, +- custom error records. + +Candidate home: + +```text +compiler/check/domain/relation +``` + +This is where error-return convention should live. It should not be encoded as +scattered checks for exactly two return slots at call sites. The canonical +shape is a relation: + +```go +type TupleRelation struct { + Slots []SlotPredicate +} +``` + +The current `(value, err)` convention is then one predefined relation, not a +special checker behavior. + +### Effect Domain + +Owns facts about what a function or call can do: + +- termination, +- error-return relation attachment, +- path refinements caused by assertions/predicates, +- table/container mutation effects, +- callback effects, +- key-collector effects, +- external contract effects. + +Candidate home: + +```text +compiler/check/domain/effect +``` + +Effect inference must be a normal abstract-interpretation output: + +```text +CallSite + CalleeSummary + AbstractState -> EffectDelta +``` + +The effect delta is then applied by transfer or stored in function facts. It +must not be an after-the-fact patch that rewrites types without going through +the memory/relation/effect domains. + +Effect summaries should be explicit: + +```go +type EffectSummary struct { + Mutations []memory.Mutation + Relations []relation.TupleRelation + Refinements []relation.PathRelation + Terminates TerminationEffect +} +``` + +Current effects such as error-return correlation, captured container mutation, +and key-collector propagation become instances of this summary. + +### Function Fact Domain + +Owns all interprocedural facts about functions. + +The stored authority remains: + +```go +type FunctionFact struct { + Summary []typ.Type + Narrow []typ.Type + Type typ.Type +} +``` + +But its operations should move out of `returns`: + +```text +compiler/check/domain/functionfact +``` + +It owns: + +- same-shape function fact merge, +- param-slot merge, +- return-vector merge delegation, +- effect/spec/refinement merge, +- function fact widening, +- function fact normalization, +- function fact equality. + +The param-slot policy must be a named domain object, not scattered helpers: + +```go +type ParamSlotDomain struct { + Mode MergeMode // precise join or convergence widening +} +``` + +The previous `candidateRefinesFunctionParam`, +`typeRefinesTableKeyByTruthiness`, `preferConcreteOverSoftType`, and related +return-package functions are being collapsed into domain-owned operations. +Parameter-specific pieces now live in `domain/paramevidence`; the remaining +function-fact merge should move to `domain/functionfact`. + +### Return Summary Domain + +Owns return-vector shape and convergence: + +- arity normalization, +- nil-slot handling, +- `unknown` as unresolved runtime behavior, +- stale nil-only regression prevention, +- recursive structural-growth cutoff, +- concrete-over-soft container refinement, +- return-slot row-tail merging, +- function-return widening. + +Candidate home: + +```text +compiler/check/domain/returnsummary +``` + +The existing `returns` package can either become this package or stop owning +non-return policy. + +### Parameter Evidence Domain + +Owns all evidence about parameters: + +- call-site observations, +- body-derived contracts, +- signature facts, +- param-use projection, +- soft annotations, +- table-top absorption, +- nilability splitting, +- map/record joins, +- call graph propagation. + +Candidate home: + +```text +compiler/check/domain/paramevidence +``` + +Current state after the first domain slice: + +- merge/canonicalization policy lives in `compiler/check/domain/paramevidence`; +- return SCC inference and interproc postflow still collect observations, but + they call the domain to merge them; +- remaining work is to separate collection orchestration from the pure domain + surface where it improves readability without adding a bridge. + +Final rule: + +Orchestration may stay in inference packages, but merge/canonicalization policy +belongs to the parameter evidence domain. + +### Interproc Fact Domain + +Owns the whole product: + +```go +type FactsDomain struct { + FunctionFacts FunctionFactDomain + ParamEvidence ParamEvidenceDomain + LiteralSigs LiteralSignatureDomain + Captures CaptureDomain + Constructors ConstructorDomain + Effects EffectDomain +} +``` + +Candidate home: + +```text +compiler/check/domain/interproc +``` + +It exposes only: + +```go +Normalize(facts) +Leq(a, b) +Join(a, b) +Widen(prev, next) +Equal(a, b) +``` + +The store calls this domain. Producers emit deltas. Producers do not call local +helper joins directly. + +## Helper Cluster Ownership + +| Current Cluster | Current Location | Final Owner | +|---|---|---| +| `JoinFacts`, `WidenFacts`, fact equality | `compiler/check/returns` | `domain/interproc` | +| function fact type merge | `compiler/check/returns/join.go` | `domain/functionfact` | +| function param-slot refinement | `domain/paramevidence` plus `domain/value`, called by `returns/join.go`, `widen.go` | `domain/functionfact.ParamSlotDomain` delegating value refinements to `domain/paramevidence`/`domain/value` | +| return-vector merge/repair | `compiler/check/returns/join.go` | `domain/returnsummary` | +| table-top absorption | `domain/paramevidence` | `domain/paramevidence` plus value-domain classifier | +| soft vs concrete evidence | `typ/soft.go`, `domain/value`, return overlay | `domain/value` evidence policy | +| open-record row-tail merge | `types/typ/policy.go` | `domain/value` row-shape policy | +| path/query/alias identity | `constraint`, `flowbuild/path`, `flow/pathkey` | `memory` | +| table/container mutation replay | `nested`, `returns`, `flowbuild`, `flow` | `memory` mutation domain | +| error-return convention | `erreffect`, call/return inference | `domain/relation` | +| effect inference | `effects`, `erreffect`, `flowbuild`, `nested`, `returns` | `domain/effect` | +| body parameter contracts | `infer/return`, `flowbuild/assign` | `domain/paramevidence` | +| Salsa snapshot inputs | `store/snapshot_inputs.go` | keep in store, but document as cache boundary | + +## Worked Consolidation Examples + +### Table-Key Truthiness Refinement + +Previous smell: + +```go +candidateRefinesFunctionParam(candidate, baseline) +typeRefinesTableKeyByTruthiness(candidate, baseline) +recordRefinesTableKeyByTruthiness(candidate, baseline) +``` + +These helpers are trying to express one domain law: + +```text +A table-like parameter fact may refine its key domain by removing falsy key +members only if the table value domain and structural frame are preserved. +``` + +Final home: + +```text +domain/value.Refinement +domain/functionfact.ParamSlotDomain +domain/paramevidence +``` + +Final expression: + +```go +refinement := value.Refinement{ + Kind: value.RefineTruthyKey, + PreserveFrame: true, + PreserveValue: true, +} +paramSlot.Join(existing, candidate, refinement) +``` + +The check is no longer a local function-param helper. It is a value-domain +refinement rule reused by parameter evidence, function facts, and return +summary map-key refinement. + +### Soft Evidence Replacement + +Current smell: + +```go +preferConcreteOverSoftType(a, b) +typ.PruneSoftUnionMembers(t) +reconcileSoftAnnotatedInference(base, inferred) +``` + +These are fragments of one evidence-ordering law: + +```text +hard concrete evidence dominates soft placeholder evidence, but nil alone does +not erase soft structured evidence. +``` + +Final home: + +```text +domain/value.EvidenceOrder +``` + +Final expression: + +```go +EvidenceOrder.Select(existing, candidate) +``` + +Every caller gets the same policy: + +- soft annotation refinement, +- function parameter facts, +- parameter evidence, +- return-summary container refinement, +- flow assignment refinement. + +### Open-Record Row Tail + +Current smell: + +Open-record behavior is split between record join, subtyping, table literal +contextualization, and external-regression fixes. + +Canonical law: + +```text +A missing field on an open record is row-tail evidence, not proof of nil. +A missing field on a closed record is absence. +``` + +Final home: + +```text +domain/value.RowShape +``` + +Final API: + +```go +RowShape.FieldEvidence(record, fieldName) FieldEvidence +``` + +The rest of the checker asks for field evidence. It does not rediscover whether +the record is open, closed, map-like, or table-top. + +### Captured Table Mutation Replay + +Current smell: + +Captured table inserts, generic container mutations, parent replay, direct +flow mutators, and nested function calls have separate paths. + +Canonical law: + +```text +A mutation has one semantic operator and one memory location. Replay is valid +only when alias identity, dominance, and operator kind are preserved. +``` + +Final home: + +```text +compiler/check/memory +``` + +Final expression: + +```go +MemoryState.Apply(Mutation{ + Kind: MutationTableElement, + Target: Location, + Value: Type, + Provenance: CapturedCall, +}) +``` + +The same apply path handles direct `table.insert`, nested captured insert, and +exported callback replay. + +### Error-Return Correlation + +Current smell: + +Several phases know about the `(value, err)` convention, arity checks, and +success/failure narrowing. + +Canonical law: + +```text +Error-return behavior is a tuple relation over return slots, not a special case +of a two-result function. +``` + +Final home: + +```text +domain/relation +``` + +Final expression: + +```go +RelationDomain.Attach(ReturnTupleRelation{ + Success: { ErrSlot: Nil, ValueSlot: NonNilOrUnknown }, + Failure: { ErrSlot: NonNil, ValueSlot: NilOrUnknown }, +}) +``` + +The canonical Lua `(value, err)` convention is one predefined relation. Future +relations do not require new helper clusters. + +## Target Data Flow + +The final flow should be: + +```text +source + -> CFG + symbol graph + -> normalized checker IR + -> abstract transfer over AbstractState + -> queryable solved state + -> function result + -> interproc fact delta + -> FactsDomain.Join or FactsDomain.Widen + -> Salsa input update + -> dependent function-result query revalidation +``` + +Every arrow has one owner. + +No phase should secretly perform another local abstract interpretation unless +that interpretation is a named domain transfer over the same `AbstractState`. + +Preflow, local SCC inference, and return overlay currently exist for good +reasons. The design target is not to delete their semantics. The design target +is to make them clients of the same domain objects instead of separate local +machines. + +## Dataflow State Machine + +The checker should have one visible state machine. + +```text +Unbuilt + -> GraphBuilt + -> IRBuilt + -> Solving + -> Solved + -> Inferred + -> Published + -> Snapshotted +``` + +### Unbuilt -> GraphBuilt + +Input: + +- source AST, +- parent scope, +- manifest environment. + +Output: + +- immutable graph bundle. + +No type-domain merge is allowed here. + +### GraphBuilt -> IRBuilt + +Input: + +- graph bundle, +- declared type environment, +- known effect specs. + +Output: + +- transfer program. + +This stage may observe syntax and produce instructions. It may not decide +fixpoint policy. + +### IRBuilt -> Solving + +Input: + +- transfer program, +- initial abstract state, +- domain set. + +Output: + +- evolving abstract state. + +All state changes go through transfer and domain operations. + +### Solving -> Solved + +Input: + +- worklist convergence, +- loop widening if needed. + +Output: + +- solved abstract state plus query view. + +No interproc publication happens before this state. + +### Solved -> Inferred + +Input: + +- query view, +- function body, +- relation/effect summaries. + +Output: + +- function result and interproc delta. + +Inference reads solved state. It does not create another path-sensitive solver. + +### Inferred -> Published + +Input: + +- immutable interproc delta. + +Output: + +- canonical fact product after join or widening. + +Only `FactsDomain` may combine this data. + +### Published -> Snapshotted + +Input: + +- canonical fact product. + +Output: + +- Salsa snapshot inputs and dependent query invalidation. + +No semantic repair is allowed here. Snapshotting is cache wiring only. + +## Nested Fixed-Point Model + +The final checker has several fixed points, but they should all use the same +domain vocabulary. The existence of multiple schedules does not justify +multiple semantic models. + +### Level 0: Pure Graph Summaries + +Graph summaries are not fixpoints over types. They are immutable facts about +syntax and binding: + +- parameter uses, +- return sites, +- local function edges, +- call sites, +- mutator sites, +- captured path mentions, +- normalized transfer instructions. + +They can be cached by graph identity because they do not read interproc facts or +solved flow state. + +### Level 1: Intraprocedural CFG Fixpoint + +The local solver computes: + +```text +CFG x TransferProgram x InitialState -> SolvedState +``` + +Convergence boundary: + +- CFG joins use `AbstractState.Join`; +- loops use the relevant domain widen only when a loop-carried component grows + past the domain's finite-height fragment; +- dead/unreachable paths update termination/reachability before value queries. + +Forbidden: + +- AST rescans during solve, +- producer-specific joins, +- query-time narrowing that writes state, +- loop-specific precision hacks outside domain widening. + +### Level 2: Local Function SCC Fixpoint + +Local functions inside a graph can be mutually recursive. The final model should +treat their summaries as another domain product: + +```text +FunctionSummary = + Parameters + x Returns + x Relations + x Effects + x Captures +``` + +Convergence boundary: + +- recursive calls read the current SCC summary through the function fact domain; +- each function body emits a new summary delta; +- SCC join/widen uses the same parameter, return, relation, effect, capture, + and memory domains used elsewhere; +- when the SCC stabilizes, the solved summaries become ordinary evidence for + the enclosing function analysis. + +This replaces "return overlay", "preflow synthesis", and "local function +snapshot repair" as separate semantic concepts. Those may remain as scheduling +or performance techniques, but not as separate laws. + +### Level 3: Interprocedural Fixpoint + +The outer fixpoint computes canonical facts across function/module boundaries: + +```text +InterprocPrev + all FunctionResult deltas -> InterprocNext +InterprocPrev' = FactsDomain.Widen(InterprocPrev, InterprocNext) +``` + +Convergence boundary: + +- producers emit immutable deltas; +- `FactsDomain` is the only merge/widen authority; +- no producer reads its own just-emitted delta except through the declared + current-iteration overlay contract; +- equality checks canonical state only; +- snapshot inputs are updated only after the canonical product changes. + +Iteration caps are diagnostics, not semantics. If convergence requires raising a +cap for normal programs, the relevant `Widen` is missing or too precise. + +### Level 4: Incremental Revalidation + +Salsa does not define type semantics. It revalidates query results after inputs +change. + +Required dependency shape: + +```text +FuncResultQ + reads GraphSummaryQ + reads Manifest/Input queries + reads SnapshotInputs + reads TypeQuery caches + computes local fixed points + publishes deltas +``` + +When a snapshot input is unchanged, dependent results should revalidate without +re-solving. When a graph summary is unchanged, function queries should not rescan +the AST to rediscover it. When a type-query cache hits, it should only avoid +structural recomputation; it must not mask missing checker dependencies. + +### Fixed-Point Proof Obligations + +Each level needs a proof surface: + +- finite input identity, +- monotone transfer or documented approximation, +- explicit join/widen boundary, +- stable equality without repair, +- deterministic publication, +- cache invalidation by immutable dependency. + +Performance and soundness meet at these obligations. A cache that is missing a +dependency is unsound. A widen that erases too much precision causes false +positives. A join that keeps rebuilding equivalent maps causes unnecessary +invalidations. + +## Salsa And Cache Model + +Current good shape: + +- function-result keys are stable graph/parent identities, +- interproc snapshots are `db.Input`s, +- updating facts bumps dependent queries through the database, +- core type queries are Salsa-style pure queries. + +Current weak shape: + +- the checker still has several non-Salsa local caches with implicit lifetimes, +- flow solution caches are manually invalidated, +- some expensive shape scans are repeated because domain operations are not + centralized, +- param-use projection can rescan AST bodies instead of reading a graph-indexed + use summary. + +Canonical Salsa wiring: + +```text +db.Input[ManifestKey] -> module/type environment queries +db.Input[GraphKey] -> graph-derived summaries +db.Input[InterprocGraphKey] -> function-result queries +db.Input[SymbolKey] -> constructor/refinement/effect summaries + +FuncResultQuery(GraphID, ParentHash) + reads graph bundle + reads interproc snapshot inputs + builds transfer program + solves abstract state + publishes immutable result +``` + +The query key is stable identity. The dependency edges come from the exact +inputs read during analysis. There should be no artificial revision number in +the function key and no manual cache clearing for correctness. + +Final cache contracts: + +1. Source inputs are `db.Input`s: + - manifests, + - parent scope, + - CFG identity, + - interproc facts, + - constructor fields, + - function refinements. +2. Pure expensive computations are `db.Query`s: + - core type lookup/index/method/operator queries, + - function result, + - parameter-use summary by graph/function, + - shape classification for large recursive types if profiling confirms it. +3. Intraprocedural flow state remains per-function and ephemeral unless it is + keyed by the exact immutable input bundle. Do not put hot per-edge transfer + into Salsa if dependency recording costs more than recomputation. +4. Domain operations must be pure and deterministic so they can be memoized + safely when profiling justifies it. +5. Cache lifetime must be explicit in package docs. No cache should depend on + call order for correctness. + +Performance target: + +- fewer repeated shape scans, +- fewer temporary maps in hot merges, +- copy-on-write vectors and maps, +- immutable fact snapshots, +- stable interning/hash-consing where already available, +- no object pools until ownership is proved and structural wins are exhausted. + +### Cache Placement Decision Model + +Use Salsa when: + +- the computation is pure, +- the inputs are immutable identities, +- dependency tracking can precisely invalidate dependent queries, +- the result is reused across functions, modules, or fixpoint iterations, +- recomputation is more expensive than dependency tracking. + +Use a per-function local cache when: + +- the computation is hot inside one solve, +- the cache key is a small local identity, +- the result is invalid after the current function solve, +- Salsa dependency tracking would be more expensive than recomputation. + +Use no cache when: + +- the operation is a cheap domain primitive, +- the input is already interned, +- the allocation is caused by poor ownership rather than repeated work, +- correctness would require observing mutable phase order. + +Do not use a pool until: + +- the allocation site remains hot after domain consolidation, +- ownership of each pooled object is single-phase and obvious, +- tests prove no retained result can observe a reused object, +- profiling shows the pool wins after synchronization and clearing costs. + +The main expected Salsa gains are: + +- graph-indexed parameter-use summaries instead of AST rescans, +- function-result queries keyed by graph and parent scope, +- pure type/operator queries, +- shape classification for large recursive types if profiling proves reuse, +- canonical interproc snapshots as inputs instead of manually invalidated maps. + +The main non-Salsa gains are: + +- domain operations that avoid rebuilding maps for no-op joins, +- path/location interning, +- copy-on-write fact vectors, +- removing compatibility projections from hot publication paths, +- making equality structural instead of repair-driven. + +### Concrete Salsa Wiring Plan + +The final design should classify every current cache and summary producer before +implementation. The goal is not "put everything in Salsa". The goal is exact +incremental boundaries and no hidden semantic cache. + +| Current Component | Final Role | Cache Kind | Owner | +|---|---|---|---| +| `api.FuncKey{GraphID, ParentHash}` | function analysis identity | Salsa query key | pipeline/analysis engine | +| `FuncResultQ` | analyze one function under one parent scope | Salsa query | analysis engine | +| `snapshotInputs.facts` | canonical interproc fact snapshot | Salsa input | store/facts domain boundary | +| `snapshotInputs.refinements` | function refinement/effect snapshot | Salsa input | effect/refinement boundary | +| `snapshotInputs.constructorFields` | constructor field snapshot | Salsa input | memory/constructor boundary | +| `types/query/core.Engine` | pure type operations | query engine cache | type-query layer | +| `types/flow.ProductDomain` | branch-local narrowing algebra | ephemeral domain state | abstract state / flow domain | +| `paramevidence.collectParamUses` | body-demand summary | graph-derived Salsa query | graph summary layer | +| `ProjectHintsToParamUse` | parameter evidence projection | domain operation over cached body summary | parameter domain | +| `PreCache` / `NarrowCache` | repeated expression synthesis inside one solve | per-function local cache | transfer/query phase | +| `FunctionTypeCache` | local function specialization during one solve | per-function local cache unless key is immutable | function analysis | +| `StableFunctionSnapshot` | read canonical function fact snapshot | Salsa query/input read, not ad hoc map | function fact domain | +| flow solution narrow caches | repeated solved-state query | solved-state local cache | query view | +| path suffix/root caches | identity interning | local/global intern cache if immutable | memory/location layer | + +This table is a migration contract. If a component does not appear here or in a +successor table before coding, adding a cache for it should be rejected. + +### Query Dependency Contract + +`FuncResultQ` must read all semantic dependencies through tracked inputs or +tracked pure queries. + +Required reads: + +- graph bundle by `GraphID`, +- parent scope by `ParentHash`, +- canonical interproc facts by `GraphKey`, +- function refinements/effects by owning symbol key, +- constructor fields by owning symbol key, +- manifest/module environment through manifest inputs, +- graph-derived body summaries through graph summary queries, +- pure type operations through the type-query layer. + +Forbidden reads: + +- mutable `InterprocPrev` maps without snapshot input tracking, +- current-iteration `InterprocNext` except through the canonical overlay input + contract, +- ad hoc stable snapshot maps inside synthesis, +- source AST rescans for reusable graph summaries, +- global variables whose mutation does not bump a tracked input. + +When a function reads a fact for a graph or symbol, the query database must know +that dependency. When the fact does not change semantically, the input should not +be rewritten. This gives both correctness and performance: no stale result, no +unnecessary invalidation. + +### Snapshot Update Protocol + +The store should be the only bridge from fixpoint state to Salsa inputs. + +```text +producer emits InterprocDelta + -> FactsDomain.Join/Widen into InterprocNext + -> iteration boundary computes canonical InterprocPrev + -> compare canonical old/new with structural equality + -> set only changed snapshot inputs + -> Salsa revalidates dependent FuncResultQ entries +``` + +Required properties: + +- `setFacts` receives canonical facts only; +- equality is structural and does not normalize; +- empty facts are represented explicitly enough to clear stale inputs; +- per-symbol inputs are used only for facts whose key is truly symbol-local; +- parent-scoped facts use `GraphKey` or `SymbolKey`, not raw `SymbolID`; +- current-iteration overlay is either part of the canonical input contract or is + not visible to `FuncResultQ`. + +This avoids manual cache clearing as a correctness mechanism. Clearing may still +exist as a memory-pressure tool, but a correct result must not depend on it. + +### Graph Summary Queries + +Several expensive operations are currently repeated because syntax-derived +summaries are computed by the consumer. These should become graph summary +queries. + +Recommended summaries: + +- parameter-use summary by `GraphID` and function symbol, +- return-site summary by `GraphID`, +- local function/call graph summary by `GraphID`, +- table mutator call summary by `GraphID`, +- key-collector summary by `GraphID`, +- captured variable/path summary by `GraphID`, +- normalized transfer program by `GraphID` plus declared environment identity. + +These queries read immutable graph/source data and produce immutable summaries. +They do not read interproc facts and they do not infer types. The analysis query +then combines those summaries with parent scope and interproc snapshots. + +### Hot Local Cache Contract + +Some caches should remain local because they are only useful during one solve. + +Local cache keys must include: + +- phase (`declared`, `preflow`, `narrow`, or final query), +- expression identity or normalized instruction identity, +- CFG point, +- parent scope identity when the answer can depend on scope, +- solved-state token when the answer depends on flow facts. + +Local caches must not: + +- survive across `FuncResultQ` computations unless the key is fully immutable, +- contain mutable domain state, +- publish facts, +- suppress dependency tracking by reading snapshots behind Salsa's back. + +This keeps hot expression synthesis fast without making it a second semantic +store. + +### Type Query Layer Contract + +The core type query engine is already the right kind of abstraction for +field/index/operator/subtype queries: pure inputs, stable type identities, and +memoized expensive structural work. + +Final rules: + +- checker domains may call pure type queries; +- type queries must not read checker store state; +- type query caches are performance-only; +- type query answers must be invalidated or keyed by all external type-provider + inputs they depend on; +- domain law tests should not depend on query cache hit order. + +This means Salsa does not replace `types/query/core`. Salsa coordinates checker +analysis dependencies. The type query engine owns repeated structural type +operations. + +### Performance Proof Requirements + +A performance correction is accepted only with a before/after profile or +benchmark that names the reduced work. + +Required measurements for the flash migration: + +- large-function checker benchmark, +- representative interproc convergence fixture, +- production replay wall time, +- allocation profile for hot joins and expression synthesis, +- cache hit/miss counters for `FuncResultQ` and graph summary queries, +- number of snapshot inputs rewritten per fixpoint iteration. + +Expected improvements: + +- fewer `collectParamUses` rescans, +- fewer repeated local function snapshot syntheses, +- fewer map allocations in no-op fact joins, +- fewer invalidated function queries after no-op fact updates, +- fewer expression synthesis calls during narrow/final query phases. + +Regression rule: + +```text +If a performance win comes from accepting less precise facts, it is invalid. +If a precision win causes repeated semantic recomputation, the cache boundary is +wrong and must be fixed before the flash migration lands. +``` + +## Weak Points To Fix In The Design + +### 1. Domain Laws Are Not Named + +The checker has laws such as: + +- hard evidence dominates soft evidence, +- `unknown` in return summaries is unresolved runtime behavior, +- open record absent field means row-tail, not nil, +- nil field can satisfy optional absence in record subtyping, +- table-top can absorb precise table evidence in parameter evidence, +- truthy refinement can remove falsy key alternatives. + +Today many of these appear as function names buried in unrelated packages. They +must become named laws of specific domains. + +### 2. Too Many Local Abstract Interpreters + +`flowbuild`, `types/flow`, return SCC inference, preflow synthesis, return +overlay refresh, condition extraction, and interproc widening each perform part +of the abstract interpretation. + +The final design should have one abstract state model and several orchestration +phases. The orchestration may be complex; the lattice rules cannot be local. + +### 3. Memory Is Not First-Class Enough + +Field writes, table inserts, dynamic indexes, aliases, captured mutations, and +path queries all affect the same memory model. They are currently split across +multiple packages. + +This causes bugs where: + +- parent-derived structure outranks explicit child-path facts, +- captured table inserts replay through the wrong mutator kind, +- alias identity and dominance are checked locally, +- nil overwrite and optional absence need separate fixes. + +The final memory domain must own these rules. + +### 4. Parameter Evidence Has Multiple Authorities + +Parameter evidence currently comes from: + +- call sites, +- body contracts, +- function facts, +- literal signatures, +- soft source annotations, +- param-use projection. + +The final design needs one `ParameterEvidence` lattice with evidence provenance +and merge mode. The implementation should not need separate helpers for +"parameter evidence" and "function param facts" that rediscover the same truthiness, +softness, and table-key laws. + +### 5. Relation Facts Are Under-Modeled + +The system supports powerful correlations, especially error-return behavior, but +the relation model is still too tied to known patterns. + +The final design should model tuple/path relations directly. `(value, err)` is +then one relation instance. This keeps the system extensible without hardcoded +branch helpers or return-slot checks. + +### 6. Effect Inference Is Too Distributed + +Effects are currently inferred and replayed from several places: + +- call specs, +- error-return inference, +- captured field/container mutation collection, +- nested mutator replay, +- key collector detection, +- predicate/assertion refinements. + +Those are all effect facts. They need one summary model and one application path +through transfer. Otherwise each new effect creates its own mini analysis and +its own invalidation/caching risks. + +### 7. Tests Are Too Positive-Heavy + +Many external-lint regressions are "this must type-check" tests. Those are +useful, but insufficient. They can pass through accidental broadening. + +Every major law needs: + +- a positive test proving wanted inference, +- a negative test proving sound rejection, +- a domain law test proving normalize/join/widen idempotence and monotonicity. + +## Anti-Pattern Catalog + +These shapes should be rejected during the flash migration. + +### Local Domain Predicate In An Orchestration Package + +Example smell: + +```go +func typeRefinesTableKeyByTruthiness(...) +``` + +If the helper defines what refinement means, it belongs to a domain package. +Orchestration packages can ask a domain whether a refinement is valid; they +cannot define the refinement locally. + +### Equality-Time Repair + +If equality normalizes, rebuilds, or reconciles facts to make two states look +equal, convergence bugs become invisible. + +Correct shape: + +```text +write boundary -> Normalize +merge boundary -> Join/Widen +equality -> structural comparison of canonical state +``` + +### Query-Time Fact Production + +If a query discovers a fact that later code relies on as if it were stored +analysis state, the system has a hidden analysis path. + +Correct shape: + +```text +query can memoize an answer, but cannot publish evidence +``` + +### Producer-Specific Merge + +If one producer has its own merge rules for a fact family, the product domain is +not canonical. + +Correct shape: + +```text +producer emits delta +store calls FactsDomain.Join or FactsDomain.Widen +``` + +### Compatibility View As Authority + +A projection may exist for display or API response, but not as stored authority. +If production code writes through a view, it recreates the legacy mirror problem. + +### Soundness Shortcut + +Any change whose main effect is "fewer external diagnostics because `any` now +passes" is rejected unless a domain proof explains why that `any` was not truly +dynamic. + +### Cache Without Input Contract + +Every cache must state: + +- exact key, +- immutable inputs, +- invalidation mechanism, +- whether it is semantic or performance-only. + +If the cache depends on phase call order, it is not SOTA. + +## Failure Taxonomy + +Future regressions should be classified by failed domain responsibility, not by +the helper function that happened to produce the symptom. + +| Symptom | Likely Owner | First Question | +|---|---|---| +| guarded field still nilable at call site | `RelationDomain` or `MemoryDomain` | Did the guard create a path relation for the same location queried by the call? | +| error-return refinement does not affect value slot | `RelationDomain` | Was the tuple relation preserved through return assignment and wrapper forwarding? | +| external dynamic value passes concrete parameter | `ValueDomain` or `ParameterEvidenceDomain` | Did `any` get treated as proof instead of dynamic top? | +| unknown disappears from return summary | `ReturnSummaryDomain` | Did join/widen erase unresolved evidence? | +| nil field write behaves like absent field | `MemoryDomain` | Was nil overwrite represented as a value fact instead of structural deletion? | +| closed missing field behaves like open row-tail | `ValueDomain` or `MemoryDomain` | Was openness carried on the record/map component being queried? | +| table insert lost before iteration | `MemoryDomain` and `EffectDomain` | Was mutation replay attached to the canonical child location and operator kind? | +| recursive type keeps growing | owning domain `Widen` | Is growth bounded at the correct SCC/fixpoint boundary? | +| result changes after no semantic input changed | Salsa/cache layer | Is a cache keyed by mutable state or phase order? | +| result does not change after facts changed | Salsa/cache layer | Did the query read the canonical snapshot input that changed? | +| lint clears by accepting too much | `ValueDomain` or assignability boundary | Which negative test proves the new acceptance is sound? | +| repeated performance hot spot after caching | domain/query boundary | Is the computation duplicated because the owner is unclear? | + +Classification rule: + +```text +If a symptom requires reading three unrelated helpers to understand why it +happened, the domain model is still wrong. +``` + +The fix should move the law to the owner, delete the scattered helpers, and add +domain law tests plus one production-shaped replay test. + +## Traceability Matrix + +Every high-value behavior should be traceable from syntax to proof. + +| Behavior | Producer | Canonical Fact | Consumer | Proof | +|---|---|---|---|---| +| truthy field guard | condition transfer | path truthiness relation | call/type query | relation law + guarded-call fixture | +| `test.is_nil(err)` success branch | predicate effect transfer | tuple-slot relation constraint | value-slot query | relation law + error-return fixture | +| body demands parameter field | transfer over field read/use | parameter obligation | interproc fact join | parameter evidence law + SCC fixture | +| call observes argument type | call transfer | call observation | parameter evidence join | authority-order law + negative any fixture | +| table insert mutates array element | effect transfer | container element mutation | iteration query | memory law + dominance fixture | +| nil overwrite | assignment transfer | explicit nil value or deletion effect | field query | nil/absent law + record fixture | +| wrapper forwards returns | return transfer | tuple relation preservation | caller assignment | relation preservation law + wrapper fixture | +| imported dynamic payload | external contract transfer | `any` or `unknown` with provenance | assignability check | value law + negative concrete-param fixture | +| recursive local function | SCC solver | widened param/return evidence | function result query | widen law + convergence fixture | +| module export | publication | immutable interproc delta | dependent Salsa query | snapshot dependency test | + +This matrix is not a test list by itself. It is the audit trail showing that a +behavior has one producer, one canonical representation, one consumer path, and +one proof family. + +## Design Review Decision Tree + +Every future rule should be classified before code is written. + +### Is It About What A Type Means? + +Examples: + +- `unknown` vs `any`, +- open row tail, +- nilability, +- truthiness, +- soft evidence, +- table top. + +Owner: + +```text +ValueDomain +``` + +Reject if implemented in return inference, call checking, or postflow writer. + +### Is It About Where A Fact Lives? + +Examples: + +- field path, +- dynamic index, +- alias target, +- tuple slot, +- captured mutation target, +- receiver `self`. + +Owner: + +```text +MemoryDomain / Location model +``` + +Reject if every producer computes its own path identity. + +### Is It About How Facts Combine? + +Examples: + +- branch join, +- parameter evidence merge, +- return vector merge, +- function fact merge, +- recursive shape cutoff. + +Owner: + +```text +The domain that owns that fact family +``` + +Reject if implemented as a producer-specific helper. + +### Is It About When Analysis Converges? + +Examples: + +- loop widening, +- local function SCC widening, +- interproc widening, +- recursive type growth. + +Owner: + +```text +Widen operation of the relevant domain +``` + +Reject if hidden inside equality, query, or local preference helpers. + +### Is It About What A Call Does? + +Examples: + +- mutates a table, +- narrows an argument, +- returns `(value, err)`, +- terminates, +- invokes a callback, +- collects keys. + +Owner: + +```text +EffectDomain + RelationDomain + MemoryDomain transfer +``` + +Reject if modeled as a one-off postprocessing pass. + +### Is It About Reusing Work? + +Examples: + +- graph summaries, +- parameter-use summaries, +- function result, +- type operator query, +- shape classification. + +Owner: + +```text +Salsa query or explicit local cache with named inputs +``` + +Reject if invalidation depends on call order or hidden mutable state. + +## Edge-Case Matrix + +The migration must consider edge cases beyond the failures already seen. The +design is not complete until each row below has an owner domain and tests. + +| Area | Edge Cases To Model | +|---|---| +| `unknown` | branch join with concrete, return merge with concrete, exported summary, table field, array element, call argument, relation slot | +| `any` | explicit cast to any, imported dynamic data, any flowing to concrete param, any in record field, any as table key/value, any through relation facts | +| `nil` | nil as Lua value, nil as field deletion, nil satisfying optional absence, nil array slot, nil map value, nil return slot | +| absent field | closed record absence, open row-tail unknown, map-tail optional value, table-top field access, absence after mutation | +| soft evidence | soft table top, soft array element, soft map value, nil plus soft shape, hard evidence replacing soft evidence, soft evidence across imports | +| table top | `table`, `{...}`, `{[any]: any}`, arrays, maps, closed records, open records, unions with precise tables | +| row shape | open vs closed, readonly fields, optional fields, metatables, map component overlap, discriminant tags | +| truthiness | false/nil removal, literal false keys, `and`/`or` branch values, truthy field guards, truthy dynamic indexes | +| mutation | field write, nil overwrite, dynamic index write, table insert, container send, captured mutation, exported callback mutation | +| aliasing | local alias, field alias, imported alias, method receiver alias, self alias, cyclic alias, alias after reassignment | +| dominance | dominating writes, branch-local writes, loop-carried writes, post-dominated assertions, early returns, dead paths | +| functions | optional function values, union of function signatures, method `self`, varargs, higher-order callbacks, recursive locals | +| returns | zero returns, one return, two returns, more than two returns, tuple expansion, nil padding, recursive containers | +| relations | `(value, err)`, custom error record, multiple independent relations, swapped slots, relation through wrapper, relation through any | +| effects | termination, assertion refinements, callback effects, captured mutation effects, key collection, external contract effects | +| interproc | parent scope change, module boundary, literal signatures, captured fields, constructor fields, sibling overlay, stale snapshots | +| caching | stale query after fact change, query reuse after no-op fact change, cache key missing parent scope, cache key missing graph identity | +| performance | recursive structural scan, repeated AST projection, repeated map allocation, query dependency overhead, equality-time canonicalization | + +Adversarial cases must include both: + +- precision cases where the checker should infer the strongest provable type; +- soundness cases where similar-looking code must still fail. + +Examples: + +- guarded `options.model` should infer `string`; `provider_info as any` should + not become `string` without proof; +- `response.body or ""` should be `string`; `response.body` alone remains + `string?`; +- open row-tail field access is `unknown`; closed missing field is absent/nil + evidence depending on context; +- table insert before an `ipairs` loop should feed element type; branch-local + insert must not leak if the loop is not dominated by that branch; +- `test.is_nil(err)` may refine a related value slot only if a relation fact + proves the tuple contract. + +The suite should be generated around these matrices, not around the names of +the old helper functions. + +## Flash Migration Shape + +The implementation should be prepared privately but merged as a direct final +shape. The production branch should not pass through partial API compatibility. + +Flash migration means: + +1. Introduce final domain packages. +2. Move domain laws into those packages. +3. Replace all call sites in one migration. +4. Delete old helper clusters in the same migration. +5. Delete obsolete tests that asserted old helper behavior. +6. Add law-oriented tests for the new domain boundaries. +7. Run the global replay and classify remaining diagnostics. + +No step should leave: + +- old helper path plus new helper path, +- adapter projections like "legacy view from canonical facts", +- duplicate merge functions for the same semantic slot, +- fallback normalization in equality, +- broad `any` acceptance to clear lints. + +## Flash Cutover Gate + +The flash migration should be reviewed as one semantic cutover, not as a chain +of transitional accommodations. The cutover is ready only when the following +artifacts can be listed before coding starts. + +### Deletion Map + +For each old helper cluster: + +- current file/package, +- semantic law it currently approximates, +- final domain owner, +- final API call site, +- tests that replace helper-specific tests, +- commit in which the helper disappears. + +If a helper cannot be mapped to a domain owner, the design is incomplete. If it +maps to more than one owner, the fact representation is probably mixed and must +be split before implementation. + +### Replacement Map + +For each production call site: + +- current call, +- final call, +- expected semantic output, +- changed cache dependency if any, +- changed diagnostic behavior if any. + +The migration should not introduce "temporary" calls that are expected to be +removed later. A call site either moves to the final API or stays unchanged until +the cutover is ready. + +### Proof Map + +For each domain law: + +- unit law test, +- one positive checker fixture, +- one negative checker fixture when soundness could be weakened, +- one replay/global-harness case if the law came from real code. + +No proof should depend only on external lint going quiet. The suite must show +both the precision gain and the rejection boundary. + +### Performance Map + +For each expensive operation touched: + +- current benchmark/profile location, +- final owner, +- expected cache key or no-cache reason, +- allocation behavior, +- invalidation story. + +Performance work should favor fewer repeated analyses and fewer duplicated data +structures before object pools. Pools are allowed only after ownership is clear +and tests prove no fact lifetime can leak across checks. + +### Cutover Rejection Rules + +Reject the migration if it contains: + +- compatibility authority, +- fallback repair, +- two writers for one fact, +- query-time publication, +- equality-time normalization, +- broad assignability introduced only to clear production code, +- new cache without an immutable input contract, +- new helper whose name describes a case instead of a domain law. + +## Proposed Final Package Map + +```text +compiler/check/domain/interproc +compiler/check/domain/functionfact +compiler/check/domain/returnsummary +compiler/check/domain/paramevidence +compiler/check/domain/relation +compiler/check/memory +compiler/check/flowstate +``` + +Existing packages remain as orchestration: + +```text +compiler/check/abstract/transfer +compiler/check/synth +compiler/check/infer/return +compiler/check/infer/interproc +compiler/check/store +compiler/check/pipeline +``` + +Low-level pure type mechanics remain under: + +```text +types/typ +types/subtype +types/query/core +types/db +``` + +The key rule: + +Orchestration packages may decide when a fact is produced. Domain packages +decide what that fact means and how it combines. + +## Minimum Final-Shape API Sketch + +This is not a transitional API. It is the smallest final surface that should +exist after the flash migration. + +```go +// compiler/check/analysis +type Engine struct { + Graphs GraphProvider + Domains Domains + Queries Queries +} + +func (e *Engine) AnalyzeFunction(input FunctionInput) FunctionResult +``` + +```go +// compiler/check/flowstate +type AbstractState struct { + Memory MemoryState + Values ValueFacts + Numeric NumericFacts + Shape ShapeFacts + Relations RelationFacts + Effects EffectFacts + Termination TerminationFacts +} + +func (s AbstractState) Join(other AbstractState, d Domains) AbstractState +func (s AbstractState) Widen(next AbstractState, d Domains) AbstractState +``` + +```go +// compiler/check/transfer +type Instruction interface { + Apply(state flowstate.AbstractState, d Domains) flowstate.AbstractState +} +``` + +```go +// compiler/check/domain +type Domains struct { + Value ValueDomain + Memory MemoryDomain + Relation RelationDomain + Effect EffectDomain + Parameter ParameterEvidenceDomain + Return ReturnSummaryDomain + Function FunctionFactDomain + Interproc InterprocFactsDomain +} +``` + +```go +// compiler/check/domain/interproc +type InterprocFactsDomain interface { + Normalize(api.Facts) api.Facts + Leq(a, b api.Facts) bool + Join(a, b api.Facts) api.Facts + Widen(prev, next api.Facts) api.Facts + Equal(a, b api.Facts) bool +} +``` + +```go +// compiler/check/query +type View interface { + TypeAt(point cfg.Point, loc memory.Location) typ.Type + RelationAt(point cfg.Point, rel relation.Query) relation.Answer + EffectAt(point cfg.Point, call CallSite) effect.Summary +} +``` + +The important part is not exact names. The important part is that: + +- state is one product; +- transfer mutates only that product; +- domains own all combination; +- query is read-only; +- interproc publication is delta-based; +- no package owns a shadow merge policy. + +## Verification Model For The Future Migration + +Required proof after the flash migration: + +```text +go test ./... +git diff --check +../scripts/verify-suite.sh +``` + +Required domain law tests: + +- `Normalize(Normalize(x)) == Normalize(x)` +- `Join(a, b) == Join(b, a)` where the domain is intended commutative +- `Join(Join(a, b), c) == Join(a, Join(b, c))` where applicable +- `Widen(Widen(a, b), b) == Widen(a, b)` +- `a <= Join(a, b)` +- `a <= Widen(a, b)` +- derived function type equals canonical function fact projection +- no equality-time normalization bridge + +Required behavior suites: + +- soft vs hard evidence, +- any vs unknown, +- nil vs absent, +- open vs closed records, +- table top vs precise table shapes, +- captured table/container mutations, +- alias and dominance, +- error-return tuple relations, +- local SCC parameter evidence, +- interproc non-convergence fixtures, +- external replay reductions. + +## Review Checklist Before Coding + +Before implementing the flash migration, each proposed package should answer: + +- What domain or boundary object does this package own? +- What are the only mutable states in this package? +- Which operation is transfer, join, meet, widen, normalize, query, or publish? +- Which laws are tested at the package boundary? +- Which edge-case matrix rows does it cover? +- Which caches does it introduce, and what exact immutable inputs key them? +- Which old helper clusters will be deleted when this lands? +- Which production call sites will move directly to the final API? +- What negative tests prevent broadening `any`, erasing `unknown`, or treating + absence as nil in the wrong domain? + +If any answer is "handled by a fallback during migration", the design is not +ready. The next implementation must be flash migration, not coexistence. + +## Current Conclusion + +The checker is not fundamentally the wrong idea. It is closer to a serious +abstract interpreter than it looks from isolated helper functions. + +The foundational problem is organizational: the product domain exists in +behavior but not cleanly enough in code. The next design correction should not +add more local helpers. It should move the existing laws into explicit domain +objects, make memory/path identity first-class, and make Salsa/cache boundaries +documented and deliberate. + +If this is done as a flash migration, the codebase should become smaller because +many helper clusters collapse into a few named domains. It should also become +easier to reason about because every merge/refinement/widening decision will +have one owner and one law-test suite. + +## 2026-05-19 Engine Verification And Classification Checkpoint + +This pass removed the remaining parameter-count heuristic in call diagnostics. +The old shape was not a domain law: graph-local calls were relaxed based on +source arity. The replacement is a semantic boundary: + +- function facts remain the call contract authority; +- explicit `any` arguments are only ignored for graph-local parameter slots + whose value is never observed by the function body; +- observed parameters still enforce their declared or inferred contract; +- the unobserved-parameter mask is computed once per function symbol during the + call-check pass from binder symbol identity, so shadowing and captured uses are + handled by symbols, not names. + +Regression coverage added: + +- an internal `any` passed to an unobserved local parameter does not create a + false positive; +- the same `any` passed to an observed `string` parameter remains an error; +- imported/manifest call boundaries that require `string` still reject `any`; +- the external-lint reductions now also cover selected HTTP response body + fallback, error-guarded imported page field casts, and captured state-field + map iteration. + +Verification from this checkpoint: + +```text +go test ./... -count=1 +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +../scripts/verify-suite.sh +``` + +Results: + +- `go test ./... -count=1` passes. +- `BenchmarkCheck_LargeFunction` is about 1.83-1.86 ms/op, about 1.05 MB/op, + and 10,695 allocs/op on this machine. +- `../scripts/verify-suite.sh` passes go-lua checker tests and builds Wippy, but + the external lint section still exits non-zero because the script builds Wippy + against `github.com/wippyai/go-lua v1.5.16`, not this checkout. +- A temporary Wippy binary built with a `/tmp` `go.mod` replacement pointed at + this checkout was used for classification without editing external Wippy code. + +Current external-lint classification: + +- The official pinned verify output cannot prove current go-lua regressions. + It reported `session` 8, `framework/src/agent/src` 13 during the script run + and 8 on direct replay, and `docker-demo` 21 errors / 2 warnings. +- The local-replace replay is stricter than the pinned binary. It reports many + explicit `any` to concrete-contract errors. Those are soundness-preserving + external code or manifest contract issues unless reduced to a go-lua false + positive. +- High-confidence engine candidates were reduced where possible. The current + reduced go-lua fixtures for response-body fallback, page-field casts, + captured state map iteration, length guards, setmetatable prototypes, query + builder back-references, and imported assertions pass. +- Remaining unreduced candidates are mostly context-sensitive Wippy package + interactions: imported module manifests that expose `unknown`/`any`, generated + package cache shape, and real code paths that pass unchecked dynamic values + into concrete APIs. They should not be fixed by weakening `any` or erasing + `unknown`. + +Design rule retained for the next pass: + +- Do not add compatibility channels or fallback facts. +- Do not make `any` silently assignable to concrete types. +- If an external diagnostic is a false positive, first reduce it into a + go-lua regression that fails for the same semantic reason, then fix the + owning domain or transfer rule. +- If a diagnostic is true external code, keep it classified and do not edit + external Wippy sources from this go-lua PR. + +## 2026-05-19 External Replay Classification Follow-Up + +The local-replace Wippy binary still reports diagnostics in external packages, +but the new reductions did not expose a go-lua engine regression. The important +distinction is that the checker is now refusing to erase dynamic source shapes +that are not proven by the Lua program. + +New regression coverage added in `external_lint_regression_test.go`: + +- optional numeric fields defaulted with `or` become non-nil before arithmetic; +- exported model-card numeric defaults remain non-nil at a consumer; +- imported modules stored in table fields preserve those numeric defaults; +- registry-derived numeric defaults still feed arithmetic after the consumer + guards the optional return; +- guarded string field values inserted into an accumulator retain a string + element type when iterated into a helper call; +- a `type(x) == "table"` guard on an untyped value keeps dynamic field fallback + reads open. + +Classification of the remaining replay clusters: + +- `llm.lua` provider contract calls are true code issues under the current + soundness rule: `provider_info = model_card.providers[1] as any` explicitly + discards the proof that `provider_model` is `string`, then passes + `provider_info.provider_model` to contracts requiring `model: string`. +- Artifact/message metadata field errors are true code issues unless external + code adds a table guard or guaranteed decode. The repositories decode JSON + into `meta`/`metadata` on success but leave the original string when decode + fails, then downstream code accesses fields after only a truthiness guard. +- `json.decode(response.body or "")` and HTTP stream-read diagnostics are still + unreduced package-boundary candidates. The go-lua reductions for optional + response body fallback and guarded stream reads pass, so the observed replay + failures are not the simple `or` transfer rule. +- Bedrock text-block parsing is not a reproduced accumulator regression. The + guarded string accumulator reduction passes; the replay source receives + response blocks from a dynamic API shape, so the value is `any` unless the + external package or manifest proves the field type. +- Docker-demo fixture failures are mostly true fixture/source issues: examples + include `state.iteration_count` being initialized only on the first-iteration + branch before arithmetic, dynamic maps passed to stricter contracts, optional + method receivers called without guards, and generated/vendor stubs whose + contextual shapes do not declare fields they later read. + +Current rule: + +- Keep explicit `any` and `unknown` barriers sound. +- Do not suppress these diagnostics in go-lua without a failing go-lua + reduction that proves the checker lost information it already had. +- External Wippy fixes, if desired, should be explicit guards, casts at real + trust boundaries, or stronger manifests; they are outside this go-lua PR. + +## 2026-05-19 Expression Call Evidence Closure + +One real engine regression remained in the external `compress` replay: local +helper calls nested under returned table fields were not always represented as +call sites for parameter evidence. The old collector handled statement calls, +top-level assignment/return source calls, and nested calls inside call +arguments, but missed expression positions like: + +- `return { field = helper(value) }`; +- `local t = { field = helper(value) }`; +- `if helper(value) then ... end`; +- numeric/generic loop header expressions; +- calls wrapped by casts or non-nil assertions. + +That was a domain bug, not a reason to weaken arithmetic or `any`/`unknown`. +The correction keeps `FunctionFacts.Params` as the only parameter-evidence +authority and expands the collector to visit every call expression that occurs +inside assignment sources, return expressions, branch conditions, and loop +headers. The final implementation walks each owned expression tree once from +its CFG point, so the collector does not need a compatibility call-site channel +or per-point dedupe maps. + +Regression coverage added: + +- returned-table and assigned-table helper calls feed numeric parameter + evidence; +- branch-condition and numeric-for-bound helper calls feed numeric parameter + evidence; +- the original `compress`/test-DSL mutable resolver reduction no longer + pollutes `tokens_to_chars`; +- guarded config update reductions verify unrelated numeric config fields stay + non-optional when call evidence proves the updates are safe; +- existing compress/model-card reductions remain green. +- negative soundness reductions verify the checker does not accept untyped + model-card fields as numbers, explicit `any` provider models as strings, or + untyped response text as `string?` without a real guard, cast, or manifest. + +Verification from this pass: + +```text +go test ./compiler/check/infer/interproc ./compiler/check/tests/regression -count=1 +go test ./... -count=1 +git diff --check +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=5 +``` + +Results: + +- `go test ./... -count=1` passes. +- `git diff --check` passes. +- `BenchmarkCheck_LargeFunction` is about 1.97-2.15 ms/op, about 1.054 MB/op, + and 10,699 allocs/op on this machine after the expression-call scan. +- A local-replace Wippy binary built from this checkout now reduces the full + `framework/src/llm/src` replay to 9 errors: the known 6 `llm.lua` contract + errors, 1 Bedrock dynamic text-block parser error, and 2 `compress.lua` + arithmetic errors. + +Updated classification: + +- The previous nested-call evidence bug is fixed in go-lua. +- The remaining `wippy.llm.util:compress` errors are now classified as an + external source/manifest proof gap. Replaying the real + `wippy.llm.discovery:models` export locally shows `get_by_name` exports + `max_tokens` and `output_tokens` as `unknown`, because registry `entry.data` + is not typed as numeric. `compress.lua` then uses those fields in arithmetic + after `or` defaults. go-lua must not invent numeric proof across that module + boundary; external code should either type the registry/model-card manifest or + coerce with `tonumber(...) or ` before arithmetic. +- The same soundness boundary covers the Bedrock and `llm.lua` diagnostics: + `block.text` comes from untyped provider JSON, and `provider_info` is + explicitly cast to `any` before being used to build contract args whose + `model` field must be `string`. +- The remaining global replay diagnostics are still true dynamic-boundary or + source-shape issues unless independently reduced to a failing go-lua engine + test. Current local-replace counts: `framework/src/llm/src` 9 errors, + `framework/src/agent/src` 11 errors, `session` 38 errors, and `docker-demo` + 60 errors. +- Standard `../scripts/verify-suite.sh` still exits non-zero because external + lint targets fail under the Wippy repo's pinned `github.com/wippyai/go-lua + v1.5.16` build, but the go-lua checker tests and Wippy binary build pass. + The external counts from that official path are currently `session` 8 errors, + `framework/src/agent/src` 8 errors, and `docker-demo` 21 errors plus + 2 warnings. + +## 2026-05-19 Remaining External Error Classification Pass + +The remaining official lint failures were replayed with the exact failing +targets and then reduced against the current go-lua checker. The purpose was to +separate real checker regressions from external source/manifest obligations. + +Additional reductions added in this pass: + +- stdlib `json.decode(response.body or "")` accepts a `string?` body fallback; +- a casted truthiness-guarded field feeds a method argument expecting `string`; +- a casted table-literal field satisfies an annotated record field; +- `#xs > 0` proves both `xs[1]` and `xs[#xs]` access in the reduced sequence + cases; +- an error-return guard narrows the successful value before field access. + +These reductions pass, so the remaining package-level errors are not the +generic transfer laws above. Current classification: + +- `json.decode(response.body or "")` diagnostics in the LLM packages are still + package-boundary issues. The local reductions for stdlib JSON, imported JSON, + and selected HTTP methods pass; the full packages depend on external + `http_client` response manifests and stream surfaces outside go-lua. +- `response.stream:read(4096)` is a native/manifest arity issue, not a checker + flow regression. +- `wippy.views:renderer` casted field calls and + `wippy.views.api:list_pages` casted table fields are covered by reductions. + Remaining full-package errors depend on the external page-registry export + shape and should be fixed with stronger manifests or source guards/casts in + the views package, not by weakening go-lua. +- Metadata field errors on `meta`/`metadata` are real source-shape problems: + empty strings are truthy in Lua, so a truthiness guard alone does not prove a + decoded table. +- Dynamic payload and provider diagnostics (`any`/`unknown` passed to string, + number, contract-argument, time, or typed-option APIs) remain true dynamic + boundary errors unless the external package provides a manifest, schema + decoder, guard, or cast. +- Docker/webscout timeout and header diagnostics are source/manifest issues: + `options.timeout = options.timeout or 30` preserves an existing truthy string, + so a sound checker cannot turn that into `number`. + +Verification after adding these reductions: + +```text +go test ./compiler/check/tests/regression -count=1 +go test ./... -count=1 +git diff --check +../scripts/verify-suite.sh +``` + +The go-lua tests and diff check pass. The official verify suite still exits +non-zero only on external lint targets: `session` 8 errors, +`framework/src/agent/src` 8 errors, and `docker-demo` 21 errors plus +2 warnings. + +## 2026-05-19 Advanced Type-System Stress Regressions + +Added a focused regression suite and a real-world fixture whose purpose is to +stress the current abstract-interpreter model without weakening soundness. + +The Go regression suite covers: + +- dynamic decode into a discriminated `Event` union after explicit `type(...)` + guards, followed by variant-specific field access; +- `(value?, err?)` multi-return correlation through higher-order callbacks; +- fluent builder state preservation through explicit self-typed methods; +- manifest/module export of tagged results and callback parameter shapes; +- generic `Result` combinators that preserve payload type parameters across + `map`, `and_then`, nested callbacks, and discriminant narrowing; +- nested config builders with typed arrays and string maps; +- negative soundness cases where truthy string fallbacks must not become + numbers, and a truthiness guard over `string | record` must not prove record + field access because Lua strings are truthy. + +Added fixture: + +- `testdata/fixtures/realworld/advanced-type-system-stress` + +The fixture runs the same laws through the repository fixture harness with +separate modules for event decoding, session creation, a metatable-style +request builder, and pipeline config. The entrypoint validates cross-module +manifest exports and includes inline `expect-error` checks for the two +soundness boundaries. + +One attempted fixture assertion was intentionally tightened: assigning +`first.config.level` directly to `string` from a `{[string]: any}` config is not +sound. The fixture now proves the local value with `type(level) == "string"` +before claiming it. This is the right model boundary: the checker should infer +what is proven by control flow and manifests, not invent structure out of +dynamic `any`. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestAdvancedTypeSystem' -count=1 -v +go test . -run 'TestFixtures/realworld/advanced-type-system-stress/check' -count=1 -v +go test ./... -count=1 +git diff --check +``` + +All checks pass. + +## 2026-05-19 Exhaustiveness Warnings for Closed Matches and Channel Select + +Added the checker warning the user asked for. The standard term is +**exhaustiveness checking**; the diagnostic is a warning for a +**non-exhaustive match**. + +Correction made during review: the real `channel.select` exhaustiveness target +is `result.channel`, not `result.value.kind`. `result.value` is the selected +receive payload. A payload discriminator such as `result.value.kind` only makes +sense after a channel guard has already proven which channel produced the +payload. It is a separate nested discriminated-union match, not the select-arm +match itself. + +Diagnostic boundary: + +- The diagnostic is warning-only: `diag.ErrNonExhaustive` with + `SeverityWarning`. It does not make type checking fail. +- Closed literal-tag proof lives in `types/narrow.ClosedDiscriminantDomain`. +- The checker hook recognizes match-like Lua `if/elseif` chains and delegates + closed literal-tag domain proof to the narrowing domain. +- The hook also recognizes real `channel.select` result arms by indexing + assignments of `channel.select { ch:case_receive(), ... }` and matching + `result.channel == ch` branches against the selected channel paths. +- The warning is emitted only for match-like chains with at least two explicit + arms and no final `else`. A single early-return guard stays silent because + fallthrough may intentionally handle the remaining case. +- Open or dynamic cases stay silent: `any`, `unknown`, `nil`, optional + discriminants, broad tags like `kind: string`, missing tags, non-record + members, unextractable select channels, and select calls with default cases. + +Correct `channel.select` warning sample: + +```lua +local result = channel.select { + events_ch:case_receive(), + stop_ch:case_receive(), + timeout_ch:case_receive(), +} + +if result.channel == events_ch then + return result.value.kind +elseif result.channel == stop_ch then + return result.value.reason +end +``` + +Warning: + +```text +non-exhaustive match on result.channel; missing case: timeout_ch +``` + +Correct complete select sample: + +```lua +if result.channel == events_ch then + return result.value.kind +elseif result.channel == stop_ch then + return result.value.reason +elseif result.channel == timeout_ch then + return tostring(result.value.sec) +end +``` + +No warning is emitted there because every selected channel arm is represented. + +The nested payload-discriminant case is still supported separately: + +```lua +if result.channel == events_ch then + if result.value.kind == "message" then + ... + elseif result.value.kind == "tool" then + ... + end +end +``` + +That warning is about the closed `Event` payload union after the `events_ch` +guard, not about the `channel.select` arm set. + +Added coverage: + +- `types/narrow/discriminant_domain_test.go` + - closed string tag domains, + - closed numeric tag domains, + - broad tag rejection, + - optional tag rejection. +- `compiler/check/tests/regression/exhaustiveness_warning_test.go` + - plain discriminated-union missing case, + - real `channel.select` missing channel case, + - real `channel.select` all-cases-handled no-warning case, + - real `channel.select` single early-return guard no-warning case, + - final `else` suppresses warning, + - all literal variants handled suppresses warning, + - open discriminant suppresses warning, + - numeric discriminant missing case. +- `testdata/fixtures/narrowing/channel-select-case-exhaustiveness-warning` + pins the real fixture harness line-level `expect-warning` for the selected + channel case pattern. + +Verification: + +```text +go test ./types/narrow -run TestClosedDiscriminantDomain -count=1 -v +go test ./compiler/check/tests/regression -run TestExhaustivenessWarning -count=1 -v +go test ./compiler/check/hooks -count=1 +go test . -run 'TestFixtures/narrowing/channel-select-case-exhaustiveness-warning/check' -count=1 -v +go test ./... -count=1 +``` + +All checks pass. + +## 2026-05-19 Adversarial Gradual-Typing Regressions + +Added a dedicated gradual-typing regression suite and fixture. The goal is to +prove the checker is permissive where the program supplies evidence, while +remaining strict at dynamic boundaries where the evidence is incomplete. + +Added Go tests: + +- `TestGradualTyping_DecodesDynamicPayloadAfterStructuralProof` +- `TestGradualTyping_DispatchesGuardedUnionThroughTypedRegistry` +- `TestGradualTyping_GenericValidatedCollectionPreservesElementType` +- `TestGradualTyping_ExplicitBoundaryCastProvidesPreciseLocalType` +- `TestGradualTyping_RejectsUncheckedAnyRecordAssignment` +- `TestGradualTyping_RejectsTruthyGuardAsStructuralProof` +- `TestGradualTyping_RejectsPartiallyCheckedCollectionAsTypedArray` +- `TestGradualTyping_RejectsDynamicCallbackAtTypedFunctionBoundary` +- `TestGradualTyping_RejectsExtraFieldsAfterNarrowBoundaryCast` + +The positive cases cover dynamic payload decoding, discriminated command +dispatch through typed registries, generic validation/traversal over `{any}`, +and explicit boundary casts that produce a precise local type. The negative +cases pin the soundness laws: `any` cannot be assigned to a precise record +without proof, truthiness is not structural evidence, checking one array element +does not prove the whole array, dynamic callbacks cannot satisfy typed callback +contracts, and a narrowed cast type does not leak extra dynamic fields. + +Added fixture: + +- `testdata/fixtures/regression/gradual-typing-adversarial` + +The fixture exercises the same model through normal fixture checking and inline +`expect-error` comments. One fixture detail is intentional: generic `ok({})` +needs a typed empty-table cast (`{} :: {string}` or +`{} :: {[string]: string}`) so the empty table does not instantiate the +validation result as an unshaped table. This keeps inference strong without +guessing structure that is not present in the literal. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestGradualTyping' -count=1 -v +go test . -run 'TestFixtures/regression/gradual-typing-adversarial/check' -count=1 -v +go test ./... -count=1 +git diff --check +``` + +All checks pass. + +## 2026-05-19 Loop-Carried Gradual Refinement Regressions + +Extended the adversarial gradual-typing coverage with loop-shaped programs +where precision is earned over several steps and then carried through typed +accumulators or loop state. + +Added Go cases: + +- `TestGradualTyping_LoopRefinesDynamicRecordsIntoTypedArray` validates a + dynamic array by stages (`table` guard, field guards, nested tag-loop guard) + before inserting precise `Item` records into a typed array. +- `TestGradualTyping_PairsLoopRefinesDynamicMapValuesInStages` validates + dynamic map keys, dynamic record values, nested header maps, and then stores + precise `Endpoint` records in a typed string-keyed map. +- `TestGradualTyping_WhileLoopCarriesOptionalRefinementThroughState` exercises + loop-carried optional state: a discriminated event loop writes `state.name` + and arithmetic state separately, then a post-loop nil guard proves the final + name before string use. +- `TestGradualTyping_NestedLoopsRefineMatrixCellsBeforeAggregation` covers + nested `ipairs` loops where row/column/value evidence builds precise cell + records. +- `TestGradualTyping_RejectsExistentialLoopProofAsSpecificElementProof` pins a + soundness boundary: seeing some string somewhere in a loop does not prove + that `raw.items[1]` is a string. + +The fixture `testdata/fixtures/regression/gradual-typing-adversarial` now +mirrors the staged `pairs` map refinement, nested matrix refinement, and +existential-loop negative case with inline `expect-error` coverage. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestGradualTyping' -count=1 -v +go test . -run 'TestFixtures/regression/gradual-typing-adversarial/check' -count=1 -v +go test ./... -count=1 +git diff --check +``` + +All checks pass. + +## 2026-05-19 Exhaustiveness Lint Wiring and Real-Code Probe + +The exhaustiveness checker is intentionally a configurable warning class for +Wippy lint, not a globally forced diagnostic. The Wippy runtime type-checker +already had `TypeCheckRules.Exhaustive` in its cache fingerprint; that bit is +now the single authority for installing `hooks.WithExhaustiveness()`. + +Design notes: + +- go-lua owns the semantic pass and exposes it as `hooks.WithExhaustiveness()`; +- Wippy lint exposes the policy switch as `wippy lint --exhaustiveness`; +- typecheck cache fingerprints already include `Rules.Exhaustive`, so cached + diagnostics cannot hide the opt-in warning state; +- default lint remains unchanged and does not emit exhaustiveness warnings unless + the flag is requested. + +Real-code proof: + +1. temporarily injected a third unhandled `channel.select` case into + `framework/src/llm/src/llm.lua`; +2. rebuilt the temporary Wippy binary against this checkout with the local + go-lua replace; +3. ran `wippy lint --cache-reset --json --exhaustiveness` in + `framework/src/llm/src`; +4. observed the expected warning: + `E0014 warning: non-exhaustive match on result.channel; missing case: c`; +5. restored `llm.lua` byte-for-byte and reran the same lint command; +6. confirmed `warning_count: 0`. + +Added Wippy-side coverage: + +- `TestTypeChecker_ExhaustiveRuleOptIn` +- `TestTypeChecker_ExhaustiveRuleOffByDefault` +- `TestParseLintFlags_Exhaustiveness` + +Verification: + +```text +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go test ./runtime/lua/code -run 'TestTypeChecker_ExhaustiveRule|TestChannelSelectNarrowing_ProcessEvent' -count=1 -v +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go test ./cmd/wippy/cmd -run TestParseLintFlags_Exhaustiveness -count=1 -v +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go build -o /tmp/wippy-local-replace-bin ./cmd/wippy +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod /tmp/wippy-local-replace-bin lint --cache-reset --json --exhaustiveness +``` + +The restored real-code lint run still reports the known nine LLM errors and no +warnings. Exhaustiveness did not backfire on real code after the temporary probe +was removed. + +## 2026-05-19 Flash Convergence Rectification: No Caps + +Removed the artificial convergence caps from the checker pipeline, return SCC +inference, assignment inference, query cycle handling, constraint solving, +flow solving, and numeric solving. Non-convergence is no longer handled by +"iterate N times then warn/fallback"; it must be handled by finite-height +abstract domains, idempotent transfer functions, and explicit widening. + +Key design decisions: + +- Interprocedural facts are a product-domain fixpoint. Captured container + mutations now canonicalize same-path writes and join element/value types on + the fact boundary instead of preserving duplicate mutation events. +- Return SCCs merge with `returnsummary.WidenForConvergence`, so recursive + return summaries stabilize through domain widening instead of an unknown + fallback. +- Assignment SCCs now test the actual SCC product state for stability after a + sweep. A transient update inside the sweep is not a semantic change unless + the final vector differs. +- `any` is treated as top in local inference joins and call-expectation merges. + This prevents `T -> any -> T` oscillation while preserving soundness. +- Numeric flow has per-point widening memory: once moving numeric facts widen + to Top, that point remains Top for the solve. This prevents `Top -> fact -> + Top -> fact` reintroduction caused by representing Top as an absent state. + +Important fixes found by real replays: + +- `types/typ.TypeEquals` no longer rejects structurally equal DAG-shaped types + just because one side shares a subnode and the other side duplicates it. The + equality proof now relies on pair-based coinduction for compound cycles. +- Array/map mutator widening is idempotent. Re-inserting an already-known array + element type returns the original abstract value instead of rebuilding an + equal value and causing false "changed" reports. +- Body-local parameter evidence is treated as evidence, not as a final declared + upper bound. A stronger whole-parameter call contract can dominate compatible + body evidence, including record evidence compatible with a string-keyed map. + +Regression coverage added: + +- same-iteration captured container mutation dedupe; +- captured container mutation joins for loop/table-insert patterns; +- assignment `any` top behavior; +- assignment SCC product-stability regressions from guarded options; +- structural equality for shared DAG-shaped records; +- array/map mutator idempotence; +- numeric widening-to-Top memory; +- body evidence plus whole-parameter call expectation; +- adversarial gradual-typing and loop-carried refinement cases; +- exhaustiveness opt-in warnings. + +Verification: + +```text +go test ./... -count=1 -timeout 180s +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go build -o /tmp/wippy-local-replace-verify ./cmd/wippy +timeout 60s /tmp/wippy-local-replace-verify lint --cache-reset --json --ns wippy.session.api +timeout 60s /tmp/wippy-local-replace-verify lint --cache-reset --json +``` + +The go-lua suite passes. The local-replace Wippy replays that previously hung +now terminate. `wippy.session.api` no longer times out; the full session target +also terminates. + +Final benchmark sample: + +```text +BenchmarkCheck_LargeFunction-32 382 3399067 ns/op 1084024 B/op 10938 allocs/op +BenchmarkCheck_LargeFunction-32 345 3236163 ns/op 1084162 B/op 10938 allocs/op +BenchmarkCheck_LargeFunction-32 385 3162319 ns/op 1084096 B/op 10938 allocs/op +``` + +Remaining verification boundary: + +- The stock `../scripts/verify-suite.sh` still cannot build Wippy without a + local replace because the Wippy checkout references the new + `hooks.WithExhaustiveness()` while its normal module graph resolves an older + published go-lua. +- Local-replace Wippy lint is not clean. The remaining diagnostics are finite + and must be classified separately as source/manifest issues or precision gaps; + this pass fixed the convergence class, not every external diagnostic. +- `tests/app` still reports an `E9999` internal-error diagnostic for + `app.test.types:lib_inner_types`; that is an engine-facing item and should be + investigated before claiming the global harness is clean. + +## 2026-05-19 Live Task Ledger: Finish the Checker Rectification + +This pass is not done until the remaining diagnostics are classified against the +current engine and every checker false positive has a regression test. The +previous "done" statement was premature: the no-cap convergence class was fixed, +but the global replay still exposed finite diagnostics that must be separated +into source/manifest issues versus engine precision bugs. + +Immediate tasks: + +- Re-run local-replace lint against clean replay targets where possible so dirty + external worktree changes do not get classified as go-lua behavior. +- Classify every remaining diagnostic as one of: + - source/manifest issue: program supplies insufficient proof, external code is + genuinely relying on `any`, optional config fields, or untyped manifest data; + - checker false positive: the program supplies proof and the abstract + interpreter loses it; + - engine internal error: the checker fails to produce a normal diagnostic. +- Reduce every checker false positive or internal error into a minimal go-lua + regression test before changing implementation. +- Keep fixes at domain boundaries, not as per-case bridges: + - subtype remains a pure semantic relation; + - assignment/checking owns expected-type write validation; + - `IndexWrite` remains the pure write-side projection query; + - provenance/freshness proves local literal writes without weakening escaped + or aliased values; + - contextual callback typing seeds nested parameter facts before body + diagnostics; + - convergence is through finite domains, idempotent transfer, and widening, + never iteration caps. +- Continue deferring broad static field-path write diagnostics until the + class/metatable self-reference model has a canonical self-type story. Dynamic + index writes are the current sound typed-write boundary. +- Verify with: + - focused regression tests for each reduced issue; + - `go test ./...`; + - local-replace replay of the affected external targets; + - `git diff --check`; + - the stock verify-suite, with the existing pinned-module boundary documented + rather than hidden. + +Open concrete items from the latest checkpoint: + +- Re-check `framework/src/llm/src` local-replace diagnostics: + - `google/mapper.lua` dynamic recursive schema filtering; + - `util/compress.lua` optional numeric config arithmetic; + - `bedrock/mapper.lua` dynamic text-block parsing; + - `llm.lua` provider contract calls where `model` is currently `any`. +- Re-check global local-replace counts for `tests/app`, `session`, + `actor/test`, `agent/src`, `docker-demo`, `views`, `relay/test`, and + `llm/test`, prioritizing internal errors and diagnostics that were previously + zero. +- Resolve the `tests/app` `E9999` class before claiming the engine has no + replay-facing crashes. +- Keep the implementation and journal aligned; no transitional bridge should + survive unless it is the named final owner of that semantic responsibility. + +## 2026-05-19 Open-Record Iterator Rectification + +The clean LLM replay exposed one remaining checker false positive in +`google/mapper.lua`: + +```lua +obj.multipleOf = nil +obj.additionalProperties = nil +for key, value in pairs(obj) do + if type(value) == "table" then + obj[key] = recursive_filter(value) + end +end +``` + +The foundational bug was not Google-specific. After `type(obj) == "table"`, +the abstract table is open: known field writes may refine visible fields, but +they must not erase the unknown row tail. `KeyType` and `ValueType` for records +were ignoring `Record.Open`, so an open table with two visible nil fields was +decomposed for `pairs()` as if its only possible values were nil. That made the +`type(value) == "table"` branch look impossible and made the same-key write +target appear to accept only `nil`. + +Final-domain correction: + +- record key decomposition now returns `string` for open records; +- record value decomposition includes `unknown` for the open row tail; +- assignment checking has a small canonical iteration-provenance query for + `for key, value in pairs(table)`: when a dynamic write uses the same key and + the ordinary write projection has collapsed to a deleted/nil slot, the write + may use the paired loop value type as the expected slot type; +- closed heterogeneous records and typed map elements remain protected by the + ordinary write projection, so this does not become a broad "dynamic key + accepts anything" escape hatch. + +Regression coverage added: + +- positive: recursive schema filtering can write `recursive_filter(value)` back + to `obj[key]` under a `type(value) == "table"` guard; +- negative: a closed record cannot use a `pairs()` loop to rewrite a numeric + field through a dynamic key with `tostring(value)`; +- negative: a typed map `{[string]: Item}` cannot write `{}` back to an + `Item` slot under a broad table guard; +- query-level tests: open records decompose to `string` keys and include + `unknown` in value iteration. + +Clean replay result after rebuilding the local-replace Wippy binary: + +- `framework/src/llm/src`: 9 errors, down from 10. The Google recursive schema + filtering error is gone. +- `framework/src/llm/test`: same 9 errors, inherited from the source package. +- `framework/src/actor/test`: 0 errors. +- `tests/app`: 2 errors, both untyped overlay URL values flowing into + `http.get(url: string, ...)`. +- `framework/src/views`: 2 errors: + - `api/list_routes.lua` writes `page.id: any` into `{[string]: string}`; + - `page_registry.lua` passes untyped `resource_id: any` to a helper requiring + `string?`. + +Classification of remaining clean replay diagnostics: + +- LLM Bedrock text-block parsing: source/manifest proof gap. The provider + response shape gives `block.text` as `any`; truthiness alone does not prove a + string for `parse_text_tool_call(text: string?)`. +- LLM `compress.lua` arithmetic: source/API proof gap. Exported + `configure(new_config)` accepts untyped external values, so numeric config + fields cannot be treated as permanently numeric without an annotation or + runtime guard. +- LLM provider contract calls: source/manifest proof gap. `provider_info` is + cast to `any`, and `provider_info.provider_model` is not proven string before + calling contracts requiring `model: string`. +- tests/app overlay URLs: source proof gap. `(args and args.url) or fallback` + is `any | string`; a string guard is required before `http.get`. +- views route/resource diagnostics: source proof gaps. Dynamic registry data + needs guards before flowing into string maps or string helper parameters. + +Verification for this pass: + +```text +go test ./compiler/check/tests/regression -run 'TestExternalLint_(PairsSchemaFilterWritesRecursiveValueBackToSameKey|RejectsPairsWriteThatChangesClosedFieldDomain|RejectsPairsWriteThatWeakensTypedMapElement|UntypedPageIDRequiresStringProofForAccessibleRoutes|GuardedPageIDFeedsAccessibleRoutes|DynamicResourceIDsRequireStringProof|GuardedResourceIDsFeedQualifier|DynamicResponseTextRequiresStringProof|AnyProviderModelRequiresStringProof|DynamicModelCardNumericFieldsRequireProof)' -count=1 -v +go test ./types/query/core -run 'Test(KeyType|ValueType|IndexWrite)' -count=1 +go test ./compiler/check/domain/iteration ./compiler/check/hooks ./compiler/check/api -count=1 +go test ./... -count=1 -timeout 300s +git diff --check +../scripts/verify-suite.sh +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go build -o /tmp/wippy-local-replace-verify ./cmd/wippy +timeout 90s /tmp/wippy-local-replace-verify lint --cache-reset --json +``` + +All go-lua tests pass. Clean local-replace replays terminate and the confirmed +engine false positive is removed. The remaining replay diagnostics are strict +dynamic-boundary errors unless later external code supplies stronger manifests +or runtime guards. + +The stock verify script still exits non-zero at the same external module +boundary: + +```text +== go-lua checker tests == +... pass ... + +== build wippy binary == +runtime/lua/code/typecheck.go:397:29: undefined: hooks.WithExhaustiveness +skip lint checks: failed to build /tmp/wippy-local +``` + +This is not a go-lua checker regression from this pass; the normal Wippy module +graph still resolves a published go-lua version older than the local +`hooks.WithExhaustiveness()` API. + +## 2026-05-19 Flash Rectification Continuation: Evidence, Writes, and Replay + +This continuation fixed the checker-suite regressions that appeared after the +first "hard annotation authority" pass. The earlier rule was too blunt: it +treated every source annotation as hard, which removed legitimate soft +structural evidence from function bodies. The final rule is now: + +- hard top annotations such as `any` and `unknown` remain authoritative; +- hard concrete annotations remain authoritative; +- soft structural annotations keep their container shape while evidence refines + the element/value domain; +- call evidence records explicit `nil` arguments, because nil is a real branch + fact, not absence of evidence; +- public call contracts must not be specialized to tuple arity just because one + call passed a literal table. + +Concrete examples now covered by tests: + +- `param: {any}` plus a literal tuple call refines to an array element domain, + not a fixed tuple contract, so a later `{}` call still type-checks. +- `merge_context(nil, {current_item = item, item_index = index})` records the + nil base argument, so the base-copy branch is unreachable for that observed + local call and the resulting map keeps a string key domain. +- maps remain invariant for concrete value domains, but a map value may widen + to expected `any`, matching the existing record-field widening rule. + +Write-side and table-shape corrections in this continuation: + +- `IndexDelete` is the canonical write-query for `t[k] = nil`. Map nil writes + delete entries; required record fields still reject deletion. +- optional record fields accept optional source values in table literals, + modeling Lua nil-as-absence for optional fields. +- soft parameter evidence is applied to function-body overlays but hard + annotations remain annotated in flow. + +Additional regression coverage added: + +- dynamic runner IDs must be proven string before calling `short_name`; +- guarded runner IDs feed the string contract; +- string metadata cannot be indexed as a record without a table proof; +- metadata table guards allow structured field access; +- manifests without assertion summaries do not narrow nil-only locals; +- untyped/variadic command handlers cannot enter typed handler maps through a + dynamic key; +- typed command handlers can enter the same registry. + +Current clean local-replace replay matrix with +`/tmp/wippy-clean-head-local-replace`: + +```text +/tmp/wippy-clean-head/tests/app 2 errors +/tmp/session-clean-head 45 errors +/tmp/framework-clean-head/src/test 0 errors +/tmp/framework-clean-head/src/actor/test 4 errors +/tmp/framework-clean-head/src/agent/src 14 errors +/tmp/framework-clean-head/src/bootloader/src 0 errors +/tmp/framework-clean-head/src/bootloader/test 0 errors +/tmp/framework-clean-head/src/llm/src 14 errors +/tmp/framework-clean-head/src/llm/test 14 errors +/tmp/framework-clean-head/src/migration 0 errors +/tmp/framework-clean-head/src/relay/test 0 errors +/tmp/framework-clean-head/src/views 2 errors +``` + +Classification of remaining clean replay diagnostics: + +- `tests/app` overlay URL errors are source proof gaps: URL values are `any` + unless guarded before `http.get(url: string, ...)`. +- session `""` / `""?` metadata field errors are source/manifest proof gaps: + string defaults are truthy strings, not decoded metadata records. +- session and actor `test.not_nil(...)` nil-index errors are locked + manifest/source-boundary cases. Source-exported assertion modules with + inferred summaries narrow correctly; a manifest without a summary does not + narrow nil-only locals by design. +- session `message_repo` number error is a source proof gap: an untyped `limit` + flows to a numeric contract. +- session `command_bus` handler error is a source proof gap: an untyped + variadic handler is assigned into a typed handler map; the typed adapter form + is covered and passes. +- session `control_handlers` errors are dynamic `any` op payloads flowing into + typed handlers without proof. +- session `start_tokens_test` is an intentional invalid negative call. +- session `checkpoint` length-guarded query shape is already covered by + real-shaped go-lua regressions; the clean replay failure depends on external + package/manifest shape. +- LLM Bedrock text-block parsing is a source/manifest proof gap: `block.text` + is `any` before the string contract. +- LLM `compress.lua` arithmetic is a source/API proof gap: exported + `configure(new_config)` can mutate numeric config fields with untyped values. +- LLM provider contract calls are source/manifest proof gaps: + `provider_info.provider_model` is `any` before contracts requiring `string`. +- LLM and actor provider/test nil-index diagnostics are the same locked + assertion-summary boundary. +- views route/resource diagnostics are source proof gaps: dynamic registry IDs + need string guards before flowing into string maps or string helper params. + +Verification for this continuation: + +```text +go test ./compiler/check/... -count=1 +go test ./types/... -count=1 +go test ./... -count=1 -timeout 300s +git diff --check +../scripts/verify-suite.sh +env GOFLAGS=-modfile=/tmp/wippy-local-replace.mod go build -buildvcs=false -o /tmp/wippy-clean-head-local-replace ./cmd/wippy +clean local-replace replay matrix above +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +``` + +The stock verify suite still passes go-lua checker tests and then stops at the +known external module boundary: + +```text +runtime/lua/code/typecheck.go:397:29: undefined: hooks.WithExhaustiveness +skip lint checks: failed to build /tmp/wippy-local +``` + +Benchmark sample: + +```text +BenchmarkCheck_LargeFunction-32 753 1883468 ns/op 988835 B/op 10030 allocs/op +BenchmarkCheck_LargeFunction-32 348 5596039 ns/op 989341 B/op 10030 allocs/op +BenchmarkCheck_LargeFunction-32 174 6461779 ns/op 989461 B/op 10031 allocs/op +``` + +The wall-time variance is high on this host, but allocations are materially +lower than the earlier recorded 1.44 MB / 20.5k allocs and the post-convergence +3.2-3.4 ms / 1.08 MB / 10.9k allocs samples. No iteration cap was reintroduced. + +### 2026-05-19 field-path type guard parent materialization + +The latest false-positive class was not an interproc fact problem. It was a +local abstract-domain shape problem: a proven fact about a field path was not +being reflected back into the parent container. The concrete failing shape was: + +```lua +if type(op) == "table" and type(op.from_pid) == "string" then + handle(op) -- handle expects {from_pid: string} +end +``` + +The checker knew `op.from_pid` was a string, but the parent value `op` still +looked like unstructured `any/table` at the function-call boundary. That made +the call look like an unproved `any` to record-contract flow. + +The design correction is a canonical narrowing operator, not a checker-side +special case: + +- `types/narrow.ByFieldTypeKey` owns positive runtime type guards on fields; +- `types/flow/domain.TypeDomain` uses it when applying path facts to flow state; +- `types/constraint.Solver` uses the same operator when applying constraints; +- `types/narrow.LiteralFromTypeKey` is the single literal-key decoder used by + both field-literal and type-key paths. + +Soundness rules: + +- `type(t.k) == "string"` in a table-proven branch materializes/refines `t` to + an open record containing `k: string`. +- `t.k == nil` materializes/refines `t` to an open record containing `k: nil`. + This is the Lua absence proof required by optional record fields. +- Hash keys that resolve to literal singletons still route through + `ByFieldLiteral`, so discriminated unions remain exact. +- Broad builtin type-key refinement must not be used as a literal-discriminant + replacement. That avoids impossible intersections such as `true & false`. +- Closed records without the proven field remain unsatisfiable; no open-field + escape is invented. + +This is the intended abstract-interpreter mental model: + +1. leaf-path facts are first-class facts; +2. when a leaf fact proves a field value, the parent shape must be updated in + the same product domain; +3. contract checking reads the parent shape from that domain; +4. no legacy bridge, fallback channel, or post-hoc compatibility projection is + involved. + +Regression coverage added in this pass: + +- `ByFieldTypeKey(any, "from_pid", string)` materializes + `{from_pid: string, ...}`; +- nil field proofs materialize optional-field absence; +- open records refine missing fields; +- unions refine existing field domains; +- closed records with missing fields become `never`; +- flow-domain `HasType(parent.field)` and `IsNil(parent.field)` update + `parent`; +- constraint-solver `HasType(parent.field)` and `IsNil(parent.field)` update + `parent`; +- untyped limit/control/start-option cases still fail without proof; +- guarded limit/control/start-option cases pass with proof. + +Verification after this correction: + +```text +go test ./types/narrow ./types/flow/domain ./types/constraint \ + -run 'Test(ByFieldTypeKey|TypeDomain_Apply(IsNil|HasType)OnField|Solver_ApplyToSingle_(HasType|IsNil)OnField)' \ + -count=1 -v + +go test ./compiler/check/tests/regression \ + -run 'TestExternalLint_(UntypedLimitRequiresNumberProof|GuardedLimitFeedsNumberContract|DynamicControlPayloadRequiresTypedProof|GuardedControlPayloadFeedsTypedHandler|StartOptionsRejectsPlainString|GuardedStartOptionsFeedsOptionalRecord)' \ + -count=1 -v + +go test ./... -count=1 -timeout 300s +git diff --check +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction \ + -benchmem -count=3 +``` + +All of the above passed. + +Benchmark sample: + +```text +BenchmarkCheck_LargeFunction-32 819 1436389 ns/op 988745 B/op 10034 allocs/op +BenchmarkCheck_LargeFunction-32 830 1480187 ns/op 988940 B/op 10034 allocs/op +BenchmarkCheck_LargeFunction-32 816 1495422 ns/op 988964 B/op 10034 allocs/op +``` + +The stock `../scripts/verify-suite.sh` result is not a clean verdict on this +checkout. It builds `/tmp/wippy-local` from `/home/wolfy-j/wippy/wippy`, whose +module graph is still pinned to `github.com/wippyai/go-lua v1.5.16`: + +```text +dep github.com/wippyai/go-lua v1.5.16 +``` + +That pinned-binary run passed go-lua checker tests and built the binary, then +exited non-zero on external lint targets: + +```text +/home/wolfy-j/wippy/session errors=8 warnings=0 +/home/wolfy-j/wippy/framework/src/agent/src errors=8 warnings=0 +/home/wolfy-j/wippy/docker-demo errors=21 warnings=2 +``` + +The current-PR local-replace binary was rebuilt with: + +```text +dep github.com/wippyai/go-lua v1.5.16 => /home/wolfy-j/wippy/go-lua (devel) +``` + +Local-replace sampling still reports external diagnostics, but the remaining +classes are source or manifest proof gaps: + +- untyped `any` values passed to `string`, `number`, `Time`, or record + contracts; +- string defaults such as `""` indexed as metadata records; +- manifest-only assertion helpers without assertion-effect summaries; +- dynamic provider/model/config values passed to typed LLM contracts; +- exported config mutation that can invalidate numeric reads; +- intentional negative tests such as passing `"not a table"` to a record + contract. + +No current engine false-positive class remains from this pass. If a later +external diagnostic is reclassified as an engine issue, the rule is unchanged: +first reduce it into a go-lua regression, then fix the canonical domain or query +owner. Do not widen `any` into typed contracts, do not assume assertion effects +from names, and do not add bridge/fallback facts. + +## 2026-05-19 Predicate Effect And Branch Product Checkpoint + +The next external replay reclassified one docker/dataflow diagnostic as a real +engine issue, not source code: + +```lua +local function validate_batch_size(size) + return type(size) == "number" and size > 0 and size <= 1000 +end + +local batch_size = config.batch_size or DEFAULTS.BATCH_SIZE +if not validate_batch_size(batch_size) then + return nil +end + +for i = 1, #items, batch_size do + ... +end +``` + +The old implementation had two foundational problems: + +- direct predicate calls in conditions did not consume inferred function + refinements, while variables assigned from predicate calls used a separate + predicate-link path; +- the falsy side of a one-sided proof could be approximated by negating the + truthy proof. For predicates such as `type(x) == "number" and x > 0`, that is + unsound: a false result does not prove `x` is not a number. + +The correction is the final abstract-interpreter shape for branch facts: + +```text +branch(expr) -> { truthy: Condition, falsy: Condition } +``` + +The branch extractor now computes this product compositionally: + +- `not e` swaps the truthy/falsy products; +- `a and b` uses short-circuit transfer: + `truthy = a.truthy & b.truthy`, + `falsy = a.falsy | (a.truthy & b.falsy)`; +- `a or b` uses: + `truthy = a.truthy | (a.falsy & b.truthy)`, + `falsy = a.falsy & b.falsy`; +- equality and inequality use their canonical positive operators on one side + and the opposite relation on the other; +- ordered comparisons only prove numeric/string operand type on the truthy + side. Their falsy side is intentionally not the negation of that type proof; +- direct predicate calls instantiate the same `FunctionRefinement` product used + by assigned predicate results; +- assigned predicate variables apply only stored `OnTruthy` and `OnFalsy` + channels. Missing `OnFalsy` means no argument narrowing, not + `not OnTruthy`. + +Return-expression effect inference was split into three channels: + +```text +OnReturn = facts guaranteed after the callee returns normally +OnTrue = facts guaranteed when the returned value is truthy +OnFalse = facts guaranteed when the returned value is falsy +``` + +This matters because callable type casts and assertion-style helpers are normal +return effects, while predicate helper results are truthiness effects. A wrapper +around `Point(x)` must publish `OnReturn: x is Point`; a wrapper around +`type(x) == "number" and x > 0` must publish `OnTrue: x is number` without +inventing a useful `OnFalse`. + +Regression coverage added in this pass: + +- a local `validate_batch_size` predicate narrows an `any` batch size after + early return and supports numeric `for` steps; +- direct predicate calls narrow their arguments on the truthy branch; +- assigned predicate results narrow their arguments on the truthy branch; +- direct and assigned one-sided predicates do not narrow the falsy branch; +- logical predicate paths narrow loop bounds through `and`; +- logical `else` paths do not over-narrow when only one disjunct proves the + predicate; +- callable type-call wrappers still publish normal-return cast refinements; +- plain identifier returns still allow guard-established path conditions to + publish assertion-style `OnReturn` refinements. + +Verification after this correction: + +```text +env GOCACHE=/tmp/go-build go test ./compiler/check/... -count=1 +env GOCACHE=/tmp/go-build go test ./... -count=1 -timeout 300s +env GOCACHE=/tmp/go-build go test ./compiler/check -run '^$' \ + -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +``` + +All passed. + +## 2026-05-20 Root Scope Canonicalization + +The local harness reductions exposed one real checker edge case outside the +parent lint environment: when the checker was constructed without stdlib or any +other root scope, the root graph used a nil parent. That meant +`returninfer.ComputeForGraph` skipped root local-function fact computation +because there was no parent scope to key the canonical product. + +Production lint normally has a stdlib scope, so this did not explain the +local-replace parent diagnostics. It was still a design inconsistency: the root +abstract interpreter should always have a canonical scope key, even when that +scope is empty. + +The driver now creates an explicit empty root scope when no stdlib/root scope is +configured: + +```text +compiler/check/pipeline/driver.go +``` + +This is not a fallback layer. It removes a nil special case and makes root +analysis use the same product-domain protocol as every other graph: + +```text +root scope -> parent hash -> GraphKey -> FunctionFacts product +``` + +Regression coverage was extended in: + +```text +compiler/check/tests/regression/local_replace_harness_precision_test.go +``` + +New/updated coverage: + +- no-stdlib root local-function return inference now propagates table fields to + helper calls; +- imported scheduler decision payload contracts survive manifest export/import; +- local scheduler-like payload narrowing and heterogeneous handler tables remain + protected. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestLocalReplaceHarness' -count=1 -timeout 120s +go test ./compiler/check/pipeline ./compiler/check -count=1 -timeout 120s +go test ./... -count=1 -timeout 240s +env GOCACHE=/tmp/go-build-cache staticcheck ./compiler/check/... ./types/flow/... ./types/narrow +env GOWORK=off GOCACHE=/tmp/go-build-cache go build -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -o /tmp/wippy-current-golua ./cmd/wippy +``` + +All passed. + +The local-replace parent harness was rerun after the rebuild. The split remained +unchanged: + +```text +/home/wolfy-j/wippy/wippy/tests/app: 2 errors +/home/wolfy-j/wippy/session: 33 errors +/home/wolfy-j/wippy/framework/src/test: 0 errors +/home/wolfy-j/wippy/framework/src/actor/test: 1 error +/home/wolfy-j/wippy/framework/src/agent/src: 6 errors +/home/wolfy-j/wippy/framework/src/bootloader: 0 errors +/home/wolfy-j/wippy/docker-demo: 61 errors +/home/wolfy-j/wippy/framework/src/llm/src: 3 errors +/home/wolfy-j/wippy/framework/src/llm/test: 3 errors +/home/wolfy-j/wippy/framework/src/migration: 0 errors +/home/wolfy-j/wippy/framework/src/views: 2 errors +/home/wolfy-j/wippy/framework/src/relay/test: 0 errors +``` + +Current conclusion: go-lua verification is clean, and the remaining +local-replace parent diagnostics are still source/manifest contract issues or +soundness-required dynamic-shape rejections. They are not explained by the root +scope edge case fixed here. + +Benchmark sample: + +```text +BenchmarkCheck_LargeFunction-32 811 1452509 ns/op 989363 B/op 10017 allocs/op +BenchmarkCheck_LargeFunction-32 831 1465673 ns/op 989516 B/op 10017 allocs/op +BenchmarkCheck_LargeFunction-32 817 1474103 ns/op 989520 B/op 10017 allocs/op +``` + +External local-replace replay after rebuilding +`/tmp/wippy-golua-predicate-current` against this checkout: + +```text +/tmp/framework-clean-head/src/llm/src 14 errors +/tmp/framework-clean-head/src/views 2 errors +/tmp/wippy-clean-head/tests/app 2 errors +/tmp/session-clean-head 45 errors +/tmp/framework-clean-head/src/actor/test 4 errors +/tmp/framework-clean-head/src/agent/src 14 errors +/tmp/framework-clean-head/src/test 0 errors +/tmp/framework-clean-head/src/bootloader/src 0 errors +/home/wolfy-j/wippy/docker-demo 66 errors +``` + +The docker/dataflow predicate false positive is fixed in the real replay: +`userspace.dataflow.node.parallel` dropped from 4 errors to 3, and the removed +diagnostic was the `batch_size` numeric-loop error. The remaining three +parallel diagnostics are map-key shape issues: + +```text +argument 3: expected {[string]: any}?, got {[any]: any}? +``` + +Those remain classified as source/manifest proof gaps unless a smaller +go-lua-only reduction proves otherwise. The engine rule remains the same: +predicate/effect facts must flow through the canonical branch product and +function-refinement channels, not through call-site expectation backflow or +name-based special cases. + +Official `../scripts/verify-suite.sh` result after this correction: + +```text +go-lua checker tests: pass +wippy binary build: pass +wippy/tests/app: 0 +session: 8 errors +framework/src/test: 0 +framework/src/actor/test: 0 +framework/src/agent/src: 11 errors +framework/src/bootloader: 0 +docker-demo: 21 errors, 2 warnings +framework/src/llm/src: 0 +framework/src/llm/test: 0 +framework/src/migration: 0 +framework/src/views: 0 +framework/src/relay/test: 0 +``` + +The script still exits non-zero because those external lint targets are part of +the Wippy checkout, not because go-lua tests or binary build failed. This is the +same pinned-suite caveat recorded above: use local-replace replay for this +checkout's checker behavior and official verify for the repository gate shape. + +## 2026-05-19 Table Mutation And Static-Key Rectification + +The next local-replace replay found two real engine false positives after the +predicate/effect checkpoint. Both were domain-boundary bugs, not reasons to add +new fact channels or compatibility bridges. + +### Deletion Is Not Element Evidence + +`userspace.docker.service:worker` failed after a table slot was initialized, +used, and later removed: + +```lua +if not active[cid] then + active[cid] = {} + run_interactive(active, cid, c) + active[cid] = nil +end +``` + +The old overlay merge treated `active[cid] = nil` as a value assignment and +merged `nil` into the map element domain. That polluted later writes and +produced an impossible element requirement. In Lua, `t[k] = nil` deletes the +slot; map reads are already optional because a key can be absent. Therefore the +write-side effect lattice is: + +```text +index write with non-nil value -> element evidence +index write with nil-only value -> deletion effect, no element evidence +``` + +The canonical implementation now drops nil-only indexer mutations when building +return/overlay map evidence. This keeps deletion semantics local to the table +mutation domain instead of encoding absence as a stored value type. + +Regression coverage: + +- nil-only indexer writes do not create map evidence; +- mixed delete/write effects keep only the non-nil write value; +- guarded map-slot initialization still accepts valid element writes; +- invalid element writes to typed maps still fail; +- captured/async map parameter writes stay precise while later deletes do not + poison the parameter evidence. + +### Empty String Is A Static Key + +`userspace.dataflow:client` failed on the root-output merge: + +```lua +local outputs = {} +... +outputs[key] = content +... +outputs[""] = root_output +``` + +The extractor was using `keySeg.Name == ""` as the sentinel for "not a static +key". That is not a valid discriminator because `""` is a valid Lua table key. +The result was a bogus dynamic indexer assignment for `outputs[""]`, which +created `{[string]: never}` evidence and then rejected the real write as +`any -> never`. + +The corrected model is explicit: + +```text +static-key extraction returns (segment, ok) +ok=false means dynamic/unknown key +segment payload may be empty because [""] is a valid static key +``` + +The assignment extractor now tracks a separate `hasStaticKeySeg` boolean, and +the shared path segment helper accepts empty string indexes as +`SegmentIndexString{Name: ""}`. This keeps path identity canonical and prevents +empty string keys from falling into the dynamic map-widening path. + +Regression coverage: + +- `StaticAttrKeySegment("")` and `StaticTableFieldKeySegment("")` produce a + static string-index segment; +- a dataflow-style output map can receive dynamic named outputs and then the + root output at `[""]` without producing `never`; +- the same fixture keeps dynamic row content as `any`, proving the fix is not a + narrow literal-only special case. + +### Truthiness Must Remove All Nil Layers + +One more precision repair was kept in the core narrowing library: `RemoveNil` +now recurses through optional wrappers. Constructors normally canonicalize nested +optionals, but imported/derived field shapes can still present equivalent +nil-capable layers. Truthy narrowing and `or` fallback must produce the non-nil +payload, not leave one optional layer behind. + +Regression coverage uses deliberately non-canonical optional wrappers so the +test protects the narrowing algorithm rather than the constructor normalizer. + +### Replay Classification After These Fixes + +Targeted local-replace replay with `/tmp/wippy-golua-current-verify`: + +```text +docker-demo userspace.docker.service: 0 errors, 0 warnings +docker-demo userspace.dataflow: 0 errors, 0 warnings +docker-demo full replay: 63 errors, 0 warnings +``` + +Official `../scripts/verify-suite.sh` after this correction: + +```text +go-lua checker tests: pass +wippy binary build: pass +wippy/tests/app: 0 +session: 8 errors +framework/src/test: 0 +framework/src/actor/test: 0 +framework/src/agent/src: 8 errors +framework/src/bootloader: 0 +docker-demo: 21 errors, 2 warnings +framework/src/llm/src: 0 +framework/src/llm/test: 0 +framework/src/migration: 0 +framework/src/views: 0 +framework/src/relay/test: 0 +``` + +The two fixed namespaces were engine false positives and are now clean. +Remaining sampled diagnostics remain source/proof-boundary issues, not current +engine regressions: + +- `wippy.llm.util:compress` exposes an untyped public `compress.configure` + method that can write arbitrary values into numeric `CONFIG` fields. The + regression suite already has both the negative untyped-config case and the + positive typed-config case. +- `wippy.llm.claude:client` in docker-demo calls `json.decode(response.body)` + where the vendored response body is `string?`; unlike other copies, that + source has no `or ""` fallback or cast at the decode site. +- the remaining full-replay classes are dynamic `any` values crossing typed + contracts, stale/vendor source shapes, intentionally string metadata used as a + record, or manifest/source proof gaps already represented by negative + regressions. + +The design rule remains unchanged: reduce every suspected false positive to a +go-lua test first, then fix the owning abstract domain. Do not add fallback fact +channels, bridge projections, or name-specific repairs. + +## 2026-05-19 Replay Reclassification And Soundness Guardrails + +After the table-mutation/static-key fixes, I replayed the current local +checker through `/tmp/wippy-golua-current-verify`. `go version -m` confirms the +binary is built with: + +```text +github.com/wippyai/go-lua v1.5.16 => /home/wolfy-j/wippy/go-lua (devel) +``` + +The current go-lua suite passes: + +```text +env GOCACHE=/tmp/go-build go test ./... -count=1 -timeout 300s +``` + +Official `../scripts/verify-suite.sh` after the final regression additions: + +```text +go-lua checker tests: pass +wippy binary build: pass +wippy/tests/app: 0 +session: 8 errors +framework/src/test: 0 +framework/src/actor/test: 0 +framework/src/agent/src: 11 errors +framework/src/bootloader: 0 +docker-demo: 21 errors, 2 warnings +framework/src/llm/src: 0 +framework/src/llm/test: 0 +framework/src/migration: 0 +framework/src/views: 0 +framework/src/relay/test: 0 +``` + +The script still exits non-zero because those external lint diagnostics are +part of the pinned Wippy verification target set. The go-lua checker suite and +binary build pass. + +Focused local-replace external lint results: + +```text +/home/wolfy-j/wippy/session 37 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 11 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 63 errors, 0 warnings +``` + +These are not the fixed table-mutation/static-key false-positive classes. The +remaining sampled classes classify as source, manifest, or dynamic-boundary +issues unless a smaller go-lua-only reduction later proves otherwise. + +Important source/version detail: the lint targets do not always use the live +framework checkout. The lock files point at vendored modules and packed `.wapp` +dependencies. For example, docker-demo uses vendored `wippy/llm 0.4.8`, whose +Claude client contains: + +```lua +json.decode(response.body) +``` + +The live framework source has the safer fallback, but that is not the source +being linted by docker-demo. The checker must reject the vendored direct +nullable-body call, and must accept the current-source fallback: + +```lua +json.decode(response.body or "") +``` + +Regression coverage now protects both sides: + +- manifest-provided `http_client.Response.body: string?` plus + `json.decode(response.body or "")` is accepted; +- direct `json.decode(response.body)` with `body: string?` is rejected; +- explicit method-call casts still satisfy parameter contracts; +- constant numeric table fields stay non-nil when no untyped mutator can poison + them; +- untyped config mutation can invalidate numeric config fields and is rejected; +- truthy `"" | record` metadata does not become a guaranteed record, because + empty strings are truthy in Lua; +- arbitrary-key dynamic maps are not accepted as string-key maps once numeric + key evidence exists. + +Representative classifications: + +- `wippy.llm.util:compress` remains soundly rejected in vendored packages when + public untyped `configure(new_config)` can write arbitrary values into + numeric `CONFIG` fields. The typed-config positive case still passes. +- `wippy.llm.*:client` nullable-body diagnostics in older vendored packages + are source/version issues when the call has no fallback. +- `wippy.views:renderer` in older vendored packages calls + `tmpl:render(page.template_name, ...)` without the current-source cast; the + explicit-cast reduction passes. +- `userspace.dataflow.node.parallel` context-map diagnostics come from dynamic + `step.context` / arbitrary key copying into APIs requiring `{[string]: any}?`; + the checker must not invent string keys. +- `session` artifact metadata diagnostics are source-boundary issues: decoded + JSON metadata and SQL/string metadata need explicit shape proof. Lua truthiness + does not turn an empty string into a metadata record. + +The design invariant is unchanged and is now covered more directly: the +abstract interpreter may refine values only from semantic evidence in the +canonical domains. It must not use optimistic compatibility bridges, call-site +wishful typing, or old fallback fact projections to hide nullable values, +dynamic keys, or untyped mutation. + +## 2026-05-20 Replay Boundary: No Heuristic Row-Shape Magic + +I rechecked the current local-replace replay with +`/tmp/wippy-golua-fb0238a7`, which is built as: + +```text +github.com/wippyai/go-lua v1.5.16 => /home/wolfy-j/wippy/go-lua (devel) +``` + +Current local-replace external lint remains: + +```text +/home/wolfy-j/wippy/session 37 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 11 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 63 errors, 0 warnings +``` + +The important convergence result still holds: the current local-replace +docker-demo replay has zero non-convergence warnings. The old +`inter-function fixpoint did not converge; unstable channels: +[InterprocFacts]` class was from the pinned verification binary, not this +checkout. + +I reduced the session checkpoint diagnostic again because it is the easiest +place to accidentally justify a hack. The protected cases now include: + +- imported fluent metatable query builders; +- repository fallback `contexts or {}` after an error-return pair; +- a separate repository module feeding a separate reader module; +- SQL-builder-shaped rows where `executor:query()` returns + `{[string]: any}[]`; +- `table.sort` before reading `existing_summaries[1].text`; +- an untyped tool argument guarded only by `if not args.session_id then`. + +All of those reduced go-lua tests pass. Therefore this is not evidence for a +new compatibility bridge, special-case length rule, or checkpoint-specific +repair. + +The SOTA boundary is: + +- The abstract interpreter may use length guards to prove indexed array + presence. +- It may use error-return correlation and nil repair to remove impossible nil + paths. +- It may propagate those facts through exported function summaries and fluent + metatable receivers. +- It must not infer structured SQL row records from arbitrary SQL strings or + dynamic database results unless the boundary provides a typed effect or typed + manifest. + +If we ever want SQL-builder row-shape inference, the final design is not an +ad-hoc checker heuristic over `sql.builder.select(...)`. It is a typed external +effect owned by the manifest/builtin boundary, for example: + +```text +select("id", "text") -> SelectBuilder +run_with(db) -> QueryExecutor +query() -> ({Row}, error?) +``` + +That effect would be a normal abstract-domain input, cached through the same +Salsa/query boundary as other builtin facts. It would not add another fact +channel and would not bypass canonical product-domain convergence. + +Current classification rule for the replay: + +- keep adding go-lua reductions for any diagnostic that looks like a precision + regression; +- implement an engine fix only when the reduced case fails; +- otherwise record the external diagnostic as source, manifest, version, or + dynamic-boundary proof debt; +- do not hide real dynamic-boundary errors with casts, fallbacks, compatibility + projections, iteration caps, or source-specific helpers. + +## 2026-05-20 Contextual Callback Rectification + +The docker-demo replay exposed a real checker gap in: + +```lua +str:gsub(pattern, function(c) + fields[#fields + 1] = c +end) +``` + +The old stdlib type for `string.gsub` accepted `repl: any`. That was soundly +permissive for call acceptance but it erased the callback contract, so the +capture parameter was checked as `any` and assigning it into `{string}` became +a false positive. Lua's semantics give us a real contract here: replacement +callbacks receive the full match/captures as strings and may return +string/number/false/nil. + +The first attempted shape was too broad: collecting contextual signatures for +all call arguments, including nested table callbacks, increased +`framework/src/agent/src` from 11 to 24 local-replace errors. That was a design +mistake, not an acceptable migration step. It globally stored too much +call-site context and over-constrained test harness callback tables. + +Final shape: + +- `string.gsub` now has a precise replacement union: + string, string-key replacement table, or replacement callback. +- direct function-literal callback arguments are probed with a shallow + signature only to discover the callee's expected callback contract; +- the actual callback body is then synthesized with that expected parameter + type, while its body still contributes return types for generic inference; +- nested table callbacks are not globally context-typed from arbitrary call + schemas; +- contextual callback signatures are stored only for direct callback literals + whose callee provides a real callback function type. + +This keeps the final model bidirectional and call-local instead of adding a +new fact channel. The compatibility view is not another fact source: the call +checker derives expected argument types from the canonical function type, and +the function-literal checker uses that expected type for the one literal at +that call site. + +Regression protection now covers: + +- `string.gsub` callback captures flowing into `{string}`; +- valid `gsub` replacement forms: string, table, callback returning + string/number/false/nil; +- invalid callback replacement returns; +- generic result combinators where callback returns infer type parameters; +- the existing iterator/result fixtures that require callback return inference. + +Local-replace replay with `/tmp/wippy-golua-narrow-callback`: + +```text +/home/wolfy-j/wippy/session 37 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 11 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 61 errors, 0 warnings +``` + +The two docker-demo errors removed from the previous 63-error baseline are the +`string.gsub` callback capture false positives in `agents_by_name.lua` and +`models_by_name.lua`. The 24-error spike in the agent replay is gone. + +Performance note from `BenchmarkCheck_LargeFunction` after the callback fix: + +```text +~2.0-3.35 ms/op, ~1.00 MB/op, 10223 allocs/op +``` + +Time is noisy on this machine, but allocations remain much lower than the +earlier ~20.5k alloc/op benchmark shape. The callback fix should not be +expanded into whole-program call-argument signature collection; that is both +slower and less precise. + +## 2026-05-20 Callback Helper Consolidation And Degradation Audit + +After the degradation report I reran both sides of the local-replace checkpoint +on the same external worktrees. + +Saved checkpoint binary `/tmp/wippy-golua-fb0238a7`: + +```text +/home/wolfy-j/wippy/session 37 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 11 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 63 errors, 0 warnings +``` + +Current rebuilt binary `/tmp/wippy-golua-current-callback`: + +```text +/home/wolfy-j/wippy/session 37 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 11 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 61 errors, 0 warnings +``` + +So the active code is not the 11-to-24 agent regression. That spike belonged to +the rejected broad call-site-signature collection. The final patch removes that +shape and preserves only the direct callback contract needed by `string.gsub`. + +I also collapsed the duplicated contextual-function helper into +`phase/core.ExpectedFunctionLiteralSignature`. That is now the single local +rule for turning an expected type into a function-literal signature, including +arity-compatible function members inside a union. The call synthesizer now +skips the contextual probe entirely when a call has no direct function literal +arguments, and it reuses already-synthesized non-callback argument types during +the probe path. That keeps the hot path simpler and avoids double synthesis of +ordinary arguments. + +Current benchmark: + +```text +BenchmarkCheck_LargeFunction-32 658 1764196 ns/op 1001938 B/op 10188 allocs/op +BenchmarkCheck_LargeFunction-32 694 1800709 ns/op 1002041 B/op 10188 allocs/op +BenchmarkCheck_LargeFunction-32 651 1716371 ns/op 1001966 B/op 10188 allocs/op +``` + +Official `../scripts/verify-suite.sh` still builds Wippy without the local +go-lua replacement. In that pinned-dependency mode, go-lua checker tests pass +and the Wippy binary builds, then external lint exits non-zero with: + +```text +/home/wolfy-j/wippy/session 8 errors, 0 warnings +/home/wolfy-j/wippy/framework/src/agent/src 7 errors, 0 warnings +/home/wolfy-j/wippy/docker-demo 21 errors, 2 warnings +``` + +Those pinned counts are useful for monitoring but are not the proof surface for +this go-lua patch. The proof surface for this patch is the local-replace replay +above plus the go-lua regression suite. + +## 2026-05-20 Flash Migration: Abstract Transfer Evidence Boundary + +Current migration state after the latest flash slice: + +- `compiler/check/flowbuild` is gone. The final transfer owner is + `compiler/check/abstract/transfer`. +- `compiler/check/domain/factproduct` is gone. The final interprocedural + product owner is `compiler/check/domain/interproc`. +- `abstract.RunTransfer` returns one `TransferResult`: flow inputs plus + transfer-owned evidence. +- `api.FuncResult` stores `Evidence api.FlowEvidence`, not a separate call side + channel. +- `api.FuncAnalysisView` replaced the misleading `FuncResultSnapshot` name. It + is a nested-processing view, not a fact snapshot authority. +- `FunctionFacts` remains the only authority for params, return summary, narrow + return summary, function type, and refinement. +- `LiteralSigs`, captured types, captured field writes, captured container + mutations, and constructor fields are product slots under `api.Facts`. +- Production publishers use `domain/interproc` delta constructors. Direct + `api.Facts{...}` construction outside tests is now limited to product-domain + internals and store cloning/empty values. + +Evidence ownership after this slice: + +- call discovery is centralized in `abstract/transfer`. +- `x.field or default` expression evidence is centralized in + `abstract/transfer`. +- captured field writes and captured container mutations are discovered in + `abstract/transfer`. +- postflow interproc publication no longer rescans nested bodies for captured + writes; it reduces transfer evidence with narrowed expression types. +- local return SCC propagation no longer scans local-function call sites; it + consumes `LocalFuncInfo.Evidence.Calls`. +- parent-call parameter evidence and nested mutation replay no longer scan + parent call sites; they consume parent `FlowEvidence.Calls`. + +Important invariant: + +```text +CFG/AST event discovery belongs to abstract transfer. +Later phases may reduce transfer evidence with solved/narrowed types. +Later phases must not rediscover call/body evidence by walking the AST again. +``` + +This is the flash migration shape, not a compatibility bridge. The old +`FunctionTypesFromFacts`, return-summary mirrors, per-slice fact snapshot +getters, scratch literal signatures, and captured-write nested rescans are not +present in production checker code. + +Remaining non-interproc graph walks are classified as follows: + +- `abstract/transfer/*`: canonical event discovery and flow-input lowering. +- `hooks/*`: validation passes over already-built analysis results. +- `effects/propagate.go`: effect validation/propagation, not a fact authority. +- `synth/*`: expression synthesis helpers, not interproc fact publication. +- `infer/return` assignment and return walks: local return-vector construction + and overlay mutation synthesis. These are still part of return inference, not + a second interproc fact channel. If they start publishing cross-function facts + directly, they must be moved behind transfer evidence first. +- `nested/constructor.go` and `nested/table.go`: constructor/self-shape pattern + recognition. Constructor publication already goes through the module + `ConstructorFields` product slot, but the pattern recognizer itself is still a + specialized structural recognizer. This is the next design area to collapse if + constructor/self inference expands. + +Verification for this checkpoint: + +```text +go test ./compiler/check/returns ./compiler/check/infer/return ./compiler/check/pipeline ./compiler/check/tests/flow ./compiler/check/tests/inference ./compiler/check -count=1 + +go test ./compiler/check/api ./compiler/check/store ./compiler/check/infer/interproc ./compiler/check/infer/nested ./compiler/check/infer/return ./compiler/check/pipeline ./compiler/check/domain/... ./compiler/check/abstract/... ./compiler/check/synth/phase/extract ./compiler/check/returns ./compiler/check/nested ./compiler/check ./compiler/check/tests/flow ./compiler/check/tests/errors ./compiler/check/tests/modules ./compiler/check/tests/inference -count=1 +``` + +Both passed. + +No external replay or global lint classification was performed in this +checkpoint because the active instruction was to finish the migration boundary +before returning to regression classification. + +## 2026-05-20 Flash Migration: Transfer Event Trace Collapse + +This slice completed the abstract-transfer boundary for the return/nested +checker path. The checker now has one canonical event source for the structural +events that downstream inference needs: + +```text +CFG/AST -> abstract/transfer -> flow.Inputs + FlowEvidence +FlowEvidence + solved/narrowed types -> reducers/publishers +FunctionFacts/api.Facts -> only persisted interprocedural product +``` + +Directly migrated event ownership: + +- `FlowEvidence` now carries assignment, return, call, field-default, + function-definition, function-escape, captured-field, and captured-container + events discovered by `abstract/transfer`. +- return inference no longer scans assignments to find local functions; it + consumes `FlowEvidence.FunctionDefinitions`. +- return inference no longer scans returns to build return vectors; it consumes + `FlowEvidence.Returns`. +- return overlay construction no longer scans assignments for local function + values, local declaration seeds, local annotations, or captured parent + annotations; it consumes local or parent `FlowEvidence.Assignments`. +- nested processing no longer calls `nested.GatherChildren` or + `ResolveNestedFuncIdentity`; those helpers were deleted. Function identity is + resolved once by transfer evidence. +- constructor/self pattern helpers no longer scan parent/nested graphs for + assignments or returns; they consume `AssignmentEvidence` and `ReturnEvidence`. +- session graph hierarchy registration no longer has separate assignment, + funcdef, and nested-function registration loops; it consumes + `FunctionDefinitionEvidence`. +- module export no longer scans return nodes; it consumes result return + evidence. + +The invariant is now stronger: + +```text +Production phases after transfer may inspect solved state, synthesize expression +types, and reduce transfer events. They must not rediscover transfer-owned +events by walking the CFG/AST in return/nested/interproc orchestration. +``` + +Proof scan for the migrated boundary: + +```text +rg "EachAssign|EachCallSite|EachFuncDef|EachReturn|EachBranch|for _, stmt := range" \ + compiler/check/infer/return compiler/check/infer/nested compiler/check/returns \ + compiler/check/pipeline compiler/check/nested compiler/check/session.go \ + compiler/check/modules/export.go -g '!**/*_test.go' -n +``` + +The scan returns no production matches. + +Legacy/fallback scan: + +```text +rg "flowbuild|factproduct|FunctionTypesFromFacts|Get.*Snapshot|StoreLiteralSigs|ScratchLiteralSigs|legacy|bridge" \ + compiler/check -g '!**/*_test.go' -n +``` + +The scan returns no production matches. + +Verification for this slice: + +```text +go test ./compiler/check/abstract/transfer ./compiler/check/infer/return \ + ./compiler/check/infer/nested ./compiler/check/nested ./compiler/check/returns \ + ./compiler/check/pipeline ./compiler/check ./compiler/check/modules -count=1 + +git diff --check +``` + +Both passed. + +Broader checker verification currently still fails in regression fixtures: + +```text +go test ./compiler/check/... -count=1 +``` + +Known failing fixtures at this checkpoint: + +- `TestExternalLint_SessionReaderQueryBuilderRealShape` +- `TestExternalLint_CompressModelInfoNumericHelpersStayNonNil` +- `TestLinterFalsePositive_TestRunnerPattern` +- `TestLinterFalsePositive_TestRunnerExact` +- `TestLinterFalsePositive_GraphLocalUnusedParamAllowsInternalAny` + +Those failures are not classified here because the active work was the flash +migration to the abstract-transfer event boundary, not the false-positive +repair pass. They remain the next correctness proof obligations after this +structural migration. + +## 2026-05-20 Correction: Event Boundary Was Still Too Loose + +The broad scan after the previous checkpoint found real remaining slop. The +earlier statement that the event boundary was clean was too strong. + +Real unresolved migration seams: + +- module alias extraction still called `modules.CollectAliases(graph)` from + resolve, runner, driver, transfer declarations, and return inference; +- overlay mutation collection still scanned assignment nodes through + `overlaymut`/`transfer/assign` wrappers; +- function-type synthesis still walked assignments, returns, and branches for + local return inference and ordered-comparison hints; +- error-return inverse-pattern proof still walks return nodes directly; +- `synth/phase/extract` still has several local discovery scans for named + functions and callback environments. + +The corrected invariant is stricter: + +```text +Graph/AST event discovery has one owner: abstract transfer. +Consumers may request transfer-owned graph evidence, then reduce that evidence +with the type state they own. Consumers must not run their own CFG event scans +for aliases, assignment mutations, returns, branches, calls, or function defs. +``` + +The immediate flash migration target is therefore not another compatibility +layer. It is one canonical graph evidence object: + +```text +cfg.Graph -> transfer.ExtractGraphEvidence -> api.FlowEvidence +api.FlowEvidence + flow/product state -> phase reducers and publishers +``` + +Direct migration steps for this correction: + +- `modules` becomes a reducer over `[]api.AssignmentEvidence`; it no longer + scans a graph. +- overlay mutation collection becomes a reducer over + `[]api.AssignmentEvidence`; it no longer scans a graph. +- `abstract.RunTransfer` seeds `FlowContext` with graph evidence before + lowering so declaration extraction and post-transfer evidence use the same + trace. +- runner/driver/resolve/return/synth callers use transfer graph evidence as + their event source instead of reconstructing assignment scans locally. + +This still leaves larger design work after the direct event collapse: + +- synthesis should become a structural expression evaluator over a product + query interface, rather than owning its own precedence rules; +- error-return inverse proof should consume return evidence; +- remaining named-function/callback discovery in `synth/phase/extract` should + be converted to transfer evidence or a stable graph summary; +- store/query APIs still expose some slice-shaped projections of the canonical + product and need a separate product-query cleanup. + +## 2026-05-20 Correction: Canonical Trace Cutover + +The corrective flash migration now has a concrete owner for event discovery: + +```text +compiler/check/abstract/trace +``` + +`trace` is the only non-transfer package that walks CFG/AST structure to build +semantic evidence. `abstract/transfer` consumes that trace and may still walk +the CFG internally while lowering flow inputs. Other phases reduce trace +evidence; they do not rediscover the same events. + +Moved to the canonical trace/reducer shape: + +- module alias inference reduces `[]api.AssignmentEvidence`; +- overlay field/index mutation inference reduces `[]api.AssignmentEvidence`; +- table mutator overlay inference reduces `[]api.CallEvidence`; +- constructor/self field inference reduces `[]api.AssignmentEvidence`; +- literal function types/signatures consume trace assignments, function + definitions, calls, and returns; +- synth local-function rebinding/captured-mutation checks consume trace + assignments and function definitions; +- error-return inverse proof consumes `[]api.ReturnEvidence`; +- parameter-use projection now gets `[]api.ParameterUseEvidence` from + `abstract/trace` instead of scanning bodies inside `domain/paramevidence`; +- iterator pair provenance consumes assignment evidence instead of scanning + inside `domain/iteration`; +- phase/synth dependencies carry `api.FlowEvidence`, so temporary synthesizers + reuse the same trace when they are analyzing the same graph. + +Correct boundary after this slice: + +```text +abstract/trace = graph/body event discovery and semantic trace records +abstract/transfer = flow-input lowering plus solved-state-dependent evidence +domain/* = lattice/reducer laws over already-lowered evidence +synth = expression evaluator over product/query state and trace +hooks = diagnostics over product state and trace +``` + +The broad production scan now classifies as: + +- `abstract/trace/*`: canonical event discovery; +- `abstract/transfer/*`: canonical transfer lowering; +- `hooks/lspindex.go`: editor index, not type/effect fact authority; +- `hooks/control_check.go`: syntax/control validation; +- `hooks/exhaustiveness_check.go`: syntactic `if`/`elseif` shape walk for + discriminated-union exhaustiveness after branch evidence indexing. + +The scan no longer shows event rediscovery in `modules`, `overlaymut`, +`domain/paramevidence`, `domain/iteration`, `infer/*`, `returns`, `nested`, +`pipeline`, or `synth`. + +Verification for this correction: + +```text +go test ./compiler/check/... -run '^$' + +go test ./compiler/check/abstract/trace ./compiler/check/abstract/transfer \ + ./compiler/check/domain/paramevidence ./compiler/check/domain/iteration \ + ./compiler/check/synth ./compiler/check/synth/phase/extract \ + ./compiler/check/hooks ./compiler/check/infer/return \ + ./compiler/check/pipeline -count=1 +``` + +Both passed. + +This is a design correction, not a file shuffle. The reason for the move is to +make semantic event discovery single-owner and cacheable. Later work can now +optimize around one trace instead of chasing assignments/calls/returns through +several helper clusters. + +## 2026-05-20 Abstract Interpreter Evidence Closure + +Follow-up flash migration tightened the boundary again. Several consumers still +looked harmless because they were diagnostics or synthesis helpers, but they +were still reconstructing semantic facts from CFG nodes. Those are now explicit +evidence lanes in the abstract interpreter trace: + +- `api.IdentifierUseEvidence`: identifier reads are discovered once by + `abstract/trace`; `hooks.CheckIdents` consumes that evidence instead of + walking `graph.RPO()` and reopening node payloads. +- `api.FreshTableLiteralEvidence`: fresh table provenance for structured + assignment checks is proven in `abstract/trace` at only the assignment sites + that need it. `domain/provenance` now only matches source identifiers to this + canonical proof; it no longer walks predecessors or reinterprets CFG events. +- `api.NormalExitEvidence`: termination reachability now checks return evidence + plus the canonical normal-exit point. `effects.TerminatesFromReachability` + no longer scans CFG nodes looking for returns. +- call-on-return extraction now receives the current `api.FlowEvidence` + explicitly; condition extraction no longer builds an ad-hoc trace when it + needs local predicate evidence. +- literal synthesis and keys/callback helpers no longer silently rebuild + evidence when callers pass an empty trace. Production callers must pass the + canonical trace; tests now build trace evidence explicitly. + +This confirms the intended abstract-interpreter shape: + +```text +CFG/AST -> abstract/trace.FlowEvidence +FlowEvidence + declared state -> abstract/transfer.Inputs +Inputs -> flow.Solution in DNF/path-sensitive form +FlowEvidence + flow/product state -> synth, effects, hooks, interproc facts +FunctionFacts/interproc product -> monotone SCC/fixpoint publication +``` + +DNF is already the checker's path-condition representation. It is not being +replaced here. The optional future SMT tier would sit after this architecture as +an obligation solver for formulas outside the current domain-specific DNF and +numeric/refinement reducers. The flash migration goal is to make that extension +possible without adding another scattered helper layer. + +Current allowed direct CFG users: + +- `abstract/trace`: canonical event/provenance discovery; +- `abstract/transfer`: graph topology, SSA versions, loop preheaders, edge + conditions, and transfer lowering; +- `phase/scope` and `scope/typedefs`: lexical/type-scope construction before + transfer facts exist; +- `phase/resolve`: initial symbol/type seeding for declared environments; +- `hooks/lspindex`: editor symbol/reference index, not checker fact authority. + +Everything else should either consume `api.FlowEvidence`, consume solved flow +state, or move its missing fact into `abstract/trace` as a named evidence lane. + +## 2026-05-20 Evidence Projection And Iterator Transfer Corrections + +This checkpoint records the latest engine-level corrections after rechecking the +remaining focused false positives. + +Canonical design clarifications: + +- DNF is already the current path-condition language. It remains the core + representation for path-sensitive flow and refinement constraints. A future + SMT/refinement tier would be an optional solver behind this same evidence + model, not a replacement for DNF and not a new scattered fact layer. +- Parameter evidence has two separate body-demand modes: + - whole-parameter use preserves the full observed call-site shape; + - direct field use completes demanded absent fields on that preserved shape. + Whole forwarding therefore must not erase local field demands such as + `options.stream`; it only prevents trimming unrelated observed fields. +- Generic-for transfer has two evidence sources: + - the solved iterator source type; + - the abstract interpreter's extracted assignment type for the loop target. + If iterator derivation is top-like (`any`/`unknown`) and the interpreter has a + concrete local refinement from sound evidence, the concrete interpreter + evidence is authoritative. Concrete iterator derivation still remains + authoritative when it proves a real element type. +- Explicit dynamic `any` is not silently specialized by a callee's expected + argument type. Unknown or soft unresolved locals may be refined by use, but + `any` remains a dynamic boundary unless a typed source or guard proves the + concrete type. + +Corrections made: + +- `ProjectToParameterUse` now completes demanded fields even when the parameter + is also forwarded as a whole value. This fixed the callback-local + `client.request(..., {headers = {}})` regression where `http_options.stream` + was falsely reported missing. +- Generic loop targets are now recorded as real value definitions for inference + visibility. Loop variables are not treated as invisible just because their + value comes from `IterExprs` instead of a normal RHS expression. +- Assignment extraction now lets the SCC-refined loop target type repair + top-like iterator output before emitting flow inputs. +- Flow transfer now reconciles iterator-derived type with the extracted loop + assignment type so `ipairs(any)` cannot erase a proven local call-expected + refinement. The sorted-test-runner fixtures now model `io.args()` with a + manifest returning `string[]`, which is the actual proof that `pattern` is + `string`. +- The intentionally unsafe dynamic-resource fixture still requires a string + proof for `resource_id`; this protects the soundness boundary that arbitrary + `any` values from untyped input cannot be accepted as strings merely because a + downstream function expects `string`. + +Regression protection added or confirmed: + +- `TestProjectToParameterUse_WholeForwardingCompletesDemandedFields` covers + whole forwarding plus direct demanded absent field completion. +- `TestMergeIteratorAssignedType_PreservesPreciseExtractedAgainstDynamicDerived` + covers iterator transfer reconciliation. +- Existing high-level regressions now pass: + - `TestFalsePositive_CallbackLocalDelegatedErrorReturnNarrowsSibling` + - `TestWippyRunner_SortedKeysWithFilterBranch` + - `TestWippyRunner_NearLiteralTestRunnerFlow` + +Verification for this correction: + +```text +go test ./compiler/check/domain/paramevidence ./types/flow \ + ./compiler/check/tests/regression \ + -run 'TestProjectToParameterUse_WholeForwardingCompletesDemandedFields|TestMergeIteratorAssignedType_PreservesPreciseExtractedAgainstDynamicDerived|TestFalsePositive_CallbackLocalDelegatedErrorReturnNarrowsSibling|TestWippyRunner_SortedKeysWithFilterBranch|TestWippyRunner_NearLiteralTestRunnerFlow' \ + -count=1 + +go test ./compiler/check/tests/regression \ + -run 'TestFalsePositive_CallbackLocalDelegatedErrorReturnNarrowsSibling|TestWippyRunner_SortedKeysWithFilterBranch|TestWippyRunner_NearLiteralTestRunnerFlow' \ + -count=1 + +go test ./types/flow ./compiler/check/abstract/transfer/assign \ + ./compiler/check/domain/paramevidence -count=1 +``` + +All commands passed. + +## 2026-05-20 Correction: Field Probes Are Nil-Producing Queries + +The `deadlock-compiler-lua` fixture exposed a remaining mismatch between the +abstract interpreter and Lua table semantics. A field read used only as a +truthiness/existence probe was still validated as a required value read: + +```lua +if edge.target_node_id or edge.is_workflow_terminal then +``` + +When `edge` is an inferred closed record that lacks `is_workflow_terminal`, +the runtime result of `edge.is_workflow_terminal` is nil. That probe is safe; +what remains unsafe is using the absent field as a real value. The canonical +rule is now: + +```text +value read = declared field required on closed records +truthiness/nil probe = missing table field may read as nil +primitive/non-table read = still an indexing error +``` + +The implementation keeps this distinction at the checker boundary instead of +weakening field lookup globally. `types/query/core.MissingFieldReadsNil` is the +single query for "does absent field access produce nil rather than an indexing +error"; union field lookup already needed the same fact to add nil for table +variants that do not carry a field. + +Regression protection: + +- `TestGuards_FieldTruthyNarrowsUnion/closed_record_missing_field_is_nil_in_truthiness_probe` + covers `or` and `not` guards on absent table fields. +- `TestGuards_FieldTruthyNarrowsUnion/closed_record_missing_field_nil_comparison_is_existence_probe` + covers `field == nil` existence probes. +- `TestStrictTypeChecks_FieldAndReturn/truthiness_probe_on_primitive_still_rejects_indexing` + keeps primitive indexing errors intact. +- `TestStrictTypeChecks_FieldAndReturn/missing_closed_record_field_still_rejects_value_read` + keeps strict value-read diagnostics intact. + +Fixture classification: + +- `regression/deadlock-compiler-lua/check` is now clean; the + `is_workflow_terminal` diagnostic was a checker false positive. +- `regression/non-dominating-field-defined-wrapper-return/check` still reports + the intended soundness error (`cannot assign unknown to string`). Its + manifest was corrected from two expected diagnostics to one because the extra + diagnostic was only duplicate/noisy reporting, not an independent proof + obligation. + +## 2026-05-20 Correction: Parameter Evidence Is Pre-State Only + +The recursive schema regression exposed a foundational ownership leak in return +inference. `FunctionFact.Params` is the accepted input contract for a function, +but return inference was merging the post-body overlay back into parameter +evidence after applying body mutations. That let writes such as: + +```lua +obj.multipleOf = nil +obj.additionalProperties = nil +``` + +become required fields on the public parameter type of `recursive_filter(obj)`. +The result was a false positive at the first valid call with a schema shape that +did not already contain fields created inside the callee body. + +Correct invariant: + +```text +FunctionFact.Params = accepted pre-state input evidence +body writes = local abstract-interpreter state and return/output shape +FunctionFact.Type = callable view over Params + return summaries/effects +``` + +Therefore: + +- call-site observations and body-read/precondition evidence may refine + `FunctionFact.Params`; +- field defaults such as `param.field or "x"` may add optional input evidence; +- helper-call expected arguments may add body-demand evidence when the parameter + or a parameter field is read and passed onward; +- final overlay state after assignments must not be merged into + `FunctionFact.Params`; +- body mutation state may still affect the return summary and local flow state. + +The implementation removed the post-mutation overlay-to-parameter-evidence +merge from return inference. This is not a bridge or a special case: it restores +the abstract-interpreter product separation. The interpreter owns mutable value +state; the function-fact parameter slot owns the accepted pre-state contract. + +Regression protection: + +- `TestExternalLint_PairsSchemaFilterWritesRecursiveValueBackToSameKey` + protects the positive recursive-schema case where body writes create fields + before returning the object. +- `TestExternalLint_RejectsPairsWriteThatChangesClosedFieldDomain` protects the + negative closed-record case so table write soundness is not weakened. +- `TestExternalLint_DynamicResourceIDsRequireStringProof` protects the dynamic + `any` boundary: expected string parameters cannot silently specialize unknown + external data. + +Verification: + +```text +go test ./compiler/check/tests/regression \ + -run 'TestExternalLint_PairsSchemaFilterWritesRecursiveValueBackToSameKey|TestExternalLint_RejectsPairsWriteThatChangesClosedFieldDomain|TestExternalLint_DynamicResourceIDsRequireStringProof|TestWippyRunner_SortedKeysWithFilterBranch|TestWippyRunner_NearLiteralTestRunnerFlow|TestFalsePositive_CallbackLocalDelegatedErrorReturnNarrowsSibling|TestExternalLint_BodyCallExpectationInfersWholeParameter|TestExternalLint_GuardedBodyUseDoesNotEraseOptionalParamBoundary' \ + -count=1 + +go test ./compiler/check/infer/return ./compiler/check/domain/paramevidence \ + ./compiler/check/abstract/transfer/assign ./types/flow -count=1 + +go test ./compiler/check/... -count=1 +``` + +All commands passed. + +## 2026-05-20 Correction: Table-Like Probes Include Arrays And Tuples + +The local-replace framework replay exposed two `wippy.agent:context` diagnostics +on agent-style untyped probe code: + +```lua +if type(tool_specs) == "string" or + (type(tool_specs) == "table" and tool_specs.id) then +``` + +The previous correction made record/map/interface field probes nil-producing, +but left arrays and tuples out of the central query. That was inconsistent with +the existing type-kind model: `type(x) == "table"` maps to records, maps, +arrays, tuples, interfaces, and intersections. All of those are Lua table-like +values, so a missing named field used only as a truthiness/existence probe reads +nil rather than raising an indexing error. + +Canonical rule: + +```text +table-like value read of missing field = strict diagnostic for value use +table-like field existence/truth probe = nil-producing, no diagnostic +primitive/function/nil field probe = still an indexing diagnostic +``` + +This is not a tuple-packaging workaround. It aligns +`types/query/core.MissingFieldReadsNil` with the same table-kind lattice used by +DNF/path-sensitive `type(x) == "table"` narrowing. Value reads remain strict, so +the checker still reports likely typos on precise table shapes. + +Regression protection: + +- `TestMissingFieldReadsNil_TableLikeContainers` covers record, map, array, + tuple, interface, and primitive boundaries in the canonical query. +- `TestFieldProbeSemantics_UntypedTableGuardAllowsExistenceProbe` covers the + agent-style untyped rewrap/probe pattern that previously produced + `field 'id' does not exist on type ((any))`. +- Existing strict-field tests still protect primitive probes and direct missing + field value reads. + +Local-replace classification after the fix: + +- `framework/src/agent/src` dropped from eight to six diagnostics; the two + `context.lua` field-probe diagnostics were checker false positives and are + fixed. +- Remaining inspected diagnostics are source/proof obligations, not abstract + interpreter regressions: + - untyped `any` passed to string APIs (`tool_id`, Bedrock parsed text, + test network URLs/error fields, page resource ids); + - optional HTTP bodies passed to `json.decode` without a fallback at that + source version; + - untyped mutable config writes that can invalidate numeric config fields; + - manifest/source mismatches such as stream `read` arity and metadata modeled + as string while code treats it as a table. + +Verification for this correction: + +```text +go test ./types/query/core \ + -run TestMissingFieldReadsNil_TableLikeContainers -count=1 -v + +go test ./compiler/check/tests/regression \ + -run 'TestFieldProbeSemantics_UntypedTableGuardAllowsExistenceProbe|TestFieldProbeSemantics_InterfaceMissingFieldIsNilOnlyInProbe' \ + -count=1 -v + +go test ./compiler/check/hooks ./compiler/check/tests/flow \ + ./compiler/check/tests/errors ./types/query/core -count=1 +``` + +All commands passed. + +## 2026-05-20 Flash Migration: Parameter Demand Is Flow Evidence + +The next remaining non-canonical seam was parameter-use demand. Several +consumers were independently asking the old tracer to walk a function body and +rediscover which parameter surface was actually demanded. That made parameter +evidence a side channel instead of part of the abstract-interpreter product. + +Canonical rule: + +```text +CFG/AST event discovery = trace.GraphEvidence +consumer phases = reducers over api.FlowEvidence +parameter demand = FlowEvidence.ParameterUses +``` + +After this correction, `api.FlowEvidence` carries `ParameterUses` alongside +calls, returns, assignments, branches, field defaults, function definitions, +escapes, and captures. `trace.GraphEvidence` is the only production builder for +that lane. Scope construction, synthesized signature projection, return +inference, and call checking now consume the evidence lane directly. + +The important design point is that this is not a fallback or compatibility +bridge. Production code does not recompute parameter use on demand. If a phase +needs to know whether call-site or fact evidence should narrow a whole +parameter, a field-only surface, or an unobserved parameter, it reduces the +already-built evidence product for that function. + +Call checking also stopped rewalking nested callees. The pass now receives the +session result map and reads the callee result's `FlowEvidence.ParameterUses`. +That keeps nested local-call validation on the same product-domain data as the +rest of the checker instead of opening the callee AST again during diagnostics. + +Proof invariant: + +```text +rg "ParameterUses\\(" compiler/check -g '!**/*_test.go' +``` + +The only production matches are: + +```text +compiler/check/abstract/trace/paramuse.go +compiler/check/abstract/trace/trace.go +``` + +Regression protection: + +- `TestGraphEvidenceIncludesParameterUses` proves `GraphEvidence` carries + whole-parameter and field-only demand in the canonical evidence product. +- Existing parameter-evidence projection tests still exercise the reducer logic + directly, but production consumers no longer invoke that discovery path. + +Verification: + +```text +go test ./compiler/check/abstract/trace ./compiler/check/pipeline \ + ./compiler/check/phase ./compiler/check/hooks ./compiler/check/infer/return \ + ./compiler/check/tests/narrowing -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Graph Provider Carries Evidence + +After the value-domain consolidation, the remaining graph-evidence ownership +leak was in nested helper consumers. The synthesizer and keys-collector detector +could still rebuild `trace.GraphEvidence` when their graph provider did not +also provide canonical evidence. That was a fallback path in consumer code. + +Canonical rule: + +```text +api.GraphProvider = canonical CFG provider + canonical FlowEvidence provider +consumer helpers = ask the provider or use the current function's evidence +trace.GraphEvidence = constructor used only by canonical materializers/tests +``` + +Implementation shape: + +- `api.GraphProvider` now includes `EvidenceForGraph`. +- the old separate `GraphEvidenceProvider` capability name is gone; store + readers declare the evidence method directly instead of embedding a second + provider abstraction. +- `check.Session` already satisfied that shape through its store-backed + evidence cache. +- Synth extraction no longer imports `abstract/trace` in production and returns + no hidden rebuilt evidence when no provider is attached. +- Keys-collector classification no longer has a consumer-side graph-evidence + rebuild fallback. Tests now provide an explicit graph/evidence provider when + they exercise nested callee classification. + +Proof invariant: + +```text +rg "GraphEvidenceProvider" compiler/check -n + +rg "trace\\.GraphEvidence" compiler/check -g '!**/*_test.go' -n +``` + +The first scan returns no matches. The second scan's remaining production +matches are canonical constructor/materializer sites: + +```text +compiler/check/store/store.go +compiler/check/abstract/transfer/evidence.go +``` + +Verification: + +```text +go test ./compiler/check/abstract/transfer/keyscoll \ + ./compiler/check/synth/phase/extract ./compiler/check/api \ + ./compiler/check/pipeline -count=1 + +go test ./compiler/check/... ./types/flow/... -count=1 + +go test ./... -count=1 + +git diff --check +``` + +All commands passed. + +## 2026-05-20 Flash Migration: Value Domain Owns Structural Shape Laws + +The next non-canonical helper cluster was in parameter evidence. Parameter +evidence was not only merging parameter vectors; it also carried local +implementations for: + +- table-top collapse and table-top upper-bound selection; +- map/record structural reconstruction; +- soft structural annotation refinement from evidence; +- table-key truthiness refinement for parameter facts. + +That was the wrong ownership boundary. Those rules are value-shape laws, not +parameter-vector laws. Keeping them in `paramevidence` created a second partial +shape lattice and made it too easy for later work to patch parameter facts +instead of improving the abstract value domain. + +Canonical rule: + +```text +domain/value = structural type-shape laws +domain/paramevidence = parameter evidence vector normalization and slot join +domain/functionfact = function fact product law +``` + +Implementation shape: + +- `domain/value/table.go` owns table-top collapse, table-top upper-bound + selection, map/record reconstruction, and table-key truthiness refinement. +- `domain/value/annotation.go` owns structural annotation refinement from + evidence. It accepts the caller's slot join function, so the value domain + owns traversal/reconstruction while parameter evidence keeps its merge law. +- `domain/paramevidence` now calls these value-domain operations and no longer + defines local table-top, map/record, annotation-shape, or table-key + truthiness helpers. + +This is not a bridge. The old helper bodies were deleted from parameter +evidence. There is still one parameter-specific public decision, +`RefinesFunctionParam`, but it is now a small composition of value-domain +relations: + +```text +optional elision | truthy refinement | table-key truthiness refinement +``` + +Proof invariant: + +```text +rg "refineAnnotationShape|arrayElementEvidence|mapEvidence|keyEvidenceCompatible|selectTableUpperBound|tableTopCoversEvidenceMember|collapseTableTopEvidence|joinMapRecordDirected|refinesTableKeyByTruthiness" \ + compiler/check/domain/paramevidence -n +``` + +The scan returns no matches. + +Regression protection added in `domain/value`: + +- table-top evidence absorbs precise table members; +- table-top upper bound absorbs a record-union observation; +- map/record shape reconstruction canonicalizes a pure map-component record + back into a map; +- structural annotation refinement derives a map value from record evidence; +- table-key truthiness refinement covers maps, records with map components, + nilable unions, and a negative value-change case. + +Verification: + +```text +go test ./compiler/check/domain/value ./compiler/check/domain/paramevidence \ + ./compiler/check/domain/functionfact ./compiler/check/domain/interproc -count=1 + +go test ./compiler/check/... ./types/flow/... -count=1 + +go test ./... -count=1 + +git diff --check +``` + +All commands passed. + +## 2026-05-20 Flash Migration: Graph Evidence Is Module-Cached + +After reducer-local evidence reconstruction was removed, the remaining +non-canonical shape was repeated graph-evidence construction by orchestration +and helper layers. The pipeline runner, driver, session hierarchy registration, +return inference, and nested helper consumers each knew how to call +`trace.GraphEvidence` directly. + +Canonical rule: + +```text +trace.GraphEvidence = low-level constructor +store.EvidenceForGraph = module-wide canonical evidence product/cache +pipeline/session/inference = consumers of the evidence provider +``` + +The implementation added `api.GraphEvidenceProvider` and made +`store.SessionStore` the module-wide evidence cache. `Session` exposes the same +provider method because it already owns graph construction. Pipeline setup, +function analysis, session hierarchy registration, and return inference now ask +the provider instead of rebuilding graph evidence independently. + +This is a structural simplification, not a compatibility bridge: + +- graph evidence is computed at most once per registered graph ID in the module + store; +- callers no longer choose bindings or re-run event discovery themselves; +- the graph provider can also provide evidence to nested analyses such as + keys-collector detection and synthesis helper paths. + +Remaining direct constructor calls are intentionally narrow: + +- `store.SessionStore.EvidenceForGraph` is the canonical module cache fill; +- `transfer.MaterializeGraphEvidence` is the standalone transfer entry fill + when a `FlowContext` is used without a store-backed provider; +- isolated utility/test paths may still construct evidence when no provider is + available. + +Regression protection: + +- `TestSessionStore_EvidenceForGraph` proves the store materializes + parameter-use evidence and returns the cached product on later reads. + +Verification: + +```text +go test ./compiler/check/store ./compiler/check/api ./compiler/check/pipeline \ + ./compiler/check/infer/return ./compiler/check/abstract/transfer/keyscoll \ + ./compiler/check/synth/phase/extract ./compiler/check -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Keys-Collector Detection Owns Callee Body Classification + +Assignment extraction still contained a duplicate keys-collector recovery path: +after calling the dedicated `keyscoll.BuildKeysCollectorDetector`, it performed +another module-binding function-literal lookup, built a nested CFG, rebuilt +graph evidence, and called `keyscoll.DetectKeysCollector` itself. That was a +local reimplementation of the detector's responsibility. + +Canonical rule: + +```text +assign.ExtractAssignments = emits assignment products +keyscoll.BuildKeysCollectorDetector = resolves candidate callees and classifies keys collectors +``` + +The detector now resolves function literals through both graph-local bindings +and module bindings before classifying the callee body. Assignment extraction no +longer imports `keyscoll` for nested body classification and no longer builds +nested graph evidence for that case. It only consumes the detector callback and +the refinement product. + +This removes one more reducer-local path that knew how to open a callee body. +The body classifier is centralized in `keyscoll`, where its cache and return +index logic already live. + +Verification: + +```text +go test ./compiler/check/abstract/transfer/assign \ + ./compiler/check/abstract/transfer/keyscoll \ + ./compiler/check/abstract/transfer/... -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Evidence Materialization Is Single-Entry + +The next evidence seam was not a type rule; it was ownership. Several transfer +reducers had their own local branch of the form "if this evidence slice is +empty, rebuild `trace.GraphEvidence` here". That made missing evidence look +valid and scattered the abstract-interpreter entry protocol across reducers. + +Canonical rule: + +```text +trace.GraphEvidence = event discovery constructor +transfer.MaterializeGraphEvidence = transfer entry materializer +reducers = pure reducers over FlowContext.Evidence +api.FlowEvidence.IsZero = only zero-product predicate +``` + +The implementation added `api.FlowEvidence.IsZero` and removed duplicate +`flowEvidenceEmpty` helpers. Transfer reducers for declarations, assignments, +table mutators, container mutators, and captured-container evidence no longer +rebuild graph evidence locally. `transfer.Run` materializes graph evidence once +before reducer execution. Standalone reducer tests now pass explicit +`trace.GraphEvidence` through `core.FlowContext`, matching the production +contract instead of relying on hidden reducer behavior. + +The session hierarchy registration also stopped calling the partial +`trace.FunctionDefinitions` discovery function directly. It now obtains +function definitions from the canonical `GraphEvidence` product for that graph. + +Proof invariant: + +```text +rg "flowEvidenceEmpty|fc\\.Evidence = trace\\.GraphEvidence|trace\\.FunctionDefinitions|EnsureGraphEvidence" \ + compiler/check -g '!**/*_test.go' +``` + +The only remaining production graph-evidence assignment is the central transfer +materializer: + +```text +compiler/check/abstract/transfer/evidence.go +``` + +Regression protection: + +- `TestFlowEvidenceIsZero` covers the shared zero-product predicate, including + parameter-demand evidence. +- Existing reducer tests now construct canonical evidence explicitly, proving + reducers no longer depend on local rediscovery. + +Verification: + +```text +go test ./compiler/check/api ./compiler/check/abstract \ + ./compiler/check/abstract/transfer/... ./compiler/check/synth/phase/extract \ + ./compiler/check -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Function Fact Projection Is Domain-Owned + +The `compiler/check/abstract/facts` package was a misleading boundary. It did +not implement abstract transfer semantics; it projected stable function facts +from the interprocedural product stored in the checker store. Keeping that +projection under `abstract` made the mental model look like transfer owned a +second fact channel. + +Canonical rule: + +```text +domain/functionfact = meaning, merge, widening, and store projection for one function fact +abstract/transfer = graph-local abstract interpretation over explicit flow evidence +store = cached module products and graph evidence provider +``` + +The projection helpers now live beside the rest of the function-fact domain: + +- `functionfact.ForSymbol` reads the canonical stable function fact for a + symbol; +- `functionfact.TypeForSymbol` projects its callable type; +- `functionfact.RefinementsFromStore` exposes refinement facts as a store view. + +This is not a bridge or compatibility layer. The old `abstract/facts` package +was deleted, and callers now name the domain that owns the data they consume. + +Proof invariant: + +```text +rg "abstract/facts|abstractfacts|FunctionFactForSymbol|FunctionTypeForSymbol|RefinementsFromFunctionFacts|package facts" \ + compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/hooks \ + ./compiler/check/pipeline ./compiler/check/synth/phase/extract -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Product Type Facts Are Domain-Owned + +The second package-boundary inversion was `compiler/check/api` importing +`compiler/check/abstract/query`. The implementation in that package was not an +abstract-transfer reducer; it was the canonical `flow.TypeFacts` view over the +checker product state: declared types, canonical function facts, literal types, +annotation markers, and the optional solved flow state. + +Canonical rule: + +```text +api/env = phase-typed environment contract and constructors +domain/typefacts = product-state query implementing flow.TypeFacts +abstract/trace = event discovery +abstract/transfer = graph-local abstract transfer +``` + +The product query now lives in `compiler/check/domain/typefacts` as +`typefacts.New(typefacts.Config{...})`. The old `abstract/query` package was +deleted, so the API layer no longer depends on an abstract implementation +package. + +This keeps the abstract-interpreter model direct: + +- product domains own product queries; +- transfer owns event reduction; +- API environment construction no longer reaches into abstract packages. + +Proof invariant: + +```text +rg "compiler/check/abstract/(facts|query)|abstractfacts|FunctionFactForSymbol|FunctionTypeForSymbol|RefinementsFromFunctionFacts|package facts|package query|NewTypeFacts|TypeFactsConfig" \ + compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/typefacts ./compiler/check/api -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Constraint Path Extraction Is Domain-Owned + +`abstract/transfer/path` was another sideways dependency. It did not lower flow +inputs by itself; it converted AST expressions plus binding identity into +canonical `constraint.Path` values. That path identity is consumed by trace, +transfer reducers, hooks, return inference, and iteration helpers. Keeping it +under `abstract/transfer` made non-transfer packages import transfer internals +for a shared semantic operation. + +Canonical rule: + +```text +domain/path = AST/binding -> constraint.Path identity +abstract/trace = event discovery using domain paths where needed +abstract/transfer = reducers that consume paths and produce flow inputs +hooks/infer = validators/reducers that may also consume canonical paths +``` + +The package moved to `compiler/check/domain/path`. No behavior changed; all +callers now import the domain owner directly, and `abstract/transfer/path` was +deleted. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer/path" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/path ./compiler/check/abstract/transfer/... \ + ./compiler/check/abstract/trace ./compiler/check/hooks \ + ./compiler/check/infer/return ./compiler/check/domain/iteration -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Guard Extraction Is Domain-Owned + +`abstract/transfer/guard` was shared by transfer reducers, field validation, and +expression synthesis. The package owns guard semantics: builtin `type(expr)` +probes, truthy path keys, and propagation of branch guard facts. That is a +domain law over AST/binding identity and branch evidence, not an implementation +detail of assignment transfer. + +Canonical rule: + +```text +domain/guard = guard/probe extraction and guard-key semantics +abstract/transfer = reducers that consume guard facts while building flow inputs +hooks/synth = consumers of the same guard domain for validation and synthesis +``` + +The package moved to `compiler/check/domain/guard`, and +`abstract/transfer/guard` was deleted. This removes another sideways transfer +import without changing guard behavior. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer/guard" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/guard ./compiler/check/abstract/transfer/assign \ + ./compiler/check/hooks ./compiler/check/synth/phase/extract -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Symbol Resolution Is Domain-Owned + +`abstract/transfer/resolve` had become a shared resolution utility package: +symbol display names, product-state type selection, input/global lookups, +context symbol resolvers, type-key lookup, and call-refinement lookup. Transfer +reducers need those operations, but they are not themselves transfer reducers. +Return inference, return callsite analysis, and pipeline captured-mutator replay +also consumed them directly. + +Canonical rule: + +```text +domain/resolve = checker symbol/type/refinement resolution helpers +domain/path = path identity construction +abstract/transfer = reducers that call domain resolution while lowering evidence +infer/pipeline = consumers of the same resolution domain +``` + +The package moved to `compiler/check/domain/resolve`, and +`abstract/transfer/resolve` was deleted. This removes another non-reducer +package from the transfer tree. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer/resolve" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/resolve ./compiler/check/abstract/transfer/... \ + ./compiler/check/returns ./compiler/check/pipeline \ + ./compiler/check/infer/return -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Overlay Mutation Collectors Have One Owner + +`abstract/transfer/assign/collect.go` was a pure forwarding bridge to +`overlaymut.CollectFieldAssignments` and `overlaymut.CollectIndexerAssignments`. +That was exactly the kind of non-final shape the migration is removing: callers +could name assignment transfer while actually using overlay mutation collection. + +Canonical rule: + +```text +overlaymut = collect and apply overlay field/indexer mutation evidence +abstract/transfer/assign = assignment transfer reducers, not overlay collector aliases +infer/nested/returns = consume overlaymut directly when they need overlay mutation facts +``` + +The forwarding file was deleted. Return inference, nested inference, and +constructor detection now call `overlaymut` directly. The former wrapper tests +moved to the owning package. + +Proof invariant: + +```text +rg "assign\\.Collect(Field|Indexer)Assignments" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/overlaymut ./compiler/check/abstract/transfer/assign \ + ./compiler/check/nested ./compiler/check/infer/nested \ + ./compiler/check/infer/return -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Return Overlay Facade Removed + +`compiler/check/returns/overlay.go` was another facade over `overlaymut`. It +re-exported field/indexer/direct mutation merge operations while adding no +return-specific semantics. That made return inference look like it had its own +overlay mutation domain. + +Canonical rule: + +```text +overlaymut = overlay mutation collection and application +returns = return graph/call/summary inference only +infer/return = consumes returns for return SCCs and overlaymut for overlay mutation +``` + +The facade was deleted. Return inference and nested inference now call +`overlaymut` directly, and the former return-overlay tests moved to the +overlay mutation package. + +Proof invariant: + +```text +rg "returns\\.(MergeFieldAssignments|ApplyFieldMergeToOverlay|MergeFieldsIntoType|ApplyIndexerMergeToOverlay|JoinValueTypes|MergeMapComponentIntoType|ApplyDirectMutationsToOverlay)" \ + compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/overlaymut ./compiler/check/returns \ + ./compiler/check/infer/nested ./compiler/check/infer/return -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Keys-Collector Detection Is Domain-Owned + +`abstract/transfer/keyscoll` detected the "collect keys from table parameter" +function pattern over graph evidence. Assignment transfer consumes that +detector, but the detector itself is a semantic domain over calls, assignments, +returns, function identity, and graph evidence. Phase-level signature projection +also needs it. + +Canonical rule: + +```text +domain/keyscoll = keys-collector body/call classification +abstract/transfer = assignment reducer consuming the detector result +phase = signature projection consuming the same detector domain +``` + +The package moved to `compiler/check/domain/keyscoll`, and +`abstract/transfer/keyscoll` was deleted. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer/keyscoll" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/domain/keyscoll ./compiler/check/abstract/transfer \ + ./compiler/check/abstract/transfer/assign ./compiler/check/phase -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Indexer Overlay Facts Are Overlay-Owned + +The dynamic-index overlay shape `IndexerInfo` and the merge operation for +table-insert-derived indexer mutations still lived in transfer/mutator. That +forced `overlaymut` to import transfer just to name the data it owned. + +Canonical rule: + +```text +overlaymut = overlay mutation data shapes and overlay merge/apply laws +abstract/transfer/mutator = call-pattern detection for table/container mutators +infer/synth = combine transfer mutator observations with overlaymut facts +``` + +`overlaymut.IndexerInfo` and `overlaymut.MergeIndexerMutations` are now the +canonical APIs. Transfer mutator detection returns that overlay-owned shape; +the old `mutator.IndexerInfo` and `mutator.MergeIndexerMutations` names were +deleted. + +Proof invariant: + +```text +rg "mutator\\.IndexerInfo|mutator\\.MergeIndexerMutations" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/overlaymut ./compiler/check/abstract/transfer/mutator \ + ./compiler/check/infer/return ./compiler/check/synth/phase/extract -count=1 +``` + +The command passed. + +## 2026-05-20 Flash Migration: Call Effects Are Domain-Owned + +`abstract/transfer/mutator` still mixed two responsibilities: resolving a +call's callee contract to discover table/container mutation effects, and +lowering those effects into flow input assignments. Return inference and +function synthesis also needed the same call-effect interpretation, which made +them reach sideways into transfer mutator code. + +Canonical rule: + +```text +domain/calleffect = contract/effect interpretation at concrete call sites +overlaymut = overlay mutation data and merge/apply laws +abstract/transfer/mutator = transfer reducers that emit flow mutator assignments +infer/synth = consumers of domain call effects and overlaymut facts +``` + +The effect interpreters moved to `compiler/check/domain/calleffect`: + +- `TableMutatorFromCall` +- `ContainerMutatorFromCall` +- `ContainerElementReturnFromCall` +- table-insert call-evidence reductions used by return/function overlays + +The transfer mutator package now only consumes `domain/calleffect` while +emitting `flow.TableMutatorAssignment` and +`flow.ContainerMutatorAssignment`. The old call-effect functions and +table-insert overlay collectors were deleted from transfer/mutator. + +Proof invariant: + +```text +rg "mutator\\.(CollectTableInsert|TableMutatorFromCall|ContainerMutatorFromCall|ContainerElementReturnFromCall)" \ + compiler/check -n +``` + +The scan returns no matches. Production imports of +`compiler/check/abstract/transfer/mutator` are now limited to the transfer +orchestrator that runs mutator reducers. + +Verification: + +```text +go test ./compiler/check/domain/calleffect ./compiler/check/abstract/transfer/mutator \ + ./compiler/check/abstract/assign ./compiler/check/infer/return \ + ./compiler/check/synth/phase/extract -count=1 +``` + +The focused command passed. + +## 2026-05-20 Flash Migration: Assignment Interpreter Is Not A Transfer Subpackage + +The assignment package had also become a shared abstract-interpreter component, +not just a private transfer reducer. It owns local assignment SCC inference, +RHS overlay synthesis, structured-write visibility, call-argument expectation +inference, and assignment-flow input emission. Return inference was previously +forced to construct a fake `transfer/core.FlowContext` only to call +`CollectInferredTypes`, which encoded the wrong mental model. + +Canonical rule: + +```text +abstract/assign = assignment abstract interpreter and assignment reducers +abstract/transfer = whole-flow transfer orchestration and reducer sequencing +infer/return = calls abstract/assign local inference with explicit evidence/config +``` + +The package moved from `compiler/check/abstract/transfer/assign` to +`compiler/check/abstract/assign`. The old `CollectInferredTypes(*FlowContext, +...)` API was deleted. Shared local inference now enters through +`assign.InferLocalTypes(assign.LocalInferenceConfig{...})`, which names the +real inputs: graph, evidence, scopes, synthesis services, seed types, +annotations, optional flow inputs, and optional preflow branch solution. + +This is a direct shape correction, not a wrapper: the old package path is gone, +and return inference no longer fabricates a transfer context. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer/assign|CollectInferredTypes" compiler/check -n +``` + +The scan returns no matches. + +Verification: + +```text +go test ./compiler/check/abstract/assign ./compiler/check/abstract/transfer \ + ./compiler/check/infer/return ./compiler/check/synth/phase/extract \ + ./compiler/check/domain/calleffect -count=1 +``` + +The focused command passed. + +Current note: the next entry supersedes this package-boundary statement by +removing `abstract/transfer` entirely. The surviving design is the root +`abstract` interpreter plus direct reducer packages. + +## 2026-05-20 Flash Migration: `abstract/transfer` Package Removed + +The previous slices exposed the remaining non-final shape: `abstract/assign` +was no longer a transfer subpackage, but it still depended on +`abstract/transfer/core`, `abstract/transfer/cond`, +`abstract/transfer/predicate`, and related reducer packages. That meant the +mental model was still split between "the abstract interpreter" and a legacy +"transfer" namespace. + +Canonical rule: + +```text +abstract = top-level abstract interpreter entrypoint +abstract/core = interpreter context, services, and derived resolvers +abstract/trace = canonical graph event materialization +abstract/{assign,cond,constprop,decl,mutator,returns,...} + = interpreter reducers over one FlowEvidence stream +domain/* = reusable semantic domains not tied to interpreter lowering +``` + +The old package `compiler/check/abstract/transfer` was removed. Its root +orchestrator moved into `compiler/check/abstract`: + +- `abstract.Run(*core.FlowContext) abstract.Result` is the full interpreter + entrypoint used by phase extraction. +- `abstract.BuildInputs(*core.FlowContext) *flow.Inputs` is the lower-level + flow-input construction step. +- `abstract.ExtractEvidence` remains in the interpreter root and records the + interpreter-owned event stream after input construction. + +Reducer packages moved directly under `compiler/check/abstract`: + +- `core` +- `cond` +- `constprop` +- `decl` +- `literal` +- `mutator` +- `numconst` +- `predicate` +- `returns` +- `sibling` +- `tblutil` + +This is a flash migration, not a bridge. There is no `abstract/transfer` +package, no `RunTransfer`, and no `TransferResult`. +Residual code comments and import aliases were also normalized so production +checker code no longer talks about a transfer namespace or `fbcore`. + +Proof invariant: + +```text +rg "compiler/check/abstract/transfer|\\btransfer\\.|RunTransfer|TransferResult|package transfer" \ + compiler/check -n +rg "fbcore|abstract transfer|abstract-transfer|transfer-owned|transfer context|\\bTransfer\\b|\\btransfer\\b" \ + compiler/check/abstract compiler/check/api compiler/check/phase/flow.go -n +``` + +Both scans return no matches. + +Verification: + +```text +go test ./compiler/check/abstract/... ./compiler/check/phase \ + ./compiler/check/infer/return -count=1 +go test ./compiler/check/... ./types/flow/... -count=1 +go test ./... -count=1 +git diff --check +go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +``` + +The commands passed. + +Benchmark sanity after the package removal stayed in the same allocation shape: +about 3.9-4.9 ms/op, 1.08 MB/op, and 10435-10436 allocs/op on this machine. + +## 2026-05-20 Validation Boundary: Soundness vs. External Lint Counts + +Current validation result: + +- The go-lua tree is clean on `go test ./... -count=1`. +- The flash-migration residue scan is clean: no `abstract/transfer`, + `RunTransfer`, `TransferResult`, `package transfer`, or `fbcore` production + references remain under `compiler/check`. +- The official `../scripts/verify-suite.sh` is not a proof of this checkout's + Wippy lint behavior because `/home/wolfy-j/wippy/wippy/go.mod` still pins + `github.com/wippyai/go-lua v1.5.16`. +- A temporary local-replace Wippy binary built against this checkout still + reports external diagnostics in several Wippy projects. Therefore the honest + statement is not "all external lint errors are gone"; the correct next proof + is classification: real unsound source/manifest use vs. checker false + positive. + +Canonical validation rule: + +```text +Do not make go-lua accept unproven any/unknown/optional values just to reduce +external counts. A remaining diagnostic is only a checker regression when the +program has a local proof that the value satisfies the contract and the +abstract interpreter fails to use that proof. +``` + +Representative local-replace diagnostics classified as soundness-expected: + +- `app.test.network:*`: `(args and args.url) or "..."` + can be non-string when `args.url` is truthy non-string. This is covered by + `TestExternalLint_UntypedOverlayURLRequiresStringProof`; the guarded variant + is `TestExternalLint_GuardedOverlayURLFeedsStringContract`. +- `wippy.llm.util:compress`: untyped `configure(new_config)` can write + non-numeric values into numeric `CONFIG` fields before arithmetic reads. + This is covered by `TestExternalLint_UntypedConfigMutationCanInvalidateNumericReads`; + the typed-update variant is + `TestExternalLint_TypedConfigMutationPreservesNumericReads`. +- `wippy.session.api:get_artifact*`: `artifact.meta` can be non-table, so + `if artifact.meta then artifact.meta.content_type end` is not enough proof. + This is covered by `TestExternalLint_StringMetadataRequiresStructuredProof`; + the table-guarded variant is + `TestExternalLint_GuardedStructuredMetadataAllowsFieldAccess`. +- `wippy.session.process:command_bus`: a `fun(...any) -> any` handler is not a + proof of the typed registry contract `(any, any) -> (any, string?)`. This is + covered by `TestExternalLint_UntypedCommandHandlerCannotEnterTypedRegistry`; + the typed-handler variant is + `TestExternalLint_TypedCommandHandlerCanEnterTypedRegistry`. +- `wippy.session.process:control_handlers`: dynamic `any` control payloads do + not satisfy typed handler records without field/type guards. This is covered + by `TestExternalLint_DynamicControlPayloadRequiresTypedProof`; the guarded + variant is `TestExternalLint_GuardedControlPayloadFeedsTypedHandler`. +- `wippy.session:start_tokens_test`: passing `"not a table"` to an optional + record parameter is a real static contract violation even when the runtime + test expects validation to reject it. This is covered by + `TestExternalLint_StartOptionsRejectsPlainString`. + +Engine capabilities that remain regression-protected and must stay hack-free: + +- optional fallback to concrete strings: + `TestExternalLint_OptionalStringFallbackIsConcreteString` and + `TestExternalLint_ManifestHTTPBodyFallbackFeedsManifestJsonDecode`. +- imported assertion summaries and non-nil narrowing: + `TestExternalLint_ImportedNotNilNarrowsNilInitializedCapturedLocal`, + `TestExternalLint_ImportedNotNilMakesNilOnlyPathUnreachable`, and + `TestExternalLint_ImportedNotNilNarrowsCapturedTableMethodWriteLocal`. +- local predicate/effect inference through control flow and loops: + `TestExternalLint_LocalPredicateGuardNarrowsNumberAfterEarlyReturn`, + `TestExternalLint_DirectPredicateTrueBranchNarrowsArgument`, + `TestExternalLint_AssignedPredicateTrueBranchNarrowsArgument`, and + `TestExternalLint_LogicalPredicateTruePathNarrowsThroughLoop`. +- expected function context for returned callbacks: + `TestExternalLint_ReturnedCallbackUsesExpectedParameterTypesInBody`, + `TestExternalLint_ReturnedMethodCallbackUsesExpectedParameterTypesInBody`, + and `TestExternalLint_ReturnedCallbackContextFlowsThroughLocalProjectionWrite`. + +Open proof obligation before claiming "no false positives": + +```text +Run the local-replace Wippy lint harness, classify every remaining diagnostic, +and for any diagnostic whose source has a real proof, add a minimal go-lua +regression and fix the abstract interpreter. For diagnostics that are real +source/manifest issues, do not weaken the checker. +``` + +## 2026-05-20 Revalidation: Current Head Still Has Integration Diagnostics + +Current head: `3cb32729`. + +Commands rerun: + +```text +rg "compiler/check/abstract/transfer|RunTransfer|TransferResult|package transfer|fbcore" compiler/check -n +go test ./compiler/check/tests/regression -run 'ExternalLint|Gradual|Advanced' -count=1 +go test ./... -count=1 +../scripts/verify-suite.sh +env WIPPY_DIR=/tmp/wippy-golua-validate \ + WIPPY_BIN=/tmp/wippy-local-replace-validate \ + GOFLAGS=-buildvcs=false \ + ../scripts/verify-suite.sh +``` + +Results: + +- migration-residue scan: no matches. +- targeted regression suite: pass. +- full go-lua suite: pass. +- official verify suite: checker tests and Wippy binary build pass, then the + script exits non-zero on pinned external lint targets: + - `/home/wolfy-j/wippy/wippy/tests/app`: 0 errors + - `/home/wolfy-j/wippy/session`: 8 errors + - `/home/wolfy-j/wippy/framework/src/agent/src`: 11 errors + - `/home/wolfy-j/wippy/docker-demo`: 21 errors, 2 warnings + - all other listed targets: 0 errors +- local-replace verify suite against this checkout: checker tests and Wippy + binary build pass, then external lint reports: + - `/tmp/wippy-golua-validate/tests/app`: 4 errors + - `/home/wolfy-j/wippy/session`: 33 errors + - `/home/wolfy-j/wippy/framework/src/agent/src`: 6 errors + - `/home/wolfy-j/wippy/docker-demo`: 68 errors + - `/home/wolfy-j/wippy/framework/src/llm/src`: 3 errors + - `/home/wolfy-j/wippy/framework/src/llm/test`: 3 errors + - `/home/wolfy-j/wippy/framework/src/views`: 2 errors + - all other listed targets: 0 errors + +Conclusion: + +```text +The flash migration is structurally clean and the go-lua regression suite +passes, but "all regressions are solved" is not proven by integration lint. +The remaining local-replace diagnostics must stay classified individually. +The checker must not add compatibility fallbacks or any-to-contract shortcuts +to hide diagnostics that are real soundness findings. +``` + +## 2026-05-20 Revalidation: Assertion Refinement Regression Fixed Without Contract Weakening + +Current base before this patch: `cd46937b`. + +Regression class: + +```text +An unannotated assertion-style helper can accept a dynamic argument, prove a +more specific type on normal return, and then use that refined value internally. +That proven internal use is an effect/refinement, not a public parameter +precondition. Merging the body observation into `FunctionFact.Params` made the +helper look like `msg: string`, so callers passing `any` got false positives +even though the helper itself proved `msg:string` before string operations. +``` + +Canonical rule implemented: + +```text +FunctionFact.Params stores caller obligations only. +If a dynamic call observation merges with a concrete body observation, and the +normal-return refinement proves that same concrete type for the parameter, keep +the parameter dynamic in the public function fact. +``` + +Call checking applies the same rule only when all of these are true: + +- the actual argument is `any`; +- the source parameter is unannotated; +- the callee has a normal-return refinement proving the expected parameter type; +- the function AST is available from the canonical graph/evidence store. + +This is not an `any` shortcut. Annotated parameters remain authoritative, and +unproven concrete demands remain preconditions. + +Implementation shape: + +- `compiler/check/domain/functionfact/refinement.go` owns the refinement-proof + predicate and the join/widen parameter preservation rule. +- `compiler/check/domain/functionfact/fact.go` calls that rule from `Join` and + `WidenForConvergence`. +- `compiler/check/hooks/call_check.go` uses the same proof predicate when + checking function facts at a call site, so imported assertion summaries and + local graph facts have the same semantics. + +Regression coverage: + +- `TestJoin_RefinementProvenParamDoesNotBecomePrecondition` +- `TestJoin_UnprovenDynamicParamUseRemainsPrecondition` +- `TestImportedTypeAssertionNarrowsArgumentInPlace` +- `TestImportedTypeAssertionNarrowsLocalBeforeStringUse` +- `TestAnnotatedImportedTypeAssertionKeepsPrecondition` + +Validation commands run: + +```text +go test ./compiler/check/domain/functionfact -count=1 +go test ./compiler/check/tests/regression -run 'TestImportedTypeAssertion|TestAnnotatedImportedTypeAssertion|TestLinterFalsePositive_TestRunnerExact|TestLinterFalsePositive_GraphLocalUnusedParamAllowsInternalAny|TestLinterFalsePositive_GraphLocalObservedParamRejectsAny|TestSessionPlugin_UntypedSessionIDGuardStillRejectsStringAPI' -count=1 +go test ./... -count=1 +git diff --check +env GOFLAGS=-buildvcs=false go build -o /tmp/wippy-local-replace-validate ./cmd/wippy +/tmp/wippy-local-replace-validate lint --cache-reset --json +env WIPPY_DIR=/tmp/wippy-golua-validate WIPPY_BIN=/tmp/wippy-local-replace-validate GOFLAGS=-buildvcs=false ../scripts/verify-suite.sh +``` + +Results: + +- focused function-fact tests: pass. +- focused regression tests: pass. +- full go-lua test suite: pass. +- diff whitespace check: pass. +- local-replace Wippy binary build: pass. +- local-replace `tests/app` replay: 2 errors, 0 warnings, 0 hints. + The prior `denied_explicit` assertion false positives are gone. +- full local-replace verify suite: checker tests and binary build pass, then + external lint exits non-zero with: + - `/tmp/wippy-golua-validate/tests/app`: 2 errors + - `/home/wolfy-j/wippy/session`: 33 errors + - `/home/wolfy-j/wippy/framework/src/agent/src`: 6 errors + - `/home/wolfy-j/wippy/docker-demo`: 66 errors + - `/home/wolfy-j/wippy/framework/src/llm/src`: 3 errors + - `/home/wolfy-j/wippy/framework/src/llm/test`: 3 errors + - `/home/wolfy-j/wippy/framework/src/views`: 2 errors + - all other listed targets: 0 errors + +Current classification boundary: + +```text +The fixed regression is the assertion-refined dynamic argument case. The two +remaining `tests/app` overlay diagnostics are soundness-expected: `(args and +args.url) or "..."` proves a string only when `args.url` is nil/false; a truthy +non-string dynamic value still reaches `http.get`. + +The broader external local-replace counts remain integration diagnostics, not +proof that go-lua should weaken contracts. Each remaining item must be +classified as source/manifest proof gap vs. checker regression before changing +engine semantics. +``` + +## 2026-05-20 Validation: No Assertion-Refinement Regression Remains + +Validated head: `ff27b721`. + +Changed-code design scan: + +```text +rg "hack|temporary|transitional|legacy|bridge|compat|fallback|TODO|FIXME|any shortcut|any-to" \ + compiler/check/domain/functionfact/refinement.go \ + compiler/check/domain/functionfact/fact.go \ + compiler/check/hooks/call_check.go \ + compiler/check/tests/regression/imported_type_assertion_refinement_test.go +``` + +Result: no matches. The refinement fix is not implemented as a fallback, +bridge, name special case, or blanket `any` relaxation. + +Legacy fact-channel scan: + +```text +rg "FunctionTypesFromFacts|ReturnSummaries|NarrowReturns|FuncTypes|NormalizeFacts|NormalizeFunctionFactChannels|legacy fact|compatibility view|compatibility bridge" \ + compiler/check types +``` + +Result: no production matches. + +Regression proof rerun: + +```text +go test ./compiler/check/domain/functionfact -count=1 +go test ./compiler/check/tests/regression -run 'TestImportedTypeAssertion|TestAnnotatedImportedTypeAssertion|TestLinterFalsePositive_TestRunnerExact|TestLinterFalsePositive_GraphLocalUnusedParamAllowsInternalAny|TestLinterFalsePositive_GraphLocalObservedParamRejectsAny|TestSessionPlugin_UntypedSessionIDGuardStillRejectsStringAPI|ExternalLint|Gradual|Advanced' -count=1 +go test ./... -count=1 +git diff --check +``` + +Results: + +- function-fact package: pass. +- targeted regression suite: pass. +- full go-lua suite: pass. +- diff check: pass. + +Local-replace replay: + +```text +env GOFLAGS=-buildvcs=false go build -o /tmp/wippy-local-replace-validate ./cmd/wippy +/tmp/wippy-local-replace-validate lint --cache-reset --json +env WIPPY_DIR=/tmp/wippy-golua-validate WIPPY_BIN=/tmp/wippy-local-replace-validate GOFLAGS=-buildvcs=false ../scripts/verify-suite.sh +``` + +Results: + +- local-replace binary build: pass. +- `tests/app`: 2 errors, 0 warnings, 0 hints. +- full local-replace verify: checker tests and binary build pass, then external + lint exits non-zero. + +Current local-replace nonzero targets from detailed JSON: + +- `/tmp/wippy-golua-validate/tests/app`: 2 errors. +- `/home/wolfy-j/wippy/session`: 33 errors. +- `/home/wolfy-j/wippy/framework/src/agent/src`: 6 errors. +- `/home/wolfy-j/wippy/docker-demo`: 68 errors in standalone JSON replay + (`verify-suite.sh` printed 67 on the same validation pass). +- `/home/wolfy-j/wippy/framework/src/llm/src`: 3 errors. +- `/home/wolfy-j/wippy/framework/src/llm/test`: 3 errors. +- `/home/wolfy-j/wippy/framework/src/views`: 2 errors. + +Assertion-regression check: + +```text +jq '.diagnostics[] | select(.entry_id|test("denied_explicit|assert"))' +``` + +Result: no matching diagnostics in the replayed targets. The +`denied_explicit` false positives remain fixed. + +Representative remaining diagnostics inspected: + +- `app.test.network:overlay_*`: `(args and args.url) or default` does not prove + `url:string` when `args.url` is truthy non-string. +- `wippy.llm.util:compress`: `compress.configure(new_config)` writes arbitrary + values into numeric `CONFIG` fields, so arithmetic on those fields is not + statically proven. +- `wippy.session.api:get_artifact*`: `artifact.meta` is truthy but not proven to + be a table before field reads. +- `wippy.session.process:command_bus`: assigning `fun(...any) -> any` into a + typed `(any, any) -> (any, string?)` handler table is a real contract gap. +- `wippy.views.api:list_pages`: `config_overrides` is cast to a string-keyed map + without proving the incoming map key shape. +- `wippy.agent.compiler:compiler`: `string.gmatch(tool_id, ...)` receives an + unproven dynamic `tool_id`. +- `wippy.llm.bedrock:mapper`: fallback text-tool parsing receives dynamic text + without proving `string?`. + +Conclusion: + +```text +No go-lua regression is reproduced by the validation suite, and the +assertion-refined dynamic argument regression is absent from local-replace +diagnostics. The implementation is proof-gated by normal-return refinements and +source annotations, not by hacks or broad compatibility. + +The remaining external diagnostics are still not a reason to weaken go-lua. +They are dynamic-boundary/source-proof gaps unless a reduced fixture shows that +the abstract interpreter missed an actual local proof. +``` + +## 2026-05-20 Flash Migration: Function-Fact Call Projection Owner + +Problem: + +```text +Call checking still owned part of the function-fact semantics. It selected +stable function facts, recovered source functions, projected refinement-proven +dynamic arguments, projected unobserved local parameters, and compared current +callee signatures against fact signatures. That kept the abstract-interpreter +meaning split between `hooks/call_check.go` and `domain/functionfact`. +``` + +Migration performed: + +- Added `compiler/check/domain/functionfact/call_projection.go`. +- Moved stable function-fact call projection into `functionfact.ProjectCall`. +- Kept `hooks/call_check.go` as orchestration only: it now gathers local call + evidence and asks the function-fact domain for the effective callee. +- Collapsed duplicate function-type rewriting into one private + `rewriteFunctionParams` helper inside the function-fact domain. +- Removed hook-local helpers: + - `functionFactCalleeType` + - `callTypeWithRefinementProvenAnyArgs` + - `callTypeWithUnobservedLocalAnyArgs` + - `canonicalFactHasWiderParams` + - hook-local unobserved-parameter mask handling + +Invariant: + +```text +The hook may decide when a call must be checked, but it must not encode what a +canonical function fact means. Function-fact projection is part of the +function-fact abstract domain. +``` + +Why this is a flash migration step, not a bridge: + +- No compatibility channel was added. +- No external/source-specific case was added. +- No broad `any` relaxation was added. +- The old hook implementation was deleted, not wrapped. +- The only public surface is the canonical projection entry point: + `functionfact.ProjectCall`. + +Validation: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/hooks -count=1 +go test ./compiler/check/tests/regression -run 'TestImportedTypeAssertion|TestAnnotatedImportedTypeAssertion|TestLinterFalsePositive_TestRunnerExact|TestLinterFalsePositive_GraphLocalUnusedParamAllowsInternalAny|TestLinterFalsePositive_GraphLocalObservedParamRejectsAny|TestSessionPlugin_UntypedSessionIDGuardStillRejectsStringAPI|ExternalLint|Gradual|Advanced' -count=1 +go test ./... -count=1 +git diff --check +``` + +Results: + +- function-fact and hooks package tests: pass. +- targeted regression suite: pass. +- full go-lua suite: pass. +- diff check: pass. + +Structural result: + +```text +`compiler/check/hooks/call_check.go` no longer contains function-fact +projection semantics. The function-fact domain owns both storage joins and +call-site projection from canonical facts, which is the correct abstract +interpreter boundary for this part of the migration. +``` + +## 2026-05-20 Flash Migration: Function-Fact Map Construction Owner + +Problem: + +```text +Return inference still rebuilt `api.FunctionFacts` from provisional return +vectors in local helpers. That meant the canonical map shape existed both in +the function-fact domain and in return-inference consumer code. +``` + +Migration performed: + +- Added `compiler/check/domain/functionfact/map.go`. +- Added canonical constructors: + - `functionfact.FromParts` + - `functionfact.FromMaps` + - `functionfact.FromSummaries` + - `functionfact.FromSummariesExcept` +- Removed return-inference local builders: + - `functionFactsFromReturnVectors` + - `functionFactsExcludingCurrent` +- Replaced local assembly logic with `functionfact.FromMaps`. +- Added function-fact-domain tests for canonical map construction. + +Invariant: + +```text +Return inference may own provisional SCC return vectors, but it must not own +the shape or normalization policy of published `api.FunctionFacts`. +``` + +Why this is a flash migration step, not a bridge: + +- No compatibility facts were introduced. +- No old builder was wrapped. +- Consumer-local map construction was deleted. +- The published shape now goes through the same `functionfact.Join` and + `functionfact.Empty` policy used by the domain. + +Validation: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/infer/return -count=1 +go test ./compiler/check/tests/regression -run 'TestImportedTypeAssertion|TestAnnotatedImportedTypeAssertion|TestLinterFalsePositive_TestRunnerExact|TestLinterFalsePositive_GraphLocalUnusedParamAllowsInternalAny|TestLinterFalsePositive_GraphLocalObservedParamRejectsAny|TestSessionPlugin_UntypedSessionIDGuardStillRejectsStringAPI|ExternalLint|Gradual|Advanced' -count=1 +go test ./... -count=1 +git diff --check +``` + +Results: + +- function-fact and return-inference package tests: pass. +- targeted regression suite: pass. +- full go-lua suite: pass. +- diff check: pass. + +Structural result: + +```text +The function-fact domain now owns both per-symbol fact normalization and +canonical map construction. Return inference publishes facts through that +domain instead of reconstructing the product shape locally. +``` + +## 2026-05-20 Flash Migration: Function-Fact Type Lookup Owner + +Problem: + +```text +The synthesizer still owned a stable function-fact type cache, graph/parent +resolution, and phase-safe fact reads. That made a consumer responsible for +part of the canonical fact projection model. +``` + +Migration performed: + +- Moved graph-local function-fact type lookup into + `compiler/check/domain/functionfact`. +- Added canonical lookup surfaces: + - `functionfact.TypeForGraph` + - `functionfact.TypeForSymbol` + - `functionfact.Cache` +- Deleted extract-local `stableFunctionFactKey`. +- Replaced `Deps.StableFunctionFactCache` with + `Deps.FunctionFactCache`, typed by the function-fact domain. +- Collapsed `stableGraphLocalFunctionFactType` and `stableFunctionFactType` + into one extract orchestration helper that only chooses the local context and + delegates lookup semantics to the domain. +- Removed prototype-method side reads of raw `api.FunctionFacts`; prototype + synthesis now uses the same domain-backed function-fact type lookup as call + synthesis. +- Added regression tests proving canonical parent-scope resolution, owning + parent-graph resolution, and per-check memoized projection. + +Invariant: + +```text +Consumers may decide when they need a function-fact type, but they must not own +how graph-local facts are resolved, how parent scope is canonized, how phase-safe +reads happen, or how those projections are memoized. +``` + +Why this is a flash migration step, not a bridge: + +- No old cache/key type remains in extract. +- No compatibility fallback was added. +- The phase-safe read logic moved out of the consumer and into the + function-fact domain. +- The synthesizer now carries only a domain-owned cache instance. + +Validation so far: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/synth/phase/extract -count=1 +go test ./compiler/check/synth/phase/extract -count=1 +``` + +Result: + +- function-fact and extract synthesizer package tests: pass. + +## 2026-05-20 Flash Migration: Slot-Complete Function-Fact Publication + +Problem: + +```text +Postflow and effect propagation still hand-built raw api.FunctionFact or +api.FunctionFacts values. The function-fact domain owned joins and some map +construction, but publication was not slot-complete: narrow summaries and +refinements could still be assembled outside the domain. +``` + +Migration performed: + +- Extended `functionfact.Parts` to cover every canonical slot: + - parameter evidence + - return summary + - narrow return summary + - function type projection + - refinement/effect summary +- Added `functionfact.FromPart` for complete single-symbol publication. +- Changed postflow return/type publication to publish through + `functionfact.FromPart`. +- Changed call-observed parameter evidence publication to accumulate raw + parameter vectors and publish through `functionfact.FromMaps`. +- Changed effect propagation to publish refinement facts through + `functionfact.FromPart`. +- Deleted the unused single raw-fact interproc delta helper after all production + publishers moved to canonical `functionfact` constructors. +- Added domain tests proving that `FromPart` canonicalizes all function-fact + slots. + +Invariant: + +```text +Production code outside the function-fact domain may collect evidence, but it +must not construct the canonical function-fact storage shape by hand. +``` + +Why this is a flash migration step, not a bridge: + +- No compatibility writer was introduced. +- No legacy fact channel was reintroduced. +- The producer-local raw fact/map construction was deleted from production + postflow and effect code. +- The old raw single-fact delta entry point was deleted. +- The canonical publication API now covers the complete function-fact product. + +Validation: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/domain/interproc ./compiler/check/infer/interproc ./compiler/check/pipeline -count=1 +go test ./... -count=1 +git diff --check +``` + +Result: + +- function-fact, interproc domain, interproc inference, and pipeline package + tests: pass. +- full go-lua suite: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: Whole-Fact Projection Cache + +Problem: + +```text +The previous projection slice centralized type lookup, but its cache was still +typed as a function-type cache. That made return-summary projection and +parameter-evidence projection continue to leak through raw `api.FunctionFacts` +selectors in synthesis, postflow, scope construction, runner staging, and +return inference. +``` + +Migration performed: + +- Replaced the type-only `functionfact.TypeCache` with a whole-fact + `functionfact.Cache`. +- Made cache keys include graph, parent scope, symbol, and checker phase. +- Added domain projection functions for: + - `FactForGraph` + - `TypeForGraph` + - `TypeForSymbol` + - `ReturnSummaryForSymbol` + - `NarrowSummaryForSymbol` + - `GraphKeyForSymbol` + - `ReturnsForPhase` + - `TypeFromMap` + - `ParameterEvidenceFromMap` + - `ReturnSummaryFromMap` + - `NarrowSummaryFromMap` +- Deleted postflow-local `returnSummaryFactForSymbol` and + `narrowSummaryFactForSymbol`. +- Deleted postflow-local `parentGraphKeyForCallee`. +- Changed synth extraction, postflow, return inference, scope construction, + runner staging, and sibling construction to use function-fact-domain + projections instead of raw selectors. +- Shared the domain projection cache across temporary synthesizers created for + overlays and nested return inference. + +Invariant: + +```text +The canonical function-fact map is a stored abstract domain. Callers may carry +the map as an input, but projection of its semantic slots belongs to +`domain/functionfact`, not to phase-local helper code. +``` + +Boundary note: + +```text +`domain/typefacts` still receives an interface with `FunctionType` because +`api` currently constructs type-fact environments and `functionfact` imports +`api`; importing `functionfact` from `typefacts` would create a cycle. That is a +real package-boundary issue, not a compatibility bridge. It should be addressed +only by moving environment construction out of `api` or splitting the storage +types from the environment constructors. +``` + +Validation so far: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/synth/phase/extract ./compiler/check/infer/interproc ./compiler/check/infer/return ./compiler/check/phase ./compiler/check/pipeline ./compiler/check/domain/typefacts ./compiler/check/siblings -count=1 +go test ./... -count=1 +git diff --check +``` + +Result: + +- focused projection and consumer packages: pass. +- full go-lua suite: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: Parameter Evidence Signature Projection Owner + +Problem: + +```text +`domain/paramevidence` still contained a store-backed `BuildSignatureMap` +helper that read canonical FunctionFacts directly. That forced the parameter +evidence lattice package to understand graph ownership, nested parent lookup, +and FunctionFacts storage projection. +``` + +Migration performed: + +- Moved function-expression keyed parameter-evidence signature projection to + `functionfact.ParameterEvidenceSignatures`. +- Deleted `paramevidence.BuildSignatureMap`. +- Updated the pipeline runner to request parameter-evidence signatures from the + function-fact domain. +- Kept `domain/paramevidence` focused on vector/signature lattice operations + such as `MergeIntoSignature`, `JoinVectors`, and parameter-use projection. +- Added function-fact-domain tests for nil inputs and current-graph signature + projection. + +Invariant: + +```text +Parameter evidence decides how parameter vectors combine. Function facts decide +where those vectors are stored, how graph ownership is resolved, and how stored +vectors are projected into phase inputs. +``` + +Validation so far: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/domain/paramevidence ./compiler/check/pipeline -count=1 +go test ./... -count=1 +git diff --check +``` + +Result: + +- function-fact, parameter-evidence, and pipeline packages: pass. +- full go-lua suite: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: Unbounded Local Parameter-Evidence Fixpoint + +Problem: + +```text +Local parameter-evidence propagation used `round < len(localFuncs)` as a +convergence cap. Even if that cap is mathematically expected to be enough for +simple call chains, the abstract-interpreter model should stop because the +domain state reached a fixed point, not because a counter expired. +``` + +Migration performed: + +- Replaced the bounded round loop in `returns.PropagateParameterEvidence` with + a direct fixed-point loop. +- Updated the comment to state the actual convergence condition. + +Invariant: + +```text +Local propagation runs until parameter evidence is stable. The stopping +condition is domain equality/no-change, not an iteration budget. +``` + +Validation so far: + +```text +go test ./compiler/check/returns -count=1 +go test ./... -count=1 +git diff --check +``` + +Result: + +- local return/parameter propagation package tests: pass. +- full go-lua suite: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: API Function-Fact Selectors Removed + +Problem: + +```text +`api.FunctionFacts` still exposed selector methods for parameter evidence, +return summaries, narrow summaries, function types, and refinements. Even after +the projection cache moved into `domain/functionfact`, these methods left a +second public projection surface in the storage package. +``` + +Migration performed: + +- Removed exported selector methods from `api.FunctionFacts`. +- Added the missing domain-owned refinement projection: + `functionfact.RefinementFromMap`. +- Updated production code and tests to use `domain/functionfact` map + projections. +- Changed `domain/typefacts` to receive a function-type lookup closure instead + of an interface implemented by `api.FunctionFacts`. +- At this checkpoint a private package-cycle adapter still existed in + `api/env.go`. That caveat is resolved by the later "API Fact Projection + Removed" checkpoint below. + +Invariant: + +```text +`api.FunctionFacts` is storage. `domain/functionfact` is the semantic projection +owner. A phase may carry the storage map as input, but it must ask the domain to +interpret slots. +``` + +Why this is a flash migration step, not a bridge: + +- The old selector API was deleted, not wrapped. +- No mirror fact channel was introduced. +- Production reads now go through domain projections. +- The private environment/typefacts adapter noted at this checkpoint was removed + in the later API fact projection slice. + +Validation status: + +```text +go test ./compiler/check/domain/functionfact ./compiler/check/domain/typefacts ./compiler/check/api ./compiler/check/store ./compiler/check/domain/interproc ./compiler/check/infer/interproc ./compiler/check/infer/nested ./compiler/check/tests/modules ./compiler/check/tests/errors ./compiler/check/tests/regression -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +git diff --check +``` + +Result: + +- focused projection/API/typefacts/interproc/nested/regression packages: pass. +- full go-lua suite with an explicit writable build cache: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: Function Facts Are Explicit Synth Inputs + +Problem: + +```text +After deleting the public `api.FunctionFacts` selector methods, synthesis still +retrieved the fact map through `FunctionFacts()` on declared/narrow +environments. That kept function-fact storage hidden behind the environment +interface even though the environment itself is not the function-fact domain. +``` + +Migration performed: + +- Removed `FunctionFacts()` from `api.DeclaredEnv` and `api.NarrowEnv`. +- Removed the stored `functionFacts` field from concrete API environments. +- Added `FunctionFacts api.FunctionFacts` to `synth.Config` and + `extract.Deps`. +- Changed synthesis to read the fact map from explicit dependencies via + `functionFactsInput`. +- Propagated function facts through phase construction, narrowed synthesis, + flow extraction, return-inference temporary synthesizers, environment overlays, + and captured-mutator synthesis. + +Invariant: + +```text +The environment answers phase-local type queries. Function facts are a separate +canonical interproc product input. Synthesis may consume that input, but it must +not recover it through an environment-side bridge. +``` + +Why this is a flash migration step, not a bridge: + +- The environment accessor was deleted. +- No replacement interface was added to `api`. +- Synth dependencies now name the product input directly. +- Slot interpretation still goes through `domain/functionfact` projections. + +Validation status: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/api ./compiler/check/synth ./compiler/check/synth/phase/extract ./compiler/check/phase ./compiler/check/pipeline ./compiler/check/infer/return ./compiler/check/tests/regression -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +git diff --check +``` + +Result: + +- focused API/synth/phase/pipeline/return/regression packages: pass. +- full go-lua suite with an explicit writable build cache: pass. +- diff check: pass. + +## 2026-05-20 Flash Migration: API Fact Projection Removed + +Problem: + +```text +`api.NewDeclaredEnv`, `api.NewNarrowEnv`, and `api.NewReturnInferenceEnv` +still accepted raw `FunctionFacts` and projected `facts[sym].Type` inside the +API package. That was not an exported selector anymore, but it was still a +second semantic projection owner. +``` + +Migration performed: + +- Removed raw `FunctionFacts` from declared, narrow, and return-inference env + configs. +- Removed the private `api.functionTypeLookup` adapter. +- Added `functionfact.TypeLookup` as the domain-owned function-type query + projection. +- Changed phase and return-inference callers to pass + `functionfact.TypeLookup(functionFacts)` into `typefacts`. +- Reworked `Checker.ClearCache` from the old no-op compatibility method into an + explicit Salsa revision boundary. Function-result memoization remains + session-local; hosts that call the public method now advance the checker + database revision instead of relying on a hidden checker-owned cache. +- Clarified Salsa fact inputs as query snapshots of the visible interproc + product, not another mirror authority. +- Added a domain test for `functionfact.TypeLookup`. + +Final dataflow: + +```text +FunctionFacts storage + -> domain/functionfact.TypeLookup + -> domain/typefacts.TypeFacts + -> api.Env.Types() +``` + +Invariant: + +```text +`api` stores environment wiring. It does not interpret function facts. +`domain/functionfact` owns function-fact slot projection. `domain/typefacts` +owns product-state type queries. +``` + +Why this is a flash migration step, not a bridge: + +- The private adapter was deleted, not wrapped. +- Env constructors now receive a typed query input instead of storage. +- No compatibility selector or mirror fact channel remains. +- The production scan now has no function-fact projection hook in `api`. + +Validation status: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/domain/functionfact ./compiler/check/api ./compiler/check/phase ./compiler/check/synth/phase/extract ./compiler/check/infer/return ./compiler/check -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +git diff --check +rg -n "functionTypeLookup|FunctionFacts FunctionFacts|Kept for API compatibility|legacy|bridge|mirror" compiler/check --glob '*.go' --glob '!**/*_test.go' +``` + +Result: + +- focused domain/API/phase/synth/return/check packages: pass. +- full go-lua suite with an explicit writable build cache: pass. +- diff check: pass. +- production bridge/legacy scan: no matches. + +Performance note: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=5 +``` + +Current checkout: + +- 3.94-4.72 ms/op +- 1.088-1.090 MB/op +- 10593-10594 allocs/op + +A clean temporary `HEAD` worktree measured the same range +(3.75-5.06 ms/op, 1.089 MB/op, 10593-10594 allocs/op), so this slice did not +add the current benchmark cost. The older 1.15 ms journal number is not the +current branch baseline. + +## 2026-05-20 Flash Migration: Nested Call-Effect Replay Owner + +Problem: + +```text +`compiler/check/returns` still owned helpers that reduce call evidence and +captured mutation facts into parent-visible field/container replay. That is not +return-vector inference. It is call-effect interpretation over transfer +evidence, and keeping it in `returns` made the package own one more abstract +interpreter side path. +``` + +Migration performed: + +- Moved nested call-effect replay from `returns` to + `domain/calleffect`: + - `CollectCalledNestedFieldAssignments` + - `CollectNestedMutatorAssignments` + - `CalledNestedMutatorAssignments` + - the call-symbol selection helper used by those reducers +- Moved the associated regression tests to `domain/calleffect`. +- Added a small returns test helper for local call evidence, because return + call-graph tests still need that fixture but should not depend on call-effect + replay helpers. +- Updated return inference and pipeline replay callers to use + `calleffect.Collect...`. +- Updated package docs: `returns` owns SCC/return/signature orchestration; + `domain/calleffect` owns call/effect projection over transfer evidence. + +Final ownership: + +```text +returns = local return SCCs, parameter evidence propagation, + return overlays, seed signatures +domain/calleffect = concrete call-site effect projection and replay payloads +overlaymut = overlay field/index mutation merge/apply laws +flow inputs = consumer of replayed table/container assignments +``` + +Invariant: + +```text +If code interprets a call contract/effect or determines which nested function +call may replay captured mutations, it belongs to `domain/calleffect`, not to +return-summary orchestration. +``` + +Validation status: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/domain/calleffect ./compiler/check/returns ./compiler/check/pipeline ./compiler/check/infer/return -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +``` + +Result: + +- focused calleffect/returns/pipeline/return packages: pass. +- full go-lua suite with an explicit writable build cache: pass. + +## 2026-05-20 Validation Correction: Public Revision Boundary And Call Context + +Problem: + +```text +The previous API-projection cleanup removed `Checker.ClearCache` as if it were +only a dead compatibility no-op. Local-replace Wippy validation proved that was +incorrect: the host still calls the public method. Keeping the public API is not +a bridge if the implementation is the correct incremental operation. + +The local-replace replay also showed that call-site contextual typing must be +wired as a single call pipeline concern. Callback re-synthesis was already in +the pipeline, but non-callback value arguments that are context-sensitive +(`x or default`, non-nil assertions, identifiers/fields with unresolved evidence) +were not using the expected parameter type computed by call inference. +``` + +Correction: + +- Restored `Checker.ClearCache` as an explicit Salsa revision boundary: + `Checker.ClearCache -> db.Bump()`. +- Kept function-analysis memoization session-local; no checker-owned cache or + legacy compatibility cache was reintroduced. +- Added `TestChecker_ClearCacheBumpsRevision`. +- Added a unified call-site re-synthesis hook: + callback function arguments still get spec environment overlays, and safe + value arguments are re-synthesized with the expected parameter type. +- Fixed `CallPipeline.reSynthArgs` so unchanged argument types do not force a + second inference pass. This protects generic expected-return constraints and + avoids unnecessary work. +- Added `TestCallPipeline_ReSynthAndReInfer_UnchangedArgDoesNotReInfer`. + +Important non-hack boundary: + +```text +Contextual re-synthesis must not rewrite explicit casts or arbitrary nested +function calls. Casts are programmer-supplied dynamic boundaries. Nested calls +run their own inference pipeline; forcing the outer expected type into them can +erase generic payload precision. +``` + +Validation: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/synth/phase/extract ./compiler/check/tests/regression -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +env GOWORK=/tmp/wippy-golua-validate-work/go.work WIPPY_BIN=/tmp/wippy-golua-validate-local GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache ../scripts/verify-suite.sh +``` + +Result: + +- focused synth/regression tests: pass. +- full checker suite: pass. +- full go-lua module suite: pass. +- large-function benchmark: 4.25-5.24 ms/op, about 1.091 MB/op, 10627-10628 + allocs/op. +- local-replace Wippy binary build: pass. +- local-replace Wippy lint replay remains nonzero: + - `wippy/tests/app`: 2 errors. + - `session`: 33 errors. + - `framework/src/agent/src`: 6 errors. + - `docker-demo`: 69 errors. + - `framework/src/llm/src`: 3 errors. + - `framework/src/llm/test`: 3 errors. + - `framework/src/views`: 2 errors. + +Current classification: + +- Real source/proof gaps, not sound false positives: + - `(args and args.url) or fallback` can return a truthy non-string `any`; + passing it to `http.get(string, ...)` is not provable. + - `artifact.meta` can remain `""`; Lua treats empty string as truthy, so + `if artifact.meta then artifact.meta.content_type end` can index a string. + - Bedrock `text_blocks` collects `block.text` from an untyped response; a + truthiness guard does not prove it is a string. +- Likely dependency/source-version replay issues, not current-source go-lua + proof failures: + - `wippy.llm.claude/openai/google:client` nullable `response.body` diagnostics + appear in `session`/`docker-demo` dependency replays, while current + `framework/src/llm/src` does not report them and reduced go-lua fixtures for + `response.body or ""` pass. Do not classify these as current engine failures + unless reproduced from the current local source. +- Still-suspect engine/inference gap: + - `wippy.llm.util:compress` in current `framework/src/llm/src` still reports + `integer?` arithmetic after `or` defaults. Reduced numeric-default fixtures + pass, so the likely missing case is full-module interaction: exported module + mutation/test mocks or inter-entry fact joining are optionalizing numeric + model-card fields. This must be reduced and fixed or classified before + claiming zero false positives. +- Still-open validation work: + - The remaining lint classes must each get a reduced go-lua fixture or a + source-proof classification before the branch can honestly claim a clean + local-replace global replay. + - The journal must not claim "all regressions solved" until that replay is + clean or every diagnostic is documented as a real source issue with a + regression fixture proving the engine behavior. + +## 2026-05-20 Validation Correction: Replay Errors Are Source-Proof Classes + +Problem: + +```text +The previous entry left `wippy.llm.util:compress` as a suspect engine gap. That +was too vague. The real replay error only appears on the Wippy export path +(`CheckParsed -> ExportManifest`), where exported function bodies are forced +through manifest/signature synthesis. Plain `testutil.Check` did not reproduce +it, which made the class look like a flaky inter-entry fact issue. +``` + +Reduction: + +- Added `TestExternalLint_DynamicRegistryModelCardRequiresNumericProofAcrossModule`. +- The reduced model mirrors `wippy.llm.discovery:models`: registry entries have + `data: any`, the model-card builder reads `entry.data.max_tokens` and + `entry.data.output_tokens`, then the consumer performs token arithmetic after + fallback defaults. +- This is a real source/API proof gap, not a false positive. With `data: any`, + `entry.data.max_tokens or 0` can still return a truthy non-number. The later + arithmetic is not provable unless the source checks/coerces the field or the + registry/model manifest gives a numeric schema. +- Added the positive boundary `TestExternalLint_PublicTypedConfigSetterPreservesNumericReads` + to preserve the intended strong case: a typed config update contract keeps + captured numeric reads stable. + +Additional current-source classifications now covered by reductions: + +- `wippy/tests/app` overlay URL diagnostics are real: + `TestExternalLint_UntypedOverlayURLRequiresStringProof` rejects + `(args and args.url) or fallback` because the truthy branch can be non-string, + and `TestExternalLint_GuardedOverlayURLFeedsStringContract` proves the guarded + shape. +- `wippy.views:page_registry` resource diagnostics are real: + `TestExternalLint_DynamicResourceIDsRequireStringProof` rejects untyped + registry resource IDs, and `TestExternalLint_GuardedResourceIDsFeedQualifier` + proves the guarded shape. +- `wippy.llm.bedrock:mapper` text-block diagnostics are real: + `TestExternalLint_DynamicResponseTextRequiresStringProof` rejects truthy + `block.text` as a string proof, and + `TestExternalLint_GuardedStringFieldAccumulatorFeedsHelper` proves the typed + guard. +- `wippy.agent.compiler:compiler` `string.gmatch(tool_id, ...)` is real: + `TestExternalLint_UntypedToolIDRequiresStringBeforeGmatch` captures that + `tool_id: any` is not a string proof. +- `wippy.llm.google:integration_test` tool-location diagnostics are real: + `TestExternalLint_NotNilDoesNotProveStringMethodReceiver` captures that + `test.not_nil(x)` proves non-nil, not string-ness, before string-typed test + helpers. +- The test-DSL mock/reset path remains protected by + `TestExternalLint_TestDslAfterEachModelResetDoesNotPolluteNumericHelper`; + model mocks and `after_each` resets are not the cause of the `compress` + replay diagnostics. + +Validation: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/tests/regression -count=1 +``` + +Result: + +- regression package: pass. + +Current status: + +```text +The focused current-source classes above are classified as source/proof errors +with regression coverage. They are allowed to keep the external replay non-clean +until the external Lua sources or manifests add the missing proof. They are not +evidence of an unsound checker workaround. +``` + +## 2026-05-20 Instability Correction: Product-Domain Conjunction Consistency + +Problem: + +```text +`userspace.uploads:upload_repo` was nondeterministic under repeated +`--cache-reset` lint runs. The bad runs reported missing fields on +`options.filters.created_after` and `options.filters.created_before`; good runs +did not. The source pattern was a normal Lua short-circuit guard: + +if options.filters and options.filters.created_after then ... end +``` + +Root cause: + +- A DNF disjunct could contain contradictory atoms for the same logical value: + `truthy(options.filters)` and `falsy(options.filters)`. +- Over `unknown`, `truthy(x)` was a no-op while `falsy(x)` could narrow to + `nil | false`, so the result depended on atom/disjunct order. +- That let an impossible disjunct contribute a partial narrowed type to the DNF + join, which then leaked into field validation as a false positive. + +Correction: + +- `ProductDomain.ApplyConjunction` now builds the equality graph first, then + rejects contradictory atom sets before any type, numeric, or shape domain is + applied. +- Rejected contradictory conjunctions mark the product domain unsat, so direct + domain callers and DNF branch pruning observe the same state. +- The contradiction check is equality-class aware: `x == y and x and not y` is + unsat for the same reason as `x and not x`. +- The check is lazy in the hot path: one-atom conjunctions return immediately, + and maps are allocated only when a relevant truth/type atom appears. +- `Solution.NarrowedTypeAt` returns `never` for syntactically false point + conditions, and `IsPointDead` treats those points as unreachable. +- The field hook now receives `FlowOps` and skips dead CFG points instead of + validating unreachable evidence. + +Non-hack boundary: + +```text +This is not an upload-specific or field-specific workaround. The law belongs to +the product abstract domain: an impossible conjunction must not contribute to a +disjunction join. Field diagnostics only observe the corrected abstract state. +``` + +Regression coverage: + +- false point condition is `never` and dead; +- truthy guard over `false?` is impossible; +- truthy field guard over `false?` is impossible; +- contradictory DNF disjuncts over `unknown` are pruned; +- alias/equality contradictions are pruned through congruence closure; +- resolver-free product-domain callers still get equality-aware contradiction + pruning from atom keys; +- external reduced regressions for repeated truthy field guards and optional + request filter forwarding stay deterministic. + +Validation: + +```text +env GOCACHE=/tmp/go-build-cache go test ./types/flow -run 'TestNarrowedTypeAt_(FalseConditionIsNever|TruthyFalseOptional.*|ContradictoryTruthyFalsyUnknownIsNever|DNFPrunesContradictoryTruthyFalsyUnknown|ContradictoryTruthyFalsyAliasIsNever)' -count=1 -v +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check -run '^$' -bench BenchmarkCheck_LargeFunction -benchmem -count=3 +env GOWORK=/tmp/wippy-golua-validate-work/go.work GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go build -o /tmp/wippy-golua-validate-local ./cmd/wippy +for i in 1..15: /tmp/wippy-golua-validate-local lint --cache-reset --json --ns userspace.uploads +env GOWORK=/tmp/wippy-golua-validate-work/go.work WIPPY_BIN=/tmp/wippy-golua-validate-local GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache ../scripts/verify-suite.sh +``` + +Result: + +- focused flow instability tests: pass. +- full checker suite: pass. +- full go-lua module suite: pass. +- large-function benchmark: about 1.58-1.59 ms/op, 1.091 MB/op, and 10627 + allocs/op. +- local Wippy binary against this checkout: builds. +- `userspace.uploads` replay is stable for 15 cache-reset runs: + `errors=2 total=11 upload_repo=0` on every run. +- The two remaining `userspace.uploads` diagnostics are separate `any` proof + errors in `pipeline` and `upload_type`; they are not the nondeterministic + short-circuit/field-access regression. +- local-replace verify suite passes go-lua checker tests and Wippy build, then + exits non-zero on external lint targets: `tests/app=2`, `session=33`, + `agent/src=6`, `docker-demo=66`, `llm/src=3`, `llm/test=3`, `views=2`. + Those remaining counts are classification work for source/proof diagnostics, + not evidence that the fixed `upload_repo` instability remains. + +## 2026-05-20 Callback Execution Context: DSL Globals Reach Real Body Analysis + +Problem: + +```text +A typed migration-style DSL can say: + +migration_lib.define(function() + database("postgres", function() + up(function(db) + db:type() + end) + end) +end) + +where `define` and `database` inject callback-scoped globals and `up` expects a +transaction callback. The call synthesizer could use those callback specs while +checking one call expression, but the nested callback body itself was still +analyzed under only `(graph, parent-scope)`. That meant callback-scoped globals +were not part of the function-analysis request, so the `up` callback parameter +could collapse to `any`. +``` + +Root cause: + +- Contextual callback typing was transient in call synthesis. +- The function-result query key represented lexical parent scope but not dynamic + callback execution context. +- Without the DSL global overlay in the nested function analysis, the parent + callback could not type the nested `up(...)` call, so expected transaction + parameter evidence was never produced for the innermost callback. + +Correction: + +- Added `api.AnalysisContext` as the canonical execution-context component for a + function analysis. +- Callback env overlays now contribute to the function-analysis parent hash, so + Salsa/query memoization separates plain lexical analysis from callback-context + analysis. +- The session store records analysis context by graph key; the runner merges the + context's global overlay into `GlobalTypes` before every phase. +- Nested processing derives callback execution context from the parent abstract + interpreter evidence and callee contract specs before analyzing a child + function. +- Dead parent callsites do not contribute callback execution context. +- Parameter evidence still flows through canonical `FunctionFacts`; no + migration-specific rule was added. + +Real migration source check: + +- `/home/wolfy-j/wippy/framework/src/migration/core.lua` still declares + `create_up_fn`, `create_after_fn`, and `create_down_fn` as returning functions + that accept `fn: any`. +- Runtime `migration.lua` begins a SQL transaction and calls migration + implementations with `tx`, so the callback argument is a transaction adapter, + not the raw SQL service. +- Therefore the external migration package still needs a real DSL/manifest + contract for the checker to reject `db:type()` in actual migration callbacks. + The engine can now enforce it when the contract exists. + +Regression coverage: + +- `TestEnvOverlay_MigrationDSLTypedTransactionRejectsServiceTypeMethod` proves + nested DSL callback context reaches the real body analysis and rejects a + service-only `type()` method on a transaction callback parameter. +- `TestEnvOverlay_MigrationDSLTypedTransactionAllowsQueryAndExecute` proves the + transaction operations remain accepted. +- `TestEnvOverlay_RuntimeSQLServiceDBAllowsTypeMethod` proves normal + `sql.get(...):type()` remains accepted for service DB values. + +Validation: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/tests/core -run 'TestEnvOverlay_(MigrationDSLTypedTransactionRejectsServiceTypeMethod|MigrationDSLTypedTransactionAllowsQueryAndExecute|RuntimeSQLServiceDBAllowsTypeMethod)' -count=1 -v +``` + +Result: + +- focused callback execution-context tests: pass. + +## 2026-05-20 Current Blocker: Higher-Order Factory Return Assigned To Local Loses Callback Shape + +Status: + +```text +The flash migration is not complete while this class remains. The abstract +interpreter can now carry callback execution context into nested body analysis, +but unannotated local assignment of a function-valued call result still loses +higher-order callback parameter evidence. +``` + +Observed probes: + +- Direct callback contract works: + +```lua +local function up(fn: (sql.Transaction) -> any) end + +up(function(db) + local result, err = db:execute("CREATE TABLE users(id TEXT)") + if err then return end + local changed: integer = result.rows_affected +end) +``` + +- Type-alias callback contract works: + +```lua +type MigrationStep = (sql.Transaction) -> any +local function up(fn: MigrationStep) end +``` + +- Immediate higher-order factory call works: + +```lua +type MigrationStep = (sql.Transaction) -> any + +local function create_up_fn(): (MigrationStep) -> () + return function(fn: MigrationStep) end +end + +create_up_fn()(function(db) + local result, err = db:execute("CREATE TABLE users(id TEXT)") + if err then return end + local changed: integer = result.rows_affected +end) +``` + +- Explicitly typed local assignment works: + +```lua +type MigrationStep = (sql.Transaction) -> any +type UpFn = (MigrationStep) -> () + +local up: UpFn = create_up_fn() +``` + +- Unannotated local assignment fails: + +```lua +local up = create_up_fn() +up(function(db) + local result, err = db:execute("CREATE TABLE users(id TEXT)") + if err then return end + local changed: integer = result.rows_affected +end) +``` + +The same failure reproduces with a plain callback type: + +```lua +type Step = (number) -> any +local step = create_step_fn() +step(function(value) + local n: number = value +end) +``` + +So this is not a SQL manifest problem, not a method `self` problem, and not a +module-qualified type problem. It is a higher-order local-assignment propagation +problem. + +Current failing diagnostics: + +```text +TestEnvOverlay_FactoryReturnCallbackUsesImportedModuleType: + cannot assign any to integer + +TestEnvOverlay_FactoryReturnCallbackUsesPlainCallbackType: + cannot assign any to number + +TestEnvOverlay_InferredGlobalCallbackUsesImportedModuleType: + cannot assign any to integer + +TestEnvOverlay_InferredGlobalCallbackImportedTransactionRejectsServiceDBMethod: + missing expected rejection because callback parameter collapsed to any +``` + +Root-cause boundary: + +- Call synthesis can compute the factory call result correctly when the returned + function is invoked immediately. +- Assignment checking can preserve the shape when the target local has an + explicit type annotation. +- The weak point is the abstract interpreter's local assignment product for + `local up = create_up_fn()`: the function-valued call result enters local + inferred state with less evidence than the canonical call result. +- The later callback call `up(function(db) ... end)` then reads `up` from local + inferred state, not from the direct call expression, so the expected callback + parameter type is gone. + +Canonical design requirement: + +```text +Function-valued values are first-class abstract values. Assignment transfer must +preserve their full function shape, including higher-order parameter and return +types, exactly as produced by canonical call synthesis. A local variable holding +a returned function must be indistinguishable from the returned function for +subsequent call-site contextual typing. +``` + +Correct owner: + +- Not the migration DSL. +- Not `EnvOverlay` inference. +- Not a call-site special case for names like `up`. +- Not an external framework manifest workaround. +- The owner is assignment abstract interpretation: + `abstract/assign` must publish the canonical function-valued call result into + the local inferred type product without widening callback parameters to `any` + or losing alias/module-resolved structure. + +Implementation rule: + +```text +Do not add a compatibility bridge or fallback lookup for this case. Fix the +single canonical assignment transfer path so every consumer that reads a local +function value observes the same function type the call expression produced. +``` + +The likely correction is to make assignment inference prefer the canonical +single-expression call synthesis result for function-valued call results when +tuple expansion or local SCC widening has produced a top-like or less-evidenced +function shape. This must be expressed as a general precision law for +function-valued assignments, not as a migration-specific recognizer. + +Rejection criteria: + +- Any helper that says "if target name is up/down/after" is invalid. +- Any helper that guesses `sql.Transaction` from migration source shape is + invalid. +- Any path that only fixes `_G` overlays while leaving `local step = + create_step_fn()` broken is invalid. +- Any change that preserves the failing tests by weakening diagnostics is + invalid. + +Required regression coverage before claiming completion: + +- direct module-qualified callback parameter; +- direct module-qualified callback alias; +- higher-order factory immediate invocation; +- higher-order factory assigned to unannotated local; +- higher-order factory assigned to explicitly typed local; +- plain non-module callback factory assigned to unannotated local; +- `_G` callback env overlay installed from a factory-returned function; +- negative transaction test proving a service-only method is still rejected. + +Required validation: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/tests/core -run 'TestEnvOverlay_(CallbackParameter|FactoryReturn|InferredGlobalCallback|MigrationDSLTypedTransaction|RuntimeSQLServiceDB)' -count=1 -v +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/tests/core -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +``` + +## 2026-05-20 Canonicalization Audit: Abstract Interpreter Is Improved But Not Fully Canonized + +Question: + +```text +Can we verify that the abstract interpreter is no longer scattered logic and is +properly canonicalized? +``` + +Verdict: + +```text +No. The core shape is much closer to the intended model, but the flash migration +is not complete. The current checkout has a real canonical event stream and a +canonical function-fact product, but higher-order value propagation is still +split between the main call synthesizer and assignment-local synthesis/merge +logic. That split is observable in the factory-returned callback regression. +``` + +What is canonical now: + +- `api.FlowEvidence` is the abstract-interpreter event stream. It owns calls, + returns, assignments, branches, normal exit, identifier uses, field defaults, + parameter uses, fresh table literals, function definitions, escaped + functions, local type predicates, and captured mutation evidence. +- `abstract.MaterializeGraphEvidence` is the materialization boundary for graph + event evidence. +- `store.SessionStore.EvidenceForGraph` caches graph evidence instead of asking + downstream consumers to rebuild it. +- `hooks.CheckIdents` now consumes `evidence.IdentifierUses` instead of walking + CFG nodes itself. +- `effects.TerminatesFromReachability` consumes `evidence.NormalExit` plus the + flow solution, not a raw return-node scan. +- `api.FunctionFact` is the canonical function interproc product: + `Params`, `Summary`, `Narrow`, `Type`, and `Refinement`. +- The old production names `ReturnSummaries`, `NarrowReturns`, `FuncTypes`, + `ParamHints`, `NormalizeFacts`, and `FunctionTypesFromFacts` do not appear in + production checker code. +- `api.AnalysisContext` is now part of the function-analysis key: callback + global overlays affect parent hash, are stored by graph key, and are merged + into runner `GlobalTypes` before analysis. + +Audit commands: + +```text +rg -n 'ReturnSummaries|NarrowReturns|FuncTypes|ParamHints|NormalizeFacts|NormalizeFunctionFactChannels|FunctionTypesFromFacts|legacy|compatibility bridge|transition' compiler/check types/flow -g'*.go' -g'!*_test.go' +rg -n 'trace\.GraphEvidence|GraphEvidence\(|EachCallSite|EachStmtCall|graph\.RPO\(|graph\.Info\(|cfg\.BuildCallInfo|cfg\.BuildWithBindings|cfg\.Build\(|FunctionLiteralForGraphSymbol|FunctionLiteralForSymbol|CurrentFreshTableLiteral|DetectKeysCollector|ExtractCapturedContainerEvidence' compiler/check types/flow -g'*.go' -g'!*_test.go' +``` + +Remaining non-canonical seams: + +1. Assignment inference has its own call-synthesis path: + +```text +compiler/check/abstract/assign/preflow_synth.go + synthCallWithOverlay -> ops.CallWithGenericInference +``` + +The main call synthesizer uses the full contextual call pipeline: + +```text +compiler/check/synth/phase/extract/call.go + synthArgsWithCallContext + NewCallPipeline(...).WithReSynth(contextualArgReSynth(...)) +``` + +This means assignment transfer can observe a weaker call result than the +canonical expression/call synthesizer. That is exactly the wrong shape for +first-class function values. + +2. Local assignment SCC merge is a separate function-valued state law: + +```text +compiler/check/abstract/assign/infer.go + assignedType = expandedAssignValues(...) + assignedType = preferPreciseDirectSourceType(...) + joined := joinInferredType(old, assignedType) +``` + +This is the path for: + +```lua +local up = create_up_fn() +``` + +It currently loses callback parameter evidence even though: + +```lua +create_up_fn()(function(db) ... end) +local up: UpFn = create_up_fn() +``` + +both work. Therefore the canonical call result exists, but assignment inference +does not preserve it as a first-class function value. + +3. Named/local function resolution still has multiple consumers: + +```text +compiler/check/callsite/function_literal.go +compiler/check/synth/phase/extract/named_function.go +compiler/check/synth/phase/extract/call.go +compiler/check/domain/functionfact/call_projection.go +``` + +Most of this is evidence-backed, not raw CFG rediscovery, but the policy is not +yet single-owned. Stable function identity, extra-arg source leniency, and +function-fact projection should read as one canonical projection surface. + +4. `domain/keyscoll` remains a specialized recognizer: + +```text +compiler/check/domain/keyscoll/keyscoll.go +``` + +It consumes `FlowEvidence`, so it is not the old scattered transfer helper, but +it is still a hard-coded semantic pattern. The final design should express this +as function refinement/effect inference over evidence, not a standalone +keys-collector recognizer. + +5. Some direct graph walks are acceptable, but they must stay classified: + +- `abstract/trace` may inspect CFG/AST. It is the event materializer. +- `abstract/cond`, `abstract/assign`, `abstract/decl`, `abstract/mutator`, and + `abstract/returns` may lower canonical evidence and graph topology into + `flow.Inputs`. +- `phase/scope`, `phase/resolve`, and `scope/typedefs` may walk graph order for + lexical scope/type-declaration construction. +- Semantic consumers outside those categories should consume `FlowEvidence`, + `FunctionFacts`, or `FlowSolution`, not rediscover graph events. + +Design blocker proven by tests: + +```text +TestEnvOverlay_FactoryReturnCallbackUsesPlainCallbackType +TestEnvOverlay_FactoryReturnCallbackUsesImportedModuleType +TestEnvOverlay_InferredGlobalCallbackUsesImportedModuleType +TestEnvOverlay_InferredGlobalCallbackImportedTransactionRejectsServiceDBMethod +``` + +The direct and immediate-call tests pass. The assigned-local tests fail. This is +not a framework contract issue and not a SQL type issue. It is a first-class +function-value preservation issue in assignment abstract interpretation. + +Required flash-migration correction: + +```text +Collapse assignment call-result synthesis onto the canonical call pipeline, or +move the relevant call-result projection into one shared abstract operation used +by both expression synthesis and assignment inference. A function-valued call +result must enter local inferred state with the exact same higher-order shape +that the call expression produced. +``` + +Do not implement: + +- a migration/up/down/after name rule; +- a SQL transaction guess; +- a local fallback lookup that only helps `_G` overlays; +- a compatibility bridge that keeps both assignment-call synthesis and canonical + call synthesis with different precision laws. + +Proof needed before declaring the migration complete: + +- the factory-return callback tests above pass; +- the regression remains negative where `sql.Transaction` rejects `sql.DB:type`; +- `rg` confirms no old fact channels or compatibility projections returned; +- targeted and full go-lua suites pass; +- the journal section is updated with the actual correction and validation. + +## 2026-05-20 Correction Target: Callback Expected Signature Is Analysis Context + +Additional verification split the failing higher-order callback case: + +```text +local step = create_step_fn() +step(function(value) ... end) +``` + +The parent abstract interpreter already infers the local value: + +```text +step : fun(Step) +``` + +at the call point. The loss happens later, when the nested callback body is +checked. Its graph is analyzed with callback global overlays in +`api.AnalysisContext`, but the expected callback function signature from the +parent callsite is not part of that context. Therefore the callback parameter +falls back to `any` even though the parent narrowed call evidence can prove the +expected signature. + +Canonical correction: + +```text +The function-analysis key must include the dynamic callsite function context: +contract-provided globals and expected callback signature. Nested callback +analysis must derive that expected signature from the parent narrowed call +pipeline/effective callee type, then run the child graph under that context. +``` + +This is the direct abstract-interpreter model: + +- parent graph solves first-class value flow; +- parent callsite projects an expected callback function type; +- child graph analysis key includes that expected function type; +- scope/parameter extraction for the child uses that expected function type. + +Rejected alternatives: + +- no `up`/`down`/`migration` name rule; +- no SQL transaction guess; +- no weakening of assignment diagnostics; +- no local fallback that bypasses the canonical call pipeline. + +## 2026-05-20 Canonicalization Verification: Fact Equality Includes Function Contracts + +Verification result after the callback-context correction: + +```text +The assigned-local higher-order callback cases passed, but the `_G` overlay +factory cases still failed. The parent abstract interpreter solved the overlay +and `InferCallbackEnvOverlays` produced the expected callback environment +spec. The spec was not reaching later callback analysis because the fact product +treated a function with a newly inferred `Spec` as equal to the old function +without that `Spec`. +``` + +Root cause: + +```text +typ.TypeEquals intentionally compares function call shape and ignores +Effects/Spec/Refinement. That relation is correct for ordinary structural +typing, but it is too weak for interprocedural fact equality. In the fact +product, a function contract is part of the abstract value. Using +typ.TypeEquals there made same-shaped function facts look unchanged, so the +newly solved callback overlay contract could be skipped from InterprocNext and +from Salsa fact inputs. +``` + +Canonical correction: + +- Added `domain/value.FactTypeEqual`. +- `FactTypeEqual` first requires ordinary structural type equality, then checks + function metadata recursively: effects, contract specs, refinements, and + nested function metadata inside records/maps/tuples/unions/interfaces/etc. +- `domain/interproc` fact equality now uses `FactTypeEqual` for function facts, + literal signatures, captured types, captured fields, captured container + mutations, and constructor fields. +- `domain/functionfact` parameter merge/widen paths now use `FactTypeEqual` + before the weaker structural `typ.TypeEquals`, so nested callback metadata is + preserved instead of erased by same-shape parameter merging. +- Removed the unexported callback-overlay wrapper; production and tests call + `InferCallbackEnvOverlays` directly. +- Renamed the assignment preflow call helper to `evalOverlayCallFirstResult` + and documented it as a transfer-local evaluator over the shared call domain. + It does not publish facts, run compatibility channels, or own an independent + fact merge law. + +Why this is canonical: + +```text +There are now two explicit equality relations: + +typ.TypeEquals = ordinary structural type equality for type checking +value.FactTypeEqual = abstract fact equality for stored checker facts + +The stored fact product no longer depends on a structural equality relation +that deliberately erases contract/effect/refinement metadata. This keeps the +abstract interpreter model honest: solved evidence becomes canonical fact state, +fact equality observes that state, and Salsa inputs are invalidated only when +the actual fact product changes. +``` + +Current verification: + +```text +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/domain/value ./compiler/check/domain/interproc ./compiler/check/domain/functionfact -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/synth/phase/extract ./compiler/check/domain/value ./compiler/check/domain/interproc ./compiler/check/domain/functionfact -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/tests/core -run 'TestEnvOverlay_(CallbackParameterUsesImportedModuleType|CallbackParameterUsesImportedModuleTypeAlias|CallbackParameterImportedTransactionRejectsServiceDBMethod|FactoryReturnCallbackUsesImportedModuleType|FactoryReturnCallbackUsesPlainCallbackType|FactoryReturnCallbackWithExplicitLocalType|FactoryReturnCallbackImmediateCallUsesImportedModuleType|InferredGlobalCallbackUsesImportedModuleType|InferredGlobalCallbackImportedTransactionRejectsServiceDBMethod)' -count=1 -v +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/abstract/assign ./compiler/check/tests/core -count=1 +env GOCACHE=/tmp/go-build-cache go test ./compiler/check/... -count=1 +env GOCACHE=/tmp/go-build-cache go test ./... -count=1 +``` + +All commands above pass. + +External verify-suite status: + +```text +../scripts/verify-suite.sh +``` + +Result on 2026-05-20: + +- go-lua checker tests pass; +- Wippy binary build passes; +- framework `llm/src`, `llm/test`, `migration`, `views`, `relay/test`, + `test`, `actor/test`, `bootloader`, and `wippy/tests/app` lint targets are + clean; +- the suite still exits non-zero on external lint targets outside go-lua: + `session` 8 errors, `framework/src/agent/src` 9 errors, `docker-demo` + 21 errors and 2 warnings. + +Those remaining external diagnostics are not edited in this go-lua migration +checkpoint. They must be classified separately before claiming the global +Wippy lint surface is clean. + +Production cleanup scans: + +```text +rg -n "synthCallWithOverlay|inferCallbackEnvOverlays|NormalizeFacts|FunctionTypesFromFacts|ReturnSummaries|NarrowReturns|FuncTypes|ParamHints|NormalizeFunctionFactChannels|functionTypeLookup|Kept for API compatibility|compatibility bridge|legacy fact|transitional" compiler/check types/flow --glob '*.go' --glob '!**/*_test.go' +``` + +Result: no matches. + +Canonicalization verdict for this checkpoint: + +- The fact system is now one canonical function-fact product, not legacy mirror + channels. +- Function contract/effect/refinement metadata is part of fact equality and + convergence, not an ignored side attachment. +- Callback expected signatures are analysis context, keyed into graph analysis, + not guessed from names or framework-specific syntax. +- Callback environment overlays are solved from `FlowEvidence` and published as + contract specs, not as a separate fallback fact channel. +- Remaining overlay-aware expression evaluation in assignment transfer is + classified as local abstract interpretation over the shared call operation. + It is not a stored fact authority. If it ever starts producing diagnostics, + facts, or merge policy, that would be a regression back into scattered logic. + +## 2026-05-20 External Engine-Gap Classification + +Local-replace replay with the current go-lua checkout: + +```text +/tmp/wippy-golua-engine-gap-check/wippy-local-current lint --cache-reset --json +``` + +Observed remaining external diagnostics: + +- `framework/src/llm/src`: 3 errors; +- `framework/src/views`: 2 errors; +- `framework/src/agent/src`: 6 errors, all inherited from agent/llm sources; +- `session`: 33 errors; +- `docker-demo`: 66 errors, mostly inherited from app/dataflow/session/llm/views + sources. + +Classification principle: + +```text +The checker must prove from Lua source facts, annotations, manifests, or +contract specs. It must not infer concrete string/number/record shapes from +`any`, from raw SQL row maps, from untyped registry entries, or from runtime +tests such as type(fn) == "function" that do not prove arity and returns. +``` + +Session findings: + +- `wippy.session.api:get_artifact`, + `wippy.session.api:get_artifact_content`, + `wippy.session.persist:artifact_repo_test`, and + `wippy.session.persist:message_repo_test` metadata field errors are real + source/contract issues. Repository code can leave `meta`/`metadata` as the raw + database string (`""` or a failed JSON decode path). Lua treats `""` as truthy, + so `if artifact.meta then artifact.meta.content_type end` is not safe unless + the value is normalized to a table/nil or guarded by `type(meta) == "table"`. +- `wippy.session.process:command_bus` is a real source/contract issue. The + registry requires `(any, any) -> (any, string?)`; `type(handler_func) == + "function"` proves only callability, not parameter arity or return shape. +- `wippy.session.persist:message_repo` line 286 is a real source/contract issue. + `limit = limit or 500` does not prove that an untyped caller supplied a + number; `limit + 1` is only safe after annotation or numeric normalization. +- `wippy.session.process:control_handlers` errors are real source/contract + issues unless the operation payload is typed before dispatch. Reading + `op.action` from `any` does not prove that the payload satisfies the narrower + context write/delete contracts. +- `wippy.session:start_tokens_test` intentionally calls a typed API with a + string to test runtime validation. That is a real static type error, not a + checker false positive. +- `wippy.session.funcs:checkpoint` is a boundary proof issue, not a local + length-guard engine gap. Reduced go-lua fixtures already prove that + `xs and #xs > 0` makes `xs[1]` non-nil for typed arrays, including imported + query-builder chains and `table.sort`. The real source reaches the checkpoint + through SQL row/manifest boundaries that do not expose `{SessionContext}` as + the return contract. The checker is correct to refuse to derive + `existing_summaries[1].text` from an untyped row source. + +Other external findings: + +- `wippy.llm.util:compress` arithmetic errors are real source/contract issues. + A public untyped `compress.configure(new_config)` can write non-numeric values + into numeric `CONFIG` fields, and dynamic model-card fields such as + `max_tokens`/`output_tokens` are not numeric just because they are truthy. +- `wippy.llm.bedrock:mapper` text-block accumulation is only safe when the + response shape says `block.text: string?` or the code uses a string guard. + Existing go-lua tests prove the guarded/typed accumulator case; the current + external diagnostic is a missing source/manifest contract at the any boundary. +- `wippy.views:page_registry` and `wippy.views.api:list_routes` are real + source/manifest boundary issues. Registry entries are dynamic; assigning + `entry.id`, `entry.data.resources[i]`, or `page.id` to string contracts + requires normalization or string guards. Existing tests intentionally reject + untyped ids and accept guarded ids. + +Regression coverage already present: + +```text +TestExternalLint_LengthGuardEliminatesEmptyTableFallback +TestExternalLint_ImportedUntypedRepositoryFallbackEliminatesNil +TestExternalLint_MultiModuleSessionQueryLengthGuardEliminatesNil +TestExternalLint_SQLBuilderRowsKeepSessionQueryLengthGuard +TestExternalLint_DynamicModelCardNumericFieldsRequireProof +TestExternalLint_PublicTypedConfigSetterPreservesNumericReads +TestExternalLint_DynamicResponseTextRequiresStringProof +TestExternalLint_GuardedStringFieldAccumulatorFeedsHelper +TestExternalLint_UntypedPageIDRequiresStringProofForAccessibleRoutes +TestExternalLint_GuardedPageIDFeedsAccessibleRoutes +TestExternalLint_UntypedCommandHandlerCannotEnterTypedRegistry +TestExternalLint_TypedCommandHandlerCanEnterTypedRegistry +``` + +Current conclusion: + +```text +The remaining checked external classes are not evidence of a scattered fact +system or failed abstract-interpreter convergence. They are dynamic source or +manifest boundaries where no canonical proof exists. Fixing them requires typed +source contracts, normalization at repository/registry boundaries, or richer +domain-specific manifests such as SQL row-shape effects. go-lua must not paper +over them by inventing facts from any. +``` + +## 2026-05-20 Canonical Product-Domain Cleanup: No Secondary Fact Paths + +The latest cleanup pass addressed the specific design problem that caused drift: +some production names still described ordinary domain queries as recovery paths. +That language was not harmless, because it made it too easy to add consumer-side +repairs instead of fixing the owning abstract domain. + +Canonical corrections made in this pass: + +- iterator variable typing now goes through one product-domain query, + `iterationElementEvidenceAt`, in this order: + point-local flow/narrowing state, same-symbol path-domain facts from visible + versions, and declared template evidence; +- condition reconstruction for linear predecessor points is named as + predecessor composition, not as a separate recovery mechanism; +- call-effect argument relations that involve literals or `#expr` are named as + original-argument constraint reconstruction, not a compatibility path; +- callee identity uses primary and secondary binding tables with one + deterministic candidate order; +- symbol-type resolution preserves placeholders only for unresolved global + lookup and no longer presents that as an alternative fact authority; +- open-record field misses are named as field misses before index lookup, not + as a type-system repair path. + +Production scan after the cleanup: + +```text +rg -n "conditionAtFallback|callConstraintFallbackFromArgs|resolveGlobalOrFallback|isOpenRecordFallback|fallback \\*bind|fallback \\*typ|fallback typ|fallback string|FunctionTypesFromFacts|NormalizeFacts|ReturnSummaries|NarrowReturns|FuncTypes|ParamHints" compiler/check types/flow -g'*.go' -g'!*_test.go' +``` + +Result: no matches. + +The remaining `compat*`/`incompatible` words in production are ordinary +type-checking vocabulary, for example "table not compatible with expected +type". They are not legacy fact channels or bridge layers. + +## 2026-05-20 Relational Map-Key Narrowing And Captured Map Effects + +The session plugin false positive at `graceful_terminate_session(session_id, ...)` +exposed a missing relation in the abstract interpreter: + +```lua +local session_info = active_sessions[session_id] +if session_info then + graceful_terminate_session(session_id, session_info, "user_closed") +end +``` + +The truthy branch proves that `session_id` belongs to the key domain of +`active_sessions`. The canonical representation is `constraint.KeyOf(table, +key)`, consumed by `ProductDomain.ApplyConjunction`. It narrows the key path to +`core.KeyType(tableType)` using intersection and marks the branch unsatisfiable +when the intersection is empty. + +This is a product-domain relation, not a function-specific special case: + +- direct dynamic-index truthiness, `if t[k] then`, emits `KeyOf(t, k)`; +- truthiness of a local assigned from a dynamic index emits the same relation + through `MapElementSource`; +- the type domain owns the key narrowing and re-propagates congruence closure + after applying it. + +Captured map/index writes are also represented canonically: + +- `CapturedContainerEvidence` records the written key expression; +- `ContainerMutation` carries `KeyType`; +- nested call effects publish `ContainerMutationMapElement`; +- flow transfer applies those effects as index assignments, so `pairs(map)` sees + the map value type produced by writes in nested functions and sibling loop + branches. + +Regression protection added or maintained in this area: + +```text +TestExternalLint_CapturedMapMutationFeedsPairsIteratorValue +TestExternalLint_LoopCarriedCapturedMapMutationFeedsSiblingBranchPairs +TestExternalLint_TruthyDynamicMapReadNarrowsKeyForCall +TestSessionPlugin_UntypedSessionIDWithoutPresenceProofRejectsStringAPI +TestExternalLint_CapturedMapTimeFieldsSurviveActivityUpdate +TestExternalLint_CapturedMapTimeFieldsSurviveUntypedPayloadKey +TestExternalLint_CapturedMapStdlibTimeFieldsSurviveUntypedPayloadKey +TestExternalLint_CapturedMapPreWriteReadsDoNotEraseTimeFields +TestExternalLint_NestedCapturedMapPreWriteReadsDoNotEraseTimeFields +TestExternalLint_LoopSiblingCapturedMapTimeFieldsConverge +``` + +Current verification: + +```text +go test ./compiler/check/tests/regression -run 'TestExternalLint_(CapturedMap.*TimeFields|CapturedMapPreWriteReads|NestedCapturedMapPreWriteReads|LoopSiblingCapturedMapTimeFields|TruthyDynamicMapReadNarrowsKeyForCall)|TestSessionPlugin_' -count=1 +go test ./compiler/check/... ./types/flow/... -count=1 +``` + +Both commands pass. + +## 2026-05-20 Local-Replace Replay: Session Process + +The correct replay binary is built from `/tmp/wippy-golua-engine-gap-check`, +whose `go.mod` replaces `github.com/wippyai/go-lua` with this checkout. The +official Wippy checkout under `/home/wolfy-j/wippy/wippy` still records the +pinned module version in `go.mod`, so it must not be used to claim current +go-lua behavior. + +Binary proof: + +```text +go version -m /tmp/wippy-golua-engine-gap-check/wippy-local-current +``` + +Relevant line: + +```text +github.com/wippyai/go-lua v1.5.16 => /home/wolfy-j/wippy/go-lua (devel) +``` + +Focused replay: + +```text +/tmp/wippy-golua-engine-gap-check/wippy-local-current lint --cache-reset --json --ns wippy.session.process +``` + +Current result: 4 errors, 0 warnings. + +Fixed by the abstract-domain work: + +- `wippy.session.process:plugin` line 370, where a truthy map read now proves + the dynamic key is a string before passing it to a string-keyed session API. + +Remaining diagnostics: + +- `wippy.session.process:command_bus` line 50: + assigning `fun(...any) -> any` into a registry typed as + `fun(any, any) -> (any, string?)`. A `type(fn) == "function"` guard proves + callability only; it does not prove arity or return shape. +- `wippy.session.process:control_handlers` lines 29 and 31: + untyped operation payloads are passed to handlers that require `from_pid: + string`. The checker cannot soundly derive that contract from `op:any`. +- `wippy.session.process:plugin` line 480: + `now:sub(last_activity)` still sees the argument as `any` in the full Wippy + source. The reduced engine regressions above prove the canonical engine + handles captured map writes, pre-write reads, nested function captures, + sibling loop branches, and `time.Time` manifests. The live source also defines + `ActiveSession.created_at` and `ActiveSession.last_activity` as `any`, so this + remains classified as a source/contract boundary unless a smaller reduced + checker case proves otherwise. + +Rule for the next pass: + +```text +If a live diagnostic has no reduced go-lua fixture that fails, do not mutate the +engine for it. First build the reduced proof. Only engine-level false positives +get checker changes; source/manifest contract gaps remain diagnostics. +``` + +## 2026-05-20 Canonical Allocation Evidence: `table.create` + +The deadlock compiler fixture exposed a non-canonical allocation boundary: + +```lua +node_order = table.create(16, 0) +``` + +The old stdlib contract returned a closed empty record, `{}`. That made the +constructed object publish `node_order: {}` across the constructor/method +boundary, so a later method saw: + +```lua +self.node_order[i] +``` + +as numeric indexing into a closed record. Fixing that in field/index checking +would have been another consumer-side exception. The canonical ownership is the +stdlib contract: `table.create` allocates a fresh mutable table whose shape is +not yet committed. It now returns the existing soft open-table representation: + +```text +{} +``` + +with the record open flag set. Flow then specializes the same value through the +normal product-domain evidence: + +- `table.insert(t, v)` uses the table mutator effect and widens the fresh table + into `v[]`; +- `t[k] = v` uses indexer assignment evidence and widens it into a map/table + row; +- constructor summaries carry that allocation evidence to method `self` without + method-specific checker rules. + +Regression coverage added: + +```text +TestExternalLint_TableCreateFreshArrayShapeSurvivesConstructorMethodBoundary +TestExternalLint_TableCreateFreshHashShapeAcceptsDynamicWrites +``` + +The path-sensitive field-write regression remains covered by: + +```text +TestExternalLint_MethodSelfMapReadPreservesDeclaredValueAfterFieldWrite +``` + +That fix is also canonical: when assigning through a sub-path, the new SSA +version is seeded from predecessor abstract snapshots, then the field write is +applied. The transfer step does not recursively re-enter `NarrowedTypeAt`; it +uses the predecessor base type plus predecessor edge condition directly, keeping +the solve monotone. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestExternalLint_(MethodSelfMapReadPreservesDeclaredValueAfterFieldWrite|TableCreateFresh)' -count=1 -v -timeout 60s +go test . -run TestFixtures -count=1 -timeout 120s +go test ./compiler/check/... ./types/flow/... ./compiler/stdlib/... -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +``` + +All four commands pass in this checkout. + +## 2026-05-20 Rejected Cache Shape: No Unowned Domain `sync.Map`s + +During the performance pass, a tempting but wrong shape appeared: package-level +`sync.Map` caches around higher-order growth predicates. That is not canonical +for this checker. + +Rejected design: + +```text +compiler/check/domain/value/growth.go package globals: + higherOrderGrowthRiskCache + functionContainmentCache + selfRecursiveMethodCache + methodReturnCache +``` + +Reason: + +- These caches are not owned by the abstract interpreter state. +- They are not invalidated by a Salsa revision or check session. +- They add a second hidden memory of domain facts beside the canonical product + domain. +- They make performance better by hiding repeated work, but make the mental + model worse. + +Canonical rule: + +```text +Pure type-domain operations may use operation-local memoization. +Cross-query reuse must be owned by the analysis context / Salsa query graph. +No package-global cache may become an implicit fact authority. +``` + +Current retained shape: + +- `growthScanState` is local to one `HasHigherOrderGrowthRisk` call. +- `returnJoinState` is local to one return-slot join operation. +- The next real performance step must wire reusable expensive predicates + through the existing per-check analysis context/Salsa boundary, or not cache + them beyond one operation. + +Verification after removing the unowned caches: + +```text +go test ./compiler/check/domain/value ./compiler/check/domain/interproc ./compiler/check/domain/returnsummary ./compiler/check/domain/functionfact -count=1 -timeout 60s +go test ./compiler/check/... ./types/flow/... ./types/typ/... -count=1 -timeout 180s +``` + +Both commands pass in this checkout. + +## 2026-05-20 Flash Migration: Canonical Call Pipeline Ownership + +The remaining split in assignment-local expression evaluation was the call +operation itself. The main synthesizer and call diagnostic hook already used a +two-phase call pipeline, but the assignment preflow evaluator still called the +one-shot helper directly. That meant a local assignment SCC could observe a +different call-result machine than normal expression synthesis. + +Canonical ownership after this migration: + +```text +compiler/check/synth/ops + owns the pure call abstract machine: + InferCall -> optional ReInfer -> FinishCall + +compiler/check/synth/callarg + owns the AST argument re-synthesis policy: + function literals, table literals, identifiers, attributes, casts, + logical expressions, and non-nil assertions + +compiler/check/synth/phase/extract + owns only phase-local callback environment overlays, then delegates to + callarg.Full for ordinary contextual argument re-synthesis + +compiler/check/abstract/assign + evaluates preflow call results through ops.NewCallPipeline; it no longer has + an independent direct call-result path + +compiler/check/hooks + checks diagnostics through the same ops.NewCallPipeline and callarg policy +``` + +Important boundary enforced by test: + +```text +ops must not import compiler/ast +``` + +The first attempted shape violated that boundary. The corrected shape keeps the +domain operation pure and puts syntax-sensitive argument selection in one shared +compiler-level package. `CallWithGenericInference` remains only as a convenience +entrypoint; it now runs `NewCallPipeline(...).Run()` and is not a separate call +implementation. + +This is a flash migration, not a bridge: + +- `phase/extract/pipeline.go` and `phase/extract/pipeline_test.go` were removed. +- `ops.NewCallPipeline` is the only pipeline implementation. +- Production code no longer calls `CallWithGenericInference`; tests keep it as + coverage for the compatibility entrypoint. +- Assignment preflow, expression synthesis, and call diagnostics now share the + same call machine. + +Verification: + +```text +go test ./compiler/check/synth/ops ./compiler/check/synth/callarg ./compiler/check/synth/phase/extract ./compiler/check/hooks ./compiler/check/abstract/assign -count=1 -timeout 120s +``` + +This command passes in this checkout. + +### Phase-2 Argument Evidence Is Refinement-Only + +The first full-suite run after moving the call pipeline exposed a precision +regression in the advanced generic result stress fixture: + +```text +cannot return Result<{attrs: {...} | {[string]: string}, id: string, nested: {attempts: unknown}}>, +expected Result +``` + +Root cause: contextual argument re-synthesis could replace a precise first-pass +argument with a broader second-pass shape. That is not a sound abstract +interpreter transfer law. The second pass may refine an argument, or fill an +unknown/any argument with stronger evidence, but it must not erase already +proved evidence. + +Canonical rule added to `ops.CallPipeline`: + +```text +existing argument evidence wins when the contextual candidate is wider; +contextual candidate wins only when it is a refinement, fills unknown/any, or is +otherwise the only comparable candidate. +``` + +Regression coverage: + +```text +TestCallPipeline_ReSynthAndReInfer_DoesNotWeakenExistingArg +TestCallPipeline_ReSynthAndReInfer_FillsUnknownArg +``` + +### Expression-Local Guards Are Part Of Expression Interpretation + +The same stress fixture also exposed a separate weak point: expression-local +logical guards such as: + +```lua +type(raw.attempts) == "number" and raw.attempts or 0 +``` + +were only using the local guard narrowing path when an outer flow narrower was +already present. That is the wrong mental model. `and`/`or` short-circuit guards +are expression semantics; they do not require a prior solved flow fact. + +Canonical correction: + +```text +Logical expression synthesis always enters the local guard-aware transfer. +The transfer composes with an outer flow solution when one exists, and otherwise +uses an expression-local narrow state with no outer parent. +``` + +Regression coverage: + +```text +TestAdvancedTypeSystem_ExpressionLocalTypeGuardRefinesExpectedTableField +TestAdvancedTypeSystem_GenericResultCombinatorsPreserveDiscriminantsAndPayloads +``` + +## 2026-05-20 Verification Checkpoint After Call Pipeline Migration + +Go-lua verification is clean: + +```text +go test ./compiler/check/... ./types/flow/... ./types/typ/... -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +git diff --check +env GOCACHE=/tmp/go-build-cache staticcheck ./compiler/check/synth/ops ./compiler/check/synth/callarg ./compiler/check/synth/phase/extract ./compiler/check/hooks ./compiler/check/abstract/assign +``` + +All four commands pass in this checkout. + +Dead-code scan on the touched checker packages: + +```text +env GOCACHE=/tmp/go-build-cache deadcode -test -filter='github.com/wippyai/go-lua/compiler/check/(synth|hooks|abstract/assign)' ./compiler/check/... +``` + +The command completes successfully but reports existing test-interface mock +methods and one test-only helper pattern. The new `callarg` and `ops` pipeline +code does not appear in that report. + +Parent verify-suite status: + +```text +../scripts/verify-suite.sh +``` + +The go-lua checker tests and parent `wippy` binary build pass. The script still +exits non-zero on parent lint targets. Important caveat: the parent module's +normal `go.mod` resolves `github.com/wippyai/go-lua v1.5.16`, so the default +verify-suite lint counts are not a proof about this checkout. + +Local checkout verification without editing the parent repo: + +```text +go work init /home/wolfy-j/wippy/wippy /home/wolfy-j/wippy/go-lua +GOWORK=/tmp/go-lua-local-verify.work go build -o /tmp/wippy-local-golua ./cmd/wippy +``` + +That temporary binary does use this local go-lua checkout. It still reports +external lint diagnostics in parent projects (`session`, `framework/src/agent/src`, +`docker-demo`, and `framework/src/llm/src`). Those are not yet classified as +source issues versus remaining engine false positives. No external code was +edited. + +Current rule for the next pass: + +```text +Do not weaken the engine to make the parent harness green. +Classify each remaining external diagnostic by reduced go-lua fixture. Only +engine-level false positives get go-lua changes; real source/manifest contract +issues remain external diagnostics. +``` + +## 2026-05-20 Length-Indexed Reads Are Canonical Flow Sources + +External replay exposed a real engine false positive in the Claude mapper shape: + +```lua +if #claude_messages == 0 then + ... +else + local last_msg = claude_messages[#claude_messages] + if last_msg.role == "user" then + ... + end +end +``` + +The branch numeric domain already proved `#claude_messages >= 1` on the else +edge, and assignment extraction had synthesized a non-nil RHS type. The solver +still re-read the RHS as a generic dynamic index source and overwrote the +assignment with `nil | element`. That was a product-domain transfer bug: the +semantic source of the assignment was not represented in solver inputs, so +presence evidence lived only in checker-side synthesis. + +Canonical correction: + +```text +AST extraction records reads of the form t[#t + k] as flow.LengthIndexSource. +The flow solver consumes LengthIndexSource during assignment transfer. +The numeric length domain supplies the lower bound. +The shared types/narrow presence proof removes nil only when the proof is valid. +``` + +The presence proof moved from compiler synthesis into `types/narrow`, so both +expression synthesis and the solver use one type-domain implementation: + +```text +RefineLengthIndex(container, indexResult, lower, offset) + offset == 0: #t > 0 proves the exact Lua length-border element is present. + offset != 0: require sequence-shaped container proof before removing nil. +``` + +This is not a fallback. It is the final product-domain shape for this class: +numeric facts prove length, assignment sources carry semantic read form, and the +abstract transfer function performs the refinement. + +Regression coverage added: + +```text +TestExtractAssignments_LengthIndexReadCarriesSemanticSource +TestExternalLint_LastLoopBuiltUnionArrayElementAfterNonZeroLengthGuardIsPresent +TestExternalLint_ExportedLoopBuiltUnionArrayElementAfterNonZeroLengthGuardIsPresent +TestExternalLint_LoopLocalLastElementAfterNonZeroLengthGuardIsPresent +TestRefineLengthIndex_RemovesNilForPositiveExactLengthIndex +TestRefineLengthIndex_RequiresSequenceForOffsetIndex +TestRefineSequenceIndex_UsesTupleLength +``` + +Related numeric-domain correction: + +```text +Numeric branch extraction now computes true and false branch constraints +directly. It no longer mechanically negates a whole true-branch constraint set, +which was unsound for expressions such as a and b because false(a and b) is not +equivalent to false(a) and false(b). +``` + +Verification after this correction: + +```text +go test ./types/flow/... ./types/narrow ./compiler/check/... -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +git diff --check +env GOCACHE=/tmp/go-build-cache staticcheck ./types/narrow ./types/flow/... ./compiler/check/abstract/assign ./compiler/check/synth/phase/extract ./compiler/check/tests/regression +``` + +All four commands pass in this checkout. + +Local parent replay with a temporary go.work and rebuilt `/tmp/wippy-local-golua`: + +```text +/tmp/wippy-local-golua lint --json --level error --limit 0 --lock-file ../test/wippy.lock +``` + +Run from `/home/wolfy-j/wippy/framework/src/llm/src`, this now reports three +errors. The previous `wippy.llm.claude:mapper` false positive is gone. Remaining +diagnostics are classified as source/manifest contract issues, not engine +regressions: + +```text +wippy.llm.bedrock:mapper line 503: untyped API block.text flows to a string parser. +wippy.llm.util:compress line 109: untyped CONFIG mutation can invalidate numeric reads. +wippy.llm.util:compress line 128: same CONFIG numeric contract issue. +``` + +Regression coverage for the Bedrock classification: + +```text +TestExternalLint_UntypedBedrockTextBlockRequiresStringContract +``` + +Official verify-suite status: + +```text +../scripts/verify-suite.sh +``` + +The go-lua checker tests and parent binary build pass. The script still exits +non-zero because parent lint targets outside go-lua report: + +```text +/home/wolfy-j/wippy/session: 8 errors +/home/wolfy-j/wippy/framework/src/agent/src: 9 errors +/home/wolfy-j/wippy/docker-demo: 21 errors, 2 warnings +``` + +The same script reports zero errors for `framework/src/llm/src` because it +builds the parent binary from the parent module's normal dependency graph. The +temporary local-replace binary remains the relevant proof for this checkout. + +## 2026-05-20 Residual Drift Audit After Canonical Migration + +After the flash migration, I re-ran a production scan for the old fact channels, +transfer package names, bridge/fallback vocabulary, debug hooks, and temporary +files. Production code had no remaining old fact API: + +```text +FunctionTypesFromFacts +NormalizeFacts +NormalizeFunctionFactChannels +ReturnSummaries +NarrowReturns +FuncTypes +ParamHints +functionTypeLookup +legacy fact +compatibility bridge +transitional +``` + +Two real residues were found and removed: + +```text +types/flow/path.go +``` + +The comment still pointed identity-aware path users at the deleted +`abstract/transfer/path` package. It now names the canonical owners: +`PathFromExprFull` for expression extraction and `pathkey.Resolver` for path-key +resolution. + +```text +compiler/check/abstract/assign/emit.go +``` + +Named field/method function assignment lowering still had a defensive lookup for +"older call metadata" when `FuncDefInfo.ReceiverSymbol` was missing. That is not +the final design. CFG construction owns `FuncDefInfo` receiver identity, and the +abstract assignment extractor now consumes that canonical field directly. If the +CFG invariant is broken, this layer will not silently rebuild the metadata from +bindings. + +This keeps the ownership line clear: + +```text +compiler/cfg = symbol identity for CFG facts +compiler/check/domain = reusable semantic domains +abstract/assign = assignment transfer extraction over canonical CFG facts +types/flow = product-domain solving and path-key resolution +``` + +The change is intentionally small because the migration worktree was already +clean; this pass removes drift, not semantics. + +Verification for this cleanup: + +```text +rg -n "FunctionTypesFromFacts|NormalizeFacts|NormalizeFunctionFactChannels|ReturnSummaries|NarrowReturns|FuncTypes|ParamHints|functionTypeLookup|legacy fact|compatibility bridge|transitional|TODO|FIXME|HACK|debug numeric|debug assignment|debug effective|temporary local|abstract/transfer|flowbuild|factproduct|facts_bridge|shim|older call metadata|Kept for API compatibility" compiler/check types/flow types/narrow --glob '*.go' --glob '!**/*_test.go' +rg -n "fallback|Fallback|legacy|Legacy|bridge|Bridge|transitional|compatibility view|older call metadata" compiler/check types/flow types/narrow --glob '*.go' --glob '!**/*_test.go' +find . -maxdepth 5 -type f \( -name '*.tmp' -o -name '*.bak' -o -name '*~' -o -name '*.orig' -o -name '*.rej' -o -name '*.prof' -o -name 'coverage.out' \) -print +go test ./compiler/check/abstract/assign ./compiler/cfg ./types/flow -count=1 -timeout 120s +go test ./types/flow/... ./types/narrow ./compiler/check/... -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +env GOCACHE=/tmp/go-build-cache staticcheck ./types/flow ./compiler/check/abstract/assign ./compiler/cfg +git diff --check +``` + +All commands passed. The two `rg` scans and scratch-file `find` produced no +matches. + +Official verify-suite: + +```text +../scripts/verify-suite.sh +``` + +The go-lua checker tests and parent binary build passed. The script still exits +non-zero on parent lint targets outside go-lua: + +```text +/home/wolfy-j/wippy/session: 8 errors +/home/wolfy-j/wippy/framework/src/agent/src: 9 errors +/home/wolfy-j/wippy/docker-demo: 21 errors, 2 warnings +``` + +All other listed targets were clean, including `framework/src/llm/src`, +`framework/src/llm/test`, `framework/src/migration`, `framework/src/views`, and +`framework/src/relay/test`. + +## 2026-05-20 Canonical Naming Pass: Resolver/Synth Hooks + +I ran another production audit for migration residue and found no old fact +channels, fallback/bridge terms, or deleted package names. The only remaining +non-canonical wording was generic "adapter" naming on live, final hooks: + +```text +compiler/check/abstract/assign/preflow_synth.go +compiler/check/infer/return/infer.go +compiler/check/infer/return/overlay_pipeline.go +compiler/check/phase/scope.go +compiler/check/synth/callarg/resynth.go +``` + +Those hooks are not compatibility shims. They are canonical resolver/synthesis +functions used to connect product-domain flow solving, TypeOps field/index +resolution, and return-overlay mutation inference. I renamed them to the domain +role they actually play: + +```text +narrowResolverAdapter -> typeOpsNarrowResolver +synthAdapter -> prelimSynth / baseSynth +enrichedSynthAdapter -> enrichedSynth +buildEnrichedSynthAdapter -> buildEnrichedSynth +``` + +Comments that described "adapting" were also rewritten to describe binding or +function-backed implementations. This is behavior-preserving cleanup, but it +matters for the design invariant: production names should explain the canonical +abstract interpreter data flow, not imply transitional glue. + +I also renamed flow alias write wording from "mirror" to "propagate": + +```text +mirrorAliasedFieldWrite -> propagateAliasedFieldWrite +``` + +This makes the solver term match the actual domain operation. The map records +path alias provenance, and a write through one alias propagates to the canonical +source path key. It is not a second fact channel. + +Staticcheck then found a real dead interface in return-overlay mutation +inference: + +```text +compiler/check/infer/return/overlay_pipeline.go: localSymbolLookup +``` + +That interface was unused after the resolver/synth hook cleanup and was deleted. + +Verification for this pass: + +```text +rg -n "mirror|Mirror|adapter|Adapter|fallback|Fallback|legacy|Legacy|bridge|Bridge|transitional|compatibility view|older call metadata|FunctionTypesFromFacts|NormalizeFacts|ReturnSummaries|NarrowReturns|FuncTypes|ParamHints|flowbuild|paramhints|abstract/transfer|factproduct" compiler/check types/flow types/narrow --glob '*.go' --glob '!**/*_test.go' +go test ./compiler/check/abstract/assign ./compiler/check/infer/return ./compiler/check/phase ./compiler/check/synth/callarg -count=1 -timeout 120s +go test ./types/flow -count=1 -timeout 120s +go test ./types/flow/... ./types/narrow ./compiler/check/... -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +env GOCACHE=/tmp/go-build-cache staticcheck ./types/flow ./compiler/check/abstract/assign ./compiler/check/infer/return ./compiler/check/phase ./compiler/check/synth/callarg +git diff --check +``` + +All commands passed. The residue scan produced no matches. + +## 2026-05-20 Proper Local Replace Verification + +The official `../scripts/verify-suite.sh` still builds parent `wippy` from its +normal dependency graph. Direct proof: + +```text +cd /home/wolfy-j/wippy/wippy +go list -m -json github.com/wippyai/go-lua +``` + +reported: + +```text +"Version": "v1.5.16" +``` + +So the official parent lint split is useful for baseline drift, but it is not a +proof of this checkout's checker behavior. I rebuilt `wippy` with an explicit +temporary replace modfile outside the parent repo: + +```text +cp /home/wolfy-j/wippy/wippy/go.mod /tmp/go-lua-current-verify/wippy.replace.mod +cp /home/wolfy-j/wippy/wippy/go.sum /tmp/go-lua-current-verify/wippy.replace.sum +go mod edit -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -replace=github.com/wippyai/go-lua=/home/wolfy-j/wippy/go-lua +env GOWORK=off go list -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -m -json github.com/wippyai/go-lua +env GOWORK=off go build -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -o /tmp/wippy-current-golua ./cmd/wippy +``` + +The replace proof reported: + +```text +"Replace": { + "Path": "/home/wolfy-j/wippy/go-lua", + "Dir": "/home/wolfy-j/wippy/go-lua" +} +``` + +No parent repo files were edited. + +Local-replace lint target split with `/tmp/wippy-current-golua`: + +```text +/home/wolfy-j/wippy/wippy/tests/app: 2 errors +/home/wolfy-j/wippy/session: 33 errors +/home/wolfy-j/wippy/framework/src/test: 0 errors +/home/wolfy-j/wippy/framework/src/actor/test: 1 error +/home/wolfy-j/wippy/framework/src/agent/src: 6 errors +/home/wolfy-j/wippy/framework/src/bootloader: 0 errors +/home/wolfy-j/wippy/docker-demo: 61 errors +/home/wolfy-j/wippy/framework/src/llm/src: 3 errors +/home/wolfy-j/wippy/framework/src/llm/test: 3 errors +/home/wolfy-j/wippy/framework/src/migration: 0 errors +/home/wolfy-j/wippy/framework/src/views: 2 errors +/home/wolfy-j/wippy/framework/src/relay/test: 0 errors +``` + +Representative classifications from the local-replace diagnostics: + +```text +app.test.network:overlay_callee / overlay_worker + args is untyped; (args and args.url) can be any truthy value, not necessarily string. + Passing it to http.get(string, ...) is a source/contract issue. + +wippy.actor:actor + channel_to_id[result.channel] proves only that a parallel map contains a key. + It does not prove registered_channels[channel_id] is non-nil without a guard or + dependent map invariant. The checker must not invent that invariant. + +wippy.agent.compiler:compiler + tool_id is annotated any and flows to string.gmatch. The caller often passes a + string, but the function's declared contract permits non-string. + +wippy.llm.bedrock:mapper + block.text is untyped any from provider response shape and flows to a parser + expecting string?. + +wippy.llm.google:integration_test + test.not_nil proves presence, not that dynamic tool-call arguments.location is + a string. Calling :lower() on unknown/dynamic data needs a contract or runtime + type check. + +wippy.llm.util:compress + CONFIG/model-card fields remain optional/dynamic at arithmetic sites. +``` + +These are not old fact-channel regressions. They are soundness diagnostics from +using this checker against parent code that still has broad `any`/optional +contracts. Fixing them requires parent source/manifest contracts, not go-lua +compatibility bridges. + +## 2026-05-20 Broad Staticcheck Cleanup + +After the canonical naming cleanup was pushed, a wider implementation audit used +staticcheck across the checker and flow packages: + +```text +env GOCACHE=/tmp/go-build-cache staticcheck ./compiler/check/... ./types/flow/... ./types/narrow +``` + +It found five concrete go-lua cleanup items: + +```text +compiler/check/callsite/candidates.go: expandAliasCandidates was unused +compiler/check/domain/paramevidence/parameter_evidence.go: duplicated parameter-evidence merge path +compiler/check/pipeline/runner.go: FuncKey should convert directly to GraphKey +compiler/check/session_test.go: test dereferenced InterprocNext before nil guard +compiler/check/store/store.go: SessionStore.requirePhase was unused +``` + +The fix was direct deletion/simplification, not another compatibility layer: + +- removed the unused alias-expansion helper; +- removed the unused store phase guard; +- collapsed synthesized parameter-evidence merging into `mergeSignatureParam`; +- converted `api.FuncKey` to `api.GraphKey` directly where the query key enters + graph-context lookup; +- moved the test nil guard before the dereference. + +Verification after the cleanup: + +```text +env GOCACHE=/tmp/go-build-cache staticcheck ./compiler/check/... ./types/flow/... ./types/narrow +go test ./compiler/check/callsite ./compiler/check/domain/paramevidence ./compiler/check/pipeline ./compiler/check/store ./compiler/check -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +git diff --check +rg -n "FunctionTypesFromFacts|NormalizeFacts|NormalizeFunctionFactChannels|ReturnSummaries|NarrowReturns|FuncTypes|ParamHints|functionTypeLookup|legacy fact|compatibility bridge|compatibility view|transitional|older call metadata|flowbuild|paramhints|abstract/transfer|factproduct|adapter|Adapter|fallback|Fallback|legacy|Legacy|bridge|Bridge|mirror|Mirror|TODO|FIXME|HACK|debug numeric|debug assignment|temporary local" compiler/check types/flow types/narrow -g '!**/*_test.go' -g '!compiler/check/tests/**' +git ls-files --others --exclude-standard +``` + +Results: + +```text +staticcheck: pass +focused go tests: pass +go test ./...: pass +git diff --check: pass +production residue scan: no matches +untracked files: none +``` + +The full text residue scan still finds intentional test fixture names such as +Lua `or fallback` patterns and explicit regression-test descriptions. Those are +not implementation bridges or old fact-channel code. + +The official parent verification was also rerun: + +```text +../scripts/verify-suite.sh +``` + +It passed go-lua checker tests and built the parent binary, then exited nonzero +on parent lint targets: + +```text +/home/wolfy-j/wippy/session: 8 errors +/home/wolfy-j/wippy/framework/src/agent/src: 11 errors +/home/wolfy-j/wippy/docker-demo: 21 errors, 2 warnings +``` + +That script still builds parent `wippy` against its normal dependency graph: + +```text +go list -m -json github.com/wippyai/go-lua +``` + +reports `Version: v1.5.16`. The local-replace proof remains the authoritative +way to verify this checkout against parent `wippy` without editing external +code: + +```text +env GOWORK=off go list -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -m -json github.com/wippyai/go-lua +env GOWORK=off GOCACHE=/tmp/go-build-cache go build -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -o /tmp/wippy-current-golua ./cmd/wippy +``` + +The modfile proof reports `Replace.Path: /home/wolfy-j/wippy/go-lua`, and the +local-replace build passed. + +## 2026-05-20 Local-Replace Harness Recheck + +After the broad staticcheck cleanup, I rebuilt the parent binary through the +temporary local-replace modfile: + +```text +env GOWORK=off GOCACHE=/tmp/go-build-cache go build -modfile=/tmp/go-lua-current-verify/wippy.replace.mod -o /tmp/wippy-current-golua ./cmd/wippy +``` + +Then I reran the parent lint target split with `/tmp/wippy-current-golua`. This +uses the current go-lua checkout without editing parent repos. + +Current local-replace harness split: + +```text +/home/wolfy-j/wippy/wippy/tests/app: 2 errors +/home/wolfy-j/wippy/session: 33 errors +/home/wolfy-j/wippy/framework/src/test: 0 errors +/home/wolfy-j/wippy/framework/src/actor/test: 1 error +/home/wolfy-j/wippy/framework/src/agent/src: 6 errors +/home/wolfy-j/wippy/framework/src/bootloader: 0 errors +/home/wolfy-j/wippy/docker-demo: 61 errors +/home/wolfy-j/wippy/framework/src/llm/src: 3 errors +/home/wolfy-j/wippy/framework/src/llm/test: 3 errors +/home/wolfy-j/wippy/framework/src/migration: 0 errors +/home/wolfy-j/wippy/framework/src/views: 2 errors +/home/wolfy-j/wippy/framework/src/relay/test: 0 errors +``` + +The split is not clean, so it must not be described as "no harness errors". +The representative classification remains source/manifest contract issues: + +- untyped overlay args passed to `http.get(string, ...)`; +- explicit `:: any` values used for string methods; +- optional/dynamic LLM config/model-card arithmetic; +- provider response `any` fields passed to string/parser contracts; +- dynamic registry/page/config maps passed where string-keyed maps are required; +- SQL/result rows and JSON-decoded values used without shape normalization; +- parent code relying on cross-map invariants that the type system cannot infer + soundly without a declared dependent invariant. + +I reduced the two suspicious docker-demo classes that looked like possible +engine precision gaps and added regression coverage: + +```text +compiler/check/tests/regression/local_replace_harness_precision_test.go +``` + +The reductions cover: + +- local function returning a table whose field is passed to another helper; +- a decision helper returning `{type = ..., payload = payload or {}}`, followed + by discriminant-based branch use of `decision.payload`; +- a heterogeneous constant-key handler table where each handler returns a + compatible callable result shape. + +These reductions pass under `testutil.WithStdlib()`, which is the relevant +analysis environment for the parent harness. The same snippets fail only without +a parent/std-lib scope because root local-function return inference is not run +when the root parent scope is nil; that is a test-harness setup issue, not a +production local-replace harness issue. + +Verification: + +```text +go test ./compiler/check/tests/regression -run 'TestLocalReplaceHarness' -count=1 -timeout 120s +go test ./compiler/check/tests/regression -count=1 -timeout 180s +go test ./... -count=1 -timeout 240s +env GOCACHE=/tmp/go-build-cache staticcheck ./compiler/check/... ./types/flow/... ./types/narrow +``` + +All passed. diff --git a/compiler/ast/expr.go b/compiler/ast/expr.go index 778df614..6bf7da19 100644 --- a/compiler/ast/expr.go +++ b/compiler/ast/expr.go @@ -89,6 +89,17 @@ type FuncCallExpr struct { AdjustRet bool // Whether return count should be adjusted } +// CanProduceMultipleValues reports whether expr can expand to multiple Lua values +// when it appears in the final slot of an expression list. +func CanProduceMultipleValues(expr Expr) bool { + switch expr.(type) { + case *FuncCallExpr, *Comma3Expr: + return true + default: + return false + } +} + // LogicalOpExpr represents a logical operator (and, or). type LogicalOpExpr struct { ExprBase diff --git a/compiler/bind/fieldpath.go b/compiler/bind/fieldpath.go index 61bc4987..14c486bb 100644 --- a/compiler/bind/fieldpath.go +++ b/compiler/bind/fieldpath.go @@ -90,3 +90,19 @@ func displayFieldPathKey(path string) string { return path } + +// DirectFieldNameFromKey returns the field name for a one-segment string-like +// field key. Numeric indexes and nested paths do not describe direct prototype +// fields and are rejected. +func DirectFieldNameFromKey(path string) (string, bool) { + segs := pathkey.ParseSuffix(path) + if len(segs) != 1 { + return "", false + } + switch segs[0].Kind { + case constraint.SegmentField, constraint.SegmentIndexString: + return segs[0].Name, segs[0].Name != "" + default: + return "", false + } +} diff --git a/compiler/bind/table.go b/compiler/bind/table.go index cae7b488..72547c5c 100644 --- a/compiler/bind/table.go +++ b/compiler/bind/table.go @@ -97,6 +97,12 @@ type fieldPathKey struct { path string } +// FieldSymbolRef identifies a direct field symbol rooted at a base symbol. +type FieldSymbolRef struct { + Name string + Symbol cfg.SymbolID +} + // NewBindingTable creates an empty binding table with all maps initialized. func NewBindingTable() *BindingTable { return NewBindingTableWithHint(0, 0) @@ -409,6 +415,38 @@ func (t *BindingTable) FieldSymbol(baseSym cfg.SymbolID, path string) (cfg.Symbo return sym, ok } +// DirectFieldSymbols returns direct field symbols rooted at baseSym. +// +// Only one-segment string-like fields are returned; nested paths and numeric +// indexes are intentionally excluded because they are not fields on the base +// prototype itself. +func (t *BindingTable) DirectFieldSymbols(baseSym cfg.SymbolID) []FieldSymbolRef { + if t == nil || baseSym == 0 || len(t.fieldSymbols) == 0 { + return nil + } + out := make([]FieldSymbolRef, 0) + for key, sym := range t.fieldSymbols { + if key.base != baseSym || sym == 0 { + continue + } + name, ok := DirectFieldNameFromKey(key.path) + if !ok { + continue + } + out = append(out, FieldSymbolRef{Name: name, Symbol: sym}) + } + if len(out) == 0 { + return nil + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name == out[j].Name { + return out[i].Symbol < out[j].Symbol + } + return out[i].Name < out[j].Name + }) + return out +} + // GetOrCreateFuncLitSymbol returns or creates a symbol for an anonymous function. // // Anonymous functions (function literals) need symbols for type assignment diff --git a/compiler/bind/table_test.go b/compiler/bind/table_test.go index 196d433e..2b01046a 100644 --- a/compiler/bind/table_test.go +++ b/compiler/bind/table_test.go @@ -524,6 +524,51 @@ func TestBindingTable_FieldSymbol_NormalizesLegacyBracketStringKey(t *testing.T) } } +func TestBindingTable_DirectFieldSymbols(t *testing.T) { + table := NewBindingTable() + baseSym := cfg.NextSymbolID() + otherSym := cfg.NextSymbolID() + + beta := table.GetOrCreateFieldSymbol(baseSym, "beta") + alpha := table.GetOrCreateFieldSymbol(baseSym, "alpha") + nested := table.GetOrCreateFieldSymbol(baseSym, "alpha.deep") + indexKey, ok := FieldPathKeyFromSegments([]constraint.Segment{ + {Kind: constraint.SegmentIndexString, Name: "quoted-key"}, + }) + if !ok { + t.Fatal("expected canonical string-index key") + } + quoted := table.GetOrCreateFieldSymbol(baseSym, indexKey) + numericKey, ok := FieldPathKeyFromSegments([]constraint.Segment{ + {Kind: constraint.SegmentIndexInt, Index: 1}, + }) + if !ok { + t.Fatal("expected canonical int-index key") + } + _ = table.GetOrCreateFieldSymbol(baseSym, numericKey) + _ = table.GetOrCreateFieldSymbol(otherSym, "alpha") + + got := table.DirectFieldSymbols(baseSym) + want := []FieldSymbolRef{ + {Name: "alpha", Symbol: alpha}, + {Name: "beta", Symbol: beta}, + {Name: "quoted-key", Symbol: quoted}, + } + if len(got) != len(want) { + t.Fatalf("DirectFieldSymbols length = %d, want %d; got %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("DirectFieldSymbols[%d] = %#v, want %#v", i, got[i], want[i]) + } + } + for _, ref := range got { + if ref.Symbol == nested { + t.Fatal("nested field path should not be returned as a direct field") + } + } +} + func TestBindingTable_GetOrCreateFieldSymbol_InvalidPathRejected(t *testing.T) { table := NewBindingTable() baseSym := cfg.NextSymbolID() diff --git a/compiler/cfg/analysis/dominators.go b/compiler/cfg/analysis/dominators.go index 9b03fa76..e850acbe 100644 --- a/compiler/cfg/analysis/dominators.go +++ b/compiler/cfg/analysis/dominators.go @@ -33,6 +33,13 @@ type rpoReader interface { RPOReadOnly() []basecfg.Point } +type immediateDominatorData struct { + rpo []basecfg.Point + rpoNum []int + idomByPoint []basecfg.Point + hasIDom []bool +} + func predecessorsOf(g basecfg.Graph, point basecfg.Point) []basecfg.Point { if direct, ok := g.(predecessorsReader); ok { return direct.PredecessorsReadOnly(point) @@ -57,33 +64,28 @@ func rpoOf(g basecfg.Graph) []basecfg.Point { return g.RPO() } -// ComputeDominators computes immediate dominators and the dominator tree. -// Uses the Cooper-Harvey-Kennedy algorithm with RPO iteration. -func ComputeDominators(g basecfg.Graph) (idom map[basecfg.Point]basecfg.Point, domTree map[basecfg.Point][]basecfg.Point) { +func computeImmediateDominatorData(g basecfg.Graph) immediateDominatorData { rpo := rpoOf(g) if len(rpo) == 0 { - return make(map[basecfg.Point]basecfg.Point), make(map[basecfg.Point][]basecfg.Point) + return immediateDominatorData{} } graphSize := g.Size() - if graphSize == 0 { - return make(map[basecfg.Point]basecfg.Point), make(map[basecfg.Point][]basecfg.Point) + return immediateDominatorData{} } - // Build RPO number lookup for intersection and deterministic sorting. rpoNum := make([]int, graphSize) for i, p := range rpo { if int(p) >= graphSize { continue } - rpoNum[p] = i } entry := g.Entry() if int(entry) >= graphSize { - return make(map[basecfg.Point]basecfg.Point), make(map[basecfg.Point][]basecfg.Point) + return immediateDominatorData{} } idomByPoint := make([]basecfg.Point, graphSize) @@ -91,96 +93,111 @@ func ComputeDominators(g basecfg.Graph) (idom map[basecfg.Point]basecfg.Point, d idomByPoint[entry] = entry hasIDom[entry] = true - // intersect finds the common dominator of two nodes - intersect := func(b1, b2 basecfg.Point) basecfg.Point { - finger1, finger2 := b1, b2 - - for finger1 != finger2 { - for rpoNum[finger1] > rpoNum[finger2] { - finger1 = idomByPoint[finger1] + intersect := func(pointA, pointB basecfg.Point) basecfg.Point { + fingerA, fingerB := pointA, pointB + for fingerA != fingerB { + for rpoNum[fingerA] > rpoNum[fingerB] { + fingerA = idomByPoint[fingerA] } - - for rpoNum[finger2] > rpoNum[finger1] { - finger2 = idomByPoint[finger2] + for rpoNum[fingerB] > rpoNum[fingerA] { + fingerB = idomByPoint[fingerB] } } - - return finger1 + return fingerA } - // Iterate until fixed point changed := true for changed { changed = false - for _, b := range rpo { - if b == entry { - continue - } - - if int(b) >= graphSize { + for _, block := range rpo { + if block == entry || int(block) >= graphSize { continue } - preds := predecessorsOf(g, b) + preds := predecessorsOf(g, block) if len(preds) == 0 { continue } - // Find first predecessor with defined idom - var newIdom basecfg.Point - + var newIDom basecfg.Point found := false - - for _, p := range preds { - if int(p) >= graphSize { + for _, pred := range preds { + predIdx := int(pred) + if predIdx >= graphSize { continue } - - if hasIDom[p] { - newIdom = p + if hasIDom[predIdx] { + newIDom = pred found = true - break } } - if !found { continue } - // Intersect with other defined predecessors - for _, p := range preds { - if p == newIdom { + for _, pred := range preds { + if pred == newIDom { continue } - - if int(p) >= graphSize { + predIdx := int(pred) + if predIdx >= graphSize { continue } - - if hasIDom[p] { - newIdom = intersect(p, newIdom) + if hasIDom[predIdx] { + newIDom = intersect(pred, newIDom) } } - if !hasIDom[b] || idomByPoint[b] != newIdom { - idomByPoint[b] = newIdom - hasIDom[b] = true + blockIdx := int(block) + if !hasIDom[blockIdx] || idomByPoint[blockIdx] != newIDom { + idomByPoint[blockIdx] = newIDom + hasIDom[blockIdx] = true changed = true } } } - idom = make(map[basecfg.Point]basecfg.Point, len(rpo)) - for _, point := range rpo { - if int(point) >= graphSize || !hasIDom[point] { + return immediateDominatorData{ + rpo: rpo, + rpoNum: rpoNum, + idomByPoint: idomByPoint, + hasIDom: hasIDom, + } +} + +func (d immediateDominatorData) asMap() map[basecfg.Point]basecfg.Point { + if len(d.rpo) == 0 { + return make(map[basecfg.Point]basecfg.Point) + } + idom := make(map[basecfg.Point]basecfg.Point, len(d.rpo)) + for _, point := range d.rpo { + idx := int(point) + if idx >= len(d.hasIDom) || !d.hasIDom[idx] { continue } + idom[point] = d.idomByPoint[idx] + } + return idom +} + +// ComputeImmediateDominators computes only the immediate-dominator map. +// +// Use this when callers only need dominance predicates. It avoids building the +// dominator tree, which is meaningful allocation in hot type-checking paths. +func ComputeImmediateDominators(g basecfg.Graph) map[basecfg.Point]basecfg.Point { + return computeImmediateDominatorData(g).asMap() +} - idom[point] = idomByPoint[point] +// ComputeDominators computes immediate dominators and the dominator tree. +// Uses the Cooper-Harvey-Kennedy algorithm with RPO iteration. +func ComputeDominators(g basecfg.Graph) (idom map[basecfg.Point]basecfg.Point, domTree map[basecfg.Point][]basecfg.Point) { + data := computeImmediateDominatorData(g) + if len(data.rpo) == 0 { + return make(map[basecfg.Point]basecfg.Point), make(map[basecfg.Point][]basecfg.Point) } - // Build dominator tree from idom + idom = data.asMap() domTree = make(map[basecfg.Point][]basecfg.Point, len(idom)) for n, dom := range idom { @@ -192,10 +209,10 @@ func ComputeDominators(g basecfg.Graph) (idom map[basecfg.Point]basecfg.Point, d // Sort children for deterministic order. for p := range domTree { slices.SortFunc(domTree[p], func(a, b basecfg.Point) int { - if rpoNum[a] < rpoNum[b] { + if data.rpoNum[a] < data.rpoNum[b] { return -1 } - if rpoNum[a] > rpoNum[b] { + if data.rpoNum[a] > data.rpoNum[b] { return 1 } return 0 @@ -599,6 +616,11 @@ func ComputePostDominators(graph basecfg.Graph) (map[basecfg.Point]basecfg.Point return ComputeDominators(&reversedGraph{g: graph}) } +// ComputeImmediatePostDominators computes only the immediate post-dominator map. +func ComputeImmediatePostDominators(graph basecfg.Graph) map[basecfg.Point]basecfg.Point { + return ComputeImmediateDominators(&reversedGraph{g: graph}) +} + // PostDominates returns true if a post-dominates b (a is on every path from b to exit). func PostDominates(postIdom map[basecfg.Point]basecfg.Point, pointA, pointB basecfg.Point) bool { return Dominates(postIdom, pointA, pointB) diff --git a/compiler/cfg/graph.go b/compiler/cfg/graph.go index c00a10a0..1f94efd5 100644 --- a/compiler/cfg/graph.go +++ b/compiler/cfg/graph.go @@ -24,6 +24,7 @@ type Graph struct { orderedBranchPoints []Point orderedFuncDefPoints []Point orderedTypeDefPoints []Point + localFunctionAssigns []LocalFunctionAssignment // Binding table (AST ident -> symbol, populated before CFG build) bindings *bind.BindingTable @@ -217,6 +218,7 @@ func BuildWithBindings(fn *ast.FunctionExpr, bindings *bind.BindingTable) *Graph size := b.Cfg.Size() pointIdx := buildPointIndex(b.Info, size) infoByPoint := denseNodeInfoByPoint(b.Info, size) + localFunctionAssigns := buildLocalFunctionAssignments(infoByPoint, pointIdx.assign) if len(visibleVersionByPoint) == 0 { visibleVersionByPoint = denseVisibleVersionByPoint(visibleVersion, size) @@ -234,6 +236,7 @@ func BuildWithBindings(fn *ast.FunctionExpr, bindings *bind.BindingTable) *Graph orderedBranchPoints: pointIdx.branch, orderedFuncDefPoints: pointIdx.funcDef, orderedTypeDefPoints: pointIdx.typeDef, + localFunctionAssigns: localFunctionAssigns, bindings: bindings, phiNodes: b.PhiNodes, visibleVersion: visibleVersion, @@ -299,6 +302,7 @@ func BuildBlock(stmts []ast.Stmt, globals ...string) *Graph { size := b.Cfg.Size() pointIdx := buildPointIndex(b.Info, size) infoByPoint := denseNodeInfoByPoint(b.Info, size) + localFunctionAssigns := buildLocalFunctionAssignments(infoByPoint, pointIdx.assign) if len(visibleVersionByPoint) == 0 { visibleVersionByPoint = denseVisibleVersionByPoint(visibleVersion, size) @@ -316,6 +320,7 @@ func BuildBlock(stmts []ast.Stmt, globals ...string) *Graph { orderedBranchPoints: pointIdx.branch, orderedFuncDefPoints: pointIdx.funcDef, orderedTypeDefPoints: pointIdx.typeDef, + localFunctionAssigns: localFunctionAssigns, bindings: bindings, phiNodes: b.PhiNodes, visibleVersion: visibleVersion, @@ -735,6 +740,14 @@ func (g *Graph) NestedFunctions() []NestedFunc { return g.nested } +// LocalFunctionAssignments returns local identifiers bound directly to function literals. +func (g *Graph) LocalFunctionAssignments() []LocalFunctionAssignment { + if g == nil { + return nil + } + return g.localFunctionAssigns +} + // CFG delegated methods. // Node returns the base CFG node at point p. @@ -1207,13 +1220,40 @@ func (g *Graph) EachAliasSymbol(targetSym basecfg.SymbolID, fn func(basecfg.Symb return } - seen := make(map[basecfg.SymbolID]struct{}, 4) + var smallSeen [8]basecfg.SymbolID + seenCount := 0 + var seen map[basecfg.SymbolID]struct{} + remember := func(sym basecfg.SymbolID) bool { + if seen != nil { + if _, ok := seen[sym]; ok { + return false + } + seen[sym] = struct{}{} + return true + } + for i := 0; i < seenCount; i++ { + if smallSeen[i] == sym { + return false + } + } + if seenCount < len(smallSeen) { + smallSeen[seenCount] = sym + seenCount++ + return true + } + seen = make(map[basecfg.SymbolID]struct{}, len(smallSeen)+1) + for i := 0; i < seenCount; i++ { + seen[smallSeen[i]] = struct{}{} + } + seen[sym] = struct{}{} + return true + } + current := targetSym for current != 0 { - if _, ok := seen[current]; ok { + if !remember(current) { return } - seen[current] = struct{}{} if fn(current) { return @@ -1311,6 +1351,14 @@ func (g *Graph) SymbolKind(sym basecfg.SymbolID) (basecfg.SymbolKind, bool) { return kind, ok } +// SymbolCount returns the number of symbols tracked by the graph. +func (g *Graph) SymbolCount() int { + if g == nil { + return 0 + } + return len(g.symbolKinds) +} + // HasScopeTracking returns true if scope visibility was computed during build. func (g *Graph) HasScopeTracking() bool { return g != nil && g.symbolScope != nil diff --git a/compiler/cfg/graph_test.go b/compiler/cfg/graph_test.go index 3a4235ed..b92b68e8 100644 --- a/compiler/cfg/graph_test.go +++ b/compiler/cfg/graph_test.go @@ -587,17 +587,16 @@ func TestGraph_CFGMethods(t *testing.T) { func TestGraph_NestedFunctions(t *testing.T) { t.Parallel() + nestedFn := &ast.FunctionExpr{ + ParList: &ast.ParList{Names: []string{"a"}}, + Stmts: []ast.Stmt{}, + } fn := &ast.FunctionExpr{ ParList: &ast.ParList{}, Stmts: []ast.Stmt{ &ast.LocalAssignStmt{ Names: []string{"fn"}, - Exprs: []ast.Expr{ - &ast.FunctionExpr{ - ParList: &ast.ParList{Names: []string{"a"}}, - Stmts: []ast.Stmt{}, - }, - }, + Exprs: []ast.Expr{nestedFn}, }, }, } @@ -611,6 +610,20 @@ func TestGraph_NestedFunctions(t *testing.T) { if len(nested) != 1 { t.Errorf("Expected 1 nested function, got %d", len(nested)) } + + localFns := g.LocalFunctionAssignments() + if len(localFns) != 1 { + t.Fatalf("Expected 1 local function assignment, got %d", len(localFns)) + } + if localFns[0].Name != "fn" { + t.Fatalf("LocalFunctionAssignments()[0].Name = %q, want fn", localFns[0].Name) + } + if localFns[0].Symbol == 0 { + t.Fatal("LocalFunctionAssignments()[0].Symbol should be non-zero") + } + if localFns[0].Func != nestedFn { + t.Fatal("LocalFunctionAssignments()[0].Func should be the assigned function literal") + } } // TestGraph_PopulateSymbols tests symbol population. @@ -974,6 +987,9 @@ func TestGraph_SymbolKind(t *testing.T) { if g == nil { t.Fatal("Build should return graph") } + if got, want := g.SymbolCount(), 4; got != want { + t.Fatalf("SymbolCount() = %d, want %d", got, want) + } // Check parameter symbols are basecfg.SymbolParam paramSymbols := g.ParamSymbols() diff --git a/compiler/cfg/local_functions.go b/compiler/cfg/local_functions.go new file mode 100644 index 00000000..f3c39e95 --- /dev/null +++ b/compiler/cfg/local_functions.go @@ -0,0 +1,36 @@ +package cfg + +import "github.com/wippyai/go-lua/compiler/ast" + +func buildLocalFunctionAssignments(infoByPoint []NodeInfo, assignPoints []Point) []LocalFunctionAssignment { + if len(infoByPoint) == 0 || len(assignPoints) == 0 { + return nil + } + + var out []LocalFunctionAssignment + for _, p := range assignPoints { + idx := int(p) + if idx < 0 || idx >= len(infoByPoint) { + continue + } + info, ok := infoByPoint[idx].(*AssignInfo) + if !ok || info == nil || !info.IsLocal || len(info.Targets) == 0 { + continue + } + info.EachTargetSource(func(_ int, target AssignTarget, source ast.Expr) { + if target.Kind != TargetIdent || target.Symbol == 0 { + return + } + fn, ok := source.(*ast.FunctionExpr) + if !ok || fn == nil { + return + } + out = append(out, LocalFunctionAssignment{ + Symbol: target.Symbol, + Name: target.Name, + Func: fn, + }) + }) + } + return out +} diff --git a/compiler/cfg/types.go b/compiler/cfg/types.go index 8e86289e..d19eddc0 100644 --- a/compiler/cfg/types.go +++ b/compiler/cfg/types.go @@ -133,6 +133,13 @@ type AssignInfo struct { singleTargetVersion [1]Version } +// LocalFunctionAssignment describes `local f = function(...) ... end`. +type LocalFunctionAssignment struct { + Symbol basecfg.SymbolID + Name string + Func *ast.FunctionExpr +} + func (*AssignInfo) nodeInfo() {} // Kind returns the node kind for AssignInfo. diff --git a/compiler/check/flowbuild/assign/doc.go b/compiler/check/abstract/assign/doc.go similarity index 100% rename from compiler/check/flowbuild/assign/doc.go rename to compiler/check/abstract/assign/doc.go diff --git a/compiler/check/flowbuild/assign/emit.go b/compiler/check/abstract/assign/emit.go similarity index 79% rename from compiler/check/flowbuild/assign/emit.go rename to compiler/check/abstract/assign/emit.go index b7245bac..ef76af93 100644 --- a/compiler/check/flowbuild/assign/emit.go +++ b/compiler/check/abstract/assign/emit.go @@ -34,19 +34,18 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" cfganalysis "github.com/wippyai/go-lua/compiler/cfg/analysis" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/constprop" + abstractcore "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/decl" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/tblutil" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/constprop" - fbcore "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/decl" - "github.com/wippyai/go-lua/compiler/check/flowbuild/guard" - "github.com/wippyai/go-lua/compiler/check/flowbuild/keyscoll" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/tblutil" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/guard" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" checkscope "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" @@ -59,7 +58,7 @@ import ( ) // ExtractAssignments extracts assignment info from graph. -func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollector KeysCollectorFunc) { +func ExtractAssignments(fc *abstractcore.FlowContext, inputs *flow.Inputs, keysCollector KeysCollectorFunc) { if fc == nil || fc.Graph == nil { return } @@ -68,7 +67,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } derived := fc.Derived if derived == nil { - derived = &fbcore.Derived{} + derived = &abstractcore.Derived{} } synth := derived.Synth if synth == nil { @@ -85,9 +84,26 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect // Worklist fixpoint for spec narrowing: // Collects spec-narrowed types from contract specs and propagates through method calls. // Uses expandValues with SpecTypes overlay for method call synthesis. - specNarrowed := CollectSpecNarrowedTypes(fc.Graph, fc.Scopes, synth, symResolver, fc.API, fc.ModuleBindings) + specNarrowed := CollectSpecNarrowedTypes(fc.Graph, fc.Evidence.Assignments, fc.Scopes, synth, symResolver, fc.API, fc.ModuleBindings) preflowBranchSolution := buildPreflowBranchSolution(fc, inputs) - inferredTypes := collectInferredTypes(fc.Graph, fc.Scopes, synth, fc.API, symResolver, specNarrowed, inputs.AnnotatedVars, inputs, fc.ModuleBindings, fc.CallCtx, fc.TypeOps, preflowBranchSolution, fc.Services) + inferenceSeeds := mergeSpecTypesInto(nil, inputs.DeclaredTypes) + inferenceSeeds = mergeSpecTypesInto(inferenceSeeds, specNarrowed) + inferredTypes := InferLocalTypes(LocalInferenceConfig{ + Graph: fc.Graph, + Evidence: fc.Evidence, + Scopes: fc.Scopes, + Synth: synth, + SynthAPI: fc.API, + SymResolver: symResolver, + SeedTypes: inferenceSeeds, + Annotated: inputs.AnnotatedVars, + Inputs: inputs, + ModuleBindings: fc.ModuleBindings, + CallCtx: fc.CallCtx, + TypeOps: fc.TypeOps, + Preflow: preflowBranchSolution, + Services: fc.Services, + }) // Promote inferred parameter types into DeclaredTypes for unannotated params. // This enables bidirectional inference at call sites (e.g., custom assert helpers). if inputs.DeclaredTypes != nil { @@ -103,32 +119,36 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect continue } current := inputs.DeclaredTypes[sym] - if current == nil || current.Kind().IsPlaceholder() { - inputs.DeclaredTypes[sym] = inferred + if merged := mergeUnannotatedParamType(current, inferred); !typ.TypeEquals(current, merged) { + inputs.DeclaredTypes[sym] = merged } } } bindings := fc.Graph.Bindings() + paramSet := paramSymbolSet(fc.Graph) + valueDefs := collectValueDefinitionVersions(fc.Graph, fc.Evidence.Assignments, fc.Evidence.FunctionDefinitions) // Precompute loop variable types for all for loops to improve RHS synthesis. // This includes both numeric for loops (integer index) and generic for loops (iterator variables). loopVarTypes := make(map[cfg.SymbolID]typ.Type) - fc.Graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info if info == nil || len(info.Targets) == 0 { - return + continue } // Handle numeric for loops if info.NumericFor != nil { target, ok := info.FirstTarget() if !ok { - return + continue } if target.Kind != cfg.TargetIdent || target.Name == "" || target.Symbol == 0 { - return + continue } loopVarTypes[target.Symbol] = typ.Integer - return + continue } // Handle generic for loops if len(info.IterExprs) > 0 { @@ -140,25 +160,90 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect if target.Kind != cfg.TargetIdent || target.Name == "" || target.Symbol == 0 { return } - if i < len(varTypes) && varTypes[i] != nil { - loopVarTypes[target.Symbol] = varTypes[i] + var inferred typ.Type + if t, ok := visibleInferredTypeAt(inferredTypes, fc.Graph, valueDefs, paramSet, target.Symbol, p); ok { + inferred = t + } + var iterType typ.Type + if i < len(varTypes) { + iterType = varTypes[i] + } + loopType := refineLoopVarTypeFromInference(iterType, inferred) + if informativeLoopVarType(loopType) { + loopVarTypes[target.Symbol] = loopType } }) } - }) - var overlayTypes api.SpecTypes - overlayTypes = mergeSpecTypesInto(overlayTypes, inputs.DeclaredTypes) - overlayTypes = mergeSpecTypesInto(overlayTypes, inferredTypes) - overlayTypes = mergeSpecTypesInto(overlayTypes, specNarrowed) - overlayTypes = mergeSpecTypesInto(overlayTypes, loopVarTypes) + } + var overlayScratch api.SpecTypes + overlayTypesAt := func(p cfg.Point) api.SpecTypes { + size := len(inferredTypes) + len(specNarrowed) + len(loopVarTypes) + if inputs != nil { + size += len(inputs.DeclaredTypes) + } + if overlayScratch == nil { + overlayScratch = make(api.SpecTypes, size) + } else { + clear(overlayScratch) + } + if inputs != nil { + for sym, t := range inputs.DeclaredTypes { + overlayScratch[sym] = t + } + } + for sym, t := range loopVarTypes { + overlayScratch[sym] = t + } + for sym, t := range inferredTypes { + if overlayTypeVisibleAt(fc.Graph, valueDefs, paramSet, sym, p) { + overlayScratch[sym] = t + } + } + for sym, t := range specNarrowed { + overlayScratch[sym] = t + } + return overlayScratch + } + overlayTypeAt := func(sym cfg.SymbolID, p cfg.Point) (typ.Type, bool) { + if t, ok := specNarrowed[sym]; ok { + return t, true + } + var declared typ.Type + var hasDeclared bool + if inputs != nil && inputs.DeclaredTypes != nil { + if t, ok := inputs.DeclaredTypes[sym]; ok { + declared = t + hasDeclared = true + if inputs.AnnotatedVars != nil && inputs.AnnotatedVars[sym] { + return t, true + } + } + } + if t, ok := visibleInferredTypeAt(inferredTypes, fc.Graph, valueDefs, paramSet, sym, p); ok { + _, staleLoopVar := loopVarTypes[sym] + if staleLoopVar || inferredOverridesUnannotatedDeclared(t, declared) { + return t, true + } + } + if hasDeclared { + return declared, true + } + if t, ok := loopVarTypes[sym]; ok { + return t, true + } + return nil, false + } // Precompute truthy guards: map from CFG point to paths that are narrowed (non-nil) at that point. // Used during table literal synthesis to unwrap optional types. - truthyGuards := guard.CollectTruthyGuards(fc.Graph, bindings) - typeGuards := guard.CollectTypeGuards(fc.Graph, bindings) - - baseSynth := synthWithOverlayAndPreflow(overlayTypes, bindings, inputs, fc.CallCtx, fc.TypeOps, preflowBranchSolution, synth) - idom, _ := cfganalysis.ComputeDominators(fc.Graph.CFG()) - structuredWrites := indexStructuredWrites(fc.Graph) + truthyGuards := guard.CollectTruthyGuards(fc.Graph, fc.Evidence.Branches, bindings) + typeGuards := guard.CollectTypeGuards(fc.Graph, fc.Evidence.Branches, bindings) + + baseSynth := synthWithOverlayAndPreflow(overlayTypeAt, bindings, inputs, fc.CallCtx, fc.TypeOps, preflowBranchSolution, synth) + structuredWrites := indexStructuredWrites(fc.Graph, fc.Evidence.Assignments) + var idom map[cfg.Point]cfg.Point + if len(structuredWrites) > 0 { + idom = cfganalysis.ComputeImmediateDominators(fc.Graph.CFG()) + } var wrappedSynth func(ast.Expr, cfg.Point) typ.Type wrappedSynth = func(expr ast.Expr, p cfg.Point) typ.Type { if table, ok := expr.(*ast.TableExpr); ok && !tblutil.TableHasFunctionField(table) { @@ -203,31 +288,36 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect return symResolver(p, sym) } - fc.Graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } sc := fc.Scopes[p] // Handle numeric for loops if info.NumericFor != nil { target, ok := info.FirstTarget() if !ok { - return + continue } if target.Kind != cfg.TargetIdent || target.Name == "" { - return + continue } inputs.Assignments = append(inputs.Assignments, flow.UnifiedAssignment{ Point: p, TargetPath: constraint.Path{Root: resolve.RootName(fc.Graph, target.Symbol, target.Name), Symbol: target.Symbol}, Type: typ.Integer, }) - return + continue } // Handle generic for loops if len(info.IterExprs) > 0 && len(info.Targets) > 0 { var varTypes []typ.Type if fc.API != nil { - varTypes = fc.API.InferIterVarsWithSpecTypes(info.IterExprs, len(info.Targets), p, overlayTypes) + varTypes = fc.API.InferIterVarsWithSpecTypes(info.IterExprs, len(info.Targets), p, overlayTypesAt(p)) } // Build const resolver for iterator source extraction constResolver := predicate.BuildConstResolver(inputs, p) @@ -241,6 +331,9 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect if i < len(varTypes) && varTypes[i] != nil { varType = varTypes[i] } + if inferred, ok := visibleInferredTypeAt(inferredTypes, fc.Graph, valueDefs, paramSet, sym, p); ok { + varType = refineLoopVarTypeFromInference(varType, inferred) + } assignment := flow.UnifiedAssignment{ Point: p, TargetPath: constraint.Path{Root: resolve.RootName(fc.Graph, sym, target.Name), Symbol: sym}, @@ -255,7 +348,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } inputs.Assignments = append(inputs.Assignments, assignment) }) - return + continue } // Build const resolver for this point @@ -271,8 +364,8 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } // Use pre-assignment symbol overlays for assignment targets so RHS // synthesis follows Lua evaluation order (`x = f(x, ...)`). - rhsOverlay := rhsSpecTypesAtAssignPoint(fc.Graph, info, p, overlayTypes, resolverWithSpec) - rhsOverlay = enrichStructuredOverlayAtPoint(fc.Graph, idom, structuredWrites, p, rhsOverlay, resolverWithSpec, wrappedSynth) + rhsOverlay := rhsSpecTypesAtAssignPoint(fc.Graph, info, p, overlayTypesAt(p), resolverWithSpec) + rhsOverlay = enrichStructuredOverlayAtPoint(fc.Graph, idom, structuredWrites, p, rhsOverlay, assignmentSourceSymbols(info, bindings), resolverWithSpec, wrappedSynth) values = expandedAssignValues(fc.API, info, p, rhsOverlay) valuesComputed = true } @@ -308,7 +401,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } } } - // Fall back to expression synthesis if no declared/known type + // Use expression synthesis if no declared or known type exists. if typ.IsAbsentOrUnknown(assignedType) { ensureValues() if value := assignValueAt(values, i); value != nil { @@ -341,6 +434,12 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect assignedType = typ.Unknown } + if source != nil && info.IsLocal && (inputs == nil || inputs.AnnotatedVars == nil || !inputs.AnnotatedVars[sym]) { + if inferred := inferredTypes[sym]; sameExpressionHasMoreEvidence(inferred, assignedType) { + assignedType = inferred + } + } + // Use pre-collected spec-narrowed type if available (via SymbolID) if narrowed, ok := specNarrowed[sym]; ok { assignedType = narrowed @@ -352,6 +451,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect // so solve-time propagation can derive the value type from map flow facts. var sourcePath constraint.Path var mapElementSource *flow.MapElementSource + var lengthIndexSource *flow.LengthIndexSource if source != nil { if sp := path.FromExprWithBindings(source, constResolver, bindings); !sp.IsEmpty() { sourcePath = constraint.Path{ @@ -360,6 +460,9 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect Segments: sp.Segments, } } else if attr, ok := source.(*ast.AttrGetExpr); ok { + if src, ok := lengthIndexSourceFromAttr(attr, constResolver, bindings); ok { + lengthIndexSource = src + } if _, isStatic := staticSegmentForAttrKey(attr.Key, constResolver); !isStatic { if mp := path.FromExprWithBindings(attr.Object, constResolver, bindings); !mp.IsEmpty() && mp.Symbol != 0 { mp = constraint.Path{ @@ -433,21 +536,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect tableSym = keysCollector(call, p, retIndex) } - // Fallback: resolve function literal via module bindings. - if tableSym == 0 && fc.ModuleBindings != nil { - for _, calleeSym := range calleeSymbols { - fn, ok := fc.ModuleBindings.FuncLitBySymbol(calleeSym) - if !ok || fn == nil { - continue - } - if info := keyscoll.DetectKeysCollector(fn); info != nil && info.ReturnIndex == retIndex { - tableSym = callsite.SymbolOrCreateFieldFromExpr(callsite.RuntimeArgAt(call, info.ParamIndex), bindings) - break - } - } - } - - // Fallback: check function refinement for KeyOf-based keys collector. + // Check function refinement for KeyOf-based keys collectors. if tableSym == 0 && derived.RefinementBySym != nil { for _, calleeSym := range calleeSymbols { eff := derived.RefinementBySym(calleeSym) @@ -473,7 +562,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect var containerElemSrc *flow.ContainerElementSource if call, retIndex := info.CallForTarget(i); call != nil { assignmentTypesResolver := resolve.BuildAssignmentTypeResolver(inputs) - if elemInfo := mutator.ContainerElementReturnFromCall(call, p, wrappedSynth, resolverWithSpec, assignmentTypesResolver, fc.Graph, bindings, fc.ModuleBindings); elemInfo != nil { + if elemInfo := calleffect.ContainerElementReturnFromCall(call, p, wrappedSynth, resolverWithSpec, assignmentTypesResolver, fc.Graph, bindings, fc.ModuleBindings); elemInfo != nil { // Check if this return index matches if elemInfo.ReturnIndex == retIndex { // For method calls, index 0 is self (receiver) @@ -500,6 +589,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect Type: resolve.Ref(assignedType, sc), ContainerElementSource: containerElemSrc, MapElementSource: mapElementSource, + LengthIndexSource: lengthIndexSource, }) // Emit per-field assignments for table literals to enable flow narrowing @@ -517,15 +607,22 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect // Determine assigned type assignedType := typ.Unknown + if expected := assignmentTargetExpectedType(target, p, wrappedSynth); expected != nil { + if expectedType := synthAssignmentSourceWithExpected(fc.API, source, p, expected); expectedType != nil { + assignedType = expectedType + } + } // First check expanded values for multi-return assignments - ensureValues() - if value := assignValueAt(values, i); !typ.IsAbsentOrUnknown(value) { - assignedType = value - } else if source != nil { - if tbl, ok := source.(*ast.TableExpr); ok && wrappedSynth != nil && !tblutil.TableHasFunctionField(tbl) { - assignedType = wrappedSynth(source, p) - } else if wrappedSynth != nil { - assignedType = wrappedSynth(source, p) + if typ.IsAbsentOrUnknown(assignedType) { + ensureValues() + if value := assignValueAt(values, i); !typ.IsAbsentOrUnknown(value) { + assignedType = value + } else if source != nil { + if tbl, ok := source.(*ast.TableExpr); ok && wrappedSynth != nil && !tblutil.TableHasFunctionField(tbl) { + assignedType = wrappedSynth(source, p) + } else if wrappedSynth != nil { + assignedType = wrappedSynth(source, p) + } } } if assignedType == nil { @@ -578,15 +675,22 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } // Determine assigned type assignedType := typ.Unknown + if expected := assignmentTargetExpectedType(target, p, wrappedSynth); expected != nil { + if expectedType := synthAssignmentSourceWithExpected(fc.API, source, p, expected); expectedType != nil { + assignedType = expectedType + } + } // First check expanded values for multi-return assignments - ensureValues() - if value := assignValueAt(values, i); !typ.IsAbsentOrUnknown(value) { - assignedType = value - } else if source != nil { - if tbl, ok := source.(*ast.TableExpr); ok && wrappedSynth != nil && !tblutil.TableHasFunctionField(tbl) { - assignedType = wrappedSynth(source, p) - } else if wrappedSynth != nil { - assignedType = wrappedSynth(source, p) + if typ.IsAbsentOrUnknown(assignedType) { + ensureValues() + if value := assignValueAt(values, i); !typ.IsAbsentOrUnknown(value) { + assignedType = value + } else if source != nil { + if tbl, ok := source.(*ast.TableExpr); ok && wrappedSynth != nil && !tblutil.TableHasFunctionField(tbl) { + assignedType = wrappedSynth(source, p) + } else if wrappedSynth != nil { + assignedType = wrappedSynth(source, p) + } } } if assignedType == nil { @@ -595,11 +699,13 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect // Determine static key segment from the key expression. var keySeg constraint.Segment + hasStaticKeySeg := false var keyType typ.Type switch k := target.Key.(type) { case *ast.StringExpr: if seg, ok := path.StaticKeySegment(k); ok { keySeg = seg + hasStaticKeySeg = true } case *ast.IdentExpr: // Variable key - try const resolution @@ -609,6 +715,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect case flow.ConstString: if seg, ok := path.StaticKeySegment(&ast.StringExpr{Value: val.Str}); ok { keySeg = seg + hasStaticKeySeg = true } case flow.ConstInt: keyType = typ.Integer @@ -637,6 +744,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect p, sc, fc.Graph, + fc.Evidence.Assignments, bindings, constResolver, wrappedSynth, @@ -650,7 +758,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } // For non-const keys, emit an IndexerAssignment to widen the table - if keySeg.Name == "" { + if !hasStaticKeySeg { // Extract key variable name and symbol using bindings. var keyVar string var keySym cfg.SymbolID @@ -760,7 +868,7 @@ func ExtractAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs, keysCollect } } } - }) + } } type attrChainStep struct { @@ -776,6 +884,7 @@ func buildLiftedDynamicIndexerAssignment( p cfg.Point, sc *checkscope.State, graph *cfg.Graph, + assignments []api.AssignmentEvidence, bindings *bind.BindingTable, constResolver func(string) *flow.ConstValue, synth func(ast.Expr, cfg.Point) typ.Type, @@ -819,7 +928,7 @@ func buildLiftedDynamicIndexerAssignment( } outer := steps[firstDynamic] - keyVar, keySym, keyType := keyInfoForStep(outer, graph, bindings, synth, symResolver, p, true) + keyVar, keySym, keyType := keyInfoForStep(outer, graph, assignments, bindings, synth, symResolver, p, true) valType := assignedType if source != nil && bindings != nil && truthyGuards != nil { @@ -832,12 +941,14 @@ func buildLiftedDynamicIndexerAssignment( valType = typ.Unknown } + wrappedValue := false for i := len(steps) - 1; i > firstDynamic; i-- { - valType = wrapStepValue(steps[i], valType, graph, bindings, synth, symResolver, p) + valType = wrapStepValue(steps[i], valType, graph, assignments, bindings, synth, symResolver, p) + wrappedValue = true } valuePath := constraint.Path{} - if source != nil { + if source != nil && !wrappedValue { if sp := path.FromExprWithBindings(source, constResolver, bindings); !sp.IsEmpty() { valuePath = constraint.Path{ Root: resolve.RootNameFromBindings(bindings, sp.Symbol, sp.Root), @@ -909,6 +1020,7 @@ func staticSegmentForAttrKey(key ast.Expr, constResolver func(string) *flow.Cons func keyInfoForStep( step attrChainStep, graph *cfg.Graph, + assignments []api.AssignmentEvidence, bindings *bind.BindingTable, synth func(ast.Expr, cfg.Point) typ.Type, symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), @@ -929,7 +1041,7 @@ func keyInfoForStep( } } if typ.IsAbsentOrUnknown(keyType) && keySym != 0 { - if resolved := inferSymbolTypeFromVisibleDef(graph, keySym, p, synth); !typ.IsAbsentOrUnknown(resolved) { + if resolved := inferSymbolTypeFromVisibleDef(graph, assignments, keySym, p, synth); !typ.IsAbsentOrUnknown(resolved) { keyType = resolved } } @@ -972,6 +1084,7 @@ func wrapStepValue( step attrChainStep, value typ.Type, graph *cfg.Graph, + assignments []api.AssignmentEvidence, bindings *bind.BindingTable, synth func(ast.Expr, cfg.Point) typ.Type, symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), @@ -991,12 +1104,13 @@ func wrapStepValue( } } - _, _, keyType := keyInfoForStep(step, graph, bindings, synth, symResolver, p, false) + _, _, keyType := keyInfoForStep(step, graph, assignments, bindings, synth, symResolver, p, false) return typ.NewMap(keyType, value) } func inferSymbolTypeFromVisibleDef( graph *cfg.Graph, + assignments []api.AssignmentEvidence, sym cfg.SymbolID, at cfg.Point, synth func(ast.Expr, cfg.Point) typ.Type, @@ -1010,12 +1124,14 @@ func inferSymbolTypeFromVisibleDef( } var inferred typ.Type - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info if inferred != nil || p > at || info == nil { - return + continue } if pv := graph.VisibleVersion(p, sym); pv.Symbol != ver.Symbol || pv.ID != ver.ID { - return + continue } info.EachTargetSource(func(i int, target cfg.AssignTarget, source ast.Expr) { if inferred != nil { @@ -1029,7 +1145,7 @@ func inferSymbolTypeFromVisibleDef( } _ = i }) - }) + } return inferred } @@ -1038,18 +1154,23 @@ func inferSymbolTypeFromVisibleDef( // - Local/global function definitions: local function foo() ... end // - Table field definitions: function M.add() ... end // - Method definitions: function M:add() ... end -func ExtractFuncDefAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs) { - fc.Graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { +func ExtractFuncDefAssignments(fc *abstractcore.FlowContext, inputs *flow.Inputs) { + for _, def := range fc.Evidence.FunctionDefinitions { + p := def.Nested.Point + info := def.FuncDef + if info == nil { + continue + } sc := fc.Scopes[p] // Handle local/global function definitions if info.TargetKind == cfg.FuncDefGlobal { if info.Symbol == 0 || info.Name == "" { - return + continue } // Skip if already in DeclaredTypes (has explicit return types) if _, exists := inputs.DeclaredTypes[info.Symbol]; exists { - return + continue } // Synthesize the function type with inferred returns var fnType typ.Type @@ -1068,31 +1189,21 @@ func ExtractFuncDefAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs) { }, Type: resolve.Ref(fnType, sc), }) - return + continue } // Handle field and method definitions on receivers if info.TargetKind != cfg.FuncDefField && info.TargetKind != cfg.FuncDefMethod { - return + continue } if info.ReceiverName == "" { - return - } - sym := info.ReceiverSymbol - // ReceiverSymbol should be populated by the binder. Fallback to bindings lookup - // if not set (for receivers that are simple identifiers). - if sym == 0 { - if bindings := fc.Graph.Bindings(); bindings != nil { - if recvIdent, ok := info.Receiver.(*ast.IdentExpr); ok { - sym, _ = bindings.SymbolOf(recvIdent) - } - } + continue } - if sym == 0 { - return + if info.ReceiverSymbol == 0 { + continue } - root := resolve.RootNameFromBindings(fc.Graph.Bindings(), sym, info.ReceiverName) + root := resolve.RootNameFromBindings(fc.Graph.Bindings(), info.ReceiverSymbol, info.ReceiverName) // Synthesize the function type var fnType typ.Type @@ -1108,12 +1219,54 @@ func ExtractFuncDefAssignments(fc *fbcore.FlowContext, inputs *flow.Inputs) { Point: p, TargetPath: constraint.Path{ Root: root, - Symbol: sym, + Symbol: info.ReceiverSymbol, Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: info.Name}}, }, Type: resolve.Ref(fnType, sc), }) - }) + } +} + +func assignmentTargetExpectedType( + target cfg.AssignTarget, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, +) typ.Type { + if target.Expr == nil || synth == nil { + return nil + } + expected := synth(target.Expr, p) + if expected == nil || typ.IsAny(expected) || typ.IsUnknown(expected) || typ.IsSoft(expected, typ.SoftAnnotationPolicy) { + return nil + } + if inner, nilable := typ.SplitNilableFieldType(expected); nilable { + return inner + } + return expected +} + +type expectedAssignmentSynth interface { + TypeOfWithExpected(ast.Expr, cfg.Point, typ.Type) typ.Type +} + +func synthAssignmentSourceWithExpected(synthAPI api.SynthAPI, source ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + if synthAPI == nil || source == nil || expected == nil { + return nil + } + switch source.(type) { + case *ast.TableExpr, *ast.FunctionExpr, *ast.LogicalOpExpr: + default: + return nil + } + withExpected, ok := synthAPI.(expectedAssignmentSynth) + if !ok { + return nil + } + inferred := withExpected.TypeOfWithExpected(source, p, expected) + if inferred == nil || typ.IsAbsentOrUnknown(inferred) { + return nil + } + return inferred } func isTopLikeResolvedAssignType(t typ.Type) bool { @@ -1148,7 +1301,7 @@ func isTopLikeResolvedAssignType(t typ.Type) bool { // extractCallCorrelations extracts ErrorReturn and CorrelatedReturn correlations from the callee's spec. // Callee type resolution is delegated to resolve.CalleeType to keep call semantics -// canonical across flowbuild passes. +// canonical across abstract interpreter passes. func extractCallCorrelations( callInfo *cfg.CallInfo, synth func(ast.Expr, cfg.Point) typ.Type, diff --git a/compiler/check/flowbuild/assign/emit_test.go b/compiler/check/abstract/assign/emit_test.go similarity index 78% rename from compiler/check/flowbuild/assign/emit_test.go rename to compiler/check/abstract/assign/emit_test.go index 537c895f..e3c4bfbd 100644 --- a/compiler/check/flowbuild/assign/emit_test.go +++ b/compiler/check/abstract/assign/emit_test.go @@ -6,10 +6,11 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/keyscoll" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/keyscoll" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" @@ -22,6 +23,42 @@ type preciseSourceSynthStub struct { preciseType typ.Type } +type testGraphProvider struct { + bindings *bind.BindingTable + cache map[*ast.FunctionExpr]*cfg.Graph +} + +func newTestGraphProvider(bindings *bind.BindingTable) *testGraphProvider { + return &testGraphProvider{ + bindings: bindings, + cache: make(map[*ast.FunctionExpr]*cfg.Graph), + } +} + +func (p *testGraphProvider) GetOrBuildCFG(fn *ast.FunctionExpr) *cfg.Graph { + if fn == nil { + return nil + } + if graph := p.cache[fn]; graph != nil { + return graph + } + var graph *cfg.Graph + if p.bindings != nil { + graph = cfg.BuildWithBindings(fn, p.bindings) + } else { + graph = cfg.Build(fn) + } + p.cache[fn] = graph + return graph +} + +func (p *testGraphProvider) EvidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + func (s *preciseSourceSynthStub) TypeOf(expr ast.Expr, _ cfg.Point) typ.Type { switch expr.(type) { case *ast.LogicalOpExpr: @@ -232,7 +269,8 @@ func TestExtractAssignments_ContainerElementSourceFromTrailingCall(t *testing.T) SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -289,7 +327,8 @@ func TestExtractAssignments_KeysCollectorEffectFallbackIgnoresNonCollectorEffect SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -331,8 +370,9 @@ func TestExtractAssignments_PrefersPreciseDirectTypeOverExpandedAnyForLogicalOr( SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, - API: synthAPI, + Graph: graph, + API: synthAPI, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: synthAPI.TypeOf, SymResolver: func(cfg.Point, cfg.SymbolID) (typ.Type, bool) { @@ -387,7 +427,8 @@ func TestExtractAssignments_KeysCollectorEffectFallbackRespectsReturnIndex(t *te SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -446,6 +487,7 @@ func TestExtractAssignments_KeysCollectorEffectFallback_TriesAllNameCandidates(t ExtractAssignments(&core.FlowContext{ Graph: graph, ModuleBindings: moduleBindings, + Evidence: trace.GraphEvidence(graph, moduleBindings), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -535,14 +577,16 @@ func TestExtractAssignments_KeysCollector_WithFilterBranch(t *testing.T) { PredicateLinks: make(map[string]flow.PredicateLink), SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } + evidence := trace.GraphEvidence(graph, graph.Bindings()) ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: evidence, Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown }, }, - }, inputs, keyscoll.BuildKeysCollectorDetector(graph, nil)) + }, inputs, keyscoll.BuildKeysCollectorDetector(graph, evidence, nil, newTestGraphProvider(graph.Bindings()))) src, ok := inputs.KeysProvenance[suiteNamesSym] if !ok || src != suitesSym { @@ -573,7 +617,8 @@ func TestExtractAssignments_IndexAssign_NonIdentifierStringKey_UsesIndexStringSe SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -602,6 +647,58 @@ func TestExtractAssignments_IndexAssign_NonIdentifierStringKey_UsesIndexStringSe } } +func TestExtractAssignments_LengthIndexReadCarriesSemanticSource(t *testing.T) { + code := ` + local messages = {} + if #messages > 0 then + local last = messages[#messages] + end + ` + chunk, err := parse.ParseString(code, "emit_length_index_read.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{Stmts: chunk}, "emit_length_index_read") + exit := graph.Exit() + messagesSym, ok := graph.SymbolAt(exit, "messages") + if !ok || messagesSym == 0 { + t.Fatal("expected symbol for messages") + } + + inputs := &flow.Inputs{ + DeclaredTypes: make(map[cfg.SymbolID]typ.Type), + PredicateLinks: make(map[string]flow.PredicateLink), + SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), + } + ExtractAssignments(&core.FlowContext{ + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), + Derived: &core.Derived{ + Synth: func(ast.Expr, cfg.Point) typ.Type { + return typ.Unknown + }, + }, + }, inputs, nil) + + for i := range inputs.Assignments { + assign := inputs.Assignments[i] + if assign.TargetPath.Root != "last" { + continue + } + if assign.LengthIndexSource == nil { + t.Fatalf("expected length-index source for last assignment, got %#v", assign) + } + if assign.LengthIndexSource.ContainerPath.Symbol != messagesSym { + t.Fatalf("length-index container symbol = %d, want %d", assign.LengthIndexSource.ContainerPath.Symbol, messagesSym) + } + if assign.LengthIndexSource.Offset != 0 { + t.Fatalf("length-index offset = %d, want 0", assign.LengthIndexSource.Offset) + } + return + } + t.Fatalf("expected assignment to last, got %#v", inputs.Assignments) +} + func TestExtractAssignments_NestedDynamicIndex_LiftsToRootIndexer(t *testing.T) { code := ` local subscribers = {} @@ -635,7 +732,8 @@ func TestExtractAssignments_NestedDynamicIndex_LiftsToRootIndexer(t *testing.T) SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), } ExtractAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown @@ -659,6 +757,78 @@ func TestExtractAssignments_NestedDynamicIndex_LiftsToRootIndexer(t *testing.T) } } +func TestExtractAssignments_NestedDynamicFieldAndSiblingMutatorStayOnSeparatePaths(t *testing.T) { + code := ` + local self = { nodes = {}, queued_commands = {} } + local node_id = "root" + self.nodes[node_id].status = "completed" + table.insert(self.queued_commands, { type = "UPDATE_NODE" }) + local node_data = self.nodes[node_id] + ` + + chunk, err := parse.ParseString(code, "emit_nested_dynamic_sibling_paths.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{Stmts: chunk}, "emit_nested_dynamic_sibling_paths") + exit := graph.Exit() + selfSym, ok := graph.SymbolAt(exit, "self") + if !ok || selfSym == 0 { + t.Fatal("expected symbol for self") + } + + inputs := &flow.Inputs{ + DeclaredTypes: make(map[cfg.SymbolID]typ.Type), + PredicateLinks: make(map[string]flow.PredicateLink), + SiblingAssignments: make(map[flow.SiblingKey]*flow.SiblingAssignment), + } + ExtractAssignments(&core.FlowContext{ + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), + Derived: &core.Derived{ + Synth: func(ast.Expr, cfg.Point) typ.Type { + return typ.Unknown + }, + }, + }, inputs, nil) + + var sawNodesWrite bool + for i := range inputs.IndexerAssignments { + assign := inputs.IndexerAssignments[i] + if assign.Symbol != selfSym || len(assign.Segments) != 1 { + continue + } + seg := assign.Segments[0] + if seg.Kind == constraint.SegmentField && seg.Name == "nodes" { + if rec, ok := assign.ValType.(*typ.Record); !ok || rec.GetField("status") == nil { + t.Fatalf("expected nested dynamic write value to preserve .status field, got %#v", assign.ValType) + } + if !assign.ValuePath.IsEmpty() { + t.Fatalf("expected shaped nested write not to use raw source value path, got %+v", assign.ValuePath) + } + sawNodesWrite = true + } + } + if !sawNodesWrite { + t.Fatalf("expected nested dynamic write under self.nodes, got %#v", inputs.IndexerAssignments) + } + + var sawNodeRead bool + for i := range inputs.Assignments { + assign := inputs.Assignments[i] + if assign.MapElementSource == nil || assign.MapElementSource.MapPath.Symbol != selfSym { + continue + } + segs := assign.MapElementSource.MapPath.Segments + if len(segs) == 1 && segs[0].Kind == constraint.SegmentField && segs[0].Name == "nodes" { + sawNodeRead = true + } + } + if !sawNodeRead { + t.Fatalf("expected dynamic read from self.nodes, got %#v", inputs.Assignments) + } +} + func TestCorrelationsFromFunctionType_NoImplicitErrorConvention(t *testing.T) { fnType := typ.Func(). Returns(typ.NewOptional(typ.String), typ.NewOptional(typ.Number)). @@ -705,6 +875,22 @@ func TestCorrelationsFromFunctionType_ImplicitLuaErrorConvention(t *testing.T) { } } +func TestCorrelationsFromFunctionType_ImplicitLuaErrorConventionWithExtraReturns(t *testing.T) { + fnType := typ.Func(). + Returns(typ.NewOptional(typ.String), typ.NewOptional(typ.LuaError), typ.NewOptional(typ.Boolean)). + Build() + inverse, co := correlationsFromFunctionType(fnType) + if len(co) != 0 { + t.Fatalf("expected no co-correlations, got %v", co) + } + if len(inverse) != 1 { + t.Fatalf("expected one convention-based correlation, got %v", inverse) + } + if inverse[0] != (flow.ReturnCorrelation{ValueIndex: 0, ErrorIndex: 1}) { + t.Fatalf("unexpected convention correlation: %+v", inverse[0]) + } +} + func TestCorrelationsFromFunctionType_ImplicitStringErrorConvention(t *testing.T) { fnType := typ.Func(). Returns(typ.NewOptional(typ.String), typ.NewOptional(typ.String)). diff --git a/compiler/check/flowbuild/assign/error_return_policy.go b/compiler/check/abstract/assign/error_return_policy.go similarity index 96% rename from compiler/check/flowbuild/assign/error_return_policy.go rename to compiler/check/abstract/assign/error_return_policy.go index e5235dab..f9d6e39e 100644 --- a/compiler/check/flowbuild/assign/error_return_policy.go +++ b/compiler/check/abstract/assign/error_return_policy.go @@ -13,7 +13,7 @@ import ( // from a function signature when no explicit effect labels are present. // // Rule: -// - Signature must have exactly two returns. +// - Signature must expose at least the conventional value and error slots. // - Error slot is selected by conventional position with type-based precedence: // - Prefer return[1] when it is Optional or Optional. // - Otherwise allow return[0] only when return[1] is not error-like and @@ -24,7 +24,7 @@ import ( // policy centralized and deterministic. func InferErrorReturnConvention(fnType typ.Type) ([]flow.ReturnCorrelation, []flow.ReturnCorrelation) { fn := unwrap.Function(fnType) - if fn == nil || len(fn.Returns) != 2 { + if fn == nil || len(fn.Returns) < 2 { return nil, nil } diff --git a/compiler/check/flowbuild/assign/infer.go b/compiler/check/abstract/assign/infer.go similarity index 53% rename from compiler/check/flowbuild/assign/infer.go rename to compiler/check/abstract/assign/infer.go index 48606dfb..72dc0571 100644 --- a/compiler/check/flowbuild/assign/infer.go +++ b/compiler/check/abstract/assign/infer.go @@ -19,8 +19,8 @@ // // - Stop when no changes occur // -// 4. Widening: If an SCC doesn't converge within maxInferIterations, widen all -// symbols in that SCC to Unknown. This ensures termination. +// 4. Convergence: recursive SCCs iterate until the widened abstract domain +// stabilizes; there is no caller-visible iteration cap. // // # SCC PROCESSING // @@ -50,28 +50,27 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" cfganalysis "github.com/wippyai/go-lua/compiler/cfg/analysis" + abstractcore "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - fbcore "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" synthpkg "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/compiler/check/synth/ops" "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" + flowjoin "github.com/wippyai/go-lua/types/flow/join" "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" ) -// maxInferIterations limits fixpoint iterations per SCC. -const maxInferIterations = 10 - func mergeSpecTypesSoftInto(out, base, override api.SpecTypes) api.SpecTypes { if out == nil { out = make(api.SpecTypes, len(base)+len(override)) @@ -106,21 +105,47 @@ func mergeSpecTypesSoft(base, override api.SpecTypes) api.SpecTypes { return mergeSpecTypesSoftInto(nil, base, override) } -// CollectInferredTypes is the exported entry point for collectInferredTypes. -// Used by return inference to resolve local variable types before synthesizing return expressions. -func CollectInferredTypes(fc *fbcore.FlowContext, specTypes api.SpecTypes, annotated map[cfg.SymbolID]bool, inputs *flow.Inputs) api.SpecTypes { - var synth func(ast.Expr, cfg.Point) typ.Type - if fc.API != nil { - synth = fc.API.TypeOf - } - var symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool) - if fc.Derived != nil { - symResolver = fc.Derived.SymResolver - } - preflowBranchSolution := buildPreflowBranchSolution(fc, inputs) +// LocalInferenceConfig is the data needed by the local assignment abstract +// interpreter. It is intentionally not a FlowContext: callers that only need +// local type inference should pass evidence and services directly instead of +// pretending to run a full interpreter pass. +type LocalInferenceConfig struct { + Graph *cfg.Graph + Evidence api.FlowEvidence + Scopes map[cfg.Point]*scope.State + Synth func(ast.Expr, cfg.Point) typ.Type + SynthAPI api.SynthAPI + SymResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool) + SeedTypes api.SpecTypes + Annotated map[cfg.SymbolID]bool + Inputs *flow.Inputs + ModuleBindings *bind.BindingTable + CallCtx *db.QueryContext + TypeOps core.TypeOps + Preflow *flow.Solution + Services abstractcore.FlowServices +} + +// InferLocalTypes computes extraction-time local variable types using the +// canonical SCC/fixpoint assignment interpreter. +func InferLocalTypes(config LocalInferenceConfig) api.SpecTypes { return collectInferredTypes( - fc.Graph, fc.Scopes, synth, fc.API, symResolver, - specTypes, annotated, inputs, fc.ModuleBindings, fc.CallCtx, fc.TypeOps, preflowBranchSolution, fc.Services, + config.Graph, + config.Evidence.Assignments, + config.Evidence.Calls, + config.Evidence.FunctionDefinitions, + config.Scopes, + config.Synth, + config.SynthAPI, + config.SymResolver, + config.SeedTypes, + config.Annotated, + config.Inputs, + config.ModuleBindings, + config.CallCtx, + config.TypeOps, + config.Preflow, + config.Services, ) } @@ -129,10 +154,12 @@ func CollectInferredTypes(fc *fbcore.FlowContext, specTypes api.SpecTypes, annot // Algorithm: // 1. Build dependency graph: symbol -> symbols referenced in RHS // 2. Compute SCCs in topological order -// 3. For each SCC, run bounded fixpoint iteration with monotone joins -// 4. If not converged by max iterations, widen to Unknown +// 3. For each SCC, run fixpoint iteration with monotone joins func collectInferredTypes( graph *cfg.Graph, + assignments []api.AssignmentEvidence, + callEvidence []api.CallEvidence, + functions []api.FunctionDefinitionEvidence, scopes map[cfg.Point]*scope.State, synth func(ast.Expr, cfg.Point) typ.Type, synthAPI api.SynthAPI, @@ -144,14 +171,18 @@ func collectInferredTypes( callCtx *db.QueryContext, typeOps core.TypeOps, preflowBranchSolution *flow.Solution, - services fbcore.FlowServices, + services abstractcore.FlowServices, ) api.SpecTypes { inferred := make(api.SpecTypes) if graph == nil { return inferred } - idom, _ := cfganalysis.ComputeDominators(graph.CFG()) - structuredWrites := indexStructuredWrites(graph) + structuredWrites := indexStructuredWrites(graph, assignments) + var idom map[cfg.Point]cfg.Point + if len(structuredWrites) > 0 { + idom = cfganalysis.ComputeImmediateDominators(graph.CFG()) + } + valueDefs := collectValueDefinitionVersions(graph, assignments, functions) bindings := graph.Bindings() if moduleBindings == nil { @@ -167,12 +198,14 @@ func collectInferredTypes( funcSigTypes := make(map[cfg.SymbolID]typ.Type) seedEngine, _ := synthAPI.(*synthpkg.Engine) if services != nil { - graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + for _, def := range functions { + p := def.Nested.Point + info := def.FuncDef if info == nil || info.Symbol == 0 { - return + continue } if info.TargetKind != cfg.FuncDefGlobal || info.FuncExpr == nil { - return + continue } sc := scopes[p] if sc == nil { @@ -181,20 +214,22 @@ func collectInferredTypes( if inputs != nil && inputs.SiblingTypes != nil { if sibling := inputs.SiblingTypes[info.Symbol]; sibling != nil { funcSigTypes[info.Symbol] = sibling - return + continue } } if sig := services.ResolveFunctionSignature(info.FuncExpr, sc); sig != nil { funcSigTypes[info.Symbol] = sig - return + continue } if seed, ok := returns.BuildSeedFunctionTypeWithBindings(info.FuncExpr, seedEngine, sc, bindings).(*typ.Function); ok && seed != nil { funcSigTypes[info.Symbol] = seed } - }) - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + } + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + continue } sc := scopes[p] if sc == nil { @@ -225,7 +260,7 @@ func collectInferredTypes( } } } - }) + } } type assignEntry struct { @@ -233,22 +268,26 @@ func collectInferredTypes( info *cfg.AssignInfo } var assigns []assignEntry - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info != nil { assigns = append(assigns, assignEntry{p: p, info: info}) } - }) + } type callEntry struct { p cfg.Point info *cfg.CallInfo } var calls []callEntry - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range callEvidence { + p := call.Point + info := call.Info if info != nil { calls = append(calls, callEntry{p: p, info: info}) } - }) + } assignsAtPoint := make(map[cfg.Point][]*cfg.AssignInfo) for _, entry := range assigns { if entry.info == nil { @@ -276,15 +315,25 @@ func collectInferredTypes( continue } for _, target := range entry.info.Targets { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { + var sym cfg.SymbolID + switch target.Kind { + case cfg.TargetIdent: + sym = target.Symbol + case cfg.TargetField: + if paramSet[target.BaseSymbol] { + sym = target.BaseSymbol + } + } + if sym == 0 { continue } - assignIdxByTargetSym[target.Symbol] = append(assignIdxByTargetSym[target.Symbol], idx) + assignIdxByTargetSym[sym] = append(assignIdxByTargetSym[sym], idx) } } callArgSymbolsByIdx := make([][]cfg.SymbolID, len(calls)) - callIdxByParamArgSym := make(map[cfg.SymbolID][]int) + callReceiverSymbolByIdx := make([]cfg.SymbolID, len(calls)) + callIdxByArgSym := make(map[cfg.SymbolID][]int) callIdxByRefSym := make(map[cfg.SymbolID][]int) for idx, entry := range calls { if entry.info == nil { @@ -292,12 +341,23 @@ func collectInferredTypes( } argSymbols := normalizedCallArgSymbols(entry.info, bindings) callArgSymbolsByIdx[idx] = argSymbols + receiverSym := normalizedCallReceiverSymbol(entry.info, bindings) + callReceiverSymbolByIdx[idx] = receiverSym + if receiverSym != 0 { + callIdxByArgSym[receiverSym] = append(callIdxByArgSym[receiverSym], idx) + } for _, sym := range argSymbols { - if sym == 0 || !paramSet[sym] { + if sym == 0 { continue } - callIdxByParamArgSym[sym] = append(callIdxByParamArgSym[sym], idx) + callIdxByArgSym[sym] = append(callIdxByArgSym[sym], idx) + } + for _, arg := range entry.info.Args { + argPath := path.FromExprWithBindings(arg, nil, bindings) + if argPath.Symbol != 0 && len(argPath.Segments) > 0 && paramSet[argPath.Symbol] { + callIdxByArgSym[argPath.Symbol] = append(callIdxByArgSym[argPath.Symbol], idx) + } } for _, sym := range callRefSymbols(entry.info, bindings) { @@ -321,10 +381,19 @@ func collectInferredTypes( for _, entry := range assigns { info := entry.info for _, target := range info.Targets { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { + var targetSymID cfg.SymbolID + switch target.Kind { + case cfg.TargetIdent: + targetSymID = target.Symbol + case cfg.TargetField: + if paramSet[target.BaseSymbol] { + targetSymID = target.BaseSymbol + } + } + if targetSymID == 0 { continue } - targetSym := uint64(target.Symbol) + targetSym := uint64(targetSymID) if deps[targetSym] == nil { deps[targetSym] = nil // ensure node exists } @@ -356,7 +425,7 @@ func collectInferredTypes( continue } - tm := mutator.TableMutatorFromCall(info, p, synth, symResolver, graph, bindings, moduleBindings) + tm := calleffect.TableMutatorFromCall(info, p, synth, symResolver, graph, bindings, moduleBindings) if tm == nil { continue } @@ -390,6 +459,43 @@ func collectInferredTypes( deps[targetKey] = append(deps[targetKey], uint64(ref)) } } + for _, entry := range calls { + info := entry.info + if info == nil { + continue + } + var calleeRefs []cfg.SymbolID + collectExprSymbols(info.Callee, bindings, &calleeRefs) + collectExprSymbols(info.Receiver, bindings, &calleeRefs) + calleeRefs = dedupeSymbolIDs(calleeRefs) + if len(calleeRefs) == 0 { + continue + } + addArgExpectationDeps := func(sym cfg.SymbolID) { + if sym == 0 { + return + } + targetKey := uint64(sym) + if deps[targetKey] == nil { + deps[targetKey] = nil + } + for _, ref := range calleeRefs { + if ref == 0 || ref == sym { + continue + } + deps[targetKey] = append(deps[targetKey], uint64(ref)) + } + } + for _, sym := range normalizedCallArgSymbols(info, bindings) { + addArgExpectationDeps(sym) + } + for _, arg := range info.Args { + argPath := path.FromExprWithBindings(arg, nil, bindings) + if argPath.Symbol != 0 && len(argPath.Segments) > 0 && paramSet[argPath.Symbol] { + addArgExpectationDeps(argPath.Symbol) + } + } + } // Deduplicate edges for sym, edges := range deps { @@ -413,7 +519,7 @@ func collectInferredTypes( sccs := internal.ComputeSCCs(deps) // Process each SCC in topological order - for sccIdx, scc := range sccs { + for _, scc := range sccs { if len(scc) == 0 { continue } @@ -429,7 +535,7 @@ func collectInferredTypes( markEpoch++ sccAssignIdx := make([]int, 0, len(scc)) - sccParamCallIdx := make([]int, 0, len(scc)) + sccArgCallIdx := make([]int, 0, len(scc)) sccMutatorCallIdx := make([]int, 0, len(scc)) for _, sym := range sccSyms { for _, idx := range assignIdxByTargetSym[sym] { @@ -439,12 +545,12 @@ func collectInferredTypes( assignIdxMarks[idx] = markEpoch sccAssignIdx = append(sccAssignIdx, idx) } - for _, idx := range callIdxByParamArgSym[sym] { + for _, idx := range callIdxByArgSym[sym] { if paramCallIdxMarks[idx] == markEpoch { continue } paramCallIdxMarks[idx] = markEpoch - sccParamCallIdx = append(sccParamCallIdx, idx) + sccArgCallIdx = append(sccArgCallIdx, idx) } for _, idx := range callIdxByRefSym[sym] { if mutatorCallIdxMarks[idx] == markEpoch { @@ -455,15 +561,16 @@ func collectInferredTypes( } } - // Fixpoint iteration for this SCC - converged := false + // Fixpoint iteration for this SCC. var overlayScratch api.SpecTypes - for iter := 0; iter < maxInferIterations; iter++ { + snapshot := make([]typ.Type, len(sccSyms)) + for { + snapshotSCCTypes(snapshot, inferred, sccSyms) changed := false overlayScratch = mergeSpecTypesSoftInto(overlayScratch, inferred, specTypes) overlay := overlayScratch - wrappedSynth := synthWithInferenceOverlay(graph, overlay, funcSigTypes, paramSet, annotated, bindings, inputs, callCtx, typeOps, preflowBranchSolution, synth) + wrappedSynth := synthWithInferenceOverlay(graph, inferred, specTypes, funcSigTypes, valueDefs, paramSet, annotated, bindings, inputs, callCtx, typeOps, preflowBranchSolution, synth) callSynthFor := func(p cfg.Point, info *cfg.CallInfo) func(ast.Expr, cfg.Point) typ.Type { if info == nil { return wrappedSynth @@ -483,15 +590,16 @@ func collectInferredTypes( return t, ok } } - callOverlay := rhsSpecTypesAtAssignPoint(graph, owner, p, overlay, func(point cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { - if t, ok := overlay[sym]; ok && t != nil && !t.Kind().IsPlaceholder() { + callOverlayBase := inferenceOverlayAtPoint(graph, p, inferred, specTypes, funcSigTypes, valueDefs, paramSet) + callOverlay := rhsSpecTypesAtAssignPoint(graph, owner, p, callOverlayBase, func(point cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { + if t, ok := callOverlayBase[sym]; ok && t != nil && !t.Kind().IsPlaceholder() { return t, true } return rhsResolver(point, sym) }) - callOverlay = enrichStructuredOverlayAtPoint(graph, idom, structuredWrites, p, callOverlay, rhsResolver, wrappedSynth) + callOverlay = enrichStructuredOverlayAtPoint(graph, idom, structuredWrites, p, callOverlay, callRefSymbols(info, bindings), rhsResolver, wrappedSynth) - return synthWithInferenceOverlay(graph, callOverlay, funcSigTypes, paramSet, annotated, bindings, inputs, callCtx, typeOps, preflowBranchSolution, synth) + return synthWithOverlayAndPreflow(mapOverlayTypeAt(callOverlay), bindings, inputs, callCtx, typeOps, preflowBranchSolution, wrappedBaseForInference(bindings, paramSet, annotated, synth)) } // Infer expected argument types for a call using the call inference pipeline. @@ -525,7 +633,7 @@ func collectInferredTypes( def.IsMethod = true def.Receiver = recvType def.MethodName = info.Method - def.ForceMethodReceiver = callsite.ForceMethodReceiver(bindings, graph, info) + def.ForceMethodReceiver = callsite.ForceMethodReceiver(bindings, graph, api.FlowEvidence{FunctionDefinitions: functions}, info) } else { setCallee := func(candidate typ.Type) { if candidate == nil { @@ -668,13 +776,14 @@ func collectInferredTypes( return t, ok } } - rhsOverlay := rhsSpecTypesAtAssignPoint(graph, info, p, overlay, func(point cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { - if t, ok := overlay[sym]; ok && t != nil && !t.Kind().IsPlaceholder() { + rhsOverlayBase := inferenceOverlayAtPoint(graph, p, inferred, specTypes, funcSigTypes, valueDefs, paramSet) + rhsOverlay := rhsSpecTypesAtAssignPoint(graph, info, p, rhsOverlayBase, func(point cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { + if t, ok := rhsOverlayBase[sym]; ok && t != nil && !t.Kind().IsPlaceholder() { return t, true } return rhsResolver(point, sym) }) - rhsOverlay = enrichStructuredOverlayAtPoint(graph, idom, structuredWrites, p, rhsOverlay, rhsResolver, wrappedSynth) + rhsOverlay = enrichStructuredOverlayAtPoint(graph, idom, structuredWrites, p, rhsOverlay, assignmentSourceSymbols(info, bindings), rhsResolver, wrappedSynth) values = expandedAssignValues(synthAPI, info, p, rhsOverlay) valuesComputed = true } @@ -695,12 +804,77 @@ func collectInferredTypes( inferred[target.Symbol] = joined changed = true } + case cfg.TargetField: + if target.BaseSymbol == 0 || len(target.FieldPath) == 0 { + continue + } + if !paramSet[target.BaseSymbol] { + continue + } + if !sccSet[target.BaseSymbol] { + continue + } + if annotated != nil && annotated[target.BaseSymbol] { + continue + } + assignedType := typ.Unknown + if !valuesComputed { + rhsResolver := symResolver + if rhsResolver == nil { + rhsResolver = func(_ cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { + t, ok := overlay[sym] + return t, ok + } + } + rhsOverlayBase := inferenceOverlayAtPoint(graph, p, inferred, specTypes, funcSigTypes, valueDefs, paramSet) + rhsOverlay := rhsSpecTypesAtAssignPoint(graph, info, p, rhsOverlayBase, func(point cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { + if t, ok := rhsOverlayBase[sym]; ok && t != nil && !t.Kind().IsPlaceholder() { + return t, true + } + return rhsResolver(point, sym) + }) + rhsOverlay = enrichStructuredOverlayAtPoint(graph, idom, structuredWrites, p, rhsOverlay, assignmentSourceSymbols(info, bindings), rhsResolver, wrappedSynth) + values = expandedAssignValues(synthAPI, info, p, rhsOverlay) + valuesComputed = true + } + if value := assignValueAt(values, i); !typ.IsAbsentOrUnknown(value) { + assignedType = value + } else if wrappedSynth != nil && source != nil { + assignedType = wrappedSynth(source, p) + } + assignedType = resolve.Ref(assignedType, sc) + if typ.IsAbsentOrUnknown(assignedType) { + continue + } + segments := make([]constraint.Segment, 0, len(target.FieldPath)) + for _, field := range target.FieldPath { + if field == "" { + continue + } + segments = append(segments, constraint.Segment{Kind: constraint.SegmentField, Name: field}) + } + if len(segments) == 0 { + continue + } + old := inferred[target.BaseSymbol] + updated := mergeExpectedAtPath(old, segments, assignedType, paramSet[target.BaseSymbol]) + if updated == nil { + continue + } + if !typ.TypeEquals(old, updated) { + inferred[target.BaseSymbol] = updated + changed = true + } } } } - // Infer parameter types from call argument expectations. - for _, idx := range sccParamCallIdx { + // Infer unannotated symbol types from call argument expectations. + // Parameters keep the traditional bidirectional behavior. Locals only + // accept expected types while still unresolved/soft, so a concrete + // assignment is not hidden by a later incompatible call and explicit + // dynamic any is not silently specialized. + for _, idx := range sccArgCallIdx { entry := calls[idx] p := entry.p info := entry.info @@ -713,40 +887,76 @@ func collectInferredTypes( sc = scopes[graph.Entry()] } expectedArgs, expectedVariadic := inferExpectedArgs(p, info, synthForCall) + + if receiverSym := callReceiverSymbolByIdx[idx]; receiverSym != 0 && sccSet[receiverSym] { + if annotated == nil || !annotated[receiverSym] { + expected := expectedReceiverTypeForMethod(callCtx, typeOps, info) + if expected != nil && !expected.Kind().IsPlaceholder() { + old := inferred[receiverSym] + if paramSet[receiverSym] || callExpectationCanRefineLocal(old) { + joined := mergeCallExpectation(old, expected, paramSet[receiverSym]) + if !typ.TypeEquals(old, joined) { + inferred[receiverSym] = joined + changed = true + } + } + } + } + } + callArgSymbols := callArgSymbolsByIdx[idx] for i := range info.Args { + expected := expectedArgAt(i, expectedArgs, expectedVariadic) var sym cfg.SymbolID if i < len(callArgSymbols) { sym = callArgSymbols[i] } - if sym == 0 || !sccSet[sym] { - continue - } - if !paramSet[sym] { - continue - } - if annotated != nil && annotated[sym] { - continue - } - expected := expectedArgAt(i, expectedArgs, expectedVariadic) - if typ.IsAbsentOrUnknown(expected) { - // Fall back to actual argument type when no expected type is available. - if i < len(info.Args) && info.Args[i] != nil { - actual := synthForCall(info.Args[i], p) - actual = resolve.Ref(actual, sc) - if actual != nil && !actual.Kind().IsPlaceholder() { - expected = actual + if sym != 0 && sccSet[sym] { + if annotated != nil && annotated[sym] { + continue + } + if typ.IsAbsentOrUnknown(expected) { + // Fall back to actual argument type when no expected type is available. + if i < len(info.Args) && info.Args[i] != nil { + actual := synthForCall(info.Args[i], p) + actual = resolve.Ref(actual, sc) + if actual != nil && !actual.Kind().IsPlaceholder() { + expected = actual + } } } + if expected == nil || expected.Kind().IsPlaceholder() { + continue + } + old := inferred[sym] + if !paramSet[sym] && !callExpectationCanRefineLocal(old) { + continue + } + joined := mergeCallExpectation(old, expected, paramSet[sym]) + if !typ.TypeEquals(old, joined) { + inferred[sym] = joined + changed = true + } } - if expected == nil || expected.Kind().IsPlaceholder() { - continue - } - old := inferred[sym] - joined := joinInferredType(old, expected) - if !typ.TypeEquals(old, joined) { - inferred[sym] = joined - changed = true + if i < len(info.Args) && expected != nil && !expected.Kind().IsPlaceholder() { + argPath := path.FromExprWithBindings(info.Args[i], nil, bindings) + if argPath.Symbol != 0 && len(argPath.Segments) > 0 && sccSet[argPath.Symbol] { + if !paramSet[argPath.Symbol] { + continue + } + if annotated != nil && annotated[argPath.Symbol] { + continue + } + old := inferred[argPath.Symbol] + if !paramSet[argPath.Symbol] && !callExpectationCanRefineLocal(old) { + continue + } + joined := mergePathCallExpectation(old, argPath.Segments, expected, paramSet[argPath.Symbol]) + if !typ.TypeEquals(old, joined) { + inferred[argPath.Symbol] = joined + changed = true + } + } } } } @@ -756,7 +966,7 @@ func collectInferredTypes( entry := calls[idx] p := entry.p info := entry.info - tm := mutator.TableMutatorFromCall(info, p, wrappedSynth, symResolver, graph, bindings, moduleBindings) + tm := calleffect.TableMutatorFromCall(info, p, wrappedSynth, symResolver, graph, bindings, moduleBindings) if tm == nil { continue } @@ -779,18 +989,20 @@ func collectInferredTypes( // Handle indexed targets (t[k]) even when key is non-const. if attr, ok := targetExpr.(*ast.AttrGetExpr); ok { - baseSym := callsite.SymbolOrCreateFieldFromExpr(attr.Object, bindings) - if baseSym != 0 && sccSet[baseSym] { - keyType := wrappedSynth(attr.Key, p) - keyType = resolve.Ref(keyType, sc) - keyType = canonicalDynamicKeyType(keyType) - old := inferred[baseSym] - newType := flow.WidenMapValueArray(old, keyType, valueType) - if newType != nil && !typ.TypeEquals(old, newType) { - inferred[baseSym] = newType - changed = true + if _, static := path.StaticKeySegment(attr.Key); !static { + baseSym := callsite.SymbolOrCreateFieldFromExpr(attr.Object, bindings) + if baseSym != 0 && sccSet[baseSym] { + keyType := wrappedSynth(attr.Key, p) + keyType = resolve.Ref(keyType, sc) + keyType = canonicalDynamicKeyType(keyType) + old := inferred[baseSym] + newType := flow.WidenMapValueArray(old, keyType, valueType) + if newType != nil && !typ.TypeEquals(old, newType) { + inferred[baseSym] = newType + changed = true + } + continue } - continue } } @@ -800,8 +1012,11 @@ func collectInferredTypes( if !sccSet[targetPath.Symbol] { continue } + if len(targetPath.Segments) > 0 && !paramSet[targetPath.Symbol] { + continue + } old := inferred[targetPath.Symbol] - newType := flow.WidenArrayElementType(old, valueType, typ.JoinPreferNonSoft) + newType := widenArrayElementAtPath(old, targetPath.Segments, valueType) if newType == nil || typ.TypeEquals(old, newType) { continue } @@ -809,29 +1024,10 @@ func collectInferredTypes( changed = true } - if !changed { - converged = true + if !changed || sccTypesStable(snapshot, inferred, sccSyms) { break } } - - // Widen ALL symbols in non-converged SCC to Unknown (except annotated). - // This is sound: partial types may be under-approximations. - if !converged { - for _, sym := range sccSyms { - if annotated != nil && annotated[sym] { - continue - } - inferred[sym] = typ.Unknown - if inputs != nil { - inputs.WideningEvents = append(inputs.WideningEvents, flow.WideningEvent{ - Symbol: sym, - SCCIndex: sccIdx, - SCC: sccSyms, - }) - } - } - } } // Default unconstrained parameters to any. @@ -842,6 +1038,11 @@ func collectInferredTypes( if annotated != nil && annotated[sym] { continue } + if inputs != nil && inputs.DeclaredTypes != nil { + if declared := inputs.DeclaredTypes[sym]; !typ.IsAbsentOrUnknown(declared) { + continue + } + } if t, ok := inferred[sym]; !ok || typ.IsAbsentOrUnknown(t) { inferred[sym] = typ.Any } @@ -850,6 +1051,32 @@ func collectInferredTypes( return inferred } +func snapshotSCCTypes(out []typ.Type, inferred api.SpecTypes, syms []cfg.SymbolID) { + for i, sym := range syms { + out[i] = inferred[sym] + } +} + +func sccTypesStable(prev []typ.Type, inferred api.SpecTypes, syms []cfg.SymbolID) bool { + for i, sym := range syms { + before := prev[i] + after := inferred[sym] + if before == after { + continue + } + if before == nil || after == nil { + return false + } + if before.Hash() != after.Hash() { + return false + } + if !typ.TypeEquals(before, after) { + return false + } + } + return true +} + func normalizedCallArgSymbols(info *cfg.CallInfo, bindings *bind.BindingTable) []cfg.SymbolID { if info == nil || len(info.Args) == 0 { return nil @@ -866,6 +1093,37 @@ func normalizedCallArgSymbols(info *cfg.CallInfo, bindings *bind.BindingTable) [ return out } +func normalizedCallReceiverSymbol(info *cfg.CallInfo, bindings *bind.BindingTable) cfg.SymbolID { + if info == nil || info.Method == "" { + return 0 + } + if info.ReceiverSymbol != 0 { + return info.ReceiverSymbol + } + if bindings == nil { + return 0 + } + return callsite.SymbolFromExpr(info.Receiver, bindings) +} + +func expectedReceiverTypeForMethod(ctx *db.QueryContext, typeOps core.TypeOps, info *cfg.CallInfo) typ.Type { + if info == nil || info.Method == "" { + return nil + } + if typeOps == nil { + return nil + } + methodType, ok := typeOps.Method(ctx, typ.String, info.Method) + if !ok || methodType == nil { + return nil + } + fn, ok := methodType.(*typ.Function) + if !ok || len(fn.Params) == 0 || !typ.TypeEquals(fn.Params[0].Type, typ.String) { + return nil + } + return typ.String +} + func callRefSymbols(info *cfg.CallInfo, bindings *bind.BindingTable) []cfg.SymbolID { if info == nil || bindings == nil { return nil @@ -889,6 +1147,24 @@ func callRefSymbols(info *cfg.CallInfo, bindings *bind.BindingTable) []cfg.Symbo return out } +func assignmentSourceSymbols(info *cfg.AssignInfo, bindings *bind.BindingTable) []cfg.SymbolID { + if info == nil || bindings == nil { + return nil + } + + var refs []cfg.SymbolID + info.EachSource(func(_ int, src ast.Expr) { + collectExprSymbols(src, bindings, &refs) + }) + for _, iter := range info.IterExprs { + collectExprSymbols(iter, bindings, &refs) + } + if len(refs) == 0 { + return nil + } + return dedupeSymbolIDs(refs) +} + func dedupeSymbolIDs(refs []cfg.SymbolID) []cfg.SymbolID { if len(refs) == 0 { return nil @@ -934,8 +1210,10 @@ func dedupeSymbolIDs(refs []cfg.SymbolID) []cfg.SymbolID { func synthWithInferenceOverlay( graph *cfg.Graph, - overlay map[cfg.SymbolID]typ.Type, + inferred map[cfg.SymbolID]typ.Type, + seedTypes map[cfg.SymbolID]typ.Type, funcSigTypes map[cfg.SymbolID]typ.Type, + valueDefs map[symbolVersionKey]struct{}, paramSet map[cfg.SymbolID]bool, annotated map[cfg.SymbolID]bool, bindings *bind.BindingTable, @@ -945,18 +1223,47 @@ func synthWithInferenceOverlay( preflow *flow.Solution, base func(ast.Expr, cfg.Point) typ.Type, ) func(ast.Expr, cfg.Point) typ.Type { - _ = graph - mergedOverlay := make(map[cfg.SymbolID]typ.Type, len(overlay)+len(funcSigTypes)) - for sym, t := range funcSigTypes { - if t != nil { - mergedOverlay[sym] = t + lookup := func(sym cfg.SymbolID, p cfg.Point) (typ.Type, bool) { + var seed typ.Type + var hasSeed bool + if t, ok := seedTypes[sym]; ok { + seed = t + hasSeed = true + if annotated != nil && annotated[sym] { + return t, true + } } - } - for sym, t := range overlay { - mergedOverlay[sym] = t + if _, ok := inferred[sym]; ok { + if t, visible := visibleInferredTypeAt(inferred, graph, valueDefs, paramSet, sym, p); visible { + if t == nil { + return nil, true + } + if inferredOverridesUnannotatedDeclared(t, seed) { + return t, true + } + } + } + if hasSeed { + return seed, true + } + if t, ok := funcSigTypes[sym]; ok { + if overlayTypeVisibleAt(graph, valueDefs, paramSet, sym, p) { + return t, true + } + } + return nil, false } - wrappedBase := func(expr ast.Expr, p cfg.Point) typ.Type { + return synthWithOverlayAndPreflow(lookup, bindings, inputs, callCtx, typeOps, preflow, wrappedBaseForInference(bindings, paramSet, annotated, base)) +} + +func wrappedBaseForInference( + bindings *bind.BindingTable, + paramSet map[cfg.SymbolID]bool, + annotated map[cfg.SymbolID]bool, + base func(ast.Expr, cfg.Point) typ.Type, +) func(ast.Expr, cfg.Point) typ.Type { + return func(expr ast.Expr, p cfg.Point) typ.Type { if ident, ok := expr.(*ast.IdentExpr); ok && bindings != nil { if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { if paramSet[sym] && (annotated == nil || !annotated[sym]) { @@ -969,8 +1276,6 @@ func synthWithInferenceOverlay( } return base(expr, p) } - - return synthWithOverlayAndPreflow(mergedOverlay, bindings, inputs, callCtx, typeOps, preflow, wrappedBase) } func assignmentOwningSourceCall(assigns []*cfg.AssignInfo, call *cfg.CallInfo) *cfg.AssignInfo { @@ -1087,13 +1392,366 @@ func joinInferredType(old, next typ.Type) typ.Type { if next == nil { return old } + if typ.IsAny(old) || typ.IsAny(next) { + return typ.Any + } if typeContains(next, old) { if !typ.IsAbsentOrUnknown(old) { return old } return subtype.WidenForInference(next) } - return typ.JoinPreferNonSoft(old, next) + return subtype.WidenForInference(flowjoin.Types(old, next)) +} + +func callExpectationCanRefineLocal(old typ.Type) bool { + return old == nil || + typ.IsUnknown(old) || + typ.IsSoft(old, typ.SoftAnnotationPolicy) +} + +func mergeCallExpectation(old, expected typ.Type, isParam bool) typ.Type { + if typ.IsAny(expected) { + return typ.Any + } + if typ.IsAny(old) { + return typ.Any + } + if isParam { + if expectedParamTypeDominates(old, expected) { + return expected + } + return joinInferredType(old, expected) + } + if callExpectationCanRefineLocal(old) { + return expected + } + return joinInferredType(old, expected) +} + +func expectedParamTypeDominates(old, expected typ.Type) bool { + if typ.IsAbsentOrUnknown(old) || typ.IsAbsentOrUnknown(expected) { + return false + } + if typ.IsAny(old) || typ.IsAny(expected) || expected.Kind().IsPlaceholder() { + return false + } + if subtype.IsSubtype(old, expected) { + return true + } + oldRec := recordForPathMerge(old) + expectedRec := recordForPathMerge(expected) + if oldRec == nil || expectedRec == nil { + return false + } + return recordEvidenceCompatibleWithExpected(oldRec, expectedRec) +} + +func recordEvidenceCompatibleWithExpected(old, expected *typ.Record) bool { + if old == nil || expected == nil { + return false + } + for _, field := range old.Fields { + expectedField := expected.GetField(field.Name) + if expectedField == nil { + if expected.Open { + continue + } + return false + } + if fieldEvidenceIsUnresolved(field.Type) { + continue + } + expectedType := expectedField.Type + if expectedField.Optional { + expectedType = typ.NewOptional(expectedType) + } + if !evidenceTypeCompatibleWithExpected(field.Type, expectedType) { + return false + } + } + if old.HasMapComponent() { + if !expected.HasMapComponent() { + return false + } + if !fieldEvidenceIsUnresolved(old.MapKey) && !evidenceTypeCompatibleWithExpected(old.MapKey, expected.MapKey) { + return false + } + if !fieldEvidenceIsUnresolved(old.MapValue) && !evidenceTypeCompatibleWithExpected(old.MapValue, expected.MapValue) { + return false + } + } + return true +} + +func evidenceTypeCompatibleWithExpected(evidence, expected typ.Type) bool { + if fieldEvidenceIsUnresolved(evidence) { + return true + } + if evidence == nil || expected == nil { + return false + } + if subtype.IsSubtype(evidence, expected) { + return true + } + switch e := typ.UnwrapAnnotated(evidence).(type) { + case *typ.Alias: + return evidenceTypeCompatibleWithExpected(e.Target, expected) + case *typ.Union: + for _, member := range e.Members { + if !evidenceTypeCompatibleWithExpected(member, expected) { + return false + } + } + return true + case *typ.Record: + if expectedMap := mapForEvidenceExpected(expected); expectedMap != nil { + return recordEvidenceCompatibleWithExpectedMap(e, expectedMap) + } + } + if opt, ok := typ.UnwrapAnnotated(expected).(*typ.Optional); ok { + return evidenceTypeCompatibleWithExpected(evidence, opt.Inner) + } + return false +} + +func mapForEvidenceExpected(t typ.Type) *typ.Map { + for { + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + t = v.Target + case *typ.Optional: + t = v.Inner + case *typ.Map: + return v + default: + return nil + } + } +} + +func recordEvidenceCompatibleWithExpectedMap(evidence *typ.Record, expected *typ.Map) bool { + if evidence == nil || expected == nil { + return false + } + for _, field := range evidence.Fields { + keyType := typ.LiteralString(field.Name) + if !evidenceTypeCompatibleWithExpected(keyType, expected.Key) { + return false + } + if !evidenceTypeCompatibleWithExpected(field.Type, expected.Value) { + return false + } + } + if evidence.HasMapComponent() { + if !evidenceTypeCompatibleWithExpected(evidence.MapKey, expected.Key) { + return false + } + if !evidenceTypeCompatibleWithExpected(evidence.MapValue, expected.Value) { + return false + } + } + return true +} + +func fieldEvidenceIsUnresolved(t typ.Type) bool { + if typ.IsAbsentOrUnknown(t) { + return true + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return fieldEvidenceIsUnresolved(v.Target) + case *typ.Record: + return len(v.Fields) == 0 && !v.HasMapComponent() + default: + return false + } +} + +func mergePathCallExpectation(old typ.Type, segments []constraint.Segment, expected typ.Type, isParam bool) typ.Type { + if len(segments) == 0 { + return mergeCallExpectation(old, expected, isParam) + } + if expected == nil || expected.Kind().IsPlaceholder() || typ.IsAbsentOrUnknown(expected) { + return old + } + return mergeExpectedAtPath(old, segments, expected, isParam) +} + +func mergeExpectedAtPath(base typ.Type, segments []constraint.Segment, expected typ.Type, isParam bool) typ.Type { + if len(segments) == 0 { + return mergeCallExpectation(base, expected, isParam) + } + seg := segments[0] + field, ok := segmentFieldName(seg) + if !ok { + return base + } + + rec := recordForPathMerge(base) + child := typ.Type(nil) + wasOptional := isParam + if rec != nil { + if existing := rec.GetField(field); existing != nil { + child = existing.Type + wasOptional = wasOptional || existing.Optional + } else if rec.HasMapComponent() && rec.MapValue != nil { + child = rec.MapValue + wasOptional = true + } + } + if child == nil { + child = typ.Unknown + } + mergedChild := mergeExpectedAtPath(child, segments[1:], expected, isParam) + if mergedChild == nil { + return base + } + return setRecordField(base, field, mergedChild, wasOptional) +} + +func widenArrayElementAtPath(base typ.Type, segments []constraint.Segment, element typ.Type) typ.Type { + if len(segments) == 0 { + return flow.WidenArrayElementType(base, element, typ.JoinPreferNonSoft) + } + seg := segments[0] + field, ok := segmentFieldName(seg) + if !ok { + return base + } + + rec := recordForPathMerge(base) + child := typ.Type(nil) + optional := false + if rec != nil { + if existing := rec.GetField(field); existing != nil { + child = existing.Type + optional = existing.Optional + } else if rec.HasMapComponent() && rec.MapValue != nil { + child = rec.MapValue + optional = true + } + } + updated := widenArrayElementAtPath(child, segments[1:], element) + if updated == nil { + return base + } + return setRecordField(base, field, updated, optional) +} + +func segmentFieldName(seg constraint.Segment) (string, bool) { + switch seg.Kind { + case constraint.SegmentField, constraint.SegmentIndexString: + return seg.Name, seg.Name != "" + default: + return "", false + } +} + +func recordForPathMerge(t typ.Type) *typ.Record { + for { + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + t = v.Target + case *typ.Optional: + t = v.Inner + case *typ.Record: + return v + default: + return nil + } + } +} + +func setRecordField(base typ.Type, field string, fieldType typ.Type, optional bool) typ.Type { + if field == "" || fieldType == nil { + return base + } + switch v := typ.UnwrapAnnotated(base).(type) { + case *typ.Alias: + updated := setRecordField(v.Target, field, fieldType, optional) + if updated == nil || typ.TypeEquals(updated, v.Target) { + return base + } + return typ.NewAlias(v.Name, updated) + case *typ.Union: + updated := make([]typ.Type, 0, len(v.Members)) + changed := false + for _, member := range v.Members { + if member == nil || typ.IsAny(member) || typ.TypeEquals(member, typ.Nil) { + updated = append(updated, member) + continue + } + next := setRecordField(member, field, fieldType, optional) + if next == nil { + next = member + } + if !typ.TypeEquals(member, next) { + changed = true + } + updated = append(updated, next) + } + if !changed { + return base + } + return typ.NewUnion(updated...) + case *typ.Optional: + updated := setRecordField(v.Inner, field, fieldType, optional) + if updated == nil || typ.TypeEquals(updated, v.Inner) { + return base + } + return typ.NewOptional(updated) + case *typ.Record: + return rebuildRecordWithField(v, field, fieldType, optional) + default: + builder := typ.NewRecord().SetOpen(true) + if optional { + builder.OptField(field, fieldType) + } else { + builder.Field(field, fieldType) + } + return builder.Build() + } +} + +func rebuildRecordWithField(rec *typ.Record, field string, fieldType typ.Type, optional bool) typ.Type { + builder := typ.NewRecord() + if rec.Open { + builder.SetOpen(true) + } + if rec.Metatable != nil { + builder.Metatable(rec.Metatable) + } + if rec.HasMapComponent() { + builder.MapComponent(rec.MapKey, rec.MapValue) + } + + added := false + for _, f := range rec.Fields { + if f.Name != field { + addRecordField(builder, f.Name, f.Type, f.Optional, f.Readonly) + continue + } + addRecordField(builder, f.Name, fieldType, optional || f.Optional, f.Readonly) + added = true + } + if !added { + addRecordField(builder, field, fieldType, optional, false) + } + return builder.Build() +} + +func addRecordField(builder *typ.RecordBuilder, name string, fieldType typ.Type, optional, readonly bool) { + switch { + case optional && readonly: + builder.OptReadonlyField(name, fieldType) + case optional: + builder.OptField(name, fieldType) + case readonly: + builder.ReadonlyField(name, fieldType) + default: + builder.Field(name, fieldType) + } } func typeContains(haystack, needle typ.Type) bool { diff --git a/compiler/check/flowbuild/assign/infer_test.go b/compiler/check/abstract/assign/infer_test.go similarity index 84% rename from compiler/check/flowbuild/assign/infer_test.go rename to compiler/check/abstract/assign/infer_test.go index 1248dcee..a8ebdf8f 100644 --- a/compiler/check/flowbuild/assign/infer_test.go +++ b/compiler/check/abstract/assign/infer_test.go @@ -6,8 +6,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" @@ -15,9 +15,8 @@ import ( "github.com/wippyai/go-lua/types/typ" ) -func TestCollectInferredTypes_NilGraph(t *testing.T) { - fc := &core.FlowContext{} - result := CollectInferredTypes(fc, nil, nil, nil) +func TestInferLocalTypes_NilGraph(t *testing.T) { + result := InferLocalTypes(LocalInferenceConfig{}) if result == nil { t.Error("expected non-nil result for nil graph") } @@ -26,34 +25,31 @@ func TestCollectInferredTypes_NilGraph(t *testing.T) { } } -func TestCollectInferredTypes_EmptySpecTypes(t *testing.T) { - fc := &core.FlowContext{} +func TestInferLocalTypes_EmptySpecTypes(t *testing.T) { specTypes := make(api.SpecTypes) - result := CollectInferredTypes(fc, specTypes, nil, nil) + result := InferLocalTypes(LocalInferenceConfig{SeedTypes: specTypes}) if result == nil { t.Error("expected non-nil result") } } -func TestCollectInferredTypes_WithAnnotated(t *testing.T) { - fc := &core.FlowContext{} +func TestInferLocalTypes_WithAnnotated(t *testing.T) { specTypes := make(api.SpecTypes) annotated := make(map[cfg.SymbolID]bool) annotated[1] = true - result := CollectInferredTypes(fc, specTypes, annotated, nil) + result := InferLocalTypes(LocalInferenceConfig{SeedTypes: specTypes, Annotated: annotated}) if result == nil { t.Error("expected non-nil result") } } -func TestCollectInferredTypes_WithInputs(t *testing.T) { - fc := &core.FlowContext{} +func TestInferLocalTypes_WithInputs(t *testing.T) { specTypes := make(api.SpecTypes) inputs := &flow.Inputs{ DeclaredTypes: make(map[cfg.SymbolID]typ.Type), AnnotatedVars: make(map[cfg.SymbolID]bool), } - result := CollectInferredTypes(fc, specTypes, nil, inputs) + result := InferLocalTypes(LocalInferenceConfig{SeedTypes: specTypes, Inputs: inputs}) if result == nil { t.Error("expected non-nil result") } @@ -217,6 +213,42 @@ func TestJoinInferredType_StopsRecursiveNestingGrowth(t *testing.T) { } } +func TestJoinInferredType_TreatsAnyAsTop(t *testing.T) { + suite := typ.NewRecord().Field("name", typ.String).Build() + + got := joinInferredType(suite, typ.Any) + if !typ.TypeEquals(got, typ.Any) { + t.Fatalf("joinInferredType(Suite, any) = %v, want any", got) + } + + got = mergeCallExpectation(typ.Any, suite, true) + if !typ.TypeEquals(got, typ.Any) { + t.Fatalf("mergeCallExpectation(any, Suite) = %v, want any", got) + } +} + +func TestMergeCallExpectation_ParamDominatesCompatibleBodyEvidence(t *testing.T) { + headerMap := typ.NewMap(typ.String, typ.String) + bodyHeaderEvidence := typ.NewRecord(). + SetOpen(true). + OptField("Accept", typ.String). + Build() + old := typ.NewRecord(). + SetOpen(true). + OptField("headers", typ.NewUnion(headerMap, bodyHeaderEvidence)). + OptField("stream", typ.Unknown). + Build() + expected := typ.NewRecord(). + Field("headers", headerMap). + OptField("stream", typ.Boolean). + Build() + + got := mergeCallExpectation(old, expected, true) + if !typ.TypeEquals(got, expected) { + t.Fatalf("mergeCallExpectation(body evidence, expected param) = %v, want %v", got, expected) + } +} + func TestTypeContains(t *testing.T) { base := typ.NewArray(typ.Unknown) outer := typ.NewArray(base) @@ -246,7 +278,7 @@ func TestMergeSpecTypesSoft_IgnoresUnknownAndNilOverrides(t *testing.T) { } } -func TestCollectInferredTypes_UsesModuleCalleeCandidatesForExpectedArgs(t *testing.T) { +func TestInferLocalTypes_UsesModuleCalleeCandidatesForExpectedArgs(t *testing.T) { body, err := parse.ParseString(`external_fn(x)`, "infer_module_candidates.lua") if err != nil { t.Fatalf("parse failed: %v", err) @@ -281,12 +313,12 @@ func TestCollectInferredTypes_UsesModuleCalleeCandidatesForExpectedArgs(t *testi const moduleCalleeSym cfg.SymbolID = 9001 moduleBindings.SetName(moduleCalleeSym, "external_fn") - inferred := collectInferredTypes( - graph, - nil, - func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown }, - nil, - func(_ cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { + evidence := trace.GraphEvidence(graph, graph.Bindings()) + inferred := InferLocalTypes(LocalInferenceConfig{ + Graph: graph, + Evidence: evidence, + Synth: func(ast.Expr, cfg.Point) typ.Type { return typ.Unknown }, + SymResolver: func(_ cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { if sym == globalCalleeSym { return nil, false } @@ -295,15 +327,10 @@ func TestCollectInferredTypes_UsesModuleCalleeCandidatesForExpectedArgs(t *testi } return nil, false }, - nil, - nil, - nil, - moduleBindings, - db.NewQueryContext(db.New()), - querycore.NewEngine(), - nil, - nil, - ) + ModuleBindings: moduleBindings, + CallCtx: db.NewQueryContext(db.New()), + TypeOps: querycore.NewEngine(), + }) got := inferred[xSym] if !typ.TypeEquals(got, typ.Number) { @@ -381,7 +408,9 @@ func TestSynthWithInferenceOverlay_PriorityAndParamFallback(t *testing.T) { synth := synthWithInferenceOverlay( nil, map[cfg.SymbolID]typ.Type{aSym: typ.String}, + nil, map[cfg.SymbolID]typ.Type{aSym: typ.Number}, + nil, paramSet, nil, bindings, @@ -396,9 +425,11 @@ func TestSynthWithInferenceOverlay_PriorityAndParamFallback(t *testing.T) { } synth = synthWithInferenceOverlay( + nil, nil, nil, map[cfg.SymbolID]typ.Type{aSym: typ.Number}, + nil, paramSet, nil, bindings, @@ -413,6 +444,8 @@ func TestSynthWithInferenceOverlay_PriorityAndParamFallback(t *testing.T) { } synth = synthWithInferenceOverlay( + nil, + nil, nil, nil, nil, @@ -430,6 +463,8 @@ func TestSynthWithInferenceOverlay_PriorityAndParamFallback(t *testing.T) { } synth = synthWithInferenceOverlay( + nil, + nil, nil, nil, nil, @@ -460,6 +495,8 @@ func TestSynthWithInferenceOverlay_PreservesNilOverlayEntries(t *testing.T) { nil, nil, nil, + nil, + nil, bindings, nil, nil, diff --git a/compiler/check/flowbuild/assign/keytype.go b/compiler/check/abstract/assign/keytype.go similarity index 100% rename from compiler/check/flowbuild/assign/keytype.go rename to compiler/check/abstract/assign/keytype.go diff --git a/compiler/check/abstract/assign/length_index.go b/compiler/check/abstract/assign/length_index.go new file mode 100644 index 00000000..b998368f --- /dev/null +++ b/compiler/check/abstract/assign/length_index.go @@ -0,0 +1,56 @@ +package assign + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/check/abstract/numconst" + fbpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/flow" +) + +func lengthIndexSourceFromAttr(attr *ast.AttrGetExpr, constResolver func(string) *flow.ConstValue, bindings *bind.BindingTable) (*flow.LengthIndexSource, bool) { + if attr == nil { + return nil, false + } + container := fbpath.FromExprWithBindings(attr.Object, constResolver, bindings) + if container.IsEmpty() || container.Symbol == 0 { + return nil, false + } + indexedPath, offset, ok := lengthIndexPathFromExpr(attr.Key, constResolver, bindings) + if !ok || !indexedPath.Equal(container) { + return nil, false + } + container.Root = resolve.RootNameFromBindings(bindings, container.Symbol, container.Root) + return &flow.LengthIndexSource{ + ContainerPath: container, + Offset: offset, + }, true +} + +func lengthIndexPathFromExpr(expr ast.Expr, constResolver func(string) *flow.ConstValue, bindings *bind.BindingTable) (constraint.Path, int64, bool) { + switch e := expr.(type) { + case *ast.UnaryLenOpExpr: + path := fbpath.FromExprWithBindings(e.Expr, constResolver, bindings) + return path, 0, !path.IsEmpty() + case *ast.ArithmeticOpExpr: + if e.Operator != "+" && e.Operator != "-" { + return constraint.Path{}, 0, false + } + path, offset, ok := lengthIndexPathFromExpr(e.Lhs, constResolver, bindings) + if !ok { + return constraint.Path{}, 0, false + } + k, ok := numconst.IntConstFromExpr(e.Rhs) + if !ok { + return constraint.Path{}, 0, false + } + if e.Operator == "-" { + k = -k + } + return path, offset + k, true + default: + return constraint.Path{}, 0, false + } +} diff --git a/compiler/check/flowbuild/assign/narrowing_test.go b/compiler/check/abstract/assign/narrowing_test.go similarity index 98% rename from compiler/check/flowbuild/assign/narrowing_test.go rename to compiler/check/abstract/assign/narrowing_test.go index 42ad24ee..92340727 100644 --- a/compiler/check/flowbuild/assign/narrowing_test.go +++ b/compiler/check/abstract/assign/narrowing_test.go @@ -5,7 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" + "github.com/wippyai/go-lua/compiler/check/abstract/assign" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" diff --git a/compiler/check/abstract/assign/precision.go b/compiler/check/abstract/assign/precision.go new file mode 100644 index 00000000..b81ef8c0 --- /dev/null +++ b/compiler/check/abstract/assign/precision.go @@ -0,0 +1,258 @@ +package assign + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" +) + +// preferPreciseDirectSourceType keeps assignment inference on the canonical +// expression-synthesis path. +// +// Direct synthesis is allowed to repair a slot only when it is strictly more +// informative than the expanded assignment value. This keeps tuple expansion as +// the primary source of truth for assignment slots while still allowing +// canonical single-expression synthesis to repair top-like degradation. +func preferPreciseDirectSourceType( + assignedType typ.Type, + source ast.Expr, + p cfg.Point, + sc *scope.State, + synth func(ast.Expr, cfg.Point) typ.Type, + singleTarget bool, +) typ.Type { + if source == nil || synth == nil { + return assignedType + } + switch source.(type) { + case *ast.Comma3Expr: + return assignedType + } + + precise := resolve.Ref(synth(source, p), sc) + if typ.IsAbsentOrUnknown(precise) { + return assignedType + } + if singleTarget { + if typ.IsAbsentOrUnknown(assignedType) || typ.IsAny(assignedType) { + return precise + } + if subtype.IsSubtype(precise, assignedType) && !subtype.IsSubtype(assignedType, precise) { + return precise + } + if preferNamedEquivalentDirectType(precise, assignedType) { + return precise + } + if sameExpressionHasMoreEvidence(precise, assignedType) { + return precise + } + return assignedType + } + if typ.IsAny(assignedType) && !typ.IsAny(precise) { + return precise + } + return assignedType +} + +func preferNamedEquivalentDirectType(precise, assignedType typ.Type) bool { + if !isNamedIdentityType(precise) || isNamedIdentityType(assignedType) { + return false + } + return subtype.IsSubtype(precise, assignedType) && subtype.IsSubtype(assignedType, precise) +} + +func isNamedIdentityType(t typ.Type) bool { + switch t.(type) { + case *typ.Alias, *typ.Ref: + return true + default: + return false + } +} + +func sameExpressionHasMoreEvidence(precise, assigned typ.Type) bool { + improved, ok := compareSameExpressionEvidence(precise, assigned, 0) + return ok && improved +} + +func mergeUnannotatedParamType(current, inferred typ.Type) typ.Type { + if typ.IsAbsentOrUnknown(inferred) || typ.IsAny(inferred) { + return current + } + if current == nil || current.Kind().IsPlaceholder() || typ.IsUnknown(current) { + return inferred + } + if inner, nilable := typ.SplitNilableFieldType(inferred); nilable { + if typ.TypeEquals(current, inner) || subtype.IsSubtype(current, inner) { + return current + } + } + if typ.IsAny(current) || subtype.IsSubtype(current, inferred) { + return current + } + return inferred +} + +func inferredOverridesUnannotatedDeclared(inferred, declared typ.Type) bool { + if typ.IsAbsentOrUnknown(inferred) { + return false + } + if declared == nil || typ.IsAbsentOrUnknown(declared) || declared.Kind().IsPlaceholder() || typ.IsSoft(declared, typ.SoftAnnotationPolicy) { + return true + } + if typ.IsAny(declared) { + return false + } + if subtype.IsSubtype(inferred, declared) && !subtype.IsSubtype(declared, inferred) { + return true + } + if sameExpressionHasMoreEvidence(inferred, declared) { + return true + } + return false +} + +func informativeLoopVarType(t typ.Type) bool { + return t != nil && !typ.IsAbsentOrUnknown(t) && !typ.IsAny(t) && !t.Kind().IsPlaceholder() +} + +func refineLoopVarTypeFromInference(iterType, inferred typ.Type) typ.Type { + if !informativeLoopVarType(inferred) { + return iterType + } + if typ.IsAbsentOrUnknown(iterType) || typ.IsAny(iterType) || iterType.Kind().IsPlaceholder() || typ.IsSoft(iterType, typ.SoftAnnotationPolicy) { + return inferred + } + if subtype.IsSubtype(inferred, iterType) && !subtype.IsSubtype(iterType, inferred) { + return inferred + } + if sameExpressionHasMoreEvidence(inferred, iterType) { + return inferred + } + return iterType +} + +func compareSameExpressionEvidence(precise, assigned typ.Type, depth int) (bool, bool) { + if depth > typ.DefaultRecursionDepth { + return false, false + } + if typ.TypeEquals(precise, assigned) { + return false, true + } + if typ.IsAbsentOrUnknown(assigned) { + return !typ.IsAbsentOrUnknown(precise), true + } + if typ.IsAbsentOrUnknown(precise) { + return false, false + } + + switch p := precise.(type) { + case *typ.Alias: + return compareSameExpressionEvidence(p.UnaliasedTarget(), assigned, depth+1) + case *typ.Ref: + if a, ok := assigned.(*typ.Alias); ok && a.Name == p.Name && p.Module == "" { + return false, true + } + } + switch a := assigned.(type) { + case *typ.Alias: + return compareSameExpressionEvidence(precise, a.UnaliasedTarget(), depth+1) + case *typ.Ref: + if p, ok := precise.(*typ.Alias); ok && p.Name == a.Name && a.Module == "" { + return false, true + } + } + + switch p := precise.(type) { + case *typ.Record: + a, ok := assigned.(*typ.Record) + if !ok { + return false, false + } + return compareRecordEvidence(p, a, depth+1) + case *typ.Optional: + a, ok := assigned.(*typ.Optional) + if !ok { + return false, false + } + return compareSameExpressionEvidence(p.Inner, a.Inner, depth+1) + case *typ.Tuple: + a, ok := assigned.(*typ.Tuple) + if !ok || len(p.Elements) != len(a.Elements) { + return false, false + } + improved := false + for i := range p.Elements { + fieldImproved, ok := compareSameExpressionEvidence(p.Elements[i], a.Elements[i], depth+1) + if !ok { + return false, false + } + improved = improved || fieldImproved + } + return improved, true + case *typ.Array: + a, ok := assigned.(*typ.Array) + if !ok { + return false, false + } + return compareSameExpressionEvidence(p.Element, a.Element, depth+1) + case *typ.Map: + a, ok := assigned.(*typ.Map) + if !ok { + return false, false + } + keyImproved, ok := compareSameExpressionEvidence(p.Key, a.Key, depth+1) + if !ok { + return false, false + } + valueImproved, ok := compareSameExpressionEvidence(p.Value, a.Value, depth+1) + if !ok { + return false, false + } + return keyImproved || valueImproved, true + default: + return false, false + } +} + +func compareRecordEvidence(precise, assigned *typ.Record, depth int) (bool, bool) { + if precise == nil || assigned == nil { + return false, false + } + if precise.Open != assigned.Open { + return false, false + } + if (precise.HasMapComponent()) != (assigned.HasMapComponent()) { + return false, false + } + improved := false + for _, assignedField := range assigned.Fields { + preciseField := precise.GetField(assignedField.Name) + if preciseField == nil { + return false, false + } + if preciseField.Optional != assignedField.Optional || preciseField.Readonly != assignedField.Readonly { + return false, false + } + fieldImproved, ok := compareSameExpressionEvidence(preciseField.Type, assignedField.Type, depth+1) + if !ok { + return false, false + } + improved = improved || fieldImproved + } + if assigned.HasMapComponent() { + keyImproved, ok := compareSameExpressionEvidence(precise.MapKey, assigned.MapKey, depth+1) + if !ok { + return false, false + } + valueImproved, ok := compareSameExpressionEvidence(precise.MapValue, assigned.MapValue, depth+1) + if !ok { + return false, false + } + improved = improved || keyImproved || valueImproved + } + return improved, true +} diff --git a/compiler/check/abstract/assign/precision_test.go b/compiler/check/abstract/assign/precision_test.go new file mode 100644 index 00000000..4b9e1943 --- /dev/null +++ b/compiler/check/abstract/assign/precision_test.go @@ -0,0 +1,94 @@ +package assign + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/types/typ" +) + +func TestPreferPreciseDirectSourceType_PrefersNamedEquivalentAliasForSingleTarget(t *testing.T) { + record := typ.NewRecord(). + Field("id", typ.String). + Field("count", typ.Integer). + Build() + alias := typ.NewAlias("Counter", record) + + got := preferPreciseDirectSourceType( + record, + &ast.IdentExpr{Value: "x"}, + 0, + nil, + func(ast.Expr, cfg.Point) typ.Type { return alias }, + true, + ) + if got != alias { + t.Fatalf("expected direct named alias to win, got %s", typ.FormatShort(got)) + } +} + +func TestPreferPreciseDirectSourceType_DoesNotReplaceNamedAssignedType(t *testing.T) { + record := typ.NewRecord(). + Field("id", typ.String). + Field("count", typ.Integer). + Build() + alias := typ.NewAlias("Counter", record) + + got := preferPreciseDirectSourceType( + alias, + &ast.IdentExpr{Value: "x"}, + 0, + nil, + func(ast.Expr, cfg.Point) typ.Type { return record }, + true, + ) + if got != alias { + t.Fatalf("expected existing named assigned type to remain, got %s", typ.FormatShort(got)) + } +} + +func TestPreferPreciseDirectSourceType_RefinesUnknownRecordFieldFromSameExpression(t *testing.T) { + assigned := typ.NewRecord(). + Field("headers", typ.NewMap(typ.String, typ.String)). + Field("timeout", typ.Unknown). + Build() + precise := typ.NewRecord(). + Field("headers", typ.NewMap(typ.String, typ.String)). + Field("timeout", typ.Number). + Build() + + got := preferPreciseDirectSourceType( + assigned, + &ast.TableExpr{}, + 0, + nil, + func(ast.Expr, cfg.Point) typ.Type { return precise }, + true, + ) + if !typ.TypeEquals(got, precise) { + t.Fatalf("expected same-expression concrete field evidence to win, got %s", typ.FormatShort(got)) + } +} + +func TestPreferPreciseDirectSourceType_DoesNotDropAssignedRecordEvidence(t *testing.T) { + assigned := typ.NewRecord(). + Field("headers", typ.NewMap(typ.String, typ.String)). + Field("timeout", typ.Unknown). + Build() + precise := typ.NewRecord(). + Field("timeout", typ.Number). + Build() + + got := preferPreciseDirectSourceType( + assigned, + &ast.TableExpr{}, + 0, + nil, + func(ast.Expr, cfg.Point) typ.Type { return precise }, + true, + ) + if !typ.TypeEquals(got, assigned) { + t.Fatalf("expected assigned evidence to remain when direct type drops fields, got %s", typ.FormatShort(got)) + } +} diff --git a/compiler/check/abstract/assign/preflow_synth.go b/compiler/check/abstract/assign/preflow_synth.go new file mode 100644 index 00000000..63c474e8 --- /dev/null +++ b/compiler/check/abstract/assign/preflow_synth.go @@ -0,0 +1,323 @@ +package assign + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + abstractcore "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + fbpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/synth/callarg" + "github.com/wippyai/go-lua/compiler/check/synth/ops" + "github.com/wippyai/go-lua/types/db" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/narrow" + "github.com/wippyai/go-lua/types/query/core" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +type typeOpsNarrowResolver struct { + ctx *db.QueryContext + ops core.TypeOps +} + +var _ narrow.Resolver = (*typeOpsNarrowResolver)(nil) + +func (r typeOpsNarrowResolver) Field(t typ.Type, name string) (typ.Type, bool) { + if r.ops == nil { + return nil, false + } + return r.ops.Field(r.ctx, t, name) +} + +func (r typeOpsNarrowResolver) Index(t typ.Type, key typ.Type) (typ.Type, bool) { + if r.ops == nil { + return nil, false + } + return r.ops.Index(r.ctx, t, key) +} + +type overlayTypeAt func(cfg.SymbolID, cfg.Point) (typ.Type, bool) + +func mapOverlayTypeAt(overlay map[cfg.SymbolID]typ.Type) overlayTypeAt { + return func(sym cfg.SymbolID, _ cfg.Point) (typ.Type, bool) { + t, ok := overlay[sym] + return t, ok + } +} + +// buildPreflowBranchSolution solves only branch/numeric edge facts that are +// already available before assignment extraction completes. +// +// This gives local inference access to canonical branch narrowing such as +// discriminant checks on parameters, without depending on later assignment- +// derived facts or full post-extraction solve. +func buildPreflowBranchSolution(fc *abstractcore.FlowContext, inputs *flow.Inputs) *flow.Solution { + if fc == nil || inputs == nil || inputs.Graph == nil || fc.TypeOps == nil { + return nil + } + + temp := *inputs + temp.EdgeConditions = nil + temp.EdgeNumericConstraints = nil + + cond.ExtractEdgeConstraints(fc, &temp) + cond.ExtractNumericConstraints(fc, &temp) + + return flow.Solve(&temp, typeOpsNarrowResolver{ctx: fc.CallCtx, ops: fc.TypeOps}) +} + +// synthWithOverlayAndPreflow wraps base synthesis with overlay lookup and a +// preflow branch-narrowing view for identifiers and attribute/index reads. +// +// This keeps assignment inference on the canonical synthesis path while letting +// recursive field/index expressions observe already-provable branch facts. +func synthWithOverlayAndPreflow( + overlay overlayTypeAt, + bindings *bind.BindingTable, + inputs *flow.Inputs, + callCtx *db.QueryContext, + typeOps core.TypeOps, + preflow *flow.Solution, + base func(ast.Expr, cfg.Point) typ.Type, +) func(ast.Expr, cfg.Point) typ.Type { + var synth func(ast.Expr, cfg.Point) typ.Type + + synth = func(expr ast.Expr, p cfg.Point) typ.Type { + if expr == nil { + return nil + } + + if ident, ok := expr.(*ast.IdentExpr); ok && bindings != nil { + if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { + if overlay != nil { + if t, exists := overlay(sym, p); exists { + return t + } + } + } + } + + if preflow != nil && bindings != nil && inputs != nil { + constResolver := predicate.BuildConstResolver(inputs, p) + if path := fbpath.FromExprWithBindings(expr, constResolver, bindings); !path.IsEmpty() { + if narrowed := preflow.NarrowedTypeAt(p, path); !typ.IsAbsentOrUnknown(narrowed) { + if attr, ok := expr.(*ast.AttrGetExpr); ok && typeOps != nil { + if objType := synth(attr.Object, p); !typ.IsAbsentOrUnknown(objType) { + if refined := refinePreflowLengthIndex(attr, objType, narrowed, p, bindings, inputs, preflow); refined != nil { + return refined + } + } + if declared := declaredAttrReadType(attr, p, synth, callCtx, typeOps); declared != nil { + refined, ok := refinePathFactWithDeclaredType(narrowed, declared, callCtx, typeOps) + if !ok { + goto skipPreflowPathFact + } + narrowed = refined + } + } + return narrowed + } + } + } + + skipPreflowPathFact: + + if attr, ok := expr.(*ast.AttrGetExpr); ok && typeOps != nil { + objType := synth(attr.Object, p) + if !typ.IsAbsentOrUnknown(objType) { + switch key := attr.Key.(type) { + case *ast.StringExpr: + if ft, ok := typeOps.Field(callCtx, objType, key.Value); ok && !typ.IsAbsentOrUnknown(ft) { + return ft + } + if it, ok := typeOps.Index(callCtx, objType, typ.LiteralString(key.Value)); ok && !typ.IsAbsentOrUnknown(it) { + return it + } + default: + keyType := synth(attr.Key, p) + if !typ.IsAbsentOrUnknown(keyType) { + if it, ok := typeOps.Index(callCtx, objType, keyType); ok && !typ.IsAbsentOrUnknown(it) { + if refined := refinePreflowLengthIndex(attr, objType, it, p, bindings, inputs, preflow); refined != nil { + return refined + } + return it + } + } + } + } + } + + if call, ok := expr.(*ast.FuncCallExpr); ok && typeOps != nil { + if result := evalOverlayCallFirstResult(call, p, synth, callCtx, typeOps); !typ.IsAbsentOrUnknown(result) { + return result + } + if base != nil { + if direct := base(expr, p); !typ.IsAbsentOrUnknown(direct) { + return direct + } + } + return typ.Unknown + } + + if logical, ok := expr.(*ast.LogicalOpExpr); ok { + left := synth(logical.Lhs, p) + right := synth(logical.Rhs, p) + var result typ.Type + switch logical.Operator { + case "and": + result = ops.LogicalAndTyped(left, right) + case "or": + result = ops.LogicalOrTyped(left, right) + default: + result = typ.Unknown + } + if (typ.IsAbsentOrUnknown(result) || typ.IsAny(result)) && base != nil { + if direct := base(expr, p); !typ.IsAbsentOrUnknown(direct) && !typ.IsAny(direct) { + return direct + } + } + return result + } + + if base == nil { + return nil + } + return base(expr, p) + } + + return synth +} + +func refinePreflowLengthIndex(attr *ast.AttrGetExpr, objType, indexResult typ.Type, p cfg.Point, bindings *bind.BindingTable, inputs *flow.Inputs, preflow *flow.Solution) typ.Type { + if attr == nil || bindings == nil || inputs == nil || preflow == nil { + return nil + } + constResolver := predicate.BuildConstResolver(inputs, p) + tablePath := fbpath.FromExprWithBindings(attr.Object, constResolver, bindings) + if tablePath.IsEmpty() { + return nil + } + lenPath, offset, ok := lengthIndexPathFromExpr(attr.Key, constResolver, bindings) + if !ok || !lenPath.Equal(tablePath) { + return nil + } + lower, _, ok := preflow.LengthBoundsAt(p, tablePath) + if !ok { + return nil + } + return narrow.RefineLengthIndex(objType, indexResult, lower, offset) +} + +// evalOverlayCallFirstResult evaluates a call expression inside assignment +// transfer using the shared call domain. It is a local value evaluator only: +// facts and diagnostics are still published by the canonical call/evidence +// consumers after the abstract state is solved. +func evalOverlayCallFirstResult( + call *ast.FuncCallExpr, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, + callCtx *db.QueryContext, + typeOps core.TypeOps, +) typ.Type { + if call == nil || synth == nil || typeOps == nil { + return nil + } + args := make([]typ.Type, len(call.Args)) + for i, arg := range call.Args { + args[i] = synth(arg, p) + } + def := ops.CallDef{ + Args: args, + Query: typeOps, + } + if call.Method != "" { + def.IsMethod = true + def.MethodName = call.Method + def.Receiver = synth(call.Receiver, p) + } else { + def.Callee = synth(call.Func, p) + } + result := ops.NewCallPipeline(callCtx, def, len(call.Args)). + WithReSynth(assignmentCallArgReSynth(call.Args, synth, p)). + Run() + if len(result.Returns) > 0 { + return result.Returns[0] + } + return ops.ExtractFirstValue(result.Type) +} + +func assignmentCallArgReSynth(args []ast.Expr, synth func(ast.Expr, cfg.Point) typ.Type, p cfg.Point) ops.ArgReSynth { + if synth == nil { + return nil + } + return callarg.ForArgs(args, callarg.Full( + func(arg ast.Expr, _ cfg.Point, _ typ.Type) typ.Type { + return synth(arg, p) + }, + nil, + p, + )) +} + +func declaredAttrReadType( + attr *ast.AttrGetExpr, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, + callCtx *db.QueryContext, + typeOps core.TypeOps, +) typ.Type { + if attr == nil || synth == nil || typeOps == nil { + return nil + } + objType := synth(attr.Object, p) + if typ.IsAbsentOrUnknown(objType) { + return nil + } + switch key := attr.Key.(type) { + case *ast.StringExpr: + if ft, ok := typeOps.Field(callCtx, objType, key.Value); ok { + return ft + } + if it, ok := typeOps.Index(callCtx, objType, typ.LiteralString(key.Value)); ok { + return it + } + default: + keyType := synth(attr.Key, p) + if !typ.IsAbsentOrUnknown(keyType) { + if it, ok := typeOps.Index(callCtx, objType, keyType); ok { + return it + } + } + } + return nil +} + +func refinePathFactWithDeclaredType(narrowed, declared typ.Type, callCtx *db.QueryContext, typeOps core.TypeOps) (typ.Type, bool) { + if narrowed == nil || declared == nil { + return narrowed, true + } + narrowed = unwrap.Alias(narrowed) + declared = unwrap.Alias(declared) + if narrowed == nil || declared == nil || declared.Kind().IsPlaceholder() { + return narrowed, true + } + if typeOps == nil { + return nil, false + } + if typeOps.IsSubtype(callCtx, narrowed, declared) { + return narrowed, true + } + declaredNonNil := narrow.RemoveNil(declared) + if !typ.IsNever(declaredNonNil) { + if typeOps.IsSubtype(callCtx, declaredNonNil, narrowed) { + return declaredNonNil, true + } + if unwrap.Function(declaredNonNil) != nil && unwrap.Function(narrowed) != nil { + return declaredNonNil, true + } + } + return nil, false +} diff --git a/compiler/check/flowbuild/assign/spec.go b/compiler/check/abstract/assign/spec.go similarity index 89% rename from compiler/check/flowbuild/assign/spec.go rename to compiler/check/abstract/assign/spec.go index c86d9c24..418c6314 100644 --- a/compiler/check/flowbuild/assign/spec.go +++ b/compiler/check/abstract/assign/spec.go @@ -32,6 +32,7 @@ import ( // Determinism: Guaranteed by sorted point processing and stable queue order. func CollectSpecNarrowedTypes( graph *cfg.Graph, + assignments []api.AssignmentEvidence, scopes map[cfg.Point]*scope.State, synth func(ast.Expr, cfg.Point) typ.Type, symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), @@ -48,15 +49,26 @@ func CollectSpecNarrowedTypes( } // Build dependency map: symbol -> points where it's used as method receiver - deps := BuildReceiverDependencies(graph) + deps := BuildReceiverDependencies(assignments) - // Collect and sort assign points for deterministic iteration - points := graph.AssignPoints() + assigns := make([]api.AssignmentEvidence, 0, len(assignments)) + assignByPoint := make(map[cfg.Point]*cfg.AssignInfo, len(assignments)) + for _, assign := range assignments { + if assign.Info == nil { + continue + } + assigns = append(assigns, assign) + assignByPoint[assign.Point] = assign.Info + } + slices.SortFunc(assigns, func(a, b api.AssignmentEvidence) int { + return int(a.Point) - int(b.Point) + }) // Seed phase: collect spec-narrowed types AND inferred types from method calls var worklist []cfg.Point - for _, p := range points { - info := graph.Assign(p) + for _, assign := range assigns { + p := assign.Point + info := assign.Info if info == nil { continue } @@ -108,7 +120,7 @@ func CollectSpecNarrowedTypes( p := worklist[0] worklist = worklist[1:] - info := graph.Assign(p) + info := assignByPoint[p] if info == nil { continue } @@ -156,9 +168,14 @@ func CollectSpecNarrowedTypes( } // BuildReceiverDependencies builds a map from symbol to points where it's used as method receiver. -func BuildReceiverDependencies(graph *cfg.Graph) map[cfg.SymbolID][]cfg.Point { +func BuildReceiverDependencies(assignments []api.AssignmentEvidence) map[cfg.SymbolID][]cfg.Point { deps := make(map[cfg.SymbolID][]cfg.Point) - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } seen := make(map[cfg.SymbolID]bool) info.EachSourceCall(func(_ int, call *cfg.CallInfo) { if call == nil { @@ -173,7 +190,7 @@ func BuildReceiverDependencies(graph *cfg.Graph) map[cfg.SymbolID][]cfg.Point { seen[call.ReceiverSymbol] = true deps[call.ReceiverSymbol] = append(deps[call.ReceiverSymbol], p) }) - }) + } return deps } diff --git a/compiler/check/flowbuild/assign/spec_test.go b/compiler/check/abstract/assign/spec_test.go similarity index 94% rename from compiler/check/flowbuild/assign/spec_test.go rename to compiler/check/abstract/assign/spec_test.go index 298b8c6d..6271aa62 100644 --- a/compiler/check/flowbuild/assign/spec_test.go +++ b/compiler/check/abstract/assign/spec_test.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/parse" @@ -14,13 +15,8 @@ import ( "github.com/wippyai/go-lua/types/typ" ) -func buildEmptyGraph() *cfg.Graph { - fn := &ast.FunctionExpr{Stmts: []ast.Stmt{}} - return cfg.Build(fn) -} - func TestCollectSpecNarrowedTypes_NilGraph(t *testing.T) { - result := CollectSpecNarrowedTypes(nil, nil, nil, nil, nil, nil) + result := CollectSpecNarrowedTypes(nil, nil, nil, nil, nil, nil, nil) if result == nil { t.Error("expected non-nil result for nil graph") } @@ -33,7 +29,8 @@ func TestCollectSpecNarrowedTypes_EmptyGraph(t *testing.T) { fn := &ast.FunctionExpr{Stmts: []ast.Stmt{}} graph := cfg.Build(fn) scopes := make(map[cfg.Point]*scope.State) - result := CollectSpecNarrowedTypes(graph, scopes, nil, nil, nil, nil) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := CollectSpecNarrowedTypes(graph, evidence.Assignments, scopes, nil, nil, nil, nil) if result == nil { t.Error("expected non-nil result") } @@ -52,7 +49,8 @@ func TestBuildReceiverDependencies_NilGraph(t *testing.T) { func TestBuildReceiverDependencies_EmptyGraph(t *testing.T) { fn := &ast.FunctionExpr{Stmts: []ast.Stmt{}} graph := cfg.Build(fn) - result := BuildReceiverDependencies(graph) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := BuildReceiverDependencies(evidence.Assignments) if result == nil { t.Error("expected non-nil result") } @@ -280,7 +278,8 @@ func TestCollectSpecNarrowedTypes_MultiReturnTrailingTarget(t *testing.T) { msgType: msgType, } - result := CollectSpecNarrowedTypes(graph, map[cfg.Point]*scope.State{}, synth, nil, synthAPI, nil) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := CollectSpecNarrowedTypes(graph, evidence.Assignments, map[cfg.Point]*scope.State{}, synth, nil, synthAPI, nil) if !typ.TypeEquals(result[symObj], objType) { t.Fatalf("expected obj type to be inferred, got %v", result[symObj]) } @@ -350,7 +349,8 @@ func TestCollectSpecNarrowedTypes_ReprocessesPointWhenNewReceiverBecomesKnown(t typeB: typeB, } - result := CollectSpecNarrowedTypes(graph, map[cfg.Point]*scope.State{}, synth, nil, synthAPI, nil) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := CollectSpecNarrowedTypes(graph, evidence.Assignments, map[cfg.Point]*scope.State{}, synth, nil, synthAPI, nil) if !typ.TypeEquals(result[symRoot], typeRoot) { t.Fatalf("expected root type to be inferred, got %v", result[symRoot]) } diff --git a/compiler/check/flowbuild/assign/spec_types.go b/compiler/check/abstract/assign/spec_types.go similarity index 100% rename from compiler/check/flowbuild/assign/spec_types.go rename to compiler/check/abstract/assign/spec_types.go diff --git a/compiler/check/flowbuild/assign/structured_overlay.go b/compiler/check/abstract/assign/structured_overlay.go similarity index 90% rename from compiler/check/flowbuild/assign/structured_overlay.go rename to compiler/check/abstract/assign/structured_overlay.go index 7c94be02..d4f28256 100644 --- a/compiler/check/flowbuild/assign/structured_overlay.go +++ b/compiler/check/abstract/assign/structured_overlay.go @@ -18,47 +18,57 @@ type structuredWrite struct { } // indexStructuredWrites collects static field/index writes keyed by base symbol. -func indexStructuredWrites(graph *cfg.Graph) map[cfg.SymbolID][]structuredWrite { - result := make(map[cfg.SymbolID][]structuredWrite) +func indexStructuredWrites(graph *cfg.Graph, assignments []api.AssignmentEvidence) map[cfg.SymbolID][]structuredWrite { if graph == nil { - return result + return nil } - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + var result map[cfg.SymbolID][]structuredWrite + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info == nil { - return + continue } for i, target := range info.Targets { write, sym, ok := structuredWriteForTarget(graph, p, info.SourceAt(i), target) if !ok { continue } + if result == nil { + result = make(map[cfg.SymbolID][]structuredWrite) + } result[sym] = append(result[sym], write) } - }) + } return result } -// enrichStructuredOverlayAtPoint applies dominating visible field writes for the -// current symbol version into a point-specific identifier overlay. +// enrichStructuredOverlayAtPoint composes dominating visible field writes for +// the symbols read by the current expression into a point-specific overlay. func enrichStructuredOverlayAtPoint( graph *cfg.Graph, idom map[cfg.Point]cfg.Point, writes map[cfg.SymbolID][]structuredWrite, p cfg.Point, overlay api.SpecTypes, + symbols []cfg.SymbolID, resolveSym func(cfg.Point, cfg.SymbolID) (typ.Type, bool), synth func(ast.Expr, cfg.Point) typ.Type, ) api.SpecTypes { - if graph == nil || len(writes) == 0 { + if graph == nil || len(writes) == 0 || len(symbols) == 0 { return overlay } out := overlay copied := false - for sym, symWrites := range writes { - if sym == 0 || len(symWrites) == 0 { + for _, sym := range dedupeSymbolIDs(symbols) { + if sym == 0 { + continue + } + symWrites := writes[sym] + if len(symWrites) == 0 { continue } diff --git a/compiler/check/flowbuild/assign/table.go b/compiler/check/abstract/assign/table.go similarity index 92% rename from compiler/check/flowbuild/assign/table.go rename to compiler/check/abstract/assign/table.go index be73e88b..55348974 100644 --- a/compiler/check/flowbuild/assign/table.go +++ b/compiler/check/abstract/assign/table.go @@ -4,8 +4,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -60,7 +60,7 @@ func EmitTableLiteralFieldAssignments( continue } - // Synthesize field value type as fallback + // Synthesize field value type from the expression. var fieldType typ.Type if synth != nil { fieldType = synth(field.Value, p) diff --git a/compiler/check/flowbuild/assign/table_test.go b/compiler/check/abstract/assign/table_test.go similarity index 100% rename from compiler/check/flowbuild/assign/table_test.go rename to compiler/check/abstract/assign/table_test.go diff --git a/compiler/check/flowbuild/assign/types.go b/compiler/check/abstract/assign/types.go similarity index 100% rename from compiler/check/flowbuild/assign/types.go rename to compiler/check/abstract/assign/types.go diff --git a/compiler/check/flowbuild/assign/values.go b/compiler/check/abstract/assign/values.go similarity index 100% rename from compiler/check/flowbuild/assign/values.go rename to compiler/check/abstract/assign/values.go diff --git a/compiler/check/flowbuild/assign/values_test.go b/compiler/check/abstract/assign/values_test.go similarity index 100% rename from compiler/check/flowbuild/assign/values_test.go rename to compiler/check/abstract/assign/values_test.go diff --git a/compiler/check/abstract/assign/visibility.go b/compiler/check/abstract/assign/visibility.go new file mode 100644 index 00000000..a59ee5dc --- /dev/null +++ b/compiler/check/abstract/assign/visibility.go @@ -0,0 +1,162 @@ +package assign + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/typ" +) + +type symbolVersionKey struct { + sym cfg.SymbolID + id int +} + +func paramSymbolSet(graph *cfg.Graph) map[cfg.SymbolID]bool { + if graph == nil { + return nil + } + params := graph.ParamSymbols() + if len(params) == 0 { + return nil + } + out := make(map[cfg.SymbolID]bool, len(params)) + for _, sym := range params { + if sym != 0 { + out[sym] = true + } + } + return out +} + +func collectValueDefinitionVersions( + graph *cfg.Graph, + assignments []api.AssignmentEvidence, + functions []api.FunctionDefinitionEvidence, +) map[symbolVersionKey]struct{} { + if graph == nil { + return nil + } + out := make(map[symbolVersionKey]struct{}) + for _, assign := range assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { + if source == nil && info.NumericFor == nil && len(info.IterExprs) == 0 { + return + } + if target.Kind != cfg.TargetIdent || target.Symbol == 0 { + return + } + if ver := graph.VisibleVersion(p, target.Symbol); ver.Symbol != 0 && ver.ID != 0 { + out[symbolVersionKey{sym: target.Symbol, id: ver.ID}] = struct{}{} + } + }) + } + for _, def := range functions { + p := def.Nested.Point + info := def.FuncDef + if info == nil || info.Symbol == 0 || info.FuncExpr == nil { + continue + } + if ver := graph.VisibleVersion(p, info.Symbol); ver.Symbol != 0 && ver.ID != 0 { + out[symbolVersionKey{sym: info.Symbol, id: ver.ID}] = struct{}{} + } + } + if len(out) == 0 { + return nil + } + return out +} + +func overlayTypeVisibleAt( + graph *cfg.Graph, + valueDefs map[symbolVersionKey]struct{}, + paramSet map[cfg.SymbolID]bool, + sym cfg.SymbolID, + p cfg.Point, +) bool { + if sym == 0 { + return false + } + if graph == nil { + return true + } + if paramSet[sym] { + return true + } + ver := graph.VisibleVersion(p, sym) + if ver.Symbol == 0 || ver.ID == 0 { + return false + } + _, ok := valueDefs[symbolVersionKey{sym: sym, id: ver.ID}] + return ok +} + +func visibleInferredTypeAt( + inferred api.SpecTypes, + graph *cfg.Graph, + valueDefs map[symbolVersionKey]struct{}, + paramSet map[cfg.SymbolID]bool, + sym cfg.SymbolID, + p cfg.Point, +) (typ.Type, bool) { + t, ok := inferred[sym] + if !ok { + return nil, false + } + if !overlayTypeVisibleAt(graph, valueDefs, paramSet, sym, p) { + return nil, false + } + return t, true +} + +func mergeVisibleInferredTypes( + out api.SpecTypes, + inferred api.SpecTypes, + graph *cfg.Graph, + valueDefs map[symbolVersionKey]struct{}, + paramSet map[cfg.SymbolID]bool, + p cfg.Point, +) api.SpecTypes { + if len(inferred) == 0 { + return out + } + for sym, t := range inferred { + if !overlayTypeVisibleAt(graph, valueDefs, paramSet, sym, p) { + continue + } + if out == nil { + out = make(api.SpecTypes, len(inferred)) + } + out[sym] = t + } + return out +} + +func inferenceOverlayAtPoint( + graph *cfg.Graph, + p cfg.Point, + inferred api.SpecTypes, + seedTypes api.SpecTypes, + funcSigTypes map[cfg.SymbolID]typ.Type, + valueDefs map[symbolVersionKey]struct{}, + paramSet map[cfg.SymbolID]bool, +) api.SpecTypes { + var out api.SpecTypes + out = mergeSpecTypesInto(out, seedTypes) + for sym, t := range funcSigTypes { + if !overlayTypeVisibleAt(graph, valueDefs, paramSet, sym, p) { + continue + } + if out == nil { + out = make(api.SpecTypes, len(funcSigTypes)) + } + out[sym] = t + } + out = mergeVisibleInferredTypes(out, inferred, graph, valueDefs, paramSet, p) + return out +} diff --git a/compiler/check/abstract/calls_test.go b/compiler/check/abstract/calls_test.go new file mode 100644 index 00000000..7279bbce --- /dev/null +++ b/compiler/check/abstract/calls_test.go @@ -0,0 +1,68 @@ +package abstract_test + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/parse" +) + +func TestExtractExpressionEvidence_IncludesNestedCalls(t *testing.T) { + chunk, err := parse.ParseString(` +local x = outer(inner(a), other()) +if guard(check(x)) then + print(x) +end +return wrap(done()) +`, "calls.lua") + if err != nil { + t.Fatalf("parse error: %v", err) + } + + graph := cfg.Build(&ast.FunctionExpr{ + ParList: &ast.ParList{HasVargs: true}, + Stmts: chunk, + }, "outer", "inner", "other", "guard", "check", "print", "wrap", "done", "a") + + evidence := trace.ExpressionEvidence(graph, graph.Bindings()).Calls + got := make(map[string]int) + for _, ev := range evidence { + if ev.Info != nil { + got[ev.Info.CalleeName]++ + } + } + for _, name := range []string{"outer", "inner", "other", "guard", "check", "print", "wrap", "done"} { + if got[name] != 1 { + t.Fatalf("call evidence for %q = %d, want 1; all=%v", name, got[name], got) + } + } +} + +func TestExtractExpressionEvidence_FieldDefaults(t *testing.T) { + chunk, err := parse.ParseString(` +local value = opts.name or "default" +`, "defaults.lua") + if err != nil { + t.Fatalf("parse error: %v", err) + } + + graph := cfg.Build(&ast.FunctionExpr{ + ParList: &ast.ParList{HasVargs: true}, + Stmts: chunk, + }, "opts") + + sym, ok := graph.SymbolAt(graph.Exit(), "opts") + if !ok || sym == 0 { + t.Fatal("expected opts symbol") + } + evidence := trace.ExpressionEvidence(graph, graph.Bindings()) + if len(evidence.FieldDefaults) != 1 { + t.Fatalf("field defaults = %#v, want one", evidence.FieldDefaults) + } + got := evidence.FieldDefaults[0] + if got.Target != sym || got.Field != "name" { + t.Fatalf("field default target = (%d,%q), want (%d,name)", got.Target, got.Field, sym) + } +} diff --git a/compiler/check/flowbuild/cond/condition.go b/compiler/check/abstract/cond/condition.go similarity index 63% rename from compiler/check/flowbuild/cond/condition.go rename to compiler/check/abstract/cond/condition.go index bf353130..04ad4de4 100644 --- a/compiler/check/flowbuild/cond/condition.go +++ b/compiler/check/abstract/cond/condition.go @@ -43,13 +43,14 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/literal" + "github.com/wippyai/go-lua/compiler/check/abstract/numconst" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/sibling" + "github.com/wippyai/go-lua/compiler/check/api" checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/literal" - "github.com/wippyai/go-lua/compiler/check/flowbuild/numconst" - flowpath "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/sibling" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/effect" @@ -68,6 +69,13 @@ type BranchConditions struct { OnFalse constraint.Condition // Constraints when condition is falsy } +// NumericBranchConstraints is the numeric product-domain evidence emitted by a +// boolean expression for each outgoing branch. +type NumericBranchConstraints struct { + OnTrue []constraint.NumericConstraint + OnFalse []constraint.NumericConstraint +} + // ConditionExtractor holds shared context for recursive condition extraction. // It processes AST condition expressions and emits type constraints for both // true and false control flow edges. @@ -87,6 +95,8 @@ type ConditionExtractor struct { TypeKeyRes func(string, *scope.State) (narrow.TypeKey, bool) // Type name resolution ConstResolver func(string) *flow.ConstValue // Constant value lookup RefinementBySym constraint.RefinementLookupBySym // Function refinement lookup + ModuleBindings *bind.BindingTable // Module-level bindings used as secondary callee identity source + Evidence api.FlowEvidence // Canonical graph event trace } // constraintsFromBranch extracts type constraints from branch info. @@ -174,6 +184,14 @@ func (ce *ConditionExtractor) graph() interface { return ce.Inputs.Graph } +func (ce *ConditionExtractor) cfgGraph() *cfg.Graph { + if ce.Inputs == nil { + return nil + } + graph, _ := ce.Inputs.Graph.(*cfg.Graph) + return graph +} + // pathFromExpr extracts a path using bindings from inputs. func (ce *ConditionExtractor) pathFromExpr(expr ast.Expr) constraint.Path { return flowpath.FromExprWithBindingsAt(expr, ce.ConstResolver, ce.bindings(), ce.graph(), ce.P) @@ -181,172 +199,76 @@ func (ce *ConditionExtractor) pathFromExpr(expr ast.Expr) constraint.Path { // constraintsFromConditionExpr extracts predicate conditions from a full condition expression. func (ce *ConditionExtractor) ConstraintsFromConditionExpr(expr ast.Expr) BranchConditions { - // Special-case nil comparisons for error-return and predicate-link patterns. - if rel, ok := expr.(*ast.RelationalOpExpr); ok && (rel.Operator == "==" || rel.Operator == "~=") { - var ident *ast.IdentExpr - if literal.IsNilExpr(rel.Lhs) { - ident, _ = rel.Rhs.(*ast.IdentExpr) - } else if literal.IsNilExpr(rel.Rhs) { - ident, _ = rel.Lhs.(*ast.IdentExpr) - } - if ident != nil { - path := ce.pathFromExpr(ident) - if !path.IsEmpty() { - sibNil := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, true), ce.graph(), ce.P) - sibNotNil := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, false), ce.graph(), ce.P) - link := predicate.LookupPredicateLink(ident.Value, ce.Inputs) - hasLink := link != nil && (link.OnTruthy.HasConstraints() || link.OnFalsy.HasConstraints()) - if hasLink || len(sibNil) > 0 || len(sibNotNil) > 0 { - var onTrue, onFalse constraint.Condition - if rel.Operator == "==" { - onTrue = constraint.FromConstraints(append([]constraint.Constraint{constraint.IsNil{Path: path}}, sibNotNil...)...) - onFalse = constraint.FromConstraints(append([]constraint.Constraint{constraint.NotNil{Path: path}}, sibNil...)...) - if hasLink { - if link.OnFalsy.HasConstraints() { - onTrue = constraint.And(onTrue, link.OnFalsy) - } - if link.OnTruthy.HasConstraints() { - onFalse = constraint.And(onFalse, link.OnTruthy) - } - } - } else { - onTrue = constraint.FromConstraints(append([]constraint.Constraint{constraint.NotNil{Path: path}}, sibNil...)...) - onFalse = constraint.FromConstraints(append([]constraint.Constraint{constraint.IsNil{Path: path}}, sibNotNil...)...) - if hasLink { - if link.OnTruthy.HasConstraints() { - onTrue = constraint.And(onTrue, link.OnTruthy) - } - if link.OnFalsy.HasConstraints() { - onFalse = constraint.And(onFalse, link.OnFalsy) - } - } - } - return BranchConditions{OnTrue: onTrue, OnFalse: onFalse} - } - } - } - } + return ce.branchConditionsFromExpr(expr) +} - // Special-case error-return patterns: if err then ... / if not err then ... - if ident, ok := expr.(*ast.IdentExpr); ok { - if sibTrue := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, true), ce.graph(), ce.P); len(sibTrue) > 0 { - path := ce.pathFromExpr(ident) - if !path.IsEmpty() { - onTrue := constraint.FromConstraints(append([]constraint.Constraint{constraint.Truthy{Path: path}}, sibTrue...)...) - sibFalse := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, false), ce.graph(), ce.P) - onFalse := constraint.FromConstraints(append([]constraint.Constraint{constraint.Falsy{Path: path}}, sibFalse...)...) - return BranchConditions{OnTrue: onTrue, OnFalse: onFalse} - } - } - } - if notExpr, ok := expr.(*ast.UnaryNotOpExpr); ok { - if ident, ok := notExpr.Expr.(*ast.IdentExpr); ok { - if sibTrue := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, true), ce.graph(), ce.P); len(sibTrue) > 0 { - path := ce.pathFromExpr(ident) - if !path.IsEmpty() { - sibFalse := versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, false), ce.graph(), ce.P) - onTrue := constraint.FromConstraints(append([]constraint.Constraint{constraint.Falsy{Path: path}}, sibFalse...)...) - onFalse := constraint.FromConstraints(append([]constraint.Constraint{constraint.Truthy{Path: path}}, sibTrue...)...) - return BranchConditions{OnTrue: onTrue, OnFalse: onFalse} - } - } - } - } +// conditionFromExpr extracts predicate conditions from an expression (true branch). +func (ce *ConditionExtractor) ConditionFromExpr(expr ast.Expr) constraint.Condition { + return ce.branchConditionsFromExpr(expr).OnTrue +} - if isConstTrueExpr(expr) { +func (ce *ConditionExtractor) branchConditionsFromExpr(expr ast.Expr) BranchConditions { + switch e := expr.(type) { + case *ast.TrueExpr: return BranchConditions{ OnTrue: constraint.TrueCondition(), OnFalse: constraint.FalseCondition(), } - } - if isConstFalseExpr(expr) { + case *ast.FalseExpr: return BranchConditions{ OnTrue: constraint.FalseCondition(), OnFalse: constraint.TrueCondition(), } - } - - onTrue := ce.ConditionFromExpr(expr) - if onTrue.IsFalse() { - return BranchConditions{ - OnTrue: onTrue, - OnFalse: constraint.TrueCondition(), - } - } - if !onTrue.HasConstraints() { - return BranchConditions{ - OnTrue: constraint.TrueCondition(), - OnFalse: constraint.TrueCondition(), - } - } - // Call-expression constraints are one-sided implications inferred from the - // callee body/signature (for example local table predicates). They are sound - // for truthy branches only; negating them is not generally representable. - if _, ok := expr.(*ast.FuncCallExpr); ok { - return BranchConditions{ - OnTrue: onTrue, - OnFalse: constraint.TrueCondition(), - } - } - return BranchConditions{ - OnTrue: onTrue, - OnFalse: constraint.Not(onTrue), - } -} - -// conditionFromExpr extracts predicate conditions from an expression (true branch). -func (ce *ConditionExtractor) ConditionFromExpr(expr ast.Expr) constraint.Condition { - switch e := expr.(type) { - case *ast.TrueExpr: - return constraint.TrueCondition() - case *ast.FalseExpr: - return constraint.FalseCondition() case *ast.UnaryNotOpExpr: - if isConstTrueExpr(e.Expr) { - return constraint.FalseCondition() - } - if isConstFalseExpr(e.Expr) { - return constraint.TrueCondition() - } - inner := ce.ConditionFromExpr(e.Expr) - if inner.HasConstraints() || inner.IsFalse() { - return constraint.Not(inner) + inner := ce.branchConditionsFromExpr(e.Expr) + return BranchConditions{ + OnTrue: inner.OnFalse, + OnFalse: inner.OnTrue, } - return constraint.TrueCondition() case *ast.LogicalOpExpr: - return ce.conditionFromLogicalExpr(e) + return ce.branchConditionsFromLogicalExpr(e) case *ast.RelationalOpExpr: - return ce.conditionFromRelationalExpr(e) + return ce.branchConditionsFromRelationalExpr(e) case *ast.FuncCallExpr: - return constraint.FromConstraints(ce.constraintsFromCallExpr(e)...) + if branches, ok := ce.branchConditionsFromPredicateCall(e); ok { + return branches + } + return BranchConditions{ + OnTrue: conditionFromOptionalConstraints(ce.constraintsFromCallExpr(e)), + OnFalse: constraint.TrueCondition(), + } case *ast.IdentExpr: if e.Value == "true" { - return constraint.TrueCondition() + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.FalseCondition(), + } } if e.Value == "false" { - return constraint.FalseCondition() - } - if link := predicate.LookupPredicateLink(e.Value, ce.Inputs); link != nil && link.OnTruthy.HasConstraints() { - path := ce.pathFromExpr(e) - if path.IsEmpty() { - return link.OnTruthy + return BranchConditions{ + OnTrue: constraint.FalseCondition(), + OnFalse: constraint.TrueCondition(), } - return constraint.And(link.OnTruthy, constraint.FromConstraints(constraint.Truthy{Path: path})) } - path := ce.pathFromExpr(e) - if path.IsEmpty() { - return constraint.TrueCondition() - } - return constraint.FromConstraints(constraint.Truthy{Path: path}) + return ce.branchConditionsFromIdent(e) case *ast.AttrGetExpr: path := ce.pathFromExpr(e) if path.IsEmpty() { - return constraint.TrueCondition() + if keyOf := ce.keyOfConstraintsFromDynamicIndex(e); len(keyOf) > 0 { + return BranchConditions{ + OnTrue: constraint.FromConstraints(keyOf...), + OnFalse: constraint.TrueCondition(), + } + } + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } } result := []constraint.Constraint{constraint.Truthy{Path: path}} basePath := ce.pathFromExpr(e.Object) @@ -359,59 +281,256 @@ func (ce *ConditionExtractor) ConditionFromExpr(expr ast.Expr) constraint.Condit }) } } - return constraint.FromConstraints(result...) + return BranchConditions{ + OnTrue: constraint.FromConstraints(result...), + OnFalse: constraint.FromConstraints(constraint.Falsy{Path: path}), + } } - return constraint.TrueCondition() -} - -func isConstTrueExpr(expr ast.Expr) bool { - switch e := expr.(type) { - case *ast.TrueExpr: - return true - case *ast.IdentExpr: - return e.Value == "true" - case *ast.UnaryNotOpExpr: - return isConstFalseExpr(e.Expr) + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), } - return false } -func isConstFalseExpr(expr ast.Expr) bool { - switch e := expr.(type) { - case *ast.FalseExpr: - return true - case *ast.IdentExpr: - return e.Value == "false" - case *ast.UnaryNotOpExpr: - return isConstTrueExpr(e.Expr) +func (ce *ConditionExtractor) branchConditionsFromLogicalExpr(expr *ast.LogicalOpExpr) BranchConditions { + if expr == nil { + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } } - return false -} - -// conditionFromLogicalExpr handles 'and' and 'or' operators. -func (ce *ConditionExtractor) conditionFromLogicalExpr(expr *ast.LogicalOpExpr) constraint.Condition { + left := ce.branchConditionsFromExpr(expr.Lhs) + right := ce.branchConditionsFromExpr(expr.Rhs) switch expr.Operator { case "and": - left := ce.ConditionFromExpr(expr.Lhs) - right := ce.ConditionFromExpr(expr.Rhs) - return constraint.And(left, right) + return BranchConditions{ + OnTrue: constraint.And(left.OnTrue, right.OnTrue), + OnFalse: constraint.Or(left.OnFalse, constraint.And(left.OnTrue, right.OnFalse)), + } case "or": - left := ce.ConditionFromExpr(expr.Lhs) - right := ce.ConditionFromExpr(expr.Rhs) - return constraint.Or(left, right) + return BranchConditions{ + OnTrue: constraint.Or(left.OnTrue, constraint.And(left.OnFalse, right.OnTrue)), + OnFalse: constraint.And(left.OnFalse, right.OnFalse), + } + default: + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } } - return constraint.TrueCondition() } -// conditionFromRelationalExpr handles comparison operators. -func (ce *ConditionExtractor) conditionFromRelationalExpr(expr *ast.RelationalOpExpr) constraint.Condition { +func (ce *ConditionExtractor) branchConditionsFromRelationalExpr(expr *ast.RelationalOpExpr) BranchConditions { + if expr == nil { + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } + } switch expr.Operator { case "==": - return ce.ConditionFromEquality(expr.Lhs, expr.Rhs) + return BranchConditions{ + OnTrue: ce.ConditionFromEquality(expr.Lhs, expr.Rhs), + OnFalse: ce.ConditionFromInequality(expr.Lhs, expr.Rhs), + } case "~=": - return ce.ConditionFromInequality(expr.Lhs, expr.Rhs) + return BranchConditions{ + OnTrue: ce.ConditionFromInequality(expr.Lhs, expr.Rhs), + OnFalse: ce.ConditionFromEquality(expr.Lhs, expr.Rhs), + } case "<", "<=", ">", ">=": - return ce.conditionFromOrderedComparison(expr.Lhs, expr.Rhs) + return BranchConditions{ + OnTrue: ce.conditionFromOrderedComparison(expr.Lhs, expr.Rhs), + OnFalse: constraint.TrueCondition(), + } + default: + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } + } +} + +func (ce *ConditionExtractor) branchConditionsFromIdent(ident *ast.IdentExpr) BranchConditions { + if ident == nil { + return BranchConditions{ + OnTrue: constraint.TrueCondition(), + OnFalse: constraint.TrueCondition(), + } + } + path := ce.pathFromExpr(ident) + onTrue := constraint.TrueCondition() + onFalse := constraint.TrueCondition() + if !path.IsEmpty() { + trueConstraints := append([]constraint.Constraint{constraint.Truthy{Path: path}}, + versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, true), ce.graph(), ce.P)...) + trueConstraints = append(trueConstraints, ce.keyOfConstraintsForTruthyValue(path)...) + onTrue = constraint.FromConstraints(trueConstraints...) + onFalse = constraint.FromConstraints(append([]constraint.Constraint{constraint.Falsy{Path: path}}, + versionSiblingConstraints(sibling.ConstraintsForIdent(ident, ce.P, ce.Inputs, false), ce.graph(), ce.P)...)...) + } + if link := predicate.LookupPredicateLink(ident.Value, ce.Inputs); link != nil { + if link.OnTruthy.HasConstraints() { + onTrue = constraint.And(onTrue, link.OnTruthy) + } + if link.OnFalsy.HasConstraints() { + onFalse = constraint.And(onFalse, link.OnFalsy) + } + } + return BranchConditions{OnTrue: onTrue, OnFalse: onFalse} +} + +func (ce *ConditionExtractor) keyOfConstraintsForTruthyValue(valuePath constraint.Path) []constraint.Constraint { + if ce == nil || ce.Inputs == nil || valuePath.IsEmpty() || valuePath.Symbol == 0 { + return nil + } + var out []constraint.Constraint + for _, assign := range ce.Inputs.Assignments { + if assign.MapElementSource == nil || assign.TargetPath.IsEmpty() { + continue + } + targetPath := flowpath.WithVersion(assign.TargetPath, ce.graph(), ce.P) + if !targetPath.Equal(valuePath) { + continue + } + if keyOf := ce.keyOfConstraintFromMapElementSource(assign.MapElementSource); keyOf != nil { + out = append(out, *keyOf) + } + } + return out +} + +func (ce *ConditionExtractor) keyOfConstraintsFromDynamicIndex(attr *ast.AttrGetExpr) []constraint.Constraint { + if ce == nil || attr == nil { + return nil + } + basePath := ce.pathFromExpr(attr.Object) + if basePath.IsEmpty() { + return nil + } + keyIdent, ok := attr.Key.(*ast.IdentExpr) + if !ok || keyIdent == nil { + return nil + } + bindings := ce.bindings() + if bindings == nil { + return nil + } + keySym, ok := bindings.SymbolOf(keyIdent) + if !ok || keySym == 0 { + return nil + } + keyPath := flowpath.WithVersion(constraint.Path{ + Root: resolve.RootNameFromBindings(bindings, keySym, keyIdent.Value), + Symbol: keySym, + }, ce.graph(), ce.P) + if keyPath.IsEmpty() { + return nil + } + return []constraint.Constraint{constraint.KeyOf{Table: basePath, Key: keyPath}} +} + +func (ce *ConditionExtractor) keyOfConstraintFromMapElementSource(src *flow.MapElementSource) *constraint.KeyOf { + if ce == nil || src == nil || src.MapPath.IsEmpty() || src.KeySymbol == 0 { + return nil + } + tablePath := flowpath.WithVersion(src.MapPath, ce.graph(), ce.P) + if tablePath.IsEmpty() { + return nil + } + keyRoot := src.KeyVar + if keyRoot == "" { + keyRoot = src.MapPath.Root + } + keyPath := flowpath.WithVersion(constraint.Path{ + Root: keyRoot, + Symbol: src.KeySymbol, + }, ce.graph(), ce.P) + if keyPath.IsEmpty() { + return nil + } + return &constraint.KeyOf{Table: tablePath, Key: keyPath} +} + +func (ce *ConditionExtractor) branchConditionsFromPredicateCall(expr *ast.FuncCallExpr) (BranchConditions, bool) { + link := ce.predicateLinkFromCallExpr(expr) + if link == nil || (!link.OnTruthy.HasConstraints() && !link.OnFalsy.HasConstraints()) { + return BranchConditions{}, false + } + return BranchConditions{ + OnTrue: conditionOrTrue(link.OnTruthy), + OnFalse: conditionOrTrue(link.OnFalsy), + }, true +} + +func (ce *ConditionExtractor) predicateLinkFromCallExpr(expr *ast.FuncCallExpr) *flow.PredicateLink { + if expr == nil { + return nil + } + info := cfg.BuildCallInfo(expr, false) + if info == nil { + return nil + } + ce.resolveSyntheticCallInfo(info) + return ExtractPredicateLinkFromCallInfo(info, 0, ce.P, ce.SC, ce.Inputs, ce.TypeKeyRes, ce.Synth, ce.RefinementBySym, ce.SymResolver, ce.cfgGraph(), ce.ModuleBindings) +} + +func (ce *ConditionExtractor) resolveSyntheticCallInfo(info *cfg.CallInfo) { + if info == nil { + return + } + bindings := ce.bindings() + if bindings != nil { + if ident, ok := info.Callee.(*ast.IdentExpr); ok { + if sym, found := bindings.SymbolOf(ident); found { + info.CalleeSymbol = sym + } + } + if ident, ok := info.Receiver.(*ast.IdentExpr); ok { + if sym, found := bindings.SymbolOf(ident); found { + info.ReceiverSymbol = sym + } + } + if len(info.Args) > 0 { + for i, arg := range info.Args { + ident, ok := arg.(*ast.IdentExpr) + if !ok { + continue + } + sym, found := bindings.SymbolOf(ident) + if !found { + continue + } + if info.ArgSymbols == nil { + info.ArgSymbols = make([]cfg.SymbolID, len(info.Args)) + } + info.ArgSymbols[i] = sym + } + } + } + if info.IsTypeCheck && len(info.Args) > 0 { + info.TypeCheckPath = ce.pathFromExpr(info.Args[0]) + } + if info.Method != "" { + info.CalleePath = ce.pathFromExpr(info.Receiver) + } else { + info.CalleePath = ce.pathFromExpr(info.Callee) + } + if info.CalleeSymbol == 0 && !info.CalleePath.IsEmpty() && len(info.CalleePath.Segments) == 0 { + info.CalleeSymbol = info.CalleePath.Symbol + } +} + +func conditionFromOptionalConstraints(items []constraint.Constraint) constraint.Condition { + if len(items) == 0 { + return constraint.TrueCondition() + } + return constraint.FromConstraints(items...) +} + +func conditionOrTrue(c constraint.Condition) constraint.Condition { + if c.HasConstraints() { + return c } return constraint.TrueCondition() } @@ -719,7 +838,7 @@ func (ce *ConditionExtractor) ConditionFromInequality(lhs, rhs ast.Expr) constra return constraint.FromConstraints(c...) } - // Fallback: negate equality constraints + // Equality negation covers constraints without a specialized inverse. eq := ce.ConditionFromEquality(lhs, rhs) if !eq.HasConstraints() { return constraint.TrueCondition() @@ -797,102 +916,71 @@ func (ce *ConditionExtractor) constraintsFromCallExpr(expr *ast.FuncCallExpr) [] return nil } - // TypeName(x) pattern - if fnIdent, ok := expr.Func.(*ast.IdentExpr); ok && ce.TypeKeyRes != nil { - if ce.calleeHasEffect(expr, effect.Row.HasCallableType) { - if typeKey, ok := ce.TypeKeyRes(fnIdent.Value, ce.SC); ok && !typeKey.IsZero() { - path := ce.pathFromExpr(expr.Args[0]) - if !path.IsEmpty() { - return []constraint.Constraint{constraint.HasType{Path: path, Type: typeKey}} - } - } - } + if constraints := ce.constraintsFromCallableTypeCallExpr(expr); len(constraints) > 0 { + return constraints } - if path := ce.pathFromExpr(expr.Args[0]); !path.IsEmpty() && ce.hasLocalTableTypePredicate(expr) { - return []constraint.Constraint{ - constraint.NotNil{Path: path}, - constraint.HasType{Path: path, Type: narrow.BuiltinTypeKey("table")}, + if path := ce.pathFromExpr(expr.Args[0]); !path.IsEmpty() { + if typeKey, ok := ce.localTypePredicateKey(expr); ok { + return []constraint.Constraint{ + constraint.NotNil{Path: path}, + constraint.HasType{Path: path, Type: typeKey}, + } } } return nil } -func (ce *ConditionExtractor) hasLocalTableTypePredicate(call *ast.FuncCallExpr) bool { +func (ce *ConditionExtractor) localTypePredicateKey(call *ast.FuncCallExpr) (narrow.TypeKey, bool) { if call == nil { - return false + return narrow.TypeKey{}, false } ident, ok := call.Func.(*ast.IdentExpr) if !ok || ident == nil { - return false + return narrow.TypeKey{}, false } bindings := ce.bindings() if bindings == nil { - return false + return narrow.TypeKey{}, false } sym, ok := bindings.SymbolOf(ident) if !ok || sym == 0 { - return false - } - graph, ok := ce.graph().(*cfg.Graph) - if !ok { - return false - } - fn := checkcallsite.FunctionLiteralForSymbol(graph, bindings, sym) - if fn == nil || fn.ParList == nil || len(fn.ParList.Names) == 0 { - return false - } - param := fn.ParList.Names[0] - if param == "" { - return false + return narrow.TypeKey{}, false } - for _, stmt := range fn.Stmts { - ret, ok := stmt.(*ast.ReturnStmt) - if !ok || ret == nil || len(ret.Exprs) == 0 { + for _, pred := range ce.Evidence.LocalTypePredicates { + if pred.Symbol != sym || pred.ParamIndex != 0 || pred.Kind == "" { continue } - if exprContainsTypeCheck(ret.Exprs[0], param, "table") { - return true + key := narrow.BuiltinTypeKey(pred.Kind) + if key.IsZero() { + continue } + return key, true } - return false + return narrow.TypeKey{}, false } -func exprContainsTypeCheck(expr ast.Expr, paramName, kindName string) bool { - switch e := expr.(type) { - case *ast.LogicalOpExpr: - return exprContainsTypeCheck(e.Lhs, paramName, kindName) || - exprContainsTypeCheck(e.Rhs, paramName, kindName) - case *ast.RelationalOpExpr: - if e.Operator != "==" { - return false - } - if callIsTypeOfParam(e.Lhs, paramName) { - if s, ok := e.Rhs.(*ast.StringExpr); ok && s.Value == kindName { - return true - } - } - if callIsTypeOfParam(e.Rhs, paramName) { - if s, ok := e.Lhs.(*ast.StringExpr); ok && s.Value == kindName { - return true - } - } +func (ce *ConditionExtractor) constraintsFromCallableTypeCallExpr(expr *ast.FuncCallExpr) []constraint.Constraint { + if expr == nil || checkcallsite.IsMethodLikeExpr(expr) || len(expr.Args) == 0 || ce.TypeKeyRes == nil { + return nil } - return false -} - -func callIsTypeOfParam(expr ast.Expr, paramName string) bool { - call, ok := expr.(*ast.FuncCallExpr) - if !ok || call == nil || checkcallsite.IsMethodLikeExpr(call) || len(call.Args) != 1 { - return false + fnIdent, ok := expr.Func.(*ast.IdentExpr) + if !ok { + return nil } - fnIdent, ok := call.Func.(*ast.IdentExpr) - if !ok || fnIdent == nil || fnIdent.Value != "type" { - return false + if !ce.calleeHasEffect(expr, effect.Row.HasCallableType) { + return nil + } + typeKey, ok := ce.TypeKeyRes(fnIdent.Value, ce.SC) + if !ok || typeKey.IsZero() { + return nil + } + path := ce.pathFromExpr(expr.Args[0]) + if path.IsEmpty() { + return nil } - argIdent, ok := call.Args[0].(*ast.IdentExpr) - return ok && argIdent != nil && argIdent.Value == paramName + return []constraint.Constraint{constraint.HasType{Path: path, Type: typeKey}} } // literalFromExpr resolves a literal from an expression using the extractor's context. @@ -909,71 +997,171 @@ func typeKeyFromStringExpr(expr ast.Expr) (narrow.TypeKey, bool) { return narrow.KnownBuiltinTypeKey(s.Value) } -// NumericConstraintsFromExpr extracts numeric constraints from an expression. -func NumericConstraintsFromExpr(expr ast.Expr, p cfg.Point, inputs *flow.Inputs) []constraint.NumericConstraint { +// NumericBranchConstraintsFromExpr extracts numeric facts from a branch +// expression. Facts are emitted per edge only when that edge is representable as +// a conjunction in the numeric domain; unrepresentable disjunctions are dropped +// instead of being approximated unsoundly. +func NumericBranchConstraintsFromExpr(expr ast.Expr, p cfg.Point, inputs *flow.Inputs) NumericBranchConstraints { bindings := resolve.GetBindings(inputs) - return numericConstraintsFromExprInternal(expr, p, inputs, bindings) + return numericBranchConstraintsFromExprInternal(expr, p, inputs, bindings) } -func numericConstraintsFromExprInternal(expr ast.Expr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) []constraint.NumericConstraint { +func numericBranchConstraintsFromExprInternal(expr ast.Expr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) NumericBranchConstraints { switch e := expr.(type) { case *ast.UnaryNotOpExpr: - inner := numericConstraintsFromExprInternal(e.Expr, p, inputs, bindings) - var negated []constraint.NumericConstraint - for _, nc := range inner { - if neg := numconst.NegateNumericConstraint(nc); neg != nil { - negated = append(negated, neg) - } - } - return negated + inner := numericBranchConstraintsFromExprInternal(e.Expr, p, inputs, bindings) + return NumericBranchConstraints{OnTrue: inner.OnFalse, OnFalse: inner.OnTrue} case *ast.LogicalOpExpr: - return numericConstraintsFromLogicalExprInternal(e, p, inputs, bindings) + return numericBranchConstraintsFromLogicalExprInternal(e, p, inputs, bindings) case *ast.RelationalOpExpr: - if nc := numconst.NumericConstraintFromComparisonWithBindings(e.Operator, e.Lhs, e.Rhs, p, inputs, bindings); nc != nil { - return []constraint.NumericConstraint{nc} - } + return numericBranchConstraintsFromRelationalExpr(e, p, inputs, bindings) } - return nil + return NumericBranchConstraints{} } -func numericConstraintsFromLogicalExprInternal(expr *ast.LogicalOpExpr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) []constraint.NumericConstraint { +func numericBranchConstraintsFromLogicalExprInternal(expr *ast.LogicalOpExpr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) NumericBranchConstraints { switch expr.Operator { case "and": - left := numericConstraintsFromExprInternal(expr.Lhs, p, inputs, bindings) - right := numericConstraintsFromExprInternal(expr.Rhs, p, inputs, bindings) - if len(left) == 0 { - return right + left := numericBranchConstraintsFromExprInternal(expr.Lhs, p, inputs, bindings) + right := numericBranchConstraintsFromExprInternal(expr.Rhs, p, inputs, bindings) + return NumericBranchConstraints{ + OnTrue: appendNumericConstraints(left.OnTrue, right.OnTrue), + } + case "or": + left := numericBranchConstraintsFromExprInternal(expr.Lhs, p, inputs, bindings) + right := numericBranchConstraintsFromExprInternal(expr.Rhs, p, inputs, bindings) + return NumericBranchConstraints{ + OnFalse: appendNumericConstraints(left.OnFalse, right.OnFalse), + } + } + return NumericBranchConstraints{} +} + +func numericBranchConstraintsFromRelationalExpr(expr *ast.RelationalOpExpr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) NumericBranchConstraints { + if expr == nil { + return NumericBranchConstraints{} + } + switch expr.Operator { + case "<", "<=", ">", ">=": + nc := numconst.NumericConstraintFromComparisonWithBindings(expr.Operator, expr.Lhs, expr.Rhs, p, inputs, bindings) + if nc == nil { + return NumericBranchConstraints{} } - if len(right) == 0 { - return left + out := NumericBranchConstraints{OnTrue: []constraint.NumericConstraint{nc}} + if neg := numconst.NegateNumericConstraint(nc); neg != nil { + out.OnFalse = []constraint.NumericConstraint{neg} } - out := make([]constraint.NumericConstraint, 0, len(left)+len(right)) - out = append(out, left...) - out = append(out, right...) return out + case "==": + return numericBranchConstraintsFromEquality(expr.Lhs, expr.Rhs, bindings) + case "~=": + eq := numericBranchConstraintsFromEquality(expr.Lhs, expr.Rhs, bindings) + return NumericBranchConstraints{OnTrue: eq.OnFalse, OnFalse: eq.OnTrue} + default: + return NumericBranchConstraints{} } - return nil +} + +func numericBranchConstraintsFromEquality(lhs, rhs ast.Expr, bindings *bind.BindingTable) NumericBranchConstraints { + if path, c, ok := lenConstComparison(lhs, rhs, bindings); ok { + out := NumericBranchConstraints{ + OnTrue: []constraint.NumericConstraint{ + constraint.LenGeConst{Array: path, C: c}, + constraint.LenLeConst{Array: path, C: c}, + }, + } + if c == 0 { + out.OnFalse = []constraint.NumericConstraint{constraint.LenGeConst{Array: path, C: 1}} + } + return out + } + if path, c, ok := pathConstComparison(lhs, rhs, bindings); ok { + return NumericBranchConstraints{ + OnTrue: []constraint.NumericConstraint{ + constraint.GeConst{X: path, C: c}, + constraint.LeConst{X: path, C: c}, + }, + } + } + return NumericBranchConstraints{} +} + +func lenConstComparison(lhs, rhs ast.Expr, bindings *bind.BindingTable) (constraint.Path, int64, bool) { + if path := lenPathFromExpr(lhs, bindings); !path.IsEmpty() { + if c, ok := numconst.IntConstFromExpr(rhs); ok { + return path, c, true + } + } + if path := lenPathFromExpr(rhs, bindings); !path.IsEmpty() { + if c, ok := numconst.IntConstFromExpr(lhs); ok { + return path, c, true + } + } + return constraint.Path{}, 0, false +} + +func pathConstComparison(lhs, rhs ast.Expr, bindings *bind.BindingTable) (constraint.Path, int64, bool) { + if path := flowpath.FromExprWithBindings(lhs, nil, bindings); !path.IsEmpty() { + if c, ok := numconst.IntConstFromExpr(rhs); ok { + return path, c, true + } + } + if path := flowpath.FromExprWithBindings(rhs, nil, bindings); !path.IsEmpty() { + if c, ok := numconst.IntConstFromExpr(lhs); ok { + return path, c, true + } + } + return constraint.Path{}, 0, false +} + +func lenPathFromExpr(expr ast.Expr, bindings *bind.BindingTable) constraint.Path { + lenOp, ok := expr.(*ast.UnaryLenOpExpr) + if !ok || lenOp == nil { + return constraint.Path{} + } + return flowpath.FromExprWithBindings(lenOp.Expr, nil, bindings) +} + +func appendNumericConstraints(left, right []constraint.NumericConstraint) []constraint.NumericConstraint { + if len(left) == 0 { + return right + } + if len(right) == 0 { + return left + } + out := make([]constraint.NumericConstraint, 0, len(left)+len(right)) + out = append(out, left...) + out = append(out, right...) + return out } // ExtractReturnExprConstraints extracts constraints from a return expression. -func ExtractReturnExprConstraints(expr ast.Expr, p cfg.Point, sc *scope.State, inputs *flow.Inputs, typeKeyResolver func(string, *scope.State) (narrow.TypeKey, bool), synthFunc func(ast.Expr, cfg.Point) typ.Type, constResolver func(string) *flow.ConstValue, symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool)) flow.ReturnExprConstraints { - if _, ok := expr.(*ast.IdentExpr); ok { - return flow.ReturnExprConstraints{} +func ExtractReturnExprConstraints(expr ast.Expr, p cfg.Point, sc *scope.State, inputs *flow.Inputs, evidence api.FlowEvidence, typeKeyResolver func(string, *scope.State) (narrow.TypeKey, bool), synthFunc func(ast.Expr, cfg.Point) typ.Type, constResolver func(string) *flow.ConstValue, symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool)) flow.ReturnExprConstraints { + if ident, ok := expr.(*ast.IdentExpr); ok { + link := predicate.LookupPredicateLink(ident.Value, inputs) + if link == nil || (!link.OnTruthy.HasConstraints() && !link.OnFalsy.HasConstraints()) { + return flow.ReturnExprConstraints{} + } } ce := &ConditionExtractor{ P: p, SC: sc, Inputs: inputs, - SymResolver: symResolver, TypeKeyRes: typeKeyResolver, + Synth: synthFunc, + SymResolver: symResolver, + TypeKeyRes: typeKeyResolver, ConstResolver: constResolver, + Evidence: evidence, } - cond := ce.ConditionFromExpr(expr) - if !cond.HasConstraints() { + branches := ce.ConstraintsFromConditionExpr(expr) + var onReturn constraint.Condition + if call, ok := expr.(*ast.FuncCallExpr); ok { + onReturn = conditionFromOptionalConstraints(ce.constraintsFromCallableTypeCallExpr(call)) + } + if !onReturn.HasConstraints() && !branches.OnTrue.HasConstraints() && !branches.OnFalse.HasConstraints() { return flow.ReturnExprConstraints{} } - return flow.ReturnExprConstraints{ - OnTrue: cond, - } + return flow.ReturnExprConstraints{OnReturn: onReturn, OnTrue: branches.OnTrue, OnFalse: branches.OnFalse} } // ChannelValueConstraint emits a HasType constraint for result.value when diff --git a/compiler/check/flowbuild/cond/condition_test.go b/compiler/check/abstract/cond/condition_test.go similarity index 80% rename from compiler/check/flowbuild/cond/condition_test.go rename to compiler/check/abstract/cond/condition_test.go index 1a331bcc..ce4e2c11 100644 --- a/compiler/check/flowbuild/cond/condition_test.go +++ b/compiler/check/abstract/cond/condition_test.go @@ -4,7 +4,8 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/narrow" @@ -117,15 +118,68 @@ func TestResolveClassifyReturnExpr_FalseExpr(t *testing.T) { } } -func TestNumericConstraintsFromExpr_NilExpr(t *testing.T) { - result := NumericConstraintsFromExpr(nil, 0, nil) - if len(result) != 0 { - t.Error("nil expr should return empty slice") +func TestNumericBranchConstraintsFromExpr_NilExpr(t *testing.T) { + result := NumericBranchConstraintsFromExpr(nil, 0, nil) + if len(result.OnTrue) != 0 || len(result.OnFalse) != 0 { + t.Error("nil expr should return empty branch constraints") + } +} + +func TestNumericBranchConstraintsFromExpr_LenEqualsZero(t *testing.T) { + expr := &ast.RelationalOpExpr{ + Operator: "==", + Lhs: &ast.UnaryLenOpExpr{Expr: &ast.IdentExpr{Value: "rows"}}, + Rhs: &ast.NumberExpr{Value: "0"}, + } + + result := NumericBranchConstraintsFromExpr(expr, 0, nil) + if len(result.OnTrue) != 2 { + t.Fatalf("true edge should get exact zero length, got %d constraints", len(result.OnTrue)) + } + if _, ok := result.OnTrue[0].(constraint.LenGeConst); !ok { + t.Fatalf("true edge first constraint = %T, want LenGeConst", result.OnTrue[0]) + } + if _, ok := result.OnTrue[1].(constraint.LenLeConst); !ok { + t.Fatalf("true edge second constraint = %T, want LenLeConst", result.OnTrue[1]) + } + if len(result.OnFalse) != 1 { + t.Fatalf("false edge should get non-empty length, got %d constraints", len(result.OnFalse)) + } + got, ok := result.OnFalse[0].(constraint.LenGeConst) + if !ok { + t.Fatalf("false edge constraint = %T, want LenGeConst", result.OnFalse[0]) + } + if got.Array.Root != "rows" || got.C != 1 { + t.Fatalf("unexpected false-edge non-empty length constraint: %#v", got) + } +} + +func TestNumericBranchConstraintsFromExpr_AndFalseEdgeIsNotApproximatedAsConjunction(t *testing.T) { + expr := &ast.LogicalOpExpr{ + Operator: "and", + Lhs: &ast.RelationalOpExpr{ + Operator: ">", + Lhs: &ast.IdentExpr{Value: "x"}, + Rhs: &ast.NumberExpr{Value: "0"}, + }, + Rhs: &ast.RelationalOpExpr{ + Operator: ">", + Lhs: &ast.IdentExpr{Value: "y"}, + Rhs: &ast.NumberExpr{Value: "0"}, + }, + } + + result := NumericBranchConstraintsFromExpr(expr, 0, nil) + if len(result.OnTrue) != 2 { + t.Fatalf("true edge should combine both comparisons, got %d constraints", len(result.OnTrue)) + } + if len(result.OnFalse) != 0 { + t.Fatalf("false edge is a disjunction and must not be approximated as %d constraints", len(result.OnFalse)) } } func TestExtractReturnExprConstraints_NilExpr(t *testing.T) { - result := ExtractReturnExprConstraints(nil, 0, nil, nil, nil, nil, nil, nil) + result := ExtractReturnExprConstraints(nil, 0, nil, nil, api.FlowEvidence{}, nil, nil, nil, nil) if result.OnTrue.HasConstraints() { t.Error("nil expr should produce no OnTrue constraints") } @@ -136,7 +190,7 @@ func TestExtractReturnExprConstraints_NilExpr(t *testing.T) { func TestExtractReturnExprConstraints_IdentExpr(t *testing.T) { expr := &ast.IdentExpr{Value: "x"} - result := ExtractReturnExprConstraints(expr, 0, nil, nil, nil, nil, nil, nil) + result := ExtractReturnExprConstraints(expr, 0, nil, nil, api.FlowEvidence{}, nil, nil, nil, nil) if result.OnTrue.HasConstraints() { t.Error("simple ident should produce no OnTrue constraints") } diff --git a/compiler/check/flowbuild/cond/extract.go b/compiler/check/abstract/cond/extract.go similarity index 89% rename from compiler/check/flowbuild/cond/extract.go rename to compiler/check/abstract/cond/extract.go index 1003a07b..51fdd875 100644 --- a/compiler/check/flowbuild/cond/extract.go +++ b/compiler/check/abstract/cond/extract.go @@ -18,14 +18,15 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/numconst" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/sibling" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" checkeffects "github.com/wippyai/go-lua/compiler/check/effects" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/numconst" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/sibling" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -35,15 +36,20 @@ import ( // ExtractEdgeConstraints extracts type constraints from branch conditions. func ExtractEdgeConstraints(fc *core.FlowContext, inputs *flow.Inputs) { - fc.Graph.EachBranch(func(p cfg.Point, info *cfg.BranchInfo) { + for _, branch := range fc.Evidence.Branches { + p := branch.Point + info := branch.Info + if info == nil { + continue + } succs := fc.Graph.Successors(p) if len(succs) < 2 { - return + continue } trueEdge, falseEdge := FindBranchEdges(fc.Graph, p, succs) if trueEdge == 0 && falseEdge == 0 { - return + continue } constResolver := predicate.BuildConstResolver(inputs, p) @@ -55,6 +61,8 @@ func ExtractEdgeConstraints(fc *core.FlowContext, inputs *flow.Inputs) { TypeKeyRes: fc.Derived.TypeKeyRes, ConstResolver: constResolver, RefinementBySym: fc.Derived.RefinementBySym, + ModuleBindings: fc.ModuleBindings, + Evidence: fc.Evidence, } constraints := ce.ConstraintsFromBranch(info) @@ -159,20 +167,25 @@ func ExtractEdgeConstraints(fc *core.FlowContext, inputs *flow.Inputs) { Condition: constraints.OnFalse, }) } - }) + } } // ExtractNumericConstraints extracts numeric constraints from branch conditions. func ExtractNumericConstraints(fc *core.FlowContext, inputs *flow.Inputs) { - fc.Graph.EachBranch(func(p cfg.Point, info *cfg.BranchInfo) { + for _, branch := range fc.Evidence.Branches { + p := branch.Point + info := branch.Info + if info == nil { + continue + } succs := fc.Graph.Successors(p) if len(succs) < 2 { - return + continue } trueEdge, falseEdge := FindBranchEdges(fc.Graph, p, succs) if trueEdge == 0 && falseEdge == 0 { - return + continue } // Handle numeric for-loop bounds @@ -187,38 +200,25 @@ func ExtractNumericConstraints(fc *core.FlowContext, inputs *flow.Inputs) { } if info.Condition == nil { - return - } - - numConstraints := NumericConstraintsFromExpr(info.Condition, p, inputs) - if len(numConstraints) == 0 { - return + continue } - if trueEdge != 0 { + numConstraints := NumericBranchConstraintsFromExpr(info.Condition, p, inputs) + if trueEdge != 0 && len(numConstraints.OnTrue) > 0 { inputs.EdgeNumericConstraints = append(inputs.EdgeNumericConstraints, flow.EdgeNumericConstraint{ From: p, To: trueEdge, - Constraints: numConstraints, + Constraints: numConstraints.OnTrue, }) } - - if falseEdge != 0 { - var negated []constraint.NumericConstraint - for _, nc := range numConstraints { - if neg := numconst.NegateNumericConstraint(nc); neg != nil { - negated = append(negated, neg) - } - } - if len(negated) > 0 { - inputs.EdgeNumericConstraints = append(inputs.EdgeNumericConstraints, flow.EdgeNumericConstraint{ - From: p, - To: falseEdge, - Constraints: negated, - }) - } + if falseEdge != 0 && len(numConstraints.OnFalse) > 0 { + inputs.EdgeNumericConstraints = append(inputs.EdgeNumericConstraints, flow.EdgeNumericConstraint{ + From: p, + To: falseEdge, + Constraints: numConstraints.OnFalse, + }) } - }) + } } // numericForConstraints extracts numeric constraints from a numeric for-loop. @@ -326,7 +326,7 @@ func ExtractLenBound(expr ast.Expr, p cfg.Point, graph *cfg.Graph) (constraint.P return arrPath, k, true } -// ExtractLenOfPath preserves legacy behavior for callers/tests that need only the path. +// ExtractLenOfPath returns only the path component of a length-bound expression. func ExtractLenOfPath(expr ast.Expr, p cfg.Point, graph *cfg.Graph) constraint.Path { arrPath, _, ok := ExtractLenBound(expr, p, graph) if !ok { @@ -363,7 +363,7 @@ func ExtractCallOnReturnConstraints( } for _, p := range fc.Graph.RPO() { - if !PointHasTerminatingCallSite(fc.Graph, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Derived.RefinementBySym, fc.ModuleBindings) { + if !PointHasTerminatingCallEvidence(fc.Graph, fc.Evidence.Calls, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Derived.RefinementBySym, fc.ModuleBindings) { continue } for _, succ := range fc.Graph.Successors(p) { @@ -378,13 +378,21 @@ func ExtractCallOnReturnConstraints( } } - fc.Graph.EachStmtCall(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range fc.Evidence.Calls { + if call.Origin != api.CallOriginStatement { + continue + } + p := call.Point + info := call.Info + if info == nil { + continue + } sc := fc.Scopes[p] constResolver := predicate.BuildConstResolver(inputs, p) - cond := ConstraintsFromCallOnReturn(info, p, sc, inputs, fc.Derived.Synth, fc.Derived.TypeKeyRes, fc.Derived.RefinementBySym, constResolver, fc.Derived.SymResolver, fc.Graph, fc.ModuleBindings) + cond := ConstraintsFromCallOnReturn(info, p, sc, inputs, fc.Derived.Synth, fc.Derived.TypeKeyRes, fc.Derived.RefinementBySym, constResolver, fc.Derived.SymResolver, fc.Graph, fc.ModuleBindings, fc.Evidence) if !cond.HasConstraints() { - return + continue } for _, succ := range fc.Graph.Successors(p) { key := EdgeKey{From: p, To: succ} @@ -394,14 +402,19 @@ func ExtractCallOnReturnConstraints( out[key] = cond } } - }) + } - fc.Graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } sc := fc.Scopes[p] constResolver := predicate.BuildConstResolver(inputs, p) - cond := ConstraintsFromAssignOnReturn(info, p, sc, inputs, fc.Derived.Synth, fc.Derived.TypeKeyRes, fc.Derived.RefinementBySym, constResolver, fc.Derived.SymResolver, fc.Graph, fc.ModuleBindings) + cond := ConstraintsFromAssignOnReturn(info, p, sc, inputs, fc.Derived.Synth, fc.Derived.TypeKeyRes, fc.Derived.RefinementBySym, constResolver, fc.Derived.SymResolver, fc.Graph, fc.ModuleBindings, fc.Evidence) if !cond.HasConstraints() { - return + continue } for _, succ := range fc.Graph.Successors(p) { key := EdgeKey{From: p, To: succ} @@ -411,7 +424,7 @@ func ExtractCallOnReturnConstraints( out[key] = cond } } - }) + } return out } @@ -429,6 +442,7 @@ func ConstraintsFromCallOnReturn( symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), graph *cfg.Graph, moduleBindings *bind.BindingTable, + evidence api.FlowEvidence, ) constraint.Condition { if info == nil { return constraint.Condition{} @@ -469,6 +483,8 @@ func ConstraintsFromCallOnReturn( TypeKeyRes: typeKeyResolver, ConstResolver: constResolver, RefinementBySym: refinementLookupSym, + ModuleBindings: moduleBindings, + Evidence: evidence, } // OnReturn summarizes all normal-return paths. At call sites we may only @@ -485,28 +501,28 @@ func ConstraintsFromCallOnReturn( switch v := c.(type) { case constraint.Falsy: if idx, ok := constraint.PlaceholderArgIndex(v.Path, len(callArgs)); ok && argPaths[idx].IsEmpty() { - fallback := ce.ConditionFromExpr(callArgs[idx]) - if fallback.HasConstraints() { - must = append(must, constraint.Not(fallback).MustConstraints()...) + reconstructed := ce.ConditionFromExpr(callArgs[idx]) + if reconstructed.HasConstraints() { + must = append(must, constraint.Not(reconstructed).MustConstraints()...) } continue } case constraint.Truthy: if idx, ok := constraint.PlaceholderArgIndex(v.Path, len(callArgs)); ok && argPaths[idx].IsEmpty() { - fallback := ce.ConditionFromExpr(callArgs[idx]) - if fallback.HasConstraints() { - must = append(must, fallback.MustConstraints()...) + reconstructed := ce.ConditionFromExpr(callArgs[idx]) + if reconstructed.HasConstraints() { + must = append(must, reconstructed.MustConstraints()...) } continue } case constraint.EqPath: - if fallback, ok := callConstraintFallbackFromArgs(ce, callArgs, argPaths, v, true); ok { - must = append(must, fallback...) + if reconstructed, ok := callConstraintFromOriginalArgs(ce, callArgs, argPaths, v, true); ok { + must = append(must, reconstructed...) continue } case constraint.NotEqPath: - if fallback, ok := callConstraintFallbackFromArgs(ce, callArgs, argPaths, v, false); ok { - must = append(must, fallback...) + if reconstructed, ok := callConstraintFromOriginalArgs(ce, callArgs, argPaths, v, false); ok { + must = append(must, reconstructed...) continue } } @@ -760,11 +776,11 @@ func normalizePathConstraint(c constraint.Constraint) constraint.Constraint { return c } -// callConstraintFallbackFromArgs canonicalizes EqPath/NotEqPath placeholder +// callConstraintFromOriginalArgs canonicalizes EqPath/NotEqPath placeholder // constraints when one argument is non-path (for example literals or #expr). -// In these cases direct path substitution drops the constraint; we recover by -// re-extracting equivalent condition constraints from the original call args. -func callConstraintFallbackFromArgs( +// Direct path substitution cannot encode that relation, so the extractor +// rebuilds the equivalent condition from the original call arguments. +func callConstraintFromOriginalArgs( ce *ConditionExtractor, args []ast.Expr, argPaths []constraint.Path, @@ -894,6 +910,31 @@ func PointHasTerminatingCallSite( return false } +// PointHasTerminatingCallEvidence reports whether the trace contains a +// definitely-terminating call at point p. +func PointHasTerminatingCallEvidence( + graph *cfg.Graph, + calls []api.CallEvidence, + p cfg.Point, + synthFn func(ast.Expr, cfg.Point) typ.Type, + symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), + refinementLookupSym constraint.RefinementLookupBySym, + moduleBindings *bind.BindingTable, +) bool { + if graph == nil { + return false + } + for _, call := range calls { + if call.Point != p { + continue + } + if CallTerminates(call.Info, p, synthFn, symResolver, refinementLookupSym, graph, moduleBindings) { + return true + } + } + return false +} + // ConstraintsFromAssignOnReturn extracts OnReturn constraints from assignment RHS calls. func ConstraintsFromAssignOnReturn( info *cfg.AssignInfo, @@ -907,13 +948,14 @@ func ConstraintsFromAssignOnReturn( symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), graph *cfg.Graph, moduleBindings *bind.BindingTable, + evidence api.FlowEvidence, ) constraint.Condition { if info == nil { return constraint.Condition{} } var combined constraint.Condition info.EachSourceCall(func(_ int, callInfo *cfg.CallInfo) { - if cond := ConstraintsFromCallOnReturn(callInfo, p, sc, inputs, synthFn, typeKeyResolver, refinementLookupSym, constResolver, symResolver, graph, moduleBindings); cond.HasConstraints() { + if cond := ConstraintsFromCallOnReturn(callInfo, p, sc, inputs, synthFn, typeKeyResolver, refinementLookupSym, constResolver, symResolver, graph, moduleBindings, evidence); cond.HasConstraints() { if !combined.HasConstraints() { combined = cond } else { @@ -1001,34 +1043,3 @@ func ExtractPredicateLinkFromCallInfo( OnFalsy: onFalsy, } } - -// ComputeDeadPoints computes dead points from a graph using effect-based termination analysis. -func ComputeDeadPoints( - graph *cfg.Graph, - synthFn func(ast.Expr, cfg.Point) typ.Type, - symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), - refinementLookupSym constraint.RefinementLookupBySym, - moduleBindings *bind.BindingTable, -) map[cfg.Point]bool { - dead := make(map[cfg.Point]bool) - for _, p := range graph.RPO() { - if PointHasTerminatingCallSite(graph, p, synthFn, symResolver, refinementLookupSym, moduleBindings) { - for _, succ := range graph.Successors(p) { - preds := graph.Predecessors(succ) - if len(preds) == 1 { - dead[succ] = true - } - } - } - } - entry := graph.Entry() - graph.EachReturn(func(p cfg.Point, _ *cfg.ReturnInfo) { - if p == entry { - return - } - if len(graph.Predecessors(p)) == 0 { - dead[p] = true - } - }) - return dead -} diff --git a/compiler/check/flowbuild/cond/extract_test.go b/compiler/check/abstract/cond/extract_test.go similarity index 84% rename from compiler/check/flowbuild/cond/extract_test.go rename to compiler/check/abstract/cond/extract_test.go index b18b71a9..95f85877 100644 --- a/compiler/check/flowbuild/cond/extract_test.go +++ b/compiler/check/abstract/cond/extract_test.go @@ -7,9 +7,10 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" checkeffects "github.com/wippyai/go-lua/compiler/check/effects" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" "github.com/wippyai/go-lua/compiler/parse" typecfg "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/constraint" @@ -42,13 +43,6 @@ func TestFindBranchEdges_EmptySuccessors(t *testing.T) { } } -func TestComputeDeadPoints_EmptyGraph(t *testing.T) { - result := ComputeDeadPoints(nil, nil, nil, nil, nil) - if result == nil { - t.Error("should return non-nil map") - } -} - func TestExtractLenOfPath_NilExpr(t *testing.T) { result := ExtractLenOfPath(nil, 0, nil) if !result.IsEmpty() { @@ -74,7 +68,7 @@ func TestExtractCallOnReturnConstraints_NilGraph(t *testing.T) { } func TestConstraintsFromCallOnReturn_NilInfo(t *testing.T) { - result := ConstraintsFromCallOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := ConstraintsFromCallOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("nil info should produce no constraints") } @@ -112,6 +106,7 @@ func TestConstraintsFromCallOnReturn_OnlyAppliesMustConstraints(t *testing.T) { nil, nil, nil, + api.FlowEvidence{}, ) if result.HasConstraints() { @@ -120,7 +115,7 @@ func TestConstraintsFromCallOnReturn_OnlyAppliesMustConstraints(t *testing.T) { } func TestConstraintsFromAssignOnReturn_NilInfo(t *testing.T) { - result := ConstraintsFromAssignOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := ConstraintsFromAssignOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("nil info should produce no constraints") } @@ -148,50 +143,20 @@ func TestExtractEffectFromType_FunctionNoRefinement(t *testing.T) { } } -func TestResolveCalleeToFunctionLiteral_NilCallee(t *testing.T) { - result := resolve.ResolveCalleeToFunctionLiteral(nil, nil) - if result != nil { - t.Error("nil callee should return nil") - } -} - -func TestResolveCalleeToFunctionLiteral_FunctionExpr(t *testing.T) { - fn := &ast.FunctionExpr{} - result := resolve.ResolveCalleeToFunctionLiteral(fn, nil) - if result != fn { - t.Error("FunctionExpr should be returned as-is") - } -} - func TestResolveSymbolToFunctionLiteral_NilGraph(t *testing.T) { - result := resolve.ResolveSymbolToFunctionLiteral(nil, 1) + result := resolve.ResolveSymbolToFunctionLiteral(api.FlowEvidence{}, nil, 1) if result != nil { t.Error("nil graph should return nil") } } func TestResolveSymbolToFunctionLiteral_ZeroSymbol(t *testing.T) { - result := resolve.ResolveSymbolToFunctionLiteral(nil, 0) + result := resolve.ResolveSymbolToFunctionLiteral(api.FlowEvidence{}, nil, 0) if result != nil { t.Error("zero symbol should return nil") } } -func TestResolveExprToTableLiteral_NilExpr(t *testing.T) { - result := resolve.ResolveExprToTableLiteral(nil, nil) - if result != nil { - t.Error("nil expr should return nil") - } -} - -func TestResolveExprToTableLiteral_NilGraph(t *testing.T) { - tbl := &ast.TableExpr{} - result := resolve.ResolveExprToTableLiteral(tbl, nil) - if result != nil { - t.Error("nil graph should return nil") - } -} - func TestCallTerminates_NilInfo(t *testing.T) { result := CallTerminates(nil, 0, nil, nil, nil, nil, nil) if result { @@ -310,15 +275,6 @@ func TestExtractPredicateLinkFromCallInfo_NilInfo(t *testing.T) { } } -func TestComputeDeadPoints_NilGraph(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Error("should not panic with nil graph") - } - }() - _ = ComputeDeadPoints(nil, nil, nil, nil, nil) -} - func TestPointHasTerminatingCallSite_AssignSourceCall(t *testing.T) { src := ` local x = error("boom") @@ -402,15 +358,12 @@ func TestComputeDeadPoints_AssignSourceCallTerminates(t *testing.T) { t.Fatal("expected x assignment with resolvable error() call symbol") } - refinementLookup := func(sym typecfg.SymbolID) *constraint.FunctionRefinement { + if !PointHasTerminatingCallSite(graph, xPoint, nil, nil, func(sym typecfg.SymbolID) *constraint.FunctionRefinement { if sym == errorSym { return &constraint.FunctionRefinement{Terminates: true} } return nil - } - - dead := ComputeDeadPoints(graph, nil, nil, refinementLookup, nil) - if len(dead) == 0 { - t.Fatal("expected at least one dead point from terminating assignment call") + }, nil) { + t.Fatal("expected assignment source call to terminate") } } diff --git a/compiler/check/flowbuild/cond/types.go b/compiler/check/abstract/cond/types.go similarity index 100% rename from compiler/check/flowbuild/cond/types.go rename to compiler/check/abstract/cond/types.go diff --git a/compiler/check/flowbuild/cond_resolve_test.go b/compiler/check/abstract/cond_resolve_test.go similarity index 99% rename from compiler/check/flowbuild/cond_resolve_test.go rename to compiler/check/abstract/cond_resolve_test.go index 69ee4c76..b8511b7f 100644 --- a/compiler/check/flowbuild/cond_resolve_test.go +++ b/compiler/check/abstract/cond_resolve_test.go @@ -1,12 +1,12 @@ -package flowbuild_test +package abstract_test import ( "testing" "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/stdlib" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" diff --git a/compiler/check/flowbuild/config_test.go b/compiler/check/abstract/config_test.go similarity index 94% rename from compiler/check/flowbuild/config_test.go rename to compiler/check/abstract/config_test.go index 39b88c1f..5770d421 100644 --- a/compiler/check/flowbuild/config_test.go +++ b/compiler/check/abstract/config_test.go @@ -1,11 +1,11 @@ -package flowbuild +package abstract import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/typ" ) diff --git a/compiler/check/flowbuild/constprop/constprop.go b/compiler/check/abstract/constprop/constprop.go similarity index 96% rename from compiler/check/flowbuild/constprop/constprop.go rename to compiler/check/abstract/constprop/constprop.go index 1276e440..90018583 100644 --- a/compiler/check/flowbuild/constprop/constprop.go +++ b/compiler/check/abstract/constprop/constprop.go @@ -3,7 +3,7 @@ package constprop import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" + "github.com/wippyai/go-lua/compiler/check/abstract/core" basecfg "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/numparse" @@ -17,7 +17,12 @@ func CollectConstAssignments(fc *core.FlowContext, inputs *flow.Inputs) { } values := make(map[cfg.SymbolID]map[cfg.Point]*flow.ConstValue) - fc.Graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { if target.Kind != cfg.TargetIdent || target.Name == "" { return @@ -34,7 +39,7 @@ func CollectConstAssignments(fc *core.FlowContext, inputs *flow.Inputs) { } values[sym][p] = val }) - }) + } inputs.ConstValues = values } diff --git a/compiler/check/flowbuild/constprop/constprop_test.go b/compiler/check/abstract/constprop/constprop_test.go similarity index 98% rename from compiler/check/flowbuild/constprop/constprop_test.go rename to compiler/check/abstract/constprop/constprop_test.go index b7c3e480..6841eaa2 100644 --- a/compiler/check/flowbuild/constprop/constprop_test.go +++ b/compiler/check/abstract/constprop/constprop_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/flowbuild/constprop" + "github.com/wippyai/go-lua/compiler/check/abstract/constprop" "github.com/wippyai/go-lua/types/flow" ) diff --git a/compiler/check/flowbuild/constprop/doc.go b/compiler/check/abstract/constprop/doc.go similarity index 100% rename from compiler/check/flowbuild/constprop/doc.go rename to compiler/check/abstract/constprop/doc.go diff --git a/compiler/check/flowbuild/core/context.go b/compiler/check/abstract/core/context.go similarity index 84% rename from compiler/check/flowbuild/core/context.go rename to compiler/check/abstract/core/context.go index 1edb7c82..570f46d7 100644 --- a/compiler/check/flowbuild/core/context.go +++ b/compiler/check/abstract/core/context.go @@ -1,4 +1,4 @@ -// Package base provides shared context types for flowbuild operations. +// Package core provides shared context types for abstract interpretation. package core import ( @@ -19,6 +19,7 @@ import ( // This unified context replaces multiple Config types that had overlapping fields. type FlowContext struct { // Core graph and scope data + Fn *ast.FunctionExpr Graph *cfg.Graph Scopes map[cfg.Point]*scope.State @@ -28,6 +29,9 @@ type FlowContext struct { // Query context for memoization CallCtx *db.QueryContext + // Graphs provides canonical CFGs for function literals. + Graphs api.GraphProvider + // Type operations TypeOps core.TypeOps @@ -52,8 +56,13 @@ type FlowContext struct { ModuleAliases map[cfg.SymbolID]string ModuleBindings *bind.BindingTable - // Derived holds computed resolvers (populated by flowbuild.Run). + // Derived holds computed resolvers populated by the abstract interpreter. Derived *Derived + + // Evidence is the interpreter-owned graph event trace. It is populated before + // lowering so all reducer stages and downstream consumers share one event + // source instead of rediscovering CFG events. + Evidence api.FlowEvidence } // FlowServices defines required resolution services for flow extraction. diff --git a/compiler/check/flowbuild/core/context_test.go b/compiler/check/abstract/core/context_test.go similarity index 100% rename from compiler/check/flowbuild/core/context_test.go rename to compiler/check/abstract/core/context_test.go diff --git a/compiler/check/flowbuild/decl/decl.go b/compiler/check/abstract/decl/decl.go similarity index 84% rename from compiler/check/flowbuild/decl/decl.go rename to compiler/check/abstract/decl/decl.go index 3a812d87..34ccd418 100644 --- a/compiler/check/flowbuild/decl/decl.go +++ b/compiler/check/abstract/decl/decl.go @@ -3,9 +3,9 @@ package decl import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/tblutil" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/tblutil" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/modules" "github.com/wippyai/go-lua/compiler/check/scope" basecfg "github.com/wippyai/go-lua/types/cfg" @@ -85,6 +85,12 @@ func ExtractDeclaredTypes(fc *core.FlowContext, inputs *flow.Inputs) { } entry := fc.Graph.Entry() + paramSymbols := make(map[cfg.SymbolID]struct{}) + for _, slot := range fc.Graph.ParamSlotsReadOnly() { + if slot.Symbol != 0 { + paramSymbols[slot.Symbol] = struct{}{} + } + } bindings := fc.Graph.Bindings() if fc.CheckCtx != nil && fc.CheckCtx.Bindings() != nil { @@ -109,28 +115,32 @@ func ExtractDeclaredTypes(fc *core.FlowContext, inputs *flow.Inputs) { } if fc.Services != nil { - fc.Graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + for _, def := range fc.Evidence.FunctionDefinitions { + p := def.Nested.Point + info := def.FuncDef if info == nil || info.Name == "" || info.FuncExpr == nil { - return + continue } if info.TargetKind != cfg.FuncDefGlobal { - return + continue } sym := info.Symbol if sym == 0 { - return + continue } sc := fc.Scopes[p] fnType := fc.Services.ResolveFunctionSignature(info.FuncExpr, sc) if fnType != nil && len(fnType.Returns) > 0 { inputs.DeclaredTypes[sym] = fnType } - }) + } } - fc.Graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info if info == nil || !info.IsLocal { - return + continue } sc := fc.Scopes[p] @@ -158,7 +168,9 @@ func ExtractDeclaredTypes(fc *core.FlowContext, inputs *flow.Inputs) { inputs.DeclaredTypes[sym] = resolved } } else { - inputs.DeclaredTypes[sym] = resolved + if _, isParam := paramSymbols[sym]; !isParam || inputs.DeclaredTypes[sym] == nil { + inputs.DeclaredTypes[sym] = resolved + } annotate = true } } @@ -171,7 +183,9 @@ func ExtractDeclaredTypes(fc *core.FlowContext, inputs *flow.Inputs) { inputs.DeclaredTypes[sym] = resolved } } else { - inputs.DeclaredTypes[sym] = resolved + if _, isParam := paramSymbols[sym]; !isParam || inputs.DeclaredTypes[sym] == nil { + inputs.DeclaredTypes[sym] = resolved + } annotate = true } } @@ -192,7 +206,7 @@ func ExtractDeclaredTypes(fc *core.FlowContext, inputs *flow.Inputs) { } } }) - }) + } } // ExtractModuleAliases collects symbol -> module path mappings from require() assignments. @@ -201,7 +215,7 @@ func ExtractModuleAliases(fc *core.FlowContext, inputs *flow.Inputs) { if fc.Graph == nil || inputs == nil { return } - aliases := modules.CollectAliases(fc.Graph) + aliases := modules.AliasesFromAssignments(fc.Evidence.Assignments, fc.Graph) if len(aliases) == 0 { return } diff --git a/compiler/check/flowbuild/decl/decl_test.go b/compiler/check/abstract/decl/decl_test.go similarity index 95% rename from compiler/check/flowbuild/decl/decl_test.go rename to compiler/check/abstract/decl/decl_test.go index 03e96296..1bf7132c 100644 --- a/compiler/check/flowbuild/decl/decl_test.go +++ b/compiler/check/abstract/decl/decl_test.go @@ -5,7 +5,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/flow" @@ -181,8 +182,9 @@ func TestExtractDeclaredTypes_SoftAnnotationNotAnnotated(t *testing.T) { } fc := &core.FlowContext{ - Graph: graph, - Scopes: scopes, + Graph: graph, + Scopes: scopes, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Services: core.FlowServicesFuncs{ TypeExprResolver: func(ast.TypeExpr, *scope.State) typ.Type { return typ.NewMap(typ.String, typ.NewArray(typ.Any)) @@ -240,8 +242,9 @@ func TestExtractDeclaredTypes_UnannotatedLocalFunctionStaysUnannotated(t *testin } fc := &core.FlowContext{ - Graph: graph, - Scopes: scopes, + Graph: graph, + Scopes: scopes, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Services: core.FlowServicesFuncs{ // Simulate return-summary-enriched signature from upstream phases. FnSigResolver: func(*ast.FunctionExpr, *scope.State) *typ.Function { @@ -296,8 +299,9 @@ func TestExtractDeclaredTypes_ExplicitAnyMarksAnnotated(t *testing.T) { } fc := &core.FlowContext{ - Graph: graph, - Scopes: scopes, + Graph: graph, + Scopes: scopes, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Services: core.FlowServicesFuncs{ TypeExprResolver: func(ast.TypeExpr, *scope.State) typ.Type { return typ.Any diff --git a/compiler/check/flowbuild/decl/doc.go b/compiler/check/abstract/decl/doc.go similarity index 100% rename from compiler/check/flowbuild/decl/doc.go rename to compiler/check/abstract/decl/doc.go diff --git a/compiler/check/abstract/doc.go b/compiler/check/abstract/doc.go new file mode 100644 index 00000000..cda950a7 --- /dev/null +++ b/compiler/check/abstract/doc.go @@ -0,0 +1,8 @@ +// Package abstract owns the checker abstract interpreter boundary. +// +// The checker is organized as one product interpreter: +// - abstract lowers CFG/AST events into flow inputs, +// - flow solves those inputs to a product state, +// - query projects stable facts from the product state, +// - domain packages own join, widening, and equality laws. +package abstract diff --git a/compiler/check/abstract/evidence.go b/compiler/check/abstract/evidence.go new file mode 100644 index 00000000..b2b08d01 --- /dev/null +++ b/compiler/check/abstract/evidence.go @@ -0,0 +1,276 @@ +package abstract + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/typ" +) + +// ExtractEvidence records abstract-interpreter events that are consumed after +// the flow solution is available. It owns AST/CFG event discovery; later phases +// only reduce this evidence with narrowed expression types. +func ExtractEvidence(fc *core.FlowContext, inputs *flow.Inputs) api.FlowEvidence { + if fc == nil || fc.Graph == nil { + return api.FlowEvidence{} + } + bindings := graphBindings(fc.Graph, fc.ModuleBindings) + out := fc.Evidence + fc.Evidence = out + captured := capturedSymbolSet(bindings, fc.Fn) + out.CapturedFields = ExtractCapturedFieldEvidence(out.Assignments, captured) + out.CapturedContainers = ExtractCapturedContainerEvidence(fc, inputs, bindings, captured) + return out +} + +// MaterializeGraphEvidence returns the canonical interpreter-owned graph event +// trace for this flow context and stores it on the context for later reducers. +func MaterializeGraphEvidence(fc *core.FlowContext) api.FlowEvidence { + if fc == nil || fc.Graph == nil { + return api.FlowEvidence{} + } + if !fc.Evidence.IsZero() { + return fc.Evidence + } + fc.Evidence = trace.GraphEvidence(fc.Graph, graphBindings(fc.Graph, fc.ModuleBindings)) + return fc.Evidence +} + +// ExtractCapturedFieldEvidence records direct field writes to captured symbols. +func ExtractCapturedFieldEvidence( + assignments []api.AssignmentEvidence, + capturedSyms map[cfg.SymbolID]bool, +) []api.CapturedFieldEvidence { + if len(assignments) == 0 || len(capturedSyms) == 0 { + return nil + } + var out []api.CapturedFieldEvidence + for _, assign := range assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + for i, target := range info.Targets { + sym, field := capturedFieldTarget(target) + if sym == 0 || field == "" || !capturedSyms[sym] { + continue + } + var value ast.Expr + if i < len(info.Sources) { + value = info.Sources[i] + } + out = append(out, api.CapturedFieldEvidence{ + Point: p, + Target: sym, + Field: field, + Value: value, + }) + } + } + return out +} + +// ExtractCapturedContainerEvidence records table/container mutator calls that +// target captured symbols. +func ExtractCapturedContainerEvidence( + fc *core.FlowContext, + inputs *flow.Inputs, + bindings *bind.BindingTable, + capturedSyms map[cfg.SymbolID]bool, +) []api.CapturedContainerEvidence { + if fc == nil || fc.Graph == nil || len(capturedSyms) == 0 { + return nil + } + + var out []api.CapturedContainerEvidence + assignmentTypes := resolve.BuildAssignmentTypeResolver(inputs) + constResolverAt := func(p cfg.Point) func(string) *flow.ConstValue { + if inputs == nil { + return nil + } + return predicate.BuildConstResolver(inputs, p) + } + for _, call := range fc.Evidence.Calls { + p := call.Point + info := call.Info + if info == nil { + continue + } + + if ceu := calleffect.ContainerMutatorFromCall( + info, + p, + derivedSynth(fc), + derivedSymResolver(fc), + assignmentTypes, + fc.Graph, + bindings, + fc.ModuleBindings, + ); ceu != nil { + target := callsite.RuntimeArgAt(info, ceu.Container.Index) + value := callsite.RuntimeArgAt(info, ceu.Value.Index) + out = appendCapturedContainerEvidence(out, fc.Graph, bindings, constResolverAt(p), capturedSyms, target, value, p, api.ContainerMutationContainerElement) + } + + if tm := calleffect.TableMutatorFromCall( + info, + p, + derivedSynth(fc), + derivedSymResolver(fc), + fc.Graph, + bindings, + fc.ModuleBindings, + ); tm != nil { + target := callsite.RuntimeArgAt(info, tm.Target.Index) + value := callsite.RuntimeArgAt(info, tm.Value.Index) + out = appendCapturedContainerEvidence(out, fc.Graph, bindings, constResolverAt(p), capturedSyms, target, value, p, api.ContainerMutationTableElement) + } + } + for _, assign := range fc.Evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + for i, target := range info.Targets { + if target.Kind != cfg.TargetIndex || target.Base == nil || target.Key == nil { + continue + } + var value ast.Expr + if i < len(info.Sources) { + value = info.Sources[i] + } + out = appendCapturedIndexAssignmentEvidence(out, fc.Graph, bindings, constResolverAt(p), capturedSyms, target.Base, target.Key, value, p) + } + } + return out +} + +func appendCapturedContainerEvidence( + out []api.CapturedContainerEvidence, + graph *cfg.Graph, + bindings *bind.BindingTable, + constResolver func(string) *flow.ConstValue, + capturedSyms map[cfg.SymbolID]bool, + target ast.Expr, + value ast.Expr, + p cfg.Point, + kind api.ContainerMutationKind, +) []api.CapturedContainerEvidence { + if target == nil || value == nil { + return out + } + path := flowpath.FromExprWithBindingsAt(target, constResolver, bindings, graph, p) + if path.IsEmpty() || path.Symbol == 0 || !capturedSyms[path.Symbol] { + return out + } + segments := make([]constraint.Segment, len(path.Segments)) + copy(segments, path.Segments) + return append(out, api.CapturedContainerEvidence{ + Point: p, + Target: path.Symbol, + Segments: segments, + Value: value, + Kind: kind, + }) +} + +func appendCapturedIndexAssignmentEvidence( + out []api.CapturedContainerEvidence, + graph *cfg.Graph, + bindings *bind.BindingTable, + constResolver func(string) *flow.ConstValue, + capturedSyms map[cfg.SymbolID]bool, + base ast.Expr, + key ast.Expr, + value ast.Expr, + p cfg.Point, +) []api.CapturedContainerEvidence { + if base == nil || key == nil || value == nil { + return out + } + path := flowpath.FromExprWithBindingsAt(base, constResolver, bindings, graph, p) + if path.IsEmpty() || path.Symbol == 0 || !capturedSyms[path.Symbol] { + return out + } + segments := make([]constraint.Segment, len(path.Segments)) + copy(segments, path.Segments) + return append(out, api.CapturedContainerEvidence{ + Point: p, + Target: path.Symbol, + Segments: segments, + Key: key, + Value: value, + Kind: api.ContainerMutationMapElement, + }) +} + +func capturedFieldTarget(target cfg.AssignTarget) (cfg.SymbolID, string) { + switch target.Kind { + case cfg.TargetField: + if target.BaseSymbol != 0 && len(target.FieldPath) == 1 { + return target.BaseSymbol, target.FieldPath[0] + } + case cfg.TargetIndex: + if target.BaseSymbol != 0 && target.Key != nil { + if key, ok := target.Key.(*ast.StringExpr); ok && key.Value != "" { + return target.BaseSymbol, key.Value + } + } + } + return 0, "" +} + +func capturedSymbolSet(bindings *bind.BindingTable, fn *ast.FunctionExpr) map[cfg.SymbolID]bool { + if bindings == nil || fn == nil { + return nil + } + captured := bindings.CapturedSymbols(fn) + if len(captured) == 0 { + return nil + } + set := make(map[cfg.SymbolID]bool, len(captured)) + for _, sym := range captured { + if sym != 0 { + set[sym] = true + } + } + if len(set) == 0 { + return nil + } + return set +} + +func graphBindings(graph *cfg.Graph, module *bind.BindingTable) *bind.BindingTable { + if graph != nil { + if bindings := graph.Bindings(); bindings != nil { + return bindings + } + } + return module +} + +func derivedSynth(fc *core.FlowContext) func(ast.Expr, cfg.Point) typ.Type { + if fc == nil || fc.Derived == nil { + return nil + } + return fc.Derived.Synth +} + +func derivedSymResolver(fc *core.FlowContext) func(cfg.Point, cfg.SymbolID) (typ.Type, bool) { + if fc == nil || fc.Derived == nil { + return nil + } + return fc.Derived.SymResolver +} diff --git a/compiler/check/abstract/evidence_test.go b/compiler/check/abstract/evidence_test.go new file mode 100644 index 00000000..a826a0b0 --- /dev/null +++ b/compiler/check/abstract/evidence_test.go @@ -0,0 +1,223 @@ +package abstract_test + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/parse" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/effect" + "github.com/wippyai/go-lua/types/typ" +) + +func TestExtractCapturedFieldEvidence(t *testing.T) { + graph := graphFromSource(t, ` +local c = {} +c.name = "worker" +c["count"] = 1 +`) + symC := mustSymbol(t, graph, "c") + evidence := trace.GraphEvidence(graph, graph.Bindings()) + got := abstract.ExtractCapturedFieldEvidence(evidence.Assignments, map[cfg.SymbolID]bool{symC: true}) + + fields := make(map[string]bool) + for _, ev := range got { + if ev.Target == symC { + fields[ev.Field] = true + } + } + for _, field := range []string{"name", "count"} { + if !fields[field] { + t.Fatalf("missing captured field evidence for %q; all=%v", field, fields) + } + } +} + +func TestExtractCapturedContainerEvidence(t *testing.T) { + graph := graphFromSource(t, ` +local c = {} +local _ = send(c, 1) +local _ = table.insert(c.items, 2) +`) + symC := mustSymbol(t, graph, "c") + got := abstract.ExtractCapturedContainerEvidence(&core.FlowContext{ + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), + Derived: &core.Derived{ + Synth: capturedMutationSynth(), + }, + }, nil, graph.Bindings(), map[cfg.SymbolID]bool{symC: true}) + + var sawContainer, sawTable bool + for _, ev := range got { + if ev.Target != symC { + continue + } + switch ev.Kind { + case api.ContainerMutationContainerElement: + sawContainer = true + case api.ContainerMutationTableElement: + sawTable = len(ev.Segments) == 1 && ev.Segments[0].Name == "items" + } + } + if !sawContainer { + t.Fatal("missing captured generic container mutation evidence") + } + if !sawTable { + t.Fatalf("missing captured table mutation evidence with .items path; all=%#v", got) + } +} + +func TestExtractFunctionEscapeEvidence(t *testing.T) { + graph := graphFromSource(t, ` +local api = {} +local function local_worker() + return nil +end +api.worker = local_worker +function api.add() + return nil +end +`) + bindings := graph.Bindings() + localSym := mustSymbol(t, graph, "local_worker") + + var addSym cfg.SymbolID + graph.EachFuncDef(func(_ cfg.Point, info *cfg.FuncDefInfo) { + if info != nil && info.Name == "add" { + addSym = info.Symbol + } + }) + if addSym == 0 { + t.Fatal("expected api.add symbol") + } + + got := trace.FunctionEscapes(graph, bindings) + seen := make(map[cfg.SymbolID]bool) + for _, ev := range got { + seen[ev.Symbol] = true + } + if !seen[localSym] { + t.Fatalf("missing field-assigned local function escape; all=%v", seen) + } + if !seen[addSym] { + t.Fatalf("missing function-definition escape; all=%v", seen) + } +} + +func TestExtractFunctionDefinitionEvidence(t *testing.T) { + graph := graphFromSource(t, ` +local api = {} +local assigned = function() + return 1 +end +local function named() + return assigned() +end +function api.add() + return named() +end +`) + assignedSym := mustSymbol(t, graph, "assigned") + namedSym := mustSymbol(t, graph, "named") + + got := trace.FunctionDefinitions(graph) + bySym := make(map[cfg.SymbolID]api.FunctionDefinitionEvidence) + for _, ev := range got { + if ev.Symbol != 0 { + bySym[ev.Symbol] = ev + } + } + + assigned := bySym[assignedSym] + if assigned.Symbol != assignedSym || assigned.Name != "assigned" || !assigned.IsLocal || assigned.FuncDef != nil { + t.Fatalf("assigned function evidence = %#v", assigned) + } + named := bySym[namedSym] + if named.Symbol != namedSym || named.Name != "named" || !named.IsLocal { + t.Fatalf("named function evidence = %#v", named) + } + + var sawFieldDefinition bool + for _, ev := range got { + if ev.FuncDef != nil && ev.FuncDef.Name == "add" && ev.Symbol != 0 { + sawFieldDefinition = true + } + } + if !sawFieldDefinition { + t.Fatalf("missing api.add function-definition evidence; all=%#v", got) + } +} + +func graphFromSource(t *testing.T, source string) *cfg.Graph { + t.Helper() + stmts, err := parse.ParseString(source, "evidence.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{ + ParList: &ast.ParList{HasVargs: true}, + Stmts: stmts, + }, "send", "table") + if graph == nil { + t.Fatal("expected graph") + } + return graph +} + +func mustSymbol(t *testing.T, graph *cfg.Graph, name string) cfg.SymbolID { + t.Helper() + sym, ok := graph.SymbolAt(graph.Exit(), name) + if !ok || sym == 0 { + t.Fatalf("expected symbol for %s", name) + } + return sym +} + +func capturedMutationSynth() func(ast.Expr, cfg.Point) typ.Type { + sendSpec := contract.NewSpec().WithEffects(effect.Mutate{ + Target: effect.ParamRef{Index: 0}, + Transform: effect.ContainerElementUnion{ + Container: effect.ParamRef{Index: 0}, + Value: effect.ParamRef{Index: 1}, + }, + }) + send := typ.Func(). + Param("container", typ.Any). + Param("value", typ.Any). + Returns(typ.Nil). + Spec(sendSpec). + Build() + + insertSpec := contract.NewSpec().WithEffects(effect.TableMutator{ + Target: effect.ParamRef{Index: 0}, + Value: effect.ParamRef{Index: 1}, + }) + insert := typ.Func(). + Param("target", typ.Any). + Param("value", typ.Any). + Returns(typ.Nil). + Spec(insertSpec). + Build() + + return func(expr ast.Expr, _ cfg.Point) typ.Type { + switch v := expr.(type) { + case *ast.IdentExpr: + if v.Value == "send" { + return send + } + case *ast.AttrGetExpr: + obj, objOK := v.Object.(*ast.IdentExpr) + key, keyOK := v.Key.(*ast.StringExpr) + if objOK && keyOK && obj.Value == "table" && key.Value == "insert" { + return insert + } + } + return typ.Unknown + } +} diff --git a/compiler/check/flowbuild/extract_assign_test.go b/compiler/check/abstract/extract_assign_test.go similarity index 90% rename from compiler/check/flowbuild/extract_assign_test.go rename to compiler/check/abstract/extract_assign_test.go index eb9769dd..35dd363f 100644 --- a/compiler/check/flowbuild/extract_assign_test.go +++ b/compiler/check/abstract/extract_assign_test.go @@ -1,18 +1,19 @@ -package flowbuild_test +package abstract_test import ( "testing" "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/assign" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/constprop" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/decl" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/constprop" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/decl" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -117,8 +118,8 @@ func TestLinkKey(t *testing.T) { } } -func TestCollectInferredTypes_NilGraph(t *testing.T) { - result := assign.CollectInferredTypes(&core.FlowContext{}, nil, nil, nil) +func TestInferLocalTypes_NilGraph(t *testing.T) { + result := assign.InferLocalTypes(assign.LocalInferenceConfig{}) if result == nil { t.Error("expected non-nil map") } @@ -145,7 +146,8 @@ func TestExtractIterSource_NotCall(t *testing.T) { func TestBuildReceiverDependencies_EmptyGraph(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{}} graph := cfg.Build(fn) - result := assign.BuildReceiverDependencies(graph) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := assign.BuildReceiverDependencies(evidence.Assignments) if result == nil { t.Error("expected non-nil map") } @@ -187,7 +189,8 @@ func TestSpecNarrowedTypes_EmptyGraph(t *testing.T) { synth := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } - result := assign.CollectSpecNarrowedTypes(graph, scopes, synth, nil, nil, nil) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + result := assign.CollectSpecNarrowedTypes(graph, evidence.Assignments, scopes, synth, nil, nil, nil) if result == nil { t.Error("expected non-nil SpecTypes map") } diff --git a/compiler/check/flowbuild/extract_constraints_test.go b/compiler/check/abstract/extract_constraints_test.go similarity index 81% rename from compiler/check/flowbuild/extract_constraints_test.go rename to compiler/check/abstract/extract_constraints_test.go index 2590bfdd..d75209d4 100644 --- a/compiler/check/flowbuild/extract_constraints_test.go +++ b/compiler/check/abstract/extract_constraints_test.go @@ -1,14 +1,15 @@ -package flowbuild_test +package abstract_test import ( "testing" "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" checkeffects "github.com/wippyai/go-lua/compiler/check/effects" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -113,53 +114,6 @@ func TestExtractEffectFromType_AliasedFunction(t *testing.T) { } } -func TestResolveCalleeToFunctionLiteral_NilCallee(t *testing.T) { - result := resolve.ResolveCalleeToFunctionLiteral(nil, nil) - if result != nil { - t.Error("expected nil for nil callee") - } -} - -func TestResolveCalleeToFunctionLiteral_DirectFunctionLiteral(t *testing.T) { - fn := &ast.FunctionExpr{} - result := resolve.ResolveCalleeToFunctionLiteral(fn, nil) - if result != fn { - t.Error("expected same function literal") - } -} - -func TestResolveCalleeToFunctionLiteral_Ident(t *testing.T) { - ident := &ast.IdentExpr{Value: "fn"} - result := resolve.ResolveCalleeToFunctionLiteral(ident, nil) - if result != nil { - t.Error("expected nil for ident without graph") - } -} - -func TestResolveExprToTableLiteral_NilExpr(t *testing.T) { - result := resolve.ResolveExprToTableLiteral(nil, nil) - if result != nil { - t.Error("expected nil for nil expr") - } -} - -func TestResolveExprToTableLiteral_DirectTable(t *testing.T) { - tbl := &ast.TableExpr{} - // Function returns nil when graph is nil (guards against nil graph access) - result := resolve.ResolveExprToTableLiteral(tbl, nil) - if result != nil { - t.Error("expected nil when graph is nil") - } -} - -func TestResolveExprToTableLiteral_IdentWithoutGraph(t *testing.T) { - ident := &ast.IdentExpr{Value: "tbl"} - result := resolve.ResolveExprToTableLiteral(ident, nil) - if result != nil { - t.Error("expected nil without graph") - } -} - func TestCallTerminates_NilInfo(t *testing.T) { result := cond.CallTerminates(nil, 0, nil, nil, nil, nil, nil) if result { @@ -181,7 +135,7 @@ func TestCallTerminates_NormalFunction(t *testing.T) { } func TestConstraintsFromCallOnReturn_NilInfo(t *testing.T) { - result := cond.ConstraintsFromCallOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := cond.ConstraintsFromCallOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("expected no constraints for nil info") } @@ -192,7 +146,7 @@ func TestConstraintsFromCallOnReturn_MethodCall(t *testing.T) { Method: "foo", Receiver: &ast.IdentExpr{Value: "obj"}, } - result := cond.ConstraintsFromCallOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := cond.ConstraintsFromCallOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("expected no constraints for method call") } @@ -203,14 +157,14 @@ func TestConstraintsFromCallOnReturn_NoArgs(t *testing.T) { Callee: &ast.IdentExpr{Value: "fn"}, Args: nil, } - result := cond.ConstraintsFromCallOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := cond.ConstraintsFromCallOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("expected no constraints for call without args") } } func TestConstraintsFromAssignOnReturn_NilInfo(t *testing.T) { - result := cond.ConstraintsFromAssignOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := cond.ConstraintsFromAssignOnReturn(nil, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("expected no constraints for nil info") } @@ -220,7 +174,7 @@ func TestConstraintsFromAssignOnReturn_NoSourceCalls(t *testing.T) { info := &cfg.AssignInfo{ SourceCalls: nil, } - result := cond.ConstraintsFromAssignOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil) + result := cond.ConstraintsFromAssignOnReturn(info, 0, nil, nil, nil, nil, nil, nil, nil, nil, nil, api.FlowEvidence{}) if result.HasConstraints() { t.Error("expected no constraints without source calls") } @@ -286,7 +240,7 @@ func TestExtractLenOfPath_LenOfIdent(t *testing.T) { } } -func TestNumericConstraintsFromExpr(t *testing.T) { +func TestNumericBranchConstraintsFromExpr(t *testing.T) { // Test basic comparison extraction expr := &ast.RelationalOpExpr{ Operator: "<", @@ -296,16 +250,16 @@ func TestNumericConstraintsFromExpr(t *testing.T) { inputs := &flow.Inputs{ ConstValues: make(map[cfg.SymbolID]map[cfg.Point]*flow.ConstValue), } - result := cond.NumericConstraintsFromExpr(expr, 0, inputs) - if len(result) == 0 { - t.Error("expected numeric constraints from comparison") + result := cond.NumericBranchConstraintsFromExpr(expr, 0, inputs) + if len(result.OnTrue) == 0 { + t.Error("expected true-edge numeric constraints from comparison") } } -func TestNumericConstraintsFromExpr_NilExpr(t *testing.T) { +func TestNumericBranchConstraintsFromExpr_NilExpr(t *testing.T) { inputs := &flow.Inputs{} - result := cond.NumericConstraintsFromExpr(nil, 0, inputs) - if len(result) != 0 { + result := cond.NumericBranchConstraintsFromExpr(nil, 0, inputs) + if len(result.OnTrue) != 0 || len(result.OnFalse) != 0 { t.Error("expected no constraints for nil expr") } } diff --git a/compiler/check/flowbuild/extract_core_test.go b/compiler/check/abstract/extract_core_test.go similarity index 95% rename from compiler/check/flowbuild/extract_core_test.go rename to compiler/check/abstract/extract_core_test.go index f5ea9307..ea90d563 100644 --- a/compiler/check/flowbuild/extract_core_test.go +++ b/compiler/check/abstract/extract_core_test.go @@ -1,4 +1,4 @@ -package flowbuild_test +package abstract_test import ( "sort" @@ -6,13 +6,13 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract" + "github.com/wippyai/go-lua/compiler/check/abstract/assign" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/decl" + "github.com/wippyai/go-lua/compiler/check/abstract/tblutil" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/decl" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/tblutil" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/kind" @@ -20,14 +20,14 @@ import ( ) func TestExtract_NilGraph(t *testing.T) { - result := flowbuild.Run(&core.FlowContext{}) + result := abstract.BuildInputs(&core.FlowContext{}) if result != nil { t.Error("expected nil for nil graph") } } func TestExtractFromConfig_NilGraph(t *testing.T) { - result := flowbuild.Run(&core.FlowContext{}) + result := abstract.BuildInputs(&core.FlowContext{}) if result != nil { t.Error("expected nil for nil graph") } @@ -39,7 +39,7 @@ func TestExtractFromConfig_MinimalGraph(t *testing.T) { } graph := cfg.Build(fn) - result := flowbuild.Run(&core.FlowContext{ + result := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Base: scope.New(), }) @@ -62,7 +62,7 @@ func TestExtractFromConfig_WithGlobals(t *testing.T) { "pairs": &typ.Function{}, } - result := flowbuild.Run(&core.FlowContext{ + result := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Base: scope.New(), Globals: globals, @@ -83,7 +83,7 @@ func TestExtractFromConfig_WithInitialDeclaredTypes(t *testing.T) { cfg.SymbolID(2): typ.Integer, } - result := flowbuild.Run(&core.FlowContext{ + result := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Base: scope.New(), InitialDeclaredTypes: initial, @@ -106,7 +106,7 @@ func TestExtractFromConfig_WithSiblingTypes(t *testing.T) { 1: typ.String, } - result := flowbuild.Run(&core.FlowContext{ + result := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Base: scope.New(), SiblingTypes: siblings, @@ -129,7 +129,7 @@ func TestExtractFromConfig_WithLiteralTypes(t *testing.T) { 1: &typ.Function{}, } - result := flowbuild.Run(&core.FlowContext{ + result := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Base: scope.New(), LiteralTypes: literals, @@ -314,7 +314,7 @@ func TestFunctionHasAnnotations_NilFn(t *testing.T) { func TestMergeCallConstraintsIntoEdges_Empty(t *testing.T) { inputs := &flow.Inputs{} - flowbuild.MergeCallConstraintsIntoEdges(inputs, nil) + abstract.MergeCallConstraintsIntoEdges(inputs, nil) if len(inputs.EdgeConditions) != 0 { t.Error("expected no edge conditions for empty input") } diff --git a/compiler/check/flowbuild/extract_test.go b/compiler/check/abstract/extract_test.go similarity index 97% rename from compiler/check/flowbuild/extract_test.go rename to compiler/check/abstract/extract_test.go index 2775e8e5..7e9376b2 100644 --- a/compiler/check/flowbuild/extract_test.go +++ b/compiler/check/abstract/extract_test.go @@ -1,13 +1,13 @@ -package flowbuild_test +package abstract_test import ( "testing" "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract" + "github.com/wippyai/go-lua/compiler/check/abstract/core" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" "github.com/wippyai/go-lua/compiler/check/phase" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/parse" @@ -144,7 +144,7 @@ local y = x p, info.CalleeName, info.Method, len(info.Args), info.Callee, info.ArgNames) }) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -263,7 +263,7 @@ local y = x.field return nil } - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -338,7 +338,7 @@ local result = M.add(1, 2) synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -411,7 +411,7 @@ func TestDeclaredTypes_AnnotatedLocal(t *testing.T) { AnnotatedVars: annotatedVars, BaseScope: base, }) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -459,7 +459,7 @@ func TestDeclaredTypes_UnannotatedLocal(t *testing.T) { synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -525,7 +525,7 @@ end synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -608,7 +608,7 @@ end synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -691,7 +691,7 @@ end synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -761,7 +761,7 @@ end } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -852,7 +852,7 @@ local y: string = "a" AnnotatedVars: annotatedVars, BaseScope: base, }) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -938,7 +938,7 @@ func TestMultiReturnExpansion(t *testing.T) { return nil } - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -1066,7 +1066,7 @@ func TestMultiReturnExpansion_TrailingCallBuildsTailSiblingAssignments(t *testin return nil } - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -1155,7 +1155,7 @@ func TestDiscardSlot(t *testing.T) { synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -1234,7 +1234,7 @@ local x = getValue() } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -1311,7 +1311,7 @@ local b = a synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return typ.Number } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, @@ -1368,7 +1368,7 @@ local msg = ch:receive() synthFunc := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } ctx := buildTestContext(graph, base) - inputs := flowbuild.Run(&core.FlowContext{ + inputs := abstract.BuildInputs(&core.FlowContext{ Graph: graph, Scopes: scopes, CheckCtx: ctx, Base: base, diff --git a/compiler/check/flowbuild/literal/doc.go b/compiler/check/abstract/literal/doc.go similarity index 100% rename from compiler/check/flowbuild/literal/doc.go rename to compiler/check/abstract/literal/doc.go diff --git a/compiler/check/flowbuild/literal/literal.go b/compiler/check/abstract/literal/literal.go similarity index 100% rename from compiler/check/flowbuild/literal/literal.go rename to compiler/check/abstract/literal/literal.go diff --git a/compiler/check/flowbuild/literal/literal_test.go b/compiler/check/abstract/literal/literal_test.go similarity index 99% rename from compiler/check/flowbuild/literal/literal_test.go rename to compiler/check/abstract/literal/literal_test.go index 81c74f6d..5c982d3a 100644 --- a/compiler/check/flowbuild/literal/literal_test.go +++ b/compiler/check/abstract/literal/literal_test.go @@ -6,7 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/literal" + "github.com/wippyai/go-lua/compiler/check/abstract/literal" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/kind" "github.com/wippyai/go-lua/types/typ" diff --git a/compiler/check/abstract/mutator/container.go b/compiler/check/abstract/mutator/container.go new file mode 100644 index 00000000..760500a5 --- /dev/null +++ b/compiler/check/abstract/mutator/container.go @@ -0,0 +1,80 @@ +package mutator + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/typ" +) + +// ExtractContainerMutatorAssignments extracts container mutator assignments (channel.send-like) +// from call sites in the graph and appends them to inputs.ContainerMutatorAssignments. +func ExtractContainerMutatorAssignments(fc *core.FlowContext, inputs *flow.Inputs) { + if fc.Graph == nil || inputs == nil { + return + } + + bindings := fc.Graph.Bindings() + + // Build a resolver that can look up types from the just-extracted assignments + assignmentTypes := resolve.BuildAssignmentTypeResolver(inputs) + + for _, call := range fc.Evidence.Calls { + p := call.Point + info := call.Info + if info == nil { + continue + } + + cm := calleffect.ContainerMutatorFromCall(info, p, fc.Derived.Synth, fc.Derived.SymResolver, assignmentTypes, fc.Graph, bindings, fc.ModuleBindings) + if cm == nil { + continue + } + + targetExpr := callsite.RuntimeArgAt(info, cm.Container.Index) + valueExpr := callsite.RuntimeArgAt(info, cm.Value.Index) + + if targetExpr == nil || valueExpr == nil { + continue + } + + sc := fc.Scopes[p] + valueType := typ.Unknown + if fc.Derived != nil && fc.Derived.Synth != nil { + if t := fc.Derived.Synth(valueExpr, p); t != nil { + valueType = t + } + } + valueType = resolve.Ref(valueType, sc) + + var valuePath constraint.Path + if ident, ok := valueExpr.(*ast.IdentExpr); ok && bindings != nil { + if sym, found := bindings.SymbolOf(ident); found && sym != 0 { + valuePath = constraint.Path{ + Root: resolve.RootNameFromBindings(bindings, sym, ident.Value), + Symbol: sym, + } + } + } + + constResolver := predicate.BuildConstResolver(inputs, p) + if path := flowpath.FromExprWithBindingsAt(targetExpr, constResolver, bindings, fc.Graph, p); !path.IsEmpty() && path.Symbol != 0 { + inputs.ContainerMutatorAssignments = append(inputs.ContainerMutatorAssignments, flow.ContainerMutatorAssignment{ + Point: p, + Target: constraint.Path{ + Root: resolve.RootNameFromBindings(bindings, path.Symbol, path.Root), + Symbol: path.Symbol, + Segments: path.Segments, + }, + ValuePath: valuePath, + ValueType: valueType, + }) + } + } +} diff --git a/compiler/check/flowbuild/mutator/container_effect_test.go b/compiler/check/abstract/mutator/container_effect_test.go similarity index 100% rename from compiler/check/flowbuild/mutator/container_effect_test.go rename to compiler/check/abstract/mutator/container_effect_test.go diff --git a/compiler/check/flowbuild/mutator/container_test.go b/compiler/check/abstract/mutator/container_test.go similarity index 90% rename from compiler/check/flowbuild/mutator/container_test.go rename to compiler/check/abstract/mutator/container_test.go index 1376e673..be2a7d21 100644 --- a/compiler/check/flowbuild/mutator/container_test.go +++ b/compiler/check/abstract/mutator/container_test.go @@ -5,8 +5,10 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/flow" @@ -31,7 +33,7 @@ func TestExtractContainerMutatorAssignments_NilInputs(t *testing.T) { } func TestContainerElementReturnInfo(t *testing.T) { - info := ContainerElementReturnInfo{ + info := calleffect.ContainerElementReturnInfo{ ReturnIndex: 1, } if info.ReturnIndex != 1 { @@ -40,7 +42,7 @@ func TestContainerElementReturnInfo(t *testing.T) { } func TestContainerElementReturnFromCall_NilInfo(t *testing.T) { - result := ContainerElementReturnFromCall(nil, 0, nil, nil, nil, nil, nil, nil) + result := calleffect.ContainerElementReturnFromCall(nil, 0, nil, nil, nil, nil, nil, nil) if result != nil { t.Error("expected nil for nil info") } @@ -113,7 +115,8 @@ func TestExtractContainerMutatorAssignments_AssignmentCallSite(t *testing.T) { } ExtractContainerMutatorAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: containerSendSynth(), }, diff --git a/compiler/check/flowbuild/mutator/doc.go b/compiler/check/abstract/mutator/doc.go similarity index 100% rename from compiler/check/flowbuild/mutator/doc.go rename to compiler/check/abstract/mutator/doc.go diff --git a/compiler/check/flowbuild/mutator/extract_table_mutator_test.go b/compiler/check/abstract/mutator/extract_table_mutator_test.go similarity index 91% rename from compiler/check/flowbuild/mutator/extract_table_mutator_test.go rename to compiler/check/abstract/mutator/extract_table_mutator_test.go index 431e3e73..575335c4 100644 --- a/compiler/check/flowbuild/mutator/extract_table_mutator_test.go +++ b/compiler/check/abstract/mutator/extract_table_mutator_test.go @@ -5,11 +5,12 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/literal" + "github.com/wippyai/go-lua/compiler/check/abstract/mutator" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/literal" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -57,7 +58,7 @@ func TestRootNameFromBindings_TableMutator_ZeroSymbol(t *testing.T) { } func TestTableMutatorFromCall_NilInfo(t *testing.T) { - result := mutator.TableMutatorFromCall(nil, 0, nil, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(nil, 0, nil, nil, nil, nil, nil) if result != nil { t.Error("expected nil for nil info") } @@ -65,7 +66,7 @@ func TestTableMutatorFromCall_NilInfo(t *testing.T) { func TestTableMutatorFromCall_NoCallee(t *testing.T) { info := &cfg.CallInfo{} - result := mutator.TableMutatorFromCall(info, 0, nil, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(info, 0, nil, nil, nil, nil, nil) if result != nil { t.Error("expected nil for info without callee") } @@ -76,7 +77,7 @@ func TestTableMutatorFromCall_MethodCall(t *testing.T) { Method: "insert", Receiver: &ast.IdentExpr{Value: "table"}, } - result := mutator.TableMutatorFromCall(info, 0, nil, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(info, 0, nil, nil, nil, nil, nil) if result != nil { t.Error("expected nil for method call (not plain function)") } @@ -89,7 +90,7 @@ func TestTableMutatorFromCall_NonFunction(t *testing.T) { synth := func(expr ast.Expr, p cfg.Point) typ.Type { return typ.String // Not a function } - result := mutator.TableMutatorFromCall(info, 0, synth, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(info, 0, synth, nil, nil, nil, nil) if result != nil { t.Error("expected nil for non-function type") } @@ -102,7 +103,7 @@ func TestTableMutatorFromCall_FunctionWithoutSpec(t *testing.T) { synth := func(expr ast.Expr, p cfg.Point) typ.Type { return &typ.Function{} // No spec } - result := mutator.TableMutatorFromCall(info, 0, synth, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(info, 0, synth, nil, nil, nil, nil) if result != nil { t.Error("expected nil for function without spec") } diff --git a/compiler/check/abstract/mutator/helpers_test.go b/compiler/check/abstract/mutator/helpers_test.go new file mode 100644 index 00000000..005395e1 --- /dev/null +++ b/compiler/check/abstract/mutator/helpers_test.go @@ -0,0 +1,66 @@ +package mutator + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/parse" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/effect" + "github.com/wippyai/go-lua/types/typ" +) + +func buildGraph(t *testing.T, code string, globals ...string) *cfg.Graph { + t.Helper() + stmts, err := parse.ParseString(code, "test.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + fn := &ast.FunctionExpr{ParList: &ast.ParList{HasVargs: true}, Stmts: stmts} + graph := cfg.Build(fn, globals...) + if graph == nil { + t.Fatal("expected non-nil graph") + } + return graph +} + +func tableInsertSynth() func(ast.Expr, cfg.Point) typ.Type { + spec := contract.NewSpec().WithEffects(effect.TableMutator{ + Target: effect.ParamRef{Index: 0}, + Value: effect.ParamRef{Index: 1}, + }) + tableInsert := typ.Func(). + Param("target", typ.Any). + Param("value", typ.Any). + Returns(typ.Nil). + Spec(spec). + Build() + + return func(expr ast.Expr, _ cfg.Point) typ.Type { + switch v := expr.(type) { + case *ast.AttrGetExpr: + obj, ok := v.Object.(*ast.IdentExpr) + if !ok || obj.Value != "table" { + return typ.Unknown + } + switch key := v.Key.(type) { + case *ast.IdentExpr: + if key.Value == "insert" { + return tableInsert + } + case *ast.StringExpr: + if key.Value == "insert" { + return tableInsert + } + } + case *ast.NumberExpr: + return typ.Integer + case *ast.IdentExpr: + if v.Value == "k" { + return typ.String + } + } + return typ.Unknown + } +} diff --git a/compiler/check/flowbuild/mutator/table.go b/compiler/check/abstract/mutator/table.go similarity index 70% rename from compiler/check/flowbuild/mutator/table.go rename to compiler/check/abstract/mutator/table.go index d473261c..f0a01c3f 100644 --- a/compiler/check/flowbuild/mutator/table.go +++ b/compiler/check/abstract/mutator/table.go @@ -2,17 +2,14 @@ package mutator import ( "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/literal" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/literal" - flowpath "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/constraint" - "github.com/wippyai/go-lua/types/contract" - "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" ) @@ -26,19 +23,21 @@ func ExtractTableMutatorAssignments(fc *core.FlowContext, inputs *flow.Inputs) { bindings := fc.Graph.Bindings() - fc.Graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range fc.Evidence.Calls { + p := call.Point + info := call.Info if info == nil { - return + continue } - tm := TableMutatorFromCall(info, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Graph, bindings, fc.ModuleBindings) + tm := calleffect.TableMutatorFromCall(info, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Graph, bindings, fc.ModuleBindings) if tm == nil { - return + continue } targetExpr := callsite.RuntimeArgAt(info, tm.Target.Index) valueExpr := callsite.RuntimeArgAt(info, tm.Value.Index) if targetExpr == nil || valueExpr == nil { - return + continue } sc := fc.Scopes[p] @@ -75,17 +74,17 @@ func ExtractTableMutatorAssignments(fc *core.FlowContext, inputs *flow.Inputs) { ValuePath: valuePath, ValueType: valueType, }) - return + continue } // Handle dynamic index targets like suites[suite] attr, ok := targetExpr.(*ast.AttrGetExpr) if !ok { - return + continue } basePath := flowpath.FromExprWithBindingsAt(attr.Object, constResolver, bindings, fc.Graph, p) if basePath.IsEmpty() || basePath.Symbol == 0 { - return + continue } assign := flow.TableMutatorAssignment{ Point: p, @@ -110,30 +109,5 @@ func ExtractTableMutatorAssignments(fc *core.FlowContext, inputs *flow.Inputs) { } inputs.TableMutatorAssignments = append(inputs.TableMutatorAssignments, assign) - }) -} - -func TableMutatorFromCall( - info *cfg.CallInfo, - p cfg.Point, - synth func(ast.Expr, cfg.Point) typ.Type, - symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), - graph *cfg.Graph, - bindings *bind.BindingTable, - moduleBindings *bind.BindingTable, -) *effect.TableMutator { - if info == nil { - return nil - } - - fnType := resolve.CalleeType(info, p, synth, symResolver, nil, graph, bindings, moduleBindings) - if fnType == nil { - return nil - } - - spec := contract.ExtractSpec(fnType) - if spec == nil { - return nil } - return spec.GetTableMutator() } diff --git a/compiler/check/flowbuild/mutator/table_test.go b/compiler/check/abstract/mutator/table_test.go similarity index 75% rename from compiler/check/flowbuild/mutator/table_test.go rename to compiler/check/abstract/mutator/table_test.go index 963253d4..90dc62ae 100644 --- a/compiler/check/flowbuild/mutator/table_test.go +++ b/compiler/check/abstract/mutator/table_test.go @@ -5,9 +5,12 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/literal" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/literal" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/flow" @@ -32,7 +35,7 @@ func TestExtractTableMutatorAssignments_NilInputs(t *testing.T) { } func TestTableMutatorFromCall_NilInfo(t *testing.T) { - result := TableMutatorFromCall(nil, 0, nil, nil, nil, nil, nil) + result := calleffect.TableMutatorFromCall(nil, 0, nil, nil, nil, nil, nil) if result != nil { t.Error("expected nil for nil info") } @@ -130,7 +133,7 @@ func TestTableMutatorFromCall_MethodCallWithCalleeSymbol(t *testing.T) { CalleeSymbol: 42, } - got := TableMutatorFromCall( + got := calleffect.TableMutatorFromCall( info, 0, nil, @@ -170,7 +173,8 @@ func TestExtractTableMutatorAssignments_AssignmentCallSite(t *testing.T) { } ExtractTableMutatorAssignments(&core.FlowContext{ - Graph: graph, + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), Derived: &core.Derived{ Synth: tableInsertSynth(), }, @@ -187,3 +191,38 @@ func TestExtractTableMutatorAssignments_AssignmentCallSite(t *testing.T) { t.Fatalf("expected target symbol %d, got %d", symT, inputs.TableMutatorAssignments[0].Target.Symbol) } } + +func TestExtractTableMutatorAssignments_StaticSiblingFieldPath(t *testing.T) { + code := ` + local self = { nodes = {}, queued_commands = {} } + table.insert(self.queued_commands, { type = "UPDATE_NODE" }) + ` + graph := buildGraph(t, code, "table") + inputs := &flow.Inputs{ + Graph: graph, + } + + ExtractTableMutatorAssignments(&core.FlowContext{ + Graph: graph, + Evidence: trace.GraphEvidence(graph, graph.Bindings()), + Derived: &core.Derived{ + Synth: tableInsertSynth(), + }, + }, inputs) + + if len(inputs.TableMutatorAssignments) != 1 { + t.Fatalf("expected 1 table mutator assignment, got %d", len(inputs.TableMutatorAssignments)) + } + selfSym, ok := graph.SymbolAt(graph.Exit(), "self") + if !ok || selfSym == 0 { + t.Fatal("expected symbol for self") + } + got := inputs.TableMutatorAssignments[0].Target + if got.Symbol != selfSym || len(got.Segments) != 1 { + t.Fatalf("expected target self.queued_commands, got %+v", got) + } + seg := got.Segments[0] + if seg.Kind != constraint.SegmentField || seg.Name != "queued_commands" { + t.Fatalf("expected queued_commands field target, got %+v", got) + } +} diff --git a/compiler/check/flowbuild/numconst/doc.go b/compiler/check/abstract/numconst/doc.go similarity index 100% rename from compiler/check/flowbuild/numconst/doc.go rename to compiler/check/abstract/numconst/doc.go diff --git a/compiler/check/flowbuild/numconst/numconst.go b/compiler/check/abstract/numconst/numconst.go similarity index 69% rename from compiler/check/flowbuild/numconst/numconst.go rename to compiler/check/abstract/numconst/numconst.go index 670b8ccb..2893f369 100644 --- a/compiler/check/flowbuild/numconst/numconst.go +++ b/compiler/check/abstract/numconst/numconst.go @@ -4,7 +4,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/numparse" @@ -28,12 +28,20 @@ func NegateConstraints(items []constraint.Constraint) []constraint.Constraint { func NumericConstraintFromComparisonWithBindings(op string, lhs, rhs ast.Expr, p cfg.Point, inputs *flow.Inputs, bindings *bind.BindingTable) constraint.NumericConstraint { leftPath := path.FromExprWithBindings(lhs, nil, bindings) rightPath := path.FromExprWithBindings(rhs, nil, bindings) + leftLenPath := lenPathFromExprWithBindings(lhs, bindings) + rightLenPath := lenPathFromExprWithBindings(rhs, bindings) leftConst, leftIsConst := IntConstFromExpr(lhs) rightConst, rightIsConst := IntConstFromExpr(rhs) switch op { case "<": + if !leftLenPath.IsEmpty() && rightIsConst { + return constraint.LenLeConst{Array: leftLenPath, C: rightConst - 1} + } + if leftIsConst && !rightLenPath.IsEmpty() { + return constraint.LenGeConst{Array: rightLenPath, C: leftConst + 1} + } if !leftPath.IsEmpty() && !rightPath.IsEmpty() { return constraint.Lt{X: leftPath, Y: rightPath} } @@ -44,6 +52,12 @@ func NumericConstraintFromComparisonWithBindings(op string, lhs, rhs ast.Expr, p return constraint.GeConst{X: rightPath, C: leftConst + 1} } case ">": + if !leftLenPath.IsEmpty() && rightIsConst { + return constraint.LenGeConst{Array: leftLenPath, C: rightConst + 1} + } + if leftIsConst && !rightLenPath.IsEmpty() { + return constraint.LenLeConst{Array: rightLenPath, C: leftConst - 1} + } if !leftPath.IsEmpty() && !rightPath.IsEmpty() { return constraint.Gt{X: leftPath, Y: rightPath} } @@ -54,6 +68,12 @@ func NumericConstraintFromComparisonWithBindings(op string, lhs, rhs ast.Expr, p return constraint.LeConst{X: rightPath, C: leftConst - 1} } case "<=": + if !leftLenPath.IsEmpty() && rightIsConst { + return constraint.LenLeConst{Array: leftLenPath, C: rightConst} + } + if leftIsConst && !rightLenPath.IsEmpty() { + return constraint.LenGeConst{Array: rightLenPath, C: leftConst} + } if !leftPath.IsEmpty() && !rightPath.IsEmpty() { return constraint.Le{X: leftPath, Y: rightPath, C: 0} } @@ -64,6 +84,12 @@ func NumericConstraintFromComparisonWithBindings(op string, lhs, rhs ast.Expr, p return constraint.GeConst{X: rightPath, C: leftConst} } case ">=": + if !leftLenPath.IsEmpty() && rightIsConst { + return constraint.LenGeConst{Array: leftLenPath, C: rightConst} + } + if leftIsConst && !rightLenPath.IsEmpty() { + return constraint.LenLeConst{Array: rightLenPath, C: leftConst} + } if !leftPath.IsEmpty() && !rightPath.IsEmpty() { return constraint.Ge{X: leftPath, Y: rightPath} } @@ -77,6 +103,14 @@ func NumericConstraintFromComparisonWithBindings(op string, lhs, rhs ast.Expr, p return nil } +func lenPathFromExprWithBindings(expr ast.Expr, bindings *bind.BindingTable) constraint.Path { + lenOp, ok := expr.(*ast.UnaryLenOpExpr) + if !ok || lenOp == nil { + return constraint.Path{} + } + return path.FromExprWithBindings(lenOp.Expr, nil, bindings) +} + // NegateNumericConstraint returns the negation of a numeric constraint. func NegateNumericConstraint(c constraint.NumericConstraint) constraint.NumericConstraint { if c == nil { @@ -95,6 +129,10 @@ func NegateNumericConstraint(c constraint.NumericConstraint) constraint.NumericC return constraint.GeConst{X: v.X, C: v.C + 1} case constraint.GeConst: return constraint.LeConst{X: v.X, C: v.C - 1} + case constraint.LenLeConst: + return constraint.LenGeConst{Array: v.Array, C: v.C + 1} + case constraint.LenGeConst: + return constraint.LenLeConst{Array: v.Array, C: v.C - 1} default: return nil } diff --git a/compiler/check/flowbuild/numconst/numconst_test.go b/compiler/check/abstract/numconst/numconst_test.go similarity index 85% rename from compiler/check/flowbuild/numconst/numconst_test.go rename to compiler/check/abstract/numconst/numconst_test.go index 493ac9b7..7b461970 100644 --- a/compiler/check/flowbuild/numconst/numconst_test.go +++ b/compiler/check/abstract/numconst/numconst_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/flowbuild/numconst" + "github.com/wippyai/go-lua/compiler/check/abstract/numconst" "github.com/wippyai/go-lua/types/constraint" ) @@ -206,6 +206,32 @@ func TestNumericConstraintFromComparisonWithBindings_GePaths(t *testing.T) { } } +func TestNumericConstraintFromComparisonWithBindings_LenLowerBound(t *testing.T) { + lhs := &ast.UnaryLenOpExpr{Expr: &ast.IdentExpr{Value: "rows"}} + rhs := &ast.NumberExpr{Value: "0"} + result := numconst.NumericConstraintFromComparisonWithBindings(">", lhs, rhs, 0, nil, nil) + got, ok := result.(constraint.LenGeConst) + if !ok { + t.Fatalf("expected LenGeConst constraint, got %T", result) + } + if got.Array.Root != "rows" || got.C != 1 { + t.Fatalf("unexpected length lower bound: %#v", got) + } +} + +func TestNumericConstraintFromComparisonWithBindings_LenUpperBound(t *testing.T) { + lhs := &ast.NumberExpr{Value: "0"} + rhs := &ast.UnaryLenOpExpr{Expr: &ast.IdentExpr{Value: "rows"}} + result := numconst.NumericConstraintFromComparisonWithBindings(">=", lhs, rhs, 0, nil, nil) + got, ok := result.(constraint.LenLeConst) + if !ok { + t.Fatalf("expected LenLeConst constraint, got %T", result) + } + if got.Array.Root != "rows" || got.C != 0 { + t.Fatalf("unexpected length upper bound: %#v", got) + } +} + func TestNumericConstraintFromComparisonWithBindings_UnknownOp(t *testing.T) { lhs := &ast.IdentExpr{Value: "a"} rhs := &ast.IdentExpr{Value: "b"} diff --git a/compiler/check/flowbuild/predicate/doc.go b/compiler/check/abstract/predicate/doc.go similarity index 100% rename from compiler/check/flowbuild/predicate/doc.go rename to compiler/check/abstract/predicate/doc.go diff --git a/compiler/check/flowbuild/predicate/predicate.go b/compiler/check/abstract/predicate/predicate.go similarity index 100% rename from compiler/check/flowbuild/predicate/predicate.go rename to compiler/check/abstract/predicate/predicate.go diff --git a/compiler/check/flowbuild/predicate/predicate_test.go b/compiler/check/abstract/predicate/predicate_test.go similarity index 98% rename from compiler/check/flowbuild/predicate/predicate_test.go rename to compiler/check/abstract/predicate/predicate_test.go index 1358e70e..bc822fcf 100644 --- a/compiler/check/flowbuild/predicate/predicate_test.go +++ b/compiler/check/abstract/predicate/predicate_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" ) diff --git a/compiler/check/flowbuild/returns/doc.go b/compiler/check/abstract/returns/doc.go similarity index 100% rename from compiler/check/flowbuild/returns/doc.go rename to compiler/check/abstract/returns/doc.go diff --git a/compiler/check/flowbuild/returns/extract_returns_test.go b/compiler/check/abstract/returns/extract_returns_test.go similarity index 95% rename from compiler/check/flowbuild/returns/extract_returns_test.go rename to compiler/check/abstract/returns/extract_returns_test.go index b45a51de..cfee15bb 100644 --- a/compiler/check/flowbuild/returns/extract_returns_test.go +++ b/compiler/check/abstract/returns/extract_returns_test.go @@ -5,10 +5,11 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/returns" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/returns" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" @@ -89,7 +90,7 @@ func TestClassifyReturnExpr_NilInput(t *testing.T) { } func TestExtractReturnExprConstraints_Nil(t *testing.T) { - result := cond.ExtractReturnExprConstraints(nil, 0, nil, nil, nil, nil, nil, nil) + result := cond.ExtractReturnExprConstraints(nil, 0, nil, nil, api.FlowEvidence{}, nil, nil, nil, nil) if result.OnTrue.HasConstraints() || result.OnFalse.HasConstraints() { t.Error("expected no constraints for nil expr") } diff --git a/compiler/check/flowbuild/returns/returns.go b/compiler/check/abstract/returns/returns.go similarity index 53% rename from compiler/check/flowbuild/returns/returns.go rename to compiler/check/abstract/returns/returns.go index 57980274..8852fb16 100644 --- a/compiler/check/flowbuild/returns/returns.go +++ b/compiler/check/abstract/returns/returns.go @@ -1,11 +1,10 @@ package returns import ( - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/predicate" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/flow" ) @@ -18,9 +17,14 @@ func ExtractReturnKinds(fc *core.FlowContext, inputs *flow.Inputs) { if derived == nil { derived = &core.Derived{} } - fc.Graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range fc.Evidence.Returns { + p := ret.Point + info := ret.Info + if info == nil { + continue + } if len(info.Exprs) == 0 { - return + continue } kind := resolve.ClassifyReturnExpr(info.Exprs[0]) @@ -32,9 +36,9 @@ func ExtractReturnKinds(fc *core.FlowContext, inputs *flow.Inputs) { constResolver := predicate.BuildConstResolver(inputs, p) sc := fc.Scopes[p] - exprConstraints := cond.ExtractReturnExprConstraints(info.Exprs[0], p, sc, inputs, derived.TypeKeyRes, derived.Synth, constResolver, derived.SymResolver) - if exprConstraints.OnTrue.HasConstraints() || exprConstraints.OnFalse.HasConstraints() { + exprConstraints := cond.ExtractReturnExprConstraints(info.Exprs[0], p, sc, inputs, fc.Evidence, derived.TypeKeyRes, derived.Synth, constResolver, derived.SymResolver) + if exprConstraints.OnReturn.HasConstraints() || exprConstraints.OnTrue.HasConstraints() || exprConstraints.OnFalse.HasConstraints() { inputs.ReturnConstraints[p] = exprConstraints } - }) + } } diff --git a/compiler/check/flowbuild/returns/returns_test.go b/compiler/check/abstract/returns/returns_test.go similarity index 94% rename from compiler/check/flowbuild/returns/returns_test.go rename to compiler/check/abstract/returns/returns_test.go index 2a29303c..8530f3d9 100644 --- a/compiler/check/flowbuild/returns/returns_test.go +++ b/compiler/check/abstract/returns/returns_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/flow" ) diff --git a/compiler/check/flowbuild/run.go b/compiler/check/abstract/run.go similarity index 82% rename from compiler/check/flowbuild/run.go rename to compiler/check/abstract/run.go index 936470bc..e9ffe417 100644 --- a/compiler/check/flowbuild/run.go +++ b/compiler/check/abstract/run.go @@ -1,10 +1,10 @@ -// Package flowbuild implements flow constraint extraction from control flow graphs. +// Package abstract implements flow constraint extraction from control flow graphs. // This is the core of Phase B that transforms CFG structure into flow.Inputs, // the constraint system that the flow solver uses to compute type narrowing. // // # EXTRACTION PIPELINE // -// The Run function executes a multi-stage extraction pipeline: +// BuildInputs executes a multi-stage extraction pipeline: // // 1. Declarations: Extract type keys, declared types, and module aliases // from the CFG. This seeds the initial type information. @@ -39,21 +39,22 @@ // - ConstValues: Constant value information // - PredicateLinks: Call-site predicate connections // - ReturnKinds: Return statement classifications -package flowbuild +package abstract import ( "slices" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/constprop" - fbcore "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/decl" - "github.com/wippyai/go-lua/compiler/check/flowbuild/keyscoll" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/flowbuild/returns" + "github.com/wippyai/go-lua/compiler/check/abstract/assign" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/constprop" + abstractcore "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/abstract/decl" + "github.com/wippyai/go-lua/compiler/check/abstract/mutator" + "github.com/wippyai/go-lua/compiler/check/abstract/returns" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/keyscoll" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/query/core" @@ -67,7 +68,7 @@ func (coreDecomposer) ElementType(t typ.Type) typ.Type { return core.ElementType func (coreDecomposer) KeyType(t typ.Type) typ.Type { return core.KeyType(t) } func (coreDecomposer) ValueType(t typ.Type) typ.Type { return core.ValueType(t) } -// Run executes the complete flow constraint extraction pipeline. +// BuildInputs executes the complete flow constraint extraction pipeline. // It processes the CFG to extract all type constraints that the flow solver // needs to compute narrowed types at each program point. // @@ -76,10 +77,11 @@ func (coreDecomposer) ValueType(t typ.Type) typ.Type { return core.ValueType(t // can use information from previous stages (e.g., const values in branches). // // Returns nil if the graph or its CFG is nil. -func Run(fc *fbcore.FlowContext) *flow.Inputs { +func BuildInputs(fc *abstractcore.FlowContext) *flow.Inputs { if fc.Graph == nil || fc.Graph.CFG() == nil { return nil } + MaterializeGraphEvidence(fc) inputs := initInputsFromContext(fc) @@ -89,7 +91,7 @@ func Run(fc *fbcore.FlowContext) *flow.Inputs { decl.ExtractModuleAliases(fc, inputs) // Compute derived resolvers and store in a separate derived bundle. - derived := &fbcore.Derived{ + derived := &abstractcore.Derived{ SymResolver: resolve.BuildInputSymbolResolver(fc.CheckCtx, inputs), TypeKeyRes: resolve.BuildContextTypeKeyResolver(fc.CheckCtx), RefinementBySym: resolve.BuildRefinementLookup(fc.CheckCtx), @@ -104,7 +106,7 @@ func Run(fc *fbcore.FlowContext) *flow.Inputs { constprop.PropagateAllConstValues(fc, inputs) // Assignments with const resolution. - assign.ExtractAssignments(fc, inputs, keyscoll.BuildKeysCollectorDetector(fc.Graph, fc.ModuleBindings)) + assign.ExtractAssignments(fc, inputs, keyscoll.BuildKeysCollectorDetector(fc.Graph, fc.Evidence, fc.ModuleBindings, fc.Graphs)) // Table mutator assignments (table.insert-like). mutator.ExtractTableMutatorAssignments(fc, inputs) @@ -130,7 +132,7 @@ func Run(fc *fbcore.FlowContext) *flow.Inputs { if fc.Derived == nil { continue } - if !cond.PointHasTerminatingCallSite(fc.Graph, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Derived.RefinementBySym, fc.ModuleBindings) { + if !cond.PointHasTerminatingCallEvidence(fc.Graph, fc.Evidence.Calls, p, fc.Derived.Synth, fc.Derived.SymResolver, fc.Derived.RefinementBySym, fc.ModuleBindings) { continue } for _, succ := range fc.Graph.Successors(p) { @@ -143,7 +145,7 @@ func Run(fc *fbcore.FlowContext) *flow.Inputs { } // Mark return points with no predecessors as dead. - markDeadReturns(fc.Graph, inputs) + markDeadReturns(fc.Graph, fc.Evidence.Returns, inputs) // Numeric constraints. cond.ExtractNumericConstraints(fc, inputs) @@ -152,7 +154,7 @@ func Run(fc *fbcore.FlowContext) *flow.Inputs { } // initInputsFromContext creates and seeds the Inputs struct from FlowContext. -func initInputsFromContext(fc *fbcore.FlowContext) *flow.Inputs { +func initInputsFromContext(fc *abstractcore.FlowContext) *flow.Inputs { initialTypes := make(map[cfg.SymbolID]typ.Type) for sym, t := range fc.InitialDeclaredTypes { if sym != 0 && t != nil { @@ -211,11 +213,12 @@ func MergeCallConstraintsIntoEdges(inputs *flow.Inputs, callConstraints map[cond } // markDeadReturns marks return points with no predecessors as dead. -func markDeadReturns(graph *cfg.Graph, inputs *flow.Inputs) { +func markDeadReturns(graph *cfg.Graph, returns []api.ReturnEvidence, inputs *flow.Inputs) { entry := graph.Entry() - graph.EachReturn(func(p cfg.Point, _ *cfg.ReturnInfo) { + for _, ret := range returns { + p := ret.Point if p == entry { - return + continue } if len(graph.Predecessors(p)) == 0 { if inputs.DeadPoints == nil { @@ -223,5 +226,5 @@ func markDeadReturns(graph *cfg.Graph, inputs *flow.Inputs) { } inputs.DeadPoints[p] = true } - }) + } } diff --git a/compiler/check/flowbuild/run_test.go b/compiler/check/abstract/run_test.go similarity index 96% rename from compiler/check/flowbuild/run_test.go rename to compiler/check/abstract/run_test.go index e9d47e5d..3bece9b4 100644 --- a/compiler/check/flowbuild/run_test.go +++ b/compiler/check/abstract/run_test.go @@ -1,11 +1,11 @@ -package flowbuild +package abstract import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" + "github.com/wippyai/go-lua/compiler/check/abstract/cond" + "github.com/wippyai/go-lua/compiler/check/abstract/core" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" @@ -13,7 +13,7 @@ import ( func TestRun_NilGraph(t *testing.T) { fc := &core.FlowContext{} - result := Run(fc) + result := BuildInputs(fc) if result != nil { t.Errorf("expected nil for nil graph, got %v", result) } diff --git a/compiler/check/flowbuild/sibling/doc.go b/compiler/check/abstract/sibling/doc.go similarity index 100% rename from compiler/check/flowbuild/sibling/doc.go rename to compiler/check/abstract/sibling/doc.go diff --git a/compiler/check/flowbuild/sibling/sibling.go b/compiler/check/abstract/sibling/sibling.go similarity index 100% rename from compiler/check/flowbuild/sibling/sibling.go rename to compiler/check/abstract/sibling/sibling.go diff --git a/compiler/check/flowbuild/sibling/sibling_test.go b/compiler/check/abstract/sibling/sibling_test.go similarity index 96% rename from compiler/check/flowbuild/sibling/sibling_test.go rename to compiler/check/abstract/sibling/sibling_test.go index 2790f68e..39f83938 100644 --- a/compiler/check/flowbuild/sibling/sibling_test.go +++ b/compiler/check/abstract/sibling/sibling_test.go @@ -6,7 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/sibling" + "github.com/wippyai/go-lua/compiler/check/abstract/sibling" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" @@ -97,7 +97,7 @@ func TestConstraintsForSymbol_SingleVariableOnly(t *testing.T) { } } -func TestConstraintsForSymbol_LegacyNarrowingIsNil(t *testing.T) { +func TestConstraintsForSymbol_NoCorrelationIsNil(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{Names: []string{"val", "err"}}, Stmts: []ast.Stmt{&ast.ReturnStmt{}}, @@ -122,11 +122,11 @@ func TestConstraintsForSymbol_LegacyNarrowingIsNil(t *testing.T) { result := sibling.ConstraintsForSymbol(errSym, 1, inputs, true, bindings) if result != nil { - t.Fatalf("expected nil constraints for legacy narrowing, got %v", result) + t.Fatalf("expected nil constraints for no correlation, got %v", result) } } -func TestConstraintsForSymbol_LegacyNarrowingNotNilResult(t *testing.T) { +func TestConstraintsForSymbol_NoCorrelationNotNilResult(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{Names: []string{"val", "err"}}, Stmts: []ast.Stmt{&ast.ReturnStmt{}}, @@ -151,7 +151,7 @@ func TestConstraintsForSymbol_LegacyNarrowingNotNilResult(t *testing.T) { result := sibling.ConstraintsForSymbol(errSym, 1, inputs, false, bindings) if result != nil { - t.Fatalf("expected nil constraints for legacy narrowing, got %v", result) + t.Fatalf("expected nil constraints for no correlation, got %v", result) } } diff --git a/compiler/check/abstract/state.go b/compiler/check/abstract/state.go new file mode 100644 index 00000000..fc869ff8 --- /dev/null +++ b/compiler/check/abstract/state.go @@ -0,0 +1,31 @@ +package abstract + +import ( + "github.com/wippyai/go-lua/compiler/check/abstract/core" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/narrow" +) + +// Result is the complete abstract-interpretation output for one function. +type Result struct { + Inputs *flow.Inputs + Evidence api.FlowEvidence +} + +// Run lowers CFG events into flow inputs and evidence streams. +func Run(ctx *core.FlowContext) Result { + inputs := BuildInputs(ctx) + return Result{ + Inputs: inputs, + Evidence: ExtractEvidence(ctx, inputs), + } +} + +// Solve computes the flow-sensitive product state for abstract flow inputs. +func Solve(inputs *flow.Inputs, resolver narrow.Resolver) *flow.Solution { + if inputs == nil { + return nil + } + return flow.Solve(inputs, resolver) +} diff --git a/compiler/check/flowbuild/tblutil/doc.go b/compiler/check/abstract/tblutil/doc.go similarity index 87% rename from compiler/check/flowbuild/tblutil/doc.go rename to compiler/check/abstract/tblutil/doc.go index 6d41bb97..7e6e94e3 100644 --- a/compiler/check/flowbuild/tblutil/doc.go +++ b/compiler/check/abstract/tblutil/doc.go @@ -13,6 +13,6 @@ // // # Usage // -// These utilities support the flowbuild system in analyzing table +// These utilities support the abstract interpreter in analyzing table // constructors, field assignments, and index operations. package tblutil diff --git a/compiler/check/flowbuild/tblutil/tblutil.go b/compiler/check/abstract/tblutil/tblutil.go similarity index 100% rename from compiler/check/flowbuild/tblutil/tblutil.go rename to compiler/check/abstract/tblutil/tblutil.go diff --git a/compiler/check/flowbuild/tblutil/tblutil_test.go b/compiler/check/abstract/tblutil/tblutil_test.go similarity index 99% rename from compiler/check/flowbuild/tblutil/tblutil_test.go rename to compiler/check/abstract/tblutil/tblutil_test.go index 44cf27eb..73914a97 100644 --- a/compiler/check/flowbuild/tblutil/tblutil_test.go +++ b/compiler/check/abstract/tblutil/tblutil_test.go @@ -5,7 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/tblutil" + "github.com/wippyai/go-lua/compiler/check/abstract/tblutil" "github.com/wippyai/go-lua/types/kind" "github.com/wippyai/go-lua/types/typ" ) diff --git a/compiler/check/abstract/trace/paramuse.go b/compiler/check/abstract/trace/paramuse.go new file mode 100644 index 00000000..0f46081b --- /dev/null +++ b/compiler/check/abstract/trace/paramuse.go @@ -0,0 +1,466 @@ +package trace + +import ( + "sort" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/types/constraint" +) + +type parameterUse struct { + whole bool + fields map[string]struct{} +} + +// ParameterUses records the parameter surface demanded by fn's body. +func ParameterUses(graph *cfg.Graph, fn *ast.FunctionExpr) []api.ParameterUseEvidence { + if graph == nil || fn == nil { + return nil + } + paramSymbols := make(map[cfg.SymbolID]struct{}) + for _, slot := range graph.ParamSlotsReadOnly() { + if slot.Symbol != 0 { + paramSymbols[slot.Symbol] = struct{}{} + } + } + if len(paramSymbols) == 0 { + return nil + } + + collector := parameterUseCollector{ + bindings: graph.Bindings(), + paramSymbols: paramSymbols, + currentFunctionSymbols: currentFunctionSymbols(graph, fn), + uses: make(map[cfg.SymbolID]parameterUse), + } + for _, stmt := range fn.Stmts { + collector.stmt(stmt) + } + if len(collector.uses) == 0 { + return nil + } + + syms := make([]int, 0, len(collector.uses)) + for sym := range collector.uses { + syms = append(syms, int(sym)) + } + sort.Ints(syms) + out := make([]api.ParameterUseEvidence, 0, len(syms)) + for _, raw := range syms { + sym := cfg.SymbolID(raw) + use := collector.uses[sym] + ev := api.ParameterUseEvidence{Symbol: sym, Whole: use.whole} + if len(use.fields) > 0 { + fields := make([]string, 0, len(use.fields)) + for field := range use.fields { + fields = append(fields, field) + } + sort.Strings(fields) + ev.Fields = fields + } + out = append(out, ev) + } + return out +} + +type parameterUseCollector struct { + bindings *bind.BindingTable + paramSymbols map[cfg.SymbolID]struct{} + currentFunctionSymbols map[cfg.SymbolID]struct{} + uses map[cfg.SymbolID]parameterUse +} + +func (c *parameterUseCollector) stmt(stmt ast.Stmt) { + switch s := stmt.(type) { + case *ast.AssignStmt: + var skipRHS map[int]struct{} + for i, lhs := range s.Lhs { + if i < len(s.Rhs) && c.isParamSelfDefault(lhs, s.Rhs[i]) { + if skipRHS == nil { + skipRHS = make(map[int]struct{}, 1) + } + skipRHS[i] = struct{}{} + continue + } + c.lvalue(lhs) + } + for i, rhs := range s.Rhs { + if skipRHS != nil { + if _, skip := skipRHS[i]; skip { + continue + } + } + c.expr(rhs) + } + case *ast.LocalAssignStmt: + for _, expr := range s.Exprs { + c.expr(expr) + } + case *ast.FuncCallStmt: + c.expr(s.Expr) + case *ast.DoBlockStmt: + c.stmts(s.Stmts) + case *ast.WhileStmt: + c.condition(s.Condition) + c.stmts(s.Stmts) + case *ast.RepeatStmt: + c.stmts(s.Stmts) + c.condition(s.Condition) + case *ast.IfStmt: + c.condition(s.Condition) + c.stmts(s.Then) + c.stmts(s.Else) + case *ast.NumberForStmt: + c.expr(s.Init) + c.expr(s.Limit) + c.expr(s.Step) + c.stmts(s.Stmts) + case *ast.GenericForStmt: + for _, expr := range s.Exprs { + c.expr(expr) + } + c.stmts(s.Stmts) + case *ast.FuncDefStmt: + if s.Name != nil { + c.expr(s.Name.Func) + c.expr(s.Name.Receiver) + } + if s.Func != nil { + c.stmts(s.Func.Stmts) + } + case *ast.ReturnStmt: + for _, expr := range s.Exprs { + c.expr(expr) + } + } +} + +func (c *parameterUseCollector) stmts(stmts []ast.Stmt) { + for _, stmt := range stmts { + c.stmt(stmt) + } +} + +func (c *parameterUseCollector) condition(expr ast.Expr) { + switch e := expr.(type) { + case *ast.IdentExpr: + if c.isParamIdent(e) { + return + } + case *ast.UnaryNotOpExpr: + if ident, ok := e.Expr.(*ast.IdentExpr); ok && c.isParamIdent(ident) { + return + } + c.condition(e.Expr) + return + case *ast.RelationalOpExpr: + if isNilLiteral(e.Lhs) && c.isParamExpr(e.Rhs) { + return + } + if isNilLiteral(e.Rhs) && c.isParamExpr(e.Lhs) { + return + } + case *ast.LogicalOpExpr: + c.condition(e.Lhs) + c.condition(e.Rhs) + return + } + c.expr(expr) +} + +func (c *parameterUseCollector) expr(expr ast.Expr) { + if expr == nil { + return + } + + switch e := expr.(type) { + case *ast.IdentExpr: + c.whole(e) + case *ast.AttrGetExpr: + if c.pathUse(expr) { + return + } + c.expr(e.Object) + c.expr(e.Key) + case *ast.TableExpr: + for _, field := range e.Fields { + if field == nil { + continue + } + c.expr(field.Key) + c.expr(field.Value) + } + case *ast.FuncCallExpr: + c.call(e) + case *ast.LogicalOpExpr: + c.expr(e.Lhs) + c.expr(e.Rhs) + case *ast.RelationalOpExpr: + c.expr(e.Lhs) + c.expr(e.Rhs) + case *ast.StringConcatOpExpr: + c.expr(e.Lhs) + c.expr(e.Rhs) + case *ast.ArithmeticOpExpr: + c.expr(e.Lhs) + c.expr(e.Rhs) + case *ast.UnaryMinusOpExpr: + c.expr(e.Expr) + case *ast.UnaryNotOpExpr: + c.expr(e.Expr) + case *ast.UnaryLenOpExpr: + c.expr(e.Expr) + case *ast.UnaryBNotOpExpr: + c.expr(e.Expr) + case *ast.FunctionExpr: + c.stmts(e.Stmts) + case *ast.CastExpr: + c.expr(e.Expr) + case *ast.NonNilAssertExpr: + c.expr(e.Expr) + } +} + +func (c *parameterUseCollector) call(call *ast.FuncCallExpr) { + if call == nil { + return + } + recursive := c.isDirectRecursiveCall(call) + if call.Method != "" { + if recv := flowpath.FromExprWithBindings(call.Receiver, nil, c.bindings); c.isParamPath(recv) { + c.field(recv.Symbol, firstFieldOrMethod(recv, call.Method)) + } else { + c.expr(call.Receiver) + } + } else if callee := flowpath.FromExprWithBindings(call.Func, nil, c.bindings); c.isParamPath(callee) { + if len(callee.Segments) == 0 { + c.markWhole(callee.Symbol) + } else { + c.field(callee.Symbol, segmentFieldName(callee.Segments[0])) + } + } else { + c.expr(call.Func) + } + + for _, arg := range call.Args { + if recursive && c.isParamExpr(arg) { + continue + } + if c.isBuiltinTypeCall(call) && c.isParamExpr(arg) { + continue + } + c.expr(arg) + } +} + +func (c *parameterUseCollector) isBuiltinTypeCall(call *ast.FuncCallExpr) bool { + if call == nil || call.Method != "" { + return false + } + ident, ok := call.Func.(*ast.IdentExpr) + if !ok || ident == nil || ident.Value != "type" { + return false + } + if c.bindings != nil { + if sym, ok := c.bindings.SymbolOf(ident); ok && sym != 0 { + kind, hasKind := c.bindings.Kind(sym) + return hasKind && kind == cfg.SymbolGlobal + } + } + return true +} + +func (c *parameterUseCollector) isDirectRecursiveCall(call *ast.FuncCallExpr) bool { + if call == nil || call.Method != "" || len(c.currentFunctionSymbols) == 0 { + return false + } + callee := flowpath.FromExprWithBindings(call.Func, nil, c.bindings) + if callee.Symbol == 0 || len(callee.Segments) != 0 { + return false + } + _, ok := c.currentFunctionSymbols[callee.Symbol] + return ok +} + +func (c *parameterUseCollector) lvalue(expr ast.Expr) { + switch e := expr.(type) { + case *ast.IdentExpr: + return + case *ast.AttrGetExpr: + if c.lvaluePathUse(expr) { + return + } + c.expr(e.Object) + c.expr(e.Key) + default: + c.expr(expr) + } +} + +func (c *parameterUseCollector) lvaluePathUse(expr ast.Expr) bool { + p := flowpath.FromExprWithBindings(expr, nil, c.bindings) + if !c.isParamPath(p) { + return false + } + if len(p.Segments) <= 1 { + return true + } + c.field(p.Symbol, segmentFieldName(p.Segments[0])) + return true +} + +func (c *parameterUseCollector) isParamSelfDefault(lhs, rhs ast.Expr) bool { + lhsIdent, ok := lhs.(*ast.IdentExpr) + if !ok || !c.isParamIdent(lhsIdent) { + return false + } + op, ok := rhs.(*ast.LogicalOpExpr) + if !ok || op.Operator != "or" { + return false + } + rhsIdent, ok := op.Lhs.(*ast.IdentExpr) + if !ok || !c.sameParamIdent(lhsIdent, rhsIdent) { + return false + } + _, ok = op.Rhs.(*ast.TableExpr) + return ok +} + +func (c *parameterUseCollector) pathUse(expr ast.Expr) bool { + p := flowpath.FromExprWithBindings(expr, nil, c.bindings) + if !c.isParamPath(p) { + return false + } + if len(p.Segments) == 0 { + c.markWhole(p.Symbol) + return true + } + c.field(p.Symbol, segmentFieldName(p.Segments[0])) + return true +} + +func (c *parameterUseCollector) whole(expr ast.Expr) { + if c.bindings == nil || expr == nil { + return + } + ident, ok := expr.(*ast.IdentExpr) + if !ok { + return + } + sym, ok := c.bindings.SymbolOf(ident) + if !ok || sym == 0 { + return + } + if _, isParam := c.paramSymbols[sym]; !isParam { + return + } + c.markWhole(sym) +} + +func (c *parameterUseCollector) isParamExpr(expr ast.Expr) bool { + ident, ok := expr.(*ast.IdentExpr) + return ok && c.isParamIdent(ident) +} + +func (c *parameterUseCollector) isParamIdent(ident *ast.IdentExpr) bool { + if c.bindings == nil || ident == nil { + return false + } + sym, ok := c.bindings.SymbolOf(ident) + if !ok || sym == 0 { + return false + } + _, ok = c.paramSymbols[sym] + return ok +} + +func (c *parameterUseCollector) sameParamIdent(a, b *ast.IdentExpr) bool { + if c.bindings == nil || a == nil || b == nil { + return false + } + asym, aok := c.bindings.SymbolOf(a) + bsym, bok := c.bindings.SymbolOf(b) + if !aok || !bok || asym == 0 || bsym == 0 || asym != bsym { + return false + } + _, ok := c.paramSymbols[asym] + return ok +} + +func isNilLiteral(expr ast.Expr) bool { + _, ok := expr.(*ast.NilExpr) + return ok +} + +func (c *parameterUseCollector) isParamPath(p constraint.Path) bool { + if p.IsEmpty() || p.Symbol == 0 { + return false + } + _, ok := c.paramSymbols[p.Symbol] + return ok +} + +func (c *parameterUseCollector) markWhole(sym cfg.SymbolID) { + use := c.uses[sym] + use.whole = true + c.uses[sym] = use +} + +func (c *parameterUseCollector) field(sym cfg.SymbolID, name string) { + if name == "" { + c.markWhole(sym) + return + } + use := c.uses[sym] + if use.fields == nil { + use.fields = make(map[string]struct{}, 1) + } + use.fields[name] = struct{}{} + c.uses[sym] = use +} + +func firstFieldOrMethod(p constraint.Path, method string) string { + if len(p.Segments) == 0 { + return method + } + return segmentFieldName(p.Segments[0]) +} + +func currentFunctionSymbols(graph *cfg.Graph, fn *ast.FunctionExpr) map[cfg.SymbolID]struct{} { + if graph == nil || fn == nil { + return nil + } + syms := make(map[cfg.SymbolID]struct{}, 1) + if bindings := graph.Bindings(); bindings != nil { + if sym, ok := bindings.FuncLitSymbol(fn); ok && sym != 0 { + syms[sym] = struct{}{} + } + } + for _, localFn := range graph.LocalFunctionAssignments() { + if localFn.Func == fn && localFn.Symbol != 0 { + syms[localFn.Symbol] = struct{}{} + } + } + for _, def := range FunctionDefinitions(graph) { + if def.FuncDef != nil && def.FuncDef.FuncExpr == fn && def.Symbol != 0 { + syms[def.Symbol] = struct{}{} + } + } + if len(syms) == 0 { + return nil + } + return syms +} + +func segmentFieldName(seg constraint.Segment) string { + switch seg.Kind { + case constraint.SegmentField, constraint.SegmentIndexString: + return seg.Name + default: + return "" + } +} diff --git a/compiler/check/abstract/trace/trace.go b/compiler/check/abstract/trace/trace.go new file mode 100644 index 00000000..e177bfef --- /dev/null +++ b/compiler/check/abstract/trace/trace.go @@ -0,0 +1,621 @@ +// Package trace owns CFG event discovery for the checker abstract interpreter. +package trace + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/provenance" +) + +// GraphEvidence records graph-local events that do not require a solved flow +// state. This is the single event discovery entry point used by the interpreter +// and consumers that need aliases, assignments, calls, returns, branches, or +// function definitions before the full interpreter result is available. +func GraphEvidence(graph *cfg.Graph, bindings *bind.BindingTable) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + bindings = graphBindings(graph, bindings) + expressions := ExpressionEvidence(graph, bindings) + functions := FunctionDefinitions(graph) + return api.FlowEvidence{ + Calls: expressions.Calls, + Returns: expressions.Returns, + Assignments: expressions.Assignments, + Branches: expressions.Branches, + NormalExit: NormalExitEvidence(graph), + IdentifierUses: expressions.IdentifierUses, + FieldDefaults: expressions.FieldDefaults, + ParameterUses: ParameterUses(graph, graph.Func()), + FreshTableLiterals: FreshTableLiterals(graph, expressions.Assignments, bindings), + FunctionDefinitions: functions, + EscapedFunctions: FunctionEscapes(graph, bindings), + LocalTypePredicates: LocalTypePredicates(functions), + } +} + +// NormalExitEvidence records the implicit function exit point. +func NormalExitEvidence(graph *cfg.Graph) api.NormalExitEvidence { + if graph == nil || graph.CFG() == nil { + return api.NormalExitEvidence{} + } + return api.NormalExitEvidence{Point: graph.Exit(), Valid: true} +} + +// ExpressionEvidence records expression-level events discovered from the graph. +func ExpressionEvidence(graph *cfg.Graph, bindings *bind.BindingTable) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + if bindings == nil { + bindings = graph.Bindings() + } + var out api.FlowEvidence + + var collectExpr func(cfg.Point, ast.Expr, api.CallOrigin) + collectCall := func(p cfg.Point, call *ast.FuncCallExpr, origin api.CallOrigin) { + if call == nil { + return + } + info := graph.CallSiteAt(p, call) + if info == nil { + info = callEvidenceInfoFromExpr(call, bindings) + } + if info != nil { + out.Calls = append(out.Calls, api.CallEvidence{Point: p, Info: info, Origin: origin}) + } + collectExpr(p, call.Func, api.CallOriginExpression) + collectExpr(p, call.Receiver, api.CallOriginExpression) + for _, arg := range call.Args { + collectExpr(p, arg, api.CallOriginExpression) + } + } + collectExpr = func(p cfg.Point, expr ast.Expr, origin api.CallOrigin) { + if expr == nil { + return + } + switch e := expr.(type) { + case *ast.IdentExpr: + out.IdentifierUses = append(out.IdentifierUses, api.IdentifierUseEvidence{Point: p, Expr: e}) + case *ast.FuncCallExpr: + collectCall(p, e, origin) + case *ast.AttrGetExpr: + collectExpr(p, e.Object, api.CallOriginExpression) + collectExpr(p, e.Key, api.CallOriginExpression) + case *ast.TableExpr: + for _, field := range e.Fields { + if field == nil { + continue + } + collectExpr(p, field.Key, api.CallOriginExpression) + collectExpr(p, field.Value, api.CallOriginExpression) + } + case *ast.LogicalOpExpr: + if e.Operator == "or" { + if sym, field, ok := fieldDefaultTarget(e.Lhs, bindings); ok { + out.FieldDefaults = append(out.FieldDefaults, api.FieldDefaultEvidence{ + Point: p, + Target: sym, + Field: field, + Value: e.Rhs, + }) + } + } + collectExpr(p, e.Lhs, api.CallOriginExpression) + collectExpr(p, e.Rhs, api.CallOriginExpression) + case *ast.RelationalOpExpr: + collectExpr(p, e.Lhs, api.CallOriginExpression) + collectExpr(p, e.Rhs, api.CallOriginExpression) + case *ast.StringConcatOpExpr: + collectExpr(p, e.Lhs, api.CallOriginExpression) + collectExpr(p, e.Rhs, api.CallOriginExpression) + case *ast.ArithmeticOpExpr: + collectExpr(p, e.Lhs, api.CallOriginExpression) + collectExpr(p, e.Rhs, api.CallOriginExpression) + case *ast.UnaryMinusOpExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + case *ast.UnaryNotOpExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + case *ast.UnaryLenOpExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + case *ast.UnaryBNotOpExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + case *ast.CastExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + case *ast.NonNilAssertExpr: + collectExpr(p, e.Expr, api.CallOriginExpression) + } + } + + graph.EachStmtCall(func(p cfg.Point, info *cfg.CallInfo) { + if info != nil { + collectCall(p, info.Call, api.CallOriginStatement) + } + }) + graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + if info == nil { + return + } + out.Assignments = append(out.Assignments, api.AssignmentEvidence{Point: p, Info: info}) + for _, expr := range info.Sources { + collectExpr(p, expr, api.CallOriginAssignment) + } + for _, expr := range info.IterExprs { + collectExpr(p, expr, api.CallOriginAssignment) + } + if info.NumericFor != nil { + collectExpr(p, info.NumericFor.Init, api.CallOriginAssignment) + collectExpr(p, info.NumericFor.Limit, api.CallOriginAssignment) + collectExpr(p, info.NumericFor.Step, api.CallOriginAssignment) + } + for _, target := range info.Targets { + if target.Kind == cfg.TargetField || target.Kind == cfg.TargetIndex { + collectExpr(p, target.Base, api.CallOriginAssignment) + collectExpr(p, target.Key, api.CallOriginAssignment) + } + } + }) + graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + if info != nil { + out.Returns = append(out.Returns, api.ReturnEvidence{Point: p, Info: info}) + for _, expr := range info.Exprs { + collectExpr(p, expr, api.CallOriginReturn) + } + } + }) + graph.EachBranch(func(p cfg.Point, info *cfg.BranchInfo) { + if info != nil { + out.Branches = append(out.Branches, api.BranchEvidence{Point: p, Info: info}) + collectExpr(p, info.Condition, api.CallOriginBranch) + } + }) + + return out +} + +// FunctionDefinitions records nested function definitions and the identity the +// checker uses for sibling grouping, local return inference, and interproc +// publication. +func FunctionDefinitions(graph *cfg.Graph) []api.FunctionDefinitionEvidence { + if graph == nil { + return nil + } + nestedFns := graph.NestedFunctions() + if len(nestedFns) == 0 { + return nil + } + out := make([]api.FunctionDefinitionEvidence, 0, len(nestedFns)) + for _, nf := range nestedFns { + if nf.Func == nil { + continue + } + funcDef := graph.FuncDef(nf.Point) + name, sym, isLocal := functionDefinitionIdentity(graph, nf, funcDef) + out = append(out, api.FunctionDefinitionEvidence{ + Nested: nf, + FuncDef: funcDef, + Name: name, + Symbol: sym, + IsLocal: isLocal, + }) + } + return out +} + +// FunctionEscapes records function values assigned to locations that may be +// invoked outside the local graph. +func FunctionEscapes(graph *cfg.Graph, bindings *bind.BindingTable) []api.FunctionEscapeEvidence { + if graph == nil { + return nil + } + var out []api.FunctionEscapeEvidence + seen := make(map[api.FunctionEscapeEvidence]bool) + appendEscape := func(p cfg.Point, sym cfg.SymbolID) { + if sym == 0 { + return + } + ev := api.FunctionEscapeEvidence{Point: p, Symbol: sym} + if seen[ev] { + return + } + seen[ev] = true + out = append(out, ev) + } + + graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + if info == nil || !funcDefEscapesGraph(info.TargetKind) { + return + } + appendEscape(p, info.Symbol) + }) + + graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + if info == nil { + return + } + info.EachTargetSource(func(i int, target cfg.AssignTarget, src ast.Expr) { + if !assignmentTargetEscapesGraph(target) { + return + } + appendEscape(p, assignmentSourceFunctionSymbol(info, i, src, bindings)) + }) + }) + + return out +} + +// LocalTypePredicates records local functions whose return value is a builtin +// type(param) predicate. The interpreter consumes this to lower predicate-call +// guards without reopening function bodies. +func LocalTypePredicates(functions []api.FunctionDefinitionEvidence) []api.LocalTypePredicateEvidence { + if len(functions) == 0 { + return nil + } + var out []api.LocalTypePredicateEvidence + for _, def := range functions { + if def.Symbol == 0 || def.Nested.Func == nil || def.Nested.Func.ParList == nil { + continue + } + names := def.Nested.Func.ParList.Names + for paramIndex, paramName := range names { + if paramName == "" { + continue + } + if kindName, ok := functionReturnsTypePredicate(def.Nested.Func, paramName); ok { + out = append(out, api.LocalTypePredicateEvidence{ + Symbol: def.Symbol, + ParamName: paramName, + ParamIndex: paramIndex, + Kind: kindName, + }) + break + } + } + } + return out +} + +// FreshTableLiterals records table-literal provenance required by structured +// assignment diagnostics. It is intentionally narrow: only identifier sources +// used in non-identifier assignment targets need this proof. +func FreshTableLiterals( + graph *cfg.Graph, + assignments []api.AssignmentEvidence, + bindings *bind.BindingTable, +) []api.FreshTableLiteralEvidence { + if graph == nil || len(assignments) == 0 { + return nil + } + if bindings == nil { + bindings = graph.Bindings() + } + if bindings == nil { + return nil + } + seen := make(map[freshTableQuery]bool) + var out []api.FreshTableLiteralEvidence + for _, assign := range assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { + if target.Kind == cfg.TargetIdent || source == nil { + return + } + ident, ok := source.(*ast.IdentExpr) + if !ok || ident == nil { + return + } + sym, ok := bindings.SymbolOf(ident) + if !ok || sym == 0 { + return + } + version := graph.VisibleVersion(p, sym) + if version.Symbol == 0 || version.ID == 0 { + return + } + query := freshTableQuery{Point: p, Symbol: sym, Version: version} + if seen[query] { + return + } + seen[query] = true + if fresh, ok := currentFreshTableLiteral(graph, bindings, p, sym, version); ok { + out = append(out, fresh) + } + }) + } + return out +} + +type freshTableQuery struct { + Point cfg.Point + Symbol cfg.SymbolID + Version cfg.Version +} + +func currentFreshTableLiteral( + graph *cfg.Graph, + bindings *bind.BindingTable, + at cfg.Point, + sym cfg.SymbolID, + version cfg.Version, +) (api.FreshTableLiteralEvidence, bool) { + current := at + seen := make(map[cfg.Point]struct{}, 4) + for { + preds := graph.PredecessorsReadOnly(current) + if len(preds) != 1 { + return api.FreshTableLiteralEvidence{}, false + } + pred := preds[0] + if _, ok := seen[pred]; ok { + return api.FreshTableLiteralEvidence{}, false + } + seen[pred] = struct{}{} + + switch info := graph.Info(pred).(type) { + case *cfg.AssignInfo: + if fresh, found, ok := freshTableAssignment(info, pred, sym, version, graph); found { + return api.FreshTableLiteralEvidence{ + Point: at, + Symbol: sym, + Version: version, + Table: fresh, + AssignmentPoint: pred, + }, ok + } + if assignmentInvalidatesFreshness(info, sym, bindings) { + return api.FreshTableLiteralEvidence{}, false + } + case *cfg.CallInfo: + return api.FreshTableLiteralEvidence{}, false + case *cfg.ReturnInfo: + if returnInvalidatesFreshness(info, sym, bindings) { + return api.FreshTableLiteralEvidence{}, false + } + case *cfg.FuncDefInfo: + return api.FreshTableLiteralEvidence{}, false + } + + current = pred + } +} + +func freshTableAssignment( + info *cfg.AssignInfo, + p cfg.Point, + sym cfg.SymbolID, + version cfg.Version, + graph *cfg.Graph, +) (*ast.TableExpr, bool, bool) { + if info == nil { + return nil, false, false + } + var table *ast.TableExpr + found := false + ok := false + info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { + if found || target.Kind != cfg.TargetIdent || target.Symbol != sym { + return + } + found = true + assignedVersion := graph.VisibleVersion(p, sym) + if assignedVersion.Symbol != version.Symbol || assignedVersion.ID != version.ID { + return + } + if t, isTable := src.(*ast.TableExpr); isTable && t != nil { + table = t + ok = true + } + }) + return table, found, ok +} + +func assignmentInvalidatesFreshness(info *cfg.AssignInfo, sym cfg.SymbolID, bindings *bind.BindingTable) bool { + if info == nil { + return false + } + for _, call := range info.SourceCalls { + if call != nil { + return true + } + } + for i, target := range info.Targets { + if target.Kind != cfg.TargetIdent { + if target.BaseSymbol == sym || provenance.ExprReferencesSymbol(target.Expr, sym, bindings) { + return true + } + } + if provenance.ExprMayExposeSymbolValue(info.SourceAt(i), sym, bindings) { + return true + } + } + return false +} + +func returnInvalidatesFreshness(info *cfg.ReturnInfo, sym cfg.SymbolID, bindings *bind.BindingTable) bool { + if info == nil { + return false + } + for _, call := range info.SourceCalls { + if call != nil { + return true + } + } + for _, expr := range info.Exprs { + if provenance.ExprMayExposeSymbolValue(expr, sym, bindings) { + return true + } + } + return false +} + +func functionReturnsTypePredicate(fn *ast.FunctionExpr, paramName string) (string, bool) { + if fn == nil || paramName == "" { + return "", false + } + for _, stmt := range fn.Stmts { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || ret == nil || len(ret.Exprs) == 0 { + continue + } + if kindName, ok := exprTypePredicateKind(ret.Exprs[0], paramName); ok { + return kindName, true + } + } + return "", false +} + +func exprTypePredicateKind(expr ast.Expr, paramName string) (string, bool) { + switch e := expr.(type) { + case *ast.LogicalOpExpr: + if kindName, ok := exprTypePredicateKind(e.Lhs, paramName); ok { + return kindName, true + } + return exprTypePredicateKind(e.Rhs, paramName) + case *ast.RelationalOpExpr: + if e.Operator != "==" { + return "", false + } + if callIsTypeOfParam(e.Lhs, paramName) { + if s, ok := e.Rhs.(*ast.StringExpr); ok && s.Value != "" { + return s.Value, true + } + } + if callIsTypeOfParam(e.Rhs, paramName) { + if s, ok := e.Lhs.(*ast.StringExpr); ok && s.Value != "" { + return s.Value, true + } + } + } + return "", false +} + +func callIsTypeOfParam(expr ast.Expr, paramName string) bool { + call, ok := expr.(*ast.FuncCallExpr) + if !ok || call == nil || callsite.IsMethodLikeExpr(call) || len(call.Args) != 1 { + return false + } + fnIdent, ok := call.Func.(*ast.IdentExpr) + if !ok || fnIdent == nil || fnIdent.Value != "type" { + return false + } + argIdent, ok := call.Args[0].(*ast.IdentExpr) + return ok && argIdent != nil && argIdent.Value == paramName +} + +func functionDefinitionIdentity( + graph *cfg.Graph, + nf cfg.NestedFunc, + funcDef *cfg.FuncDefInfo, +) (string, cfg.SymbolID, bool) { + if funcDef != nil { + return funcDef.Name, funcDef.Symbol, funcDef.TargetKind == cfg.FuncDefGlobal + } + if graph != nil { + if assignInfo := graph.Assign(nf.Point); assignInfo != nil && assignInfo.IsLocal { + if len(assignInfo.Targets) == 1 && assignInfo.Targets[0].Kind == cfg.TargetIdent { + if len(assignInfo.Sources) == 1 && assignInfo.Sources[0] == nf.Func { + return assignInfo.Targets[0].Name, assignInfo.Targets[0].Symbol, true + } + } + } + } + if nf.Symbol != 0 { + return "", nf.Symbol, true + } + return "", 0, false +} + +func fieldDefaultTarget(expr ast.Expr, bindings *bind.BindingTable) (cfg.SymbolID, string, bool) { + attr, ok := expr.(*ast.AttrGetExpr) + if !ok || attr == nil || bindings == nil { + return 0, "", false + } + obj, ok := attr.Object.(*ast.IdentExpr) + if !ok || obj == nil { + return 0, "", false + } + key, ok := attr.Key.(*ast.StringExpr) + if !ok || key == nil || key.Value == "" { + return 0, "", false + } + sym, ok := bindings.SymbolOf(obj) + if !ok || sym == 0 { + return 0, "", false + } + return sym, key.Value, true +} + +func callEvidenceInfoFromExpr(ex *ast.FuncCallExpr, bindings *bind.BindingTable) *cfg.CallInfo { + if ex == nil { + return nil + } + info := cfg.BuildCallInfo(ex, false) + if bindings == nil { + return info + } + info.CalleeSymbol = callsite.SymbolFromExpr(ex.Func, bindings) + if ex.Receiver != nil { + info.ReceiverSymbol = callsite.SymbolFromExpr(ex.Receiver, bindings) + if id, ok := ex.Receiver.(*ast.IdentExpr); ok { + info.ReceiverName = id.Value + } + } + info.ArgSymbols = make([]cfg.SymbolID, len(ex.Args)) + for i, arg := range ex.Args { + info.ArgSymbols[i] = callsite.SymbolFromExpr(arg, bindings) + } + return info +} + +func funcDefEscapesGraph(kind cfg.FuncDefTargetKind) bool { + switch kind { + case cfg.FuncDefGlobal, cfg.FuncDefField, cfg.FuncDefMethod: + return true + default: + return false + } +} + +func assignmentTargetEscapesGraph(target cfg.AssignTarget) bool { + switch target.Kind { + case cfg.TargetField, cfg.TargetIndex: + return true + default: + return false + } +} + +func assignmentSourceFunctionSymbol( + info *cfg.AssignInfo, + i int, + src ast.Expr, + bindings *bind.BindingTable, +) cfg.SymbolID { + if info != nil && i >= 0 && i < len(info.SourceSymbols) { + if sym := info.SourceSymbols[i]; sym != 0 { + return sym + } + } + if fn, ok := src.(*ast.FunctionExpr); ok && bindings != nil { + if sym, found := bindings.FuncLitSymbol(fn); found { + return sym + } + } + return 0 +} + +func graphBindings(graph *cfg.Graph, module *bind.BindingTable) *bind.BindingTable { + if graph != nil { + if bindings := graph.Bindings(); bindings != nil { + return bindings + } + } + return module +} diff --git a/compiler/check/abstract/trace/trace_test.go b/compiler/check/abstract/trace/trace_test.go new file mode 100644 index 00000000..4041b62f --- /dev/null +++ b/compiler/check/abstract/trace/trace_test.go @@ -0,0 +1,53 @@ +package trace + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/parse" +) + +func TestGraphEvidenceIncludesParameterUses(t *testing.T) { + stmts, err := parse.ParseString(` + local name = client.name + return raw, name + `, "test.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + fn := &ast.FunctionExpr{ + ParList: &ast.ParList{Names: []string{"client", "raw"}}, + Stmts: stmts, + } + graph := cfg.Build(fn) + if graph == nil { + t.Fatal("expected graph") + } + + evidence := GraphEvidence(graph, graph.Bindings()) + if len(evidence.ParameterUses) != 2 { + t.Fatalf("expected parameter uses for client and raw, got %d", len(evidence.ParameterUses)) + } + + nameBySymbol := make(map[cfg.SymbolID]string) + for _, slot := range graph.ParamSlotsReadOnly() { + nameBySymbol[slot.Symbol] = slot.Name + } + uses := make(map[string]struct { + whole bool + fields []string + }) + for _, use := range evidence.ParameterUses { + uses[nameBySymbol[use.Symbol]] = struct { + whole bool + fields []string + }{whole: use.Whole, fields: use.Fields} + } + if raw := uses["raw"]; !raw.whole { + t.Fatalf("expected raw to be used whole, got %+v", raw) + } + if client := uses["client"]; client.whole || len(client.fields) != 1 || client.fields[0] != "name" { + t.Fatalf("expected client.name field use only, got %+v", client) + } +} diff --git a/compiler/check/api/analysis_context.go b/compiler/check/api/analysis_context.go new file mode 100644 index 00000000..1658860e --- /dev/null +++ b/compiler/check/api/analysis_context.go @@ -0,0 +1,231 @@ +package api + +import ( + "sort" + + "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/typ" +) + +// AnalysisContext is the execution context for analyzing one function graph. +// +// Parent scope captures lexical/type context. AnalysisContext captures dynamic +// checker context that also changes the meaning of a function body, such as +// contract-provided callback globals and callsite-provided callback signatures. +type AnalysisContext struct { + GlobalOverlay map[string]typ.Type + ExpectedFunction *typ.Function +} + +// Empty reports whether this context carries no analysis-sensitive state. +func (c AnalysisContext) Empty() bool { + return len(c.GlobalOverlay) == 0 && c.ExpectedFunction == nil +} + +// ParentHash returns the function-analysis parent key including this context. +func (c AnalysisContext) ParentHash(parentHash uint64) uint64 { + if c.Empty() { + return parentHash + } + h := internal.HashCombine(parentHash, internal.FnvString("$analysis-context")) + if len(c.GlobalOverlay) > 0 { + h = internal.HashCombine(h, internal.FnvString("globals")) + for _, name := range sortedContextNames(c.GlobalOverlay) { + h = internal.HashCombine(h, internal.FnvString(name)) + if t := c.GlobalOverlay[name]; t != nil { + h = internal.HashCombine(h, t.Hash()) + } + } + } + if c.ExpectedFunction != nil { + h = internal.HashCombine(h, internal.FnvString("expected-function")) + h = internal.HashCombine(h, c.ExpectedFunction.Hash()) + } + return h +} + +// MergeAnalysisContext joins two context descriptions deterministically. +func MergeAnalysisContext(a, b AnalysisContext) AnalysisContext { + if b.Empty() { + return cloneAnalysisContext(a) + } + if a.Empty() { + return cloneAnalysisContext(b) + } + out := cloneAnalysisContext(a) + if len(b.GlobalOverlay) > 0 { + if out.GlobalOverlay == nil { + out.GlobalOverlay = make(map[string]typ.Type, len(b.GlobalOverlay)) + } + for _, name := range sortedContextNames(b.GlobalOverlay) { + candidate := b.GlobalOverlay[name] + if candidate == nil { + continue + } + if existing := out.GlobalOverlay[name]; existing != nil { + out.GlobalOverlay[name] = typ.JoinPreferNonSoft(existing, candidate) + } else { + out.GlobalOverlay[name] = candidate + } + } + } + out.ExpectedFunction = mergeContextExpectedFunction(out.ExpectedFunction, b.ExpectedFunction) + return out +} + +func cloneAnalysisContext(ctx AnalysisContext) AnalysisContext { + if ctx.Empty() { + return AnalysisContext{} + } + out := AnalysisContext{} + if len(ctx.GlobalOverlay) > 0 { + out.GlobalOverlay = make(map[string]typ.Type, len(ctx.GlobalOverlay)) + for name, t := range ctx.GlobalOverlay { + out.GlobalOverlay[name] = t + } + } + out.ExpectedFunction = ctx.ExpectedFunction + return out +} + +func mergeContextExpectedFunction(a, b *typ.Function) *typ.Function { + if a == nil { + return b + } + if b == nil { + return a + } + if typ.TypeEquals(a, b) { + return a + } + + builder := typ.Func().ReserveParams(maxInt(len(a.Params), len(b.Params))) + if sameTypeParams(a.TypeParams, b.TypeParams) { + for _, tp := range a.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + } + + paramCount := maxInt(len(a.Params), len(b.Params)) + for i := 0; i < paramCount; i++ { + name := "" + var pt typ.Type + optional := false + if i < len(a.Params) { + p := a.Params[i] + name = p.Name + pt = p.Type + optional = p.Optional + } else { + pt = typ.Nil + optional = true + } + if i < len(b.Params) { + p := b.Params[i] + if name == "" { + name = p.Name + } + pt = joinContextType(pt, p.Type) + optional = optional || p.Optional + } else { + pt = joinContextType(pt, typ.Nil) + optional = true + } + if optional { + builder = builder.OptParam(name, pt) + } else { + builder = builder.Param(name, pt) + } + } + + if a.Variadic != nil || b.Variadic != nil { + builder = builder.Variadic(joinContextType(a.Variadic, b.Variadic)) + } + + returnCount := maxInt(len(a.Returns), len(b.Returns)) + if returnCount > 0 { + returns := make([]typ.Type, 0, returnCount) + for i := 0; i < returnCount; i++ { + var at, bt typ.Type + if i < len(a.Returns) { + at = a.Returns[i] + } + if i < len(b.Returns) { + bt = b.Returns[i] + } + returns = append(returns, joinContextType(at, bt)) + } + builder = builder.Returns(returns...) + } + + if a.Effects != nil && b.Effects == nil { + builder = builder.Effects(a.Effects) + } else if b.Effects != nil && a.Effects == nil { + builder = builder.Effects(b.Effects) + } + if a.Spec != nil && b.Spec == nil { + builder = builder.Spec(a.Spec) + } else if b.Spec != nil && a.Spec == nil { + builder = builder.Spec(b.Spec) + } + if a.Refinement != nil && b.Refinement == nil { + builder = builder.WithRefinement(a.Refinement) + } else if b.Refinement != nil && a.Refinement == nil { + builder = builder.WithRefinement(b.Refinement) + } + + return builder.Build() +} + +func joinContextType(a, b typ.Type) typ.Type { + switch { + case a == nil: + if b == nil { + return typ.Unknown + } + return b + case b == nil: + return a + case typ.TypeEquals(a, b): + return a + default: + return typ.JoinPreferNonSoft(a, b) + } +} + +func sameTypeParams(a, b []*typ.TypeParam) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] == nil || b[i] == nil { + if a[i] != b[i] { + return false + } + continue + } + if a[i].Name != b[i].Name || !typ.TypeEquals(a[i].Constraint, b[i].Constraint) { + return false + } + } + return true +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func sortedContextNames(m map[string]typ.Type) []string { + if len(m) == 0 { + return nil + } + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/compiler/check/api/doc.go b/compiler/check/api/doc.go index 770e3f45..2cfb4177 100644 --- a/compiler/check/api/doc.go +++ b/compiler/check/api/doc.go @@ -11,8 +11,8 @@ // - [ModuleStore]: Module-level bindings and alias maps // - [GraphStore]: Access to built CFGs by ID // - [ParentScopes]: Parent scope lookup for nested functions -// - [SnapshotStore]: Stable interprocedural fact snapshots -// - [StoreView]: Read-only view combining all above +// - [InterprocFactReader]: Visible interprocedural fact products +// - [StoreReader]: Read contract combining the immutable stores above // - [IterationStore]: Adds mutation for fixpoint iteration // // These interfaces allow different phases to declare their dependencies and @@ -27,19 +27,17 @@ // // # Interprocedural Facts // -// The [Facts] type bundles interprocedural analysis results for a single -// function graph: +// The [Facts] type bundles one interprocedural product slice: // -// - [FunctionFacts]: Canonical per-function return/signature facts -// - [ReturnSummaries]: Inferred return types by function symbol -// - [NarrowReturnSummaries]: Post-narrowing return types -// - [ParamHints]: Parameter types inferred from call sites -// - [FuncTypes]: Canonical types for local function symbols +// - [FunctionFacts]: Canonical per-function parameter/return/signature facts // - [LiteralSigs]: Signatures for anonymous function literals +// - [CapturedTypes]: Flow-derived types for captured variables // - [CapturedFieldAssigns]: Field assignments to captured variables +// - [CapturedContainerMutations]: Container writes to captured variables +// - [ConstructorFields]: Instance fields collected from constructors // -// Facts are computed incrementally and stored per (graph, parent-scope) pair. -// The [GraphKey] type provides the canonical key for this lookup. +// Facts are emitted as canonical deltas. Function-local products are keyed by a +// (graph, parent-scope) [GraphKey]; module-wide products use [ModuleFactsKey]. // // # Function References // diff --git a/compiler/check/api/effects.go b/compiler/check/api/effects.go index 2e234590..10945c65 100644 --- a/compiler/check/api/effects.go +++ b/compiler/check/api/effects.go @@ -10,29 +10,23 @@ type RefinementFacts interface { LookupBySym(sym cfg.SymbolID) *constraint.FunctionRefinement } -// RefinementStore provides methods for storing and retrieving function refinements. -type RefinementStore interface { - LookupRefinementBySym(sym cfg.SymbolID) *constraint.FunctionRefinement -} - -// storeRefinementFacts implements RefinementFacts backed by a RefinementStore. -type storeRefinementFacts struct { - store RefinementStore -} +// RefinementLookup adapts canonical function-fact projections into refinement +// facts used by flow interpretation and call-effect propagation. +type RefinementLookup func(sym cfg.SymbolID) *constraint.FunctionRefinement -// NewRefinementFacts creates RefinementFacts backed by a RefinementStore. -func NewRefinementFacts(store RefinementStore) RefinementFacts { - if store == nil { +// NewRefinementFacts creates RefinementFacts from a canonical lookup function. +func NewRefinementFacts(lookup RefinementLookup) RefinementFacts { + if lookup == nil { return nilRefinementFacts{} } - return &storeRefinementFacts{store: store} + return lookup } -func (f *storeRefinementFacts) LookupBySym(sym cfg.SymbolID) *constraint.FunctionRefinement { - if f.store == nil || sym == 0 { +func (f RefinementLookup) LookupBySym(sym cfg.SymbolID) *constraint.FunctionRefinement { + if f == nil || sym == 0 { return nil } - return f.store.LookupRefinementBySym(sym) + return f(sym) } // nilRefinementFacts is a no-op RefinementFacts implementation. diff --git a/compiler/check/api/env.go b/compiler/check/api/env.go index 4ff2c471..5cb3ca64 100644 --- a/compiler/check/api/env.go +++ b/compiler/check/api/env.go @@ -1,10 +1,11 @@ // env.go defines phase-typed environments for synthesis. // DeclaredEnv is used pre-flow; NarrowEnv is used post-flow. -// This split prevents pre-flow return summaries from being accessed in narrowing. +// This split keeps declared and flow-refined function facts phase-explicit. package api import ( "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/check/domain/typefacts" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/flow" @@ -37,7 +38,7 @@ func (p Phase) String() string { } // BaseEnv is the shared environment interface for synthesis. -// It intentionally excludes return summaries to prevent cross-phase misuse. +// It intentionally excludes FunctionFacts to prevent cross-phase misuse. type BaseEnv interface { Phase() Phase Graph() cfg.VersionedGraph @@ -53,16 +54,14 @@ type BaseEnv interface { WithGlobalOverlay(overlay map[string]typ.Type) BaseEnv } -// DeclaredEnv provides access to pre-flow return summaries. +// DeclaredEnv is the declared-phase synthesis environment. type DeclaredEnv interface { BaseEnv - ReturnSummaries() map[cfg.SymbolID][]typ.Type } -// NarrowEnv provides access to post-flow return summaries. +// NarrowEnv is the narrowing-phase synthesis environment. type NarrowEnv interface { BaseEnv - NarrowReturnSummaries() map[cfg.SymbolID][]typ.Type } type envBase struct { @@ -77,8 +76,8 @@ type envBase struct { globalTypes map[string]typ.Type } -func (b *envBase) withGlobalOverlay(overlay map[string]typ.Type) *envBase { - if b == nil || len(overlay) == 0 { +func (b envBase) withGlobalOverlay(overlay map[string]typ.Type) envBase { + if len(overlay) == 0 { return b } merged := make(map[string]typ.Type, len(b.globalTypes)+len(overlay)) @@ -88,25 +87,28 @@ func (b *envBase) withGlobalOverlay(overlay map[string]typ.Type) *envBase { for k, v := range overlay { merged[k] = v } - next := *b + next := b next.globalTypes = merged - return &next + return next } type envCommon struct { - base *envBase + base envBase } -func (c *envCommon) withGlobalOverlay(overlay map[string]typ.Type) *envCommon { +func (c *envCommon) withGlobalOverlay(overlay map[string]typ.Type) envCommon { if c == nil || len(overlay) == 0 { - return c + if c == nil { + return envCommon{} + } + return *c } - return &envCommon{base: c.base.withGlobalOverlay(overlay)} + return envCommon{base: c.base.withGlobalOverlay(overlay)} } // Phase returns the current checking phase. func (c *envCommon) Phase() Phase { - if c == nil || c.base == nil { + if c == nil { return PhaseScopeCompute } return c.base.phase @@ -114,7 +116,7 @@ func (c *envCommon) Phase() Phase { // Graph returns the versioned CFG graph. func (c *envCommon) Graph() cfg.VersionedGraph { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.graph @@ -122,7 +124,7 @@ func (c *envCommon) Graph() cfg.VersionedGraph { // Types returns the type facts provider. func (c *envCommon) Types() flow.TypeFacts { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.types @@ -130,7 +132,7 @@ func (c *envCommon) Types() flow.TypeFacts { // Consts returns the flow solution for constant value lookup. func (c *envCommon) Consts() *flow.Solution { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.solution @@ -138,7 +140,7 @@ func (c *envCommon) Consts() *flow.Solution { // Refinements returns the refinement facts provider. func (c *envCommon) Refinements() RefinementFacts { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.refinements @@ -146,7 +148,7 @@ func (c *envCommon) Refinements() RefinementFacts { // TypeNames returns the scope state for type name resolution. func (c *envCommon) TypeNames() *scope.State { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.typeNames @@ -154,7 +156,7 @@ func (c *envCommon) TypeNames() *scope.State { // Bindings returns the binding table for AST-based symbol resolution. func (c *envCommon) Bindings() *bind.BindingTable { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.bindings @@ -162,7 +164,7 @@ func (c *envCommon) Bindings() *bind.BindingTable { // ModuleAliases returns the module alias map (symbol -> module path). func (c *envCommon) ModuleAliases() map[cfg.SymbolID]string { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.moduleAliases @@ -170,7 +172,7 @@ func (c *envCommon) ModuleAliases() map[cfg.SymbolID]string { // ModuleAlias returns the module path for a symbol assigned from require(). func (c *envCommon) ModuleAlias(sym cfg.SymbolID) string { - if c == nil || c.base == nil || c.base.moduleAliases == nil { + if c == nil || c.base.moduleAliases == nil { return "" } return c.base.moduleAliases[sym] @@ -178,7 +180,7 @@ func (c *envCommon) ModuleAlias(sym cfg.SymbolID) string { // GlobalType returns the global type for a symbol if it is a confirmed global. func (c *envCommon) GlobalType(sym cfg.SymbolID) (typ.Type, bool) { - if c == nil || c.base == nil || c.base.globalTypes == nil || sym == 0 { + if c == nil || c.base.globalTypes == nil || sym == 0 { return nil, false } if c.base.bindings == nil { @@ -198,7 +200,7 @@ func (c *envCommon) GlobalType(sym cfg.SymbolID) (typ.Type, bool) { // GlobalTypes returns the global type map. func (c *envCommon) GlobalTypes() map[string]typ.Type { - if c == nil || c.base == nil { + if c == nil { return nil } return c.base.globalTypes @@ -206,14 +208,12 @@ func (c *envCommon) GlobalTypes() map[string]typ.Type { // DeclaredEnvImpl is the concrete declared-phase environment. type DeclaredEnvImpl struct { - *envCommon - returnSummaries map[cfg.SymbolID][]typ.Type + envCommon } // NarrowEnvImpl is the concrete narrowing-phase environment. type NarrowEnvImpl struct { - *envCommon - narrowReturns map[cfg.SymbolID][]typ.Type + envCommon } var _ BaseEnv = (*DeclaredEnvImpl)(nil) @@ -423,51 +423,33 @@ func (e *NarrowEnvImpl) WithGlobalOverlay(overlay map[string]typ.Type) BaseEnv { return &next } -// ReturnSummaries returns the return type summaries for sibling functions. -func (e *DeclaredEnvImpl) ReturnSummaries() map[cfg.SymbolID][]typ.Type { - if e == nil { - return nil - } - return e.returnSummaries -} - -// NarrowReturnSummaries returns post-flow return summaries for narrowing. -func (e *NarrowEnvImpl) NarrowReturnSummaries() map[cfg.SymbolID][]typ.Type { - if e == nil { - return nil - } - return e.narrowReturns -} - // DeclaredEnvConfig holds inputs for building a declared-phase Env. type DeclaredEnvConfig struct { - Graph cfg.VersionedGraph - Bindings *bind.BindingTable - DeclaredTypes flow.DeclaredTypes - AnnotatedVars map[cfg.SymbolID]bool - BaseScope *scope.State - RefinementStore RefinementStore - ModuleAliases map[cfg.SymbolID]string - GlobalTypes map[string]typ.Type - SiblingTypes map[cfg.SymbolID]typ.Type - LiteralTypes flow.DeclaredTypes - ReturnSummaries map[cfg.SymbolID][]typ.Type + Graph cfg.VersionedGraph + Bindings *bind.BindingTable + DeclaredTypes flow.DeclaredTypes + AnnotatedVars map[cfg.SymbolID]bool + BaseScope *scope.State + Refinements RefinementFacts + ModuleAliases map[cfg.SymbolID]string + GlobalTypes map[string]typ.Type + LiteralTypes flow.DeclaredTypes + FunctionType typefacts.FunctionTypeLookup } // NarrowEnvConfig holds inputs for building a narrowing-phase Env. type NarrowEnvConfig struct { - Graph cfg.VersionedGraph - Bindings *bind.BindingTable - DeclaredTypes flow.DeclaredTypes - AnnotatedVars map[cfg.SymbolID]bool - Solution *flow.Solution - BaseScope *scope.State - RefinementStore RefinementStore - ModuleAliases map[cfg.SymbolID]string - GlobalTypes map[string]typ.Type - SiblingTypes map[cfg.SymbolID]typ.Type - LiteralTypes flow.DeclaredTypes - NarrowReturnSummaries map[cfg.SymbolID][]typ.Type + Graph cfg.VersionedGraph + Bindings *bind.BindingTable + DeclaredTypes flow.DeclaredTypes + AnnotatedVars map[cfg.SymbolID]bool + Solution *flow.Solution + BaseScope *scope.State + Refinements RefinementFacts + ModuleAliases map[cfg.SymbolID]string + GlobalTypes map[string]typ.Type + LiteralTypes flow.DeclaredTypes + FunctionType typefacts.FunctionTypeLookup } func newEnvBase( @@ -480,8 +462,8 @@ func newEnvBase( typeNames *scope.State, moduleAliases map[cfg.SymbolID]string, globalTypes map[string]typ.Type, -) *envBase { - return &envBase{ +) envBase { + return envBase{ phase: phase, graph: graph, bindings: bindings, @@ -503,14 +485,19 @@ func NewDeclaredEnv(cfg DeclaredEnvConfig) *DeclaredEnvImpl { PhaseScopeCompute, cfg.Graph, cfg.Bindings, - newUnifiedTypeFacts(cfg.Graph, cfg.DeclaredTypes, cfg.SiblingTypes, cfg.LiteralTypes, cfg.AnnotatedVars, nil), + typefacts.New(typefacts.Config{ + Declared: cfg.DeclaredTypes, + FunctionType: cfg.FunctionType, + Literals: cfg.LiteralTypes, + AnnotatedVars: cfg.AnnotatedVars, + }), nil, - NewRefinementFacts(cfg.RefinementStore), + refinementFactsOrNil(cfg.Refinements), cfg.BaseScope, cfg.ModuleAliases, cfg.GlobalTypes, ) - return &DeclaredEnvImpl{envCommon: &envCommon{base: base}, returnSummaries: cfg.ReturnSummaries} + return &DeclaredEnvImpl{envCommon: envCommon{base: base}} } // NewNarrowEnv creates a narrowing-phase Env. @@ -522,25 +509,31 @@ func NewNarrowEnv(cfg NarrowEnvConfig) *NarrowEnvImpl { PhaseNarrowing, cfg.Graph, cfg.Bindings, - newUnifiedTypeFacts(cfg.Graph, cfg.DeclaredTypes, cfg.SiblingTypes, cfg.LiteralTypes, cfg.AnnotatedVars, cfg.Solution), + typefacts.New(typefacts.Config{ + Declared: cfg.DeclaredTypes, + FunctionType: cfg.FunctionType, + Literals: cfg.LiteralTypes, + AnnotatedVars: cfg.AnnotatedVars, + Solution: cfg.Solution, + }), cfg.Solution, - NewRefinementFacts(cfg.RefinementStore), + refinementFactsOrNil(cfg.Refinements), cfg.BaseScope, cfg.ModuleAliases, cfg.GlobalTypes, ) - return &NarrowEnvImpl{envCommon: &envCommon{base: base}, narrowReturns: cfg.NarrowReturnSummaries} + return &NarrowEnvImpl{envCommon: envCommon{base: base}} } // ReturnInferenceEnvConfig holds inputs for return type inference. type ReturnInferenceEnvConfig struct { - Graph cfg.VersionedGraph - Bindings *bind.BindingTable - BaseScope *scope.State - DeclaredTypes flow.DeclaredTypes - GlobalTypes map[string]typ.Type - ModuleAliases map[cfg.SymbolID]string - ReturnSummaries map[cfg.SymbolID][]typ.Type + Graph cfg.VersionedGraph + Bindings *bind.BindingTable + BaseScope *scope.State + DeclaredTypes flow.DeclaredTypes + GlobalTypes map[string]typ.Type + ModuleAliases map[cfg.SymbolID]string + FunctionType typefacts.FunctionTypeLookup } // NewReturnInferenceEnv creates a declared-phase Env for return inference. @@ -552,114 +545,22 @@ func NewReturnInferenceEnv(cfg ReturnInferenceEnvConfig) *DeclaredEnvImpl { PhaseScopeCompute, cfg.Graph, cfg.Bindings, - newUnifiedTypeFacts(cfg.Graph, cfg.DeclaredTypes, nil, nil, nil, nil), + typefacts.New(typefacts.Config{ + Declared: cfg.DeclaredTypes, + FunctionType: cfg.FunctionType, + }), nil, - NewRefinementFacts(nil), + nilRefinementFacts{}, cfg.BaseScope, cfg.ModuleAliases, cfg.GlobalTypes, ) - return &DeclaredEnvImpl{envCommon: &envCommon{base: base}, returnSummaries: cfg.ReturnSummaries} -} - -// unifiedTypeFacts implements flow.TypeFacts with layered type source lookup. -type unifiedTypeFacts struct { - graph cfg.VersionedGraph - declaredTypes flow.DeclaredTypes - siblingTypes map[cfg.SymbolID]typ.Type - literalTypes flow.DeclaredTypes - annotatedVars map[cfg.SymbolID]bool - solution *flow.Solution -} - -func newUnifiedTypeFacts( - graph cfg.VersionedGraph, - declared flow.DeclaredTypes, - siblings map[cfg.SymbolID]typ.Type, - literals flow.DeclaredTypes, - annotated map[cfg.SymbolID]bool, - solution *flow.Solution, -) flow.TypeFacts { - return &unifiedTypeFacts{ - graph: graph, - declaredTypes: declared, - siblingTypes: siblings, - literalTypes: literals, - annotatedVars: annotated, - solution: solution, - } -} - -// DeclaredAt returns the declared type for a symbol at a CFG point. -// Searches: literal types, sibling function types, then declared types. -func (f *unifiedTypeFacts) DeclaredAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { - if sym == 0 { - return flow.TypedValue{Type: typ.Unknown, State: flow.StateUnknown} - } - // For explicitly annotated symbols, prefer the declared type over literal overlays. - if f.annotatedVars != nil && f.annotatedVars[sym] { - if f.declaredTypes != nil { - if t, ok := f.declaredTypes[sym]; ok && t != nil { - return f.toTypedValue(t) - } - } - } - if f.siblingTypes != nil { - if t, ok := f.siblingTypes[sym]; ok && t != nil { - return f.toTypedValue(t) - } - } - if f.declaredTypes != nil { - if t, ok := f.declaredTypes[sym]; ok && t != nil { - return f.toTypedValue(t) - } - } - // Literal overlays are synthesized from function/table literals and can lag - // behind canonical declared/sibling symbol types during fixpoint iterations. - // Keep them as fallback only when no canonical symbol type is available. - if f.literalTypes != nil { - if f.annotatedVars == nil || !f.annotatedVars[sym] { - if t, ok := f.literalTypes[sym]; ok && t != nil { - return f.toTypedValue(t) - } - } - } - return flow.TypedValue{Type: typ.Unknown, State: flow.StateUnknown} -} - -// RefinedAt returns the flow-refined type for a symbol at a CFG point. -// Returns nil type if no solution is available or symbol is unknown. -func (f *unifiedTypeFacts) RefinedAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { - if f == nil || sym == 0 { - return flow.TypedValue{Type: nil, State: flow.StateUnknown} - } - if f.solution == nil { - return flow.TypedValue{Type: nil, State: flow.StateUnknown} - } - return f.solution.RefinedAt(p, sym) -} - -// EffectiveTypeAt returns the effective type for a symbol at a CFG point. -// Prefers refined (narrowed) type if available, otherwise falls back to declared. -func (f *unifiedTypeFacts) EffectiveTypeAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { - refined := f.RefinedAt(p, sym) - if refined.Type != nil && refined.State == flow.StateResolved { - return refined - } - return f.DeclaredAt(p, sym) -} - -// IsAnnotated returns true if the symbol has an explicit type annotation. -func (f *unifiedTypeFacts) IsAnnotated(sym cfg.SymbolID) bool { - if f.annotatedVars == nil { - return false - } - return f.annotatedVars[sym] + return &DeclaredEnvImpl{envCommon: envCommon{base: base}} } -func (f *unifiedTypeFacts) toTypedValue(t typ.Type) flow.TypedValue { - if typ.IsUnknown(t) { - return flow.TypedValue{Type: t, State: flow.StateUnknown} +func refinementFactsOrNil(f RefinementFacts) RefinementFacts { + if f == nil { + return nilRefinementFacts{} } - return flow.TypedValue{Type: t, State: flow.StateResolved} + return f } diff --git a/compiler/check/api/evidence.go b/compiler/check/api/evidence.go new file mode 100644 index 00000000..52829eec --- /dev/null +++ b/compiler/check/api/evidence.go @@ -0,0 +1,169 @@ +package api + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/types/constraint" +) + +// FlowEvidence is the abstract-interpreter event stream that later checker +// phases reduce with solved, narrowed expression types. +type FlowEvidence struct { + Calls []CallEvidence + Returns []ReturnEvidence + Assignments []AssignmentEvidence + Branches []BranchEvidence + NormalExit NormalExitEvidence + IdentifierUses []IdentifierUseEvidence + FieldDefaults []FieldDefaultEvidence + ParameterUses []ParameterUseEvidence + FreshTableLiterals []FreshTableLiteralEvidence + FunctionDefinitions []FunctionDefinitionEvidence + EscapedFunctions []FunctionEscapeEvidence + LocalTypePredicates []LocalTypePredicateEvidence + CapturedFields []CapturedFieldEvidence + CapturedContainers []CapturedContainerEvidence +} + +// IsZero reports whether no abstract-interpreter event evidence has been +// materialized for a graph. +func (e FlowEvidence) IsZero() bool { + return len(e.Calls) == 0 && + len(e.Returns) == 0 && + len(e.Assignments) == 0 && + len(e.Branches) == 0 && + !e.NormalExit.Valid && + len(e.IdentifierUses) == 0 && + len(e.FieldDefaults) == 0 && + len(e.ParameterUses) == 0 && + len(e.FreshTableLiterals) == 0 && + len(e.FunctionDefinitions) == 0 && + len(e.EscapedFunctions) == 0 && + len(e.LocalTypePredicates) == 0 && + len(e.CapturedFields) == 0 && + len(e.CapturedContainers) == 0 +} + +// CallOrigin classifies the graph event that owns a call expression. +type CallOrigin uint8 + +const ( + CallOriginExpression CallOrigin = iota + CallOriginStatement + CallOriginAssignment + CallOriginReturn + CallOriginBranch +) + +// CallEvidence records a call site discovered by the abstract interpreter. +type CallEvidence struct { + Point cfg.Point + Info *cfg.CallInfo + Origin CallOrigin +} + +// ReturnEvidence records a return point discovered by the abstract interpreter. +type ReturnEvidence struct { + Point cfg.Point + Info *cfg.ReturnInfo +} + +// AssignmentEvidence records an assignment point discovered by the abstract +// interpreter. +type AssignmentEvidence struct { + Point cfg.Point + Info *cfg.AssignInfo +} + +// BranchEvidence records a branch point discovered by the abstract interpreter. +type BranchEvidence struct { + Point cfg.Point + Info *cfg.BranchInfo +} + +// NormalExitEvidence records the graph exit point that represents implicit +// normal return from the function body. +type NormalExitEvidence struct { + Point cfg.Point + Valid bool +} + +// IdentifierUseEvidence records an identifier expression read by one graph +// event. Definition targets are not uses; assignment sources, call operands, +// return expressions, branch conditions, and structured assignment bases/keys +// are. +type IdentifierUseEvidence struct { + Point cfg.Point + Expr *ast.IdentExpr +} + +// FieldDefaultEvidence records an `x.field or default` expression. +type FieldDefaultEvidence struct { + Point cfg.Point + Target cfg.SymbolID + Field string + Value ast.Expr +} + +// FreshTableLiteralEvidence records that at Point, Symbol's visible value is a +// fresh table literal assigned at AssignmentPoint and not yet exposed through +// an alias, call, return, function definition, or structured mutation along the +// unique predecessor chain. +type FreshTableLiteralEvidence struct { + Point cfg.Point + Symbol cfg.SymbolID + Version cfg.Version + Table *ast.TableExpr + AssignmentPoint cfg.Point +} + +// FunctionDefinitionEvidence records a nested function definition and its +// resolved source-level identity. +type FunctionDefinitionEvidence struct { + Nested cfg.NestedFunc + FuncDef *cfg.FuncDefInfo + Name string + Symbol cfg.SymbolID + IsLocal bool +} + +// FunctionEscapeEvidence records a local function value published through a +// global/field/indexed position that can run outside the defining graph. +type FunctionEscapeEvidence struct { + Point cfg.Point + Symbol cfg.SymbolID +} + +// LocalTypePredicateEvidence records a local function that returns a builtin +// runtime type predicate for one of its parameters. +type LocalTypePredicateEvidence struct { + Symbol cfg.SymbolID + ParamName string + ParamIndex int + Kind string +} + +// ParameterUseEvidence records how a function body demands one parameter. +type ParameterUseEvidence struct { + Symbol cfg.SymbolID + Whole bool + Fields []string +} + +// CapturedFieldEvidence records a direct field write to a captured symbol. +type CapturedFieldEvidence struct { + Point cfg.Point + Target cfg.SymbolID + Field string + Value ast.Expr +} + +// CapturedContainerEvidence records an element mutation on a captured symbol. +type CapturedContainerEvidence struct { + Point cfg.Point + Target cfg.SymbolID + Segments []constraint.Segment + Key ast.Expr + Value ast.Expr + Kind ContainerMutationKind +} diff --git a/compiler/check/api/evidence_test.go b/compiler/check/api/evidence_test.go new file mode 100644 index 00000000..26140871 --- /dev/null +++ b/compiler/check/api/evidence_test.go @@ -0,0 +1,19 @@ +package api + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" +) + +func TestFlowEvidenceIsZero(t *testing.T) { + if !(FlowEvidence{}).IsZero() { + t.Fatal("empty evidence should be zero") + } + if (FlowEvidence{Calls: []CallEvidence{{Point: cfg.Point(1)}}}).IsZero() { + t.Fatal("call evidence should make product non-zero") + } + if (FlowEvidence{ParameterUses: []ParameterUseEvidence{{Symbol: cfg.SymbolID(1), Whole: true}}}).IsZero() { + t.Fatal("parameter-use evidence should make product non-zero") + } +} diff --git a/compiler/check/api/facts.go b/compiler/check/api/facts.go index ada6fe9e..7d8fd5df 100644 --- a/compiler/check/api/facts.go +++ b/compiler/check/api/facts.go @@ -12,38 +12,24 @@ import ( "github.com/wippyai/go-lua/types/typ" ) -// ReturnSummaries maps function symbols to their inferred return type vectors. -// Each entry is a slice of types representing the tuple of values returned -// by the function. For example, a function returning (value, error) has -// a two-element slice [valueType, errorType]. -type ReturnSummaries = map[cfg.SymbolID][]typ.Type - -// NarrowReturnSummaries holds post-flow return summaries with narrowing applied. -// These are computed after flow analysis and reflect the precise types at -// each return statement, accounting for control flow narrowing. -type NarrowReturnSummaries = map[cfg.SymbolID][]typ.Type - -// ParamHints maps function symbols to parameter type hints inferred from call sites. -// When a function is called with known argument types, those types are recorded -// as hints and propagated to the function's parameter declarations. -type ParamHints = map[cfg.SymbolID][]typ.Type - -// FuncTypes maps local function symbols to their canonical function types. -// Used for sibling function lookups where the function is defined in the -// same scope as the call site. -type FuncTypes = map[cfg.SymbolID]typ.Type - // FunctionFact is the canonical function-related interproc fact for one symbol. -// Legacy channels (ReturnSummaries/NarrowReturns/FuncTypes) are compatibility -// views and should be derivable from this value. +// All return and local-function type evidence for a function converges here. type FunctionFact struct { + // Params is the canonical parameter evidence vector. For method calls, slot + // 0 is the receiver/self argument and the remaining slots are source args. + Params []typ.Type + // Summary is the declared/pre-flow return vector. Summary []typ.Type - Narrow []typ.Type - Func typ.Type + // Narrow is the post-flow return vector. + Narrow []typ.Type + // Type is the canonical local function type evidence. + Type typ.Type + // Refinement is the canonical effect/refinement summary for the function. + Refinement *constraint.FunctionRefinement } // FunctionFacts maps function symbols to their canonical function facts. -type FunctionFacts = map[cfg.SymbolID]FunctionFact +type FunctionFacts map[cfg.SymbolID]FunctionFact // LiteralSigs maps anonymous function literal expressions to their signatures. // Used when function literals are passed as arguments or assigned to variables @@ -64,16 +50,56 @@ type CapturedTypes = map[cfg.SymbolID]typ.Type // to its captured variables, supporting constructor inference patterns. type CapturedFieldAssigns = map[cfg.SymbolID]map[cfg.SymbolID]map[string]typ.Type +// ContainerMutationKind describes the operator used for a captured container +// mutation. Different operators have different abstract interpreter effects in +// the parent flow. +type ContainerMutationKind uint8 + +const ( + // ContainerMutationContainerElement widens generic container element types, + // such as channel:send(value) through a ContainerElementUnion effect. + ContainerMutationContainerElement ContainerMutationKind = iota + // ContainerMutationTableElement widens Lua table array/map-array element + // types, such as table.insert(t, value). + ContainerMutationTableElement + // ContainerMutationMapElement widens Lua table map values from dynamic + // assignments such as t[k] = value. + ContainerMutationMapElement +) + // ContainerMutation records a container element mutation on a captured variable. // Segments capture the path from the base symbol (e.g., .ch, ["queue"]). type ContainerMutation struct { + Kind ContainerMutationKind Segments []constraint.Segment + KeyType typ.Type ValueType typ.Type } // ContainerMutationKey returns the canonical path key for a container mutation. func ContainerMutationKey(m ContainerMutation) string { - return constraint.FormatSegments(m.Segments) + return containerMutationKindKey(m.Kind) + containerMutationKeyMode(m) + ":" + constraint.FormatSegments(m.Segments) +} + +func containerMutationKeyMode(m ContainerMutation) string { + if m.Kind != ContainerMutationTableElement && m.Kind != ContainerMutationMapElement { + return "" + } + if m.KeyType != nil { + return ":keyed" + } + return ":append" +} + +func containerMutationKindKey(kind ContainerMutationKind) string { + switch kind { + case ContainerMutationMapElement: + return "map" + case ContainerMutationTableElement: + return "table" + default: + return "container" + } } // CapturedContainerMutations maps nested function symbols to container mutations @@ -86,17 +112,10 @@ type CapturedContainerMutations = map[cfg.SymbolID]map[cfg.SymbolID][]ContainerM // Structure: classSymbol -> fieldName -> fieldType. type ConstructorFields = map[cfg.SymbolID]map[string]typ.Type -// Facts bundles all interprocedural analysis results for a single function graph. -// These facts are computed during analysis and stored per (graph, parent) pair. +// Facts bundles one canonical interprocedural product slice. Most slices are +// stored per (graph, parent) pair; module-wide facts use ModuleFactsKey. type Facts struct { - FunctionFacts FunctionFacts - // Compatibility mirror derived from FunctionFacts. - ReturnSummaries ReturnSummaries - // Compatibility mirror derived from FunctionFacts. - NarrowReturns NarrowReturnSummaries - ParamHints ParamHints - // Compatibility mirror derived from FunctionFacts. - FuncTypes FuncTypes + FunctionFacts FunctionFacts LiteralSigs LiteralSigs CapturedTypes CapturedTypes CapturedFields CapturedFieldAssigns diff --git a/compiler/check/api/facts_test.go b/compiler/check/api/facts_test.go index 2335f50f..dd40fdc5 100644 --- a/compiler/check/api/facts_test.go +++ b/compiler/check/api/facts_test.go @@ -13,18 +13,6 @@ func TestFacts_Zero(t *testing.T) { if f.FunctionFacts != nil { t.Error("zero Facts should have nil FunctionFacts") } - if f.ReturnSummaries != nil { - t.Error("zero Facts should have nil ReturnSummaries") - } - if f.NarrowReturns != nil { - t.Error("zero Facts should have nil NarrowReturns") - } - if f.ParamHints != nil { - t.Error("zero Facts should have nil ParamHints") - } - if f.FuncTypes != nil { - t.Error("zero Facts should have nil FuncTypes") - } if f.LiteralSigs != nil { t.Error("zero Facts should have nil LiteralSigs") } @@ -39,49 +27,6 @@ func TestFacts_Zero(t *testing.T) { } } -func TestReturnSummaries_Basic(t *testing.T) { - summaries := make(ReturnSummaries) - sym := cfg.SymbolID(1) - summaries[sym] = []typ.Type{typ.String, typ.Nil} - - rets, ok := summaries[sym] - if !ok { - t.Fatal("expected symbol to be in summaries") - } - if len(rets) != 2 { - t.Errorf("expected 2 return types, got %d", len(rets)) - } -} - -func TestParamHints_Basic(t *testing.T) { - hints := make(ParamHints) - sym := cfg.SymbolID(1) - hints[sym] = []typ.Type{typ.Number, typ.String} - - params, ok := hints[sym] - if !ok { - t.Fatal("expected symbol to be in hints") - } - if len(params) != 2 { - t.Errorf("expected 2 param hints, got %d", len(params)) - } -} - -func TestFuncTypes_Basic(t *testing.T) { - funcTypes := make(FuncTypes) - sym := cfg.SymbolID(1) - fn := typ.Func().Param("x", typ.Number).Returns(typ.String).Build() - funcTypes[sym] = fn - - retrieved, ok := funcTypes[sym] - if !ok { - t.Fatal("expected symbol to be in funcTypes") - } - if retrieved == nil { - t.Error("expected non-nil function type") - } -} - func TestCapturedTypes_Basic(t *testing.T) { captured := make(CapturedTypes) sym := cfg.SymbolID(1) @@ -163,8 +108,16 @@ func TestContainerMutationKey(t *testing.T) { }, ValueType: typ.String, } - if got, want := ContainerMutationKey(m), ".queue[\"jobs\"][2]"; got != want { - t.Fatalf("ContainerMutationKey() = %q, want %q", got, want) + if got, want := ContainerMutationKey(m), "container:.queue[\"jobs\"][2]"; got != want { + t.Fatalf("container key = %q, want %q", got, want) + } + m.Kind = ContainerMutationTableElement + if got, want := ContainerMutationKey(m), "table:append:.queue[\"jobs\"][2]"; got != want { + t.Fatalf("table key = %q, want %q", got, want) + } + m.KeyType = typ.String + if got, want := ContainerMutationKey(m), "table:keyed:.queue[\"jobs\"][2]"; got != want { + t.Fatalf("keyed table key = %q, want %q", got, want) } } @@ -172,32 +125,15 @@ func TestFacts_WithData(t *testing.T) { f := Facts{ FunctionFacts: FunctionFacts{ 4: { + Params: []typ.Type{typ.Number}, Summary: []typ.Type{typ.Boolean}, Narrow: []typ.Type{typ.Boolean}, - Func: typ.Func().Returns(typ.Boolean).Build(), + Type: typ.Func().Returns(typ.Boolean).Build(), }, }, - ReturnSummaries: ReturnSummaries{ - 1: []typ.Type{typ.String}, - }, - ParamHints: ParamHints{ - 2: []typ.Type{typ.Number}, - }, - FuncTypes: FuncTypes{ - 3: typ.Func().Build(), - }, } - if len(f.ReturnSummaries) != 1 { - t.Error("expected 1 return summary") - } if len(f.FunctionFacts) != 1 { t.Error("expected 1 function fact") } - if len(f.ParamHints) != 1 { - t.Error("expected 1 param hint") - } - if len(f.FuncTypes) != 1 { - t.Error("expected 1 func type") - } } diff --git a/compiler/check/api/keys.go b/compiler/check/api/keys.go index 962076b5..ae2f0fa1 100644 --- a/compiler/check/api/keys.go +++ b/compiler/check/api/keys.go @@ -14,14 +14,22 @@ type GraphKey struct { ParentHash uint64 // Parent scope hash from SessionStore.Parents() } +// ModuleFactsKey identifies module-wide interprocedural facts that are not tied +// to one function graph, such as constructor field summaries keyed by class +// symbol. +func ModuleFactsKey() GraphKey { + return GraphKey{} +} + // SymbolKey uniquely identifies a symbol within a parent scope. type SymbolKey struct { Symbol cfg.SymbolID ParentHash uint64 } -// FuncKey uniquely identifies a function analysis request for memoization purposes. -// The key combines three components to ensure cache correctness: +// FuncKey uniquely identifies a function analysis request for memoization. +// Fact dependencies are tracked by the query database as the function result +// reads canonical interprocedural products. // // - GraphID: Unique identifier for the function's control flow graph. Each CFG // receives a monotonically increasing ID during construction, ensuring distinct @@ -30,15 +38,9 @@ type SymbolKey struct { // - ParentHash: Hash of the parent scope state. Functions with identical code but // different lexical environments (e.g., different captured variables or type // definitions in scope) must be analyzed separately. -// -// - StoreRevision: Counter incremented at each fixpoint iteration boundary. -// This ensures cached results are invalidated when inter-function summaries -// (return types, effects, sibling types) change, forcing recomputation with -// updated cross-function information. type FuncKey struct { - GraphID uint64 - ParentHash uint64 - StoreRevision uint64 + GraphID uint64 + ParentHash uint64 } // KeyForGraph creates a GraphKey from a graph and parent scope. diff --git a/compiler/check/api/keys_test.go b/compiler/check/api/keys_test.go index 423e6df3..eb8f0b98 100644 --- a/compiler/check/api/keys_test.go +++ b/compiler/check/api/keys_test.go @@ -44,24 +44,24 @@ func TestSymbolKey_Equality(t *testing.T) { func TestFuncKey_Zero(t *testing.T) { k := FuncKey{} - if k.GraphID != 0 || k.ParentHash != 0 || k.StoreRevision != 0 { + if k.GraphID != 0 || k.ParentHash != 0 { t.Error("zero FuncKey should have zero fields") } } func TestFuncKey_Equality(t *testing.T) { - a := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 3} - b := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 3} + a := FuncKey{GraphID: 1, ParentHash: 2} + b := FuncKey{GraphID: 1, ParentHash: 2} if a != b { t.Error("equal FuncKeys should be ==") } } -func TestFuncKey_DifferentRevision(t *testing.T) { - a := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 3} - b := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 4} +func TestFuncKey_DifferentParent(t *testing.T) { + a := FuncKey{GraphID: 1, ParentHash: 2} + b := FuncKey{GraphID: 1, ParentHash: 3} if a == b { - t.Error("FuncKeys with different revisions should not be ==") + t.Error("FuncKeys with different parents should not be ==") } } @@ -87,8 +87,8 @@ func TestKeyForGraph_AsMapKey(t *testing.T) { func TestFuncKey_AsMapKey(t *testing.T) { m := make(map[FuncKey]int) - k1 := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 3} - k2 := FuncKey{GraphID: 1, ParentHash: 2, StoreRevision: 3} + k1 := FuncKey{GraphID: 1, ParentHash: 2} + k2 := FuncKey{GraphID: 1, ParentHash: 2} m[k1] = 42 if m[k2] != 42 { t.Error("FuncKey should work as map key") diff --git a/compiler/check/api/parents.go b/compiler/check/api/parents.go index 99aeb3bf..a06f7271 100644 --- a/compiler/check/api/parents.go +++ b/compiler/check/api/parents.go @@ -3,33 +3,33 @@ package api import "github.com/wippyai/go-lua/compiler/check/scope" // ParentScopeForGraph resolves the canonical parent scope for a graph. -// It prefers the stable parent-scope snapshot recorded in store and falls -// back to fallback when no stable parent is available. -func ParentScopeForGraph(store ParentScopes, graphID uint64, fallback *scope.State) *scope.State { +// It prefers the canonical parent-scope hash recorded in store and uses +// defaultScope only when no stable parent is available. +func ParentScopeForGraph(store ParentScopes, graphID uint64, defaultScope *scope.State) *scope.State { if store == nil || graphID == 0 { - return fallback + return defaultScope } parentHash := store.GraphParentHashOf(graphID) if parentHash == 0 { - return fallback + return defaultScope } if parent := store.Parents()[parentHash]; parent != nil { return parent } - return fallback + return defaultScope } // ParentHashForGraph resolves the canonical parent hash for a graph. -// It prefers the stable graph-parent hash recorded in store and falls back to -// fallback.Hash() when no stable hash exists. -func ParentHashForGraph(store ParentScopes, graphID uint64, fallback *scope.State) uint64 { +// It prefers the stable graph-parent hash recorded in store and uses +// defaultScope.Hash() when no stable hash exists. +func ParentHashForGraph(store ParentScopes, graphID uint64, defaultScope *scope.State) uint64 { if store != nil && graphID != 0 { if parentHash := store.GraphParentHashOf(graphID); parentHash != 0 { return parentHash } } - if fallback != nil { - return fallback.Hash() + if defaultScope != nil { + return defaultScope.Hash() } return 0 } diff --git a/compiler/check/api/parents_test.go b/compiler/check/api/parents_test.go index 102f37b7..34b03392 100644 --- a/compiler/check/api/parents_test.go +++ b/compiler/check/api/parents_test.go @@ -25,54 +25,54 @@ func (s *parentScopeStoreStub) GraphKeyFor(graph *cfg.Graph, parent *scope.State } func TestParentScopeForGraph_PrefersStoredParent(t *testing.T) { - fallback := scope.New() + defaultScope := scope.New() stored := scope.New() store := &parentScopeStoreStub{ parents: map[uint64]*scope.State{11: stored}, hashes: map[uint64]uint64{7: 11}, } - got := ParentScopeForGraph(store, 7, fallback) + got := ParentScopeForGraph(store, 7, defaultScope) if got != stored { t.Fatalf("expected stored parent, got %p want %p", got, stored) } } -func TestParentScopeForGraph_FallsBackWhenStoredMissing(t *testing.T) { - fallback := scope.New() +func TestParentScopeForGraph_UsesDefaultWhenStoredMissing(t *testing.T) { + defaultScope := scope.New() store := &parentScopeStoreStub{ parents: map[uint64]*scope.State{}, hashes: map[uint64]uint64{7: 11}, } - got := ParentScopeForGraph(store, 7, fallback) - if got != fallback { - t.Fatalf("expected fallback parent, got %p want %p", got, fallback) + got := ParentScopeForGraph(store, 7, defaultScope) + if got != defaultScope { + t.Fatalf("expected default parent, got %p want %p", got, defaultScope) } } func TestParentHashForGraph_PrefersStoredHash(t *testing.T) { - fallback := scope.New() + defaultScope := scope.New() store := &parentScopeStoreStub{ parents: map[uint64]*scope.State{}, hashes: map[uint64]uint64{7: 11}, } - got := ParentHashForGraph(store, 7, fallback) + got := ParentHashForGraph(store, 7, defaultScope) if got != 11 { t.Fatalf("expected stored hash 11, got %d", got) } } -func TestParentHashForGraph_FallsBackToScopeHash(t *testing.T) { - fallback := scope.New() +func TestParentHashForGraph_UsesDefaultScopeHash(t *testing.T) { + defaultScope := scope.New() store := &parentScopeStoreStub{ parents: map[uint64]*scope.State{}, hashes: map[uint64]uint64{}, } - got := ParentHashForGraph(store, 7, fallback) - if got != fallback.Hash() { - t.Fatalf("expected fallback hash %d, got %d", fallback.Hash(), got) + got := ParentHashForGraph(store, 7, defaultScope) + if got != defaultScope.Hash() { + t.Fatalf("expected default hash %d, got %d", defaultScope.Hash(), got) } } diff --git a/compiler/check/api/result.go b/compiler/check/api/result.go index 72fd5905..064ab21c 100644 --- a/compiler/check/api/result.go +++ b/compiler/check/api/result.go @@ -24,8 +24,8 @@ type FuncResult struct { // binding information, and iteration metadata. Graph *cfg.Graph - // ModuleBindings is the module-level binding table used as fallback when - // graph-local bindings are insufficient for canonical symbol resolution. + // ModuleBindings is the module-level binding table used when graph-local + // bindings are insufficient for canonical symbol resolution. ModuleBindings *bind.BindingTable // BaseScope is the function's entry scope containing parameters, @@ -48,6 +48,9 @@ type FuncResult struct { // Provides reachability conditions and exclusion facts for narrowing. FlowSolution *flow.Solution + // Evidence records events discovered during abstract interpretation. + Evidence FlowEvidence + // FnRefinement captures the function's inferred refinement summary. // It includes propagated effect rows and branch-specific narrowing facts. FnRefinement *constraint.FunctionRefinement @@ -109,26 +112,28 @@ func (r *FuncResult) ExcludesTypeAt(p cfg.Point, path constraint.Path, declared return r.FlowSolution.ExcludesTypeAt(p, path, declared) } -// FuncResultView is the minimal view of a function analysis result +// FuncAnalysisView is the stable slice of a function analysis result // required by nested processing and interprocedural helpers. -type FuncResultView struct { +type FuncAnalysisView struct { Graph *cfg.Graph Scopes map[cfg.Point]*scope.State Facts flow.TypeFacts FlowSolution *flow.Solution + Evidence FlowEvidence NarrowSynth Synth } -// ViewFromResult constructs a minimal view from a full function result. -func ViewFromResult(r *FuncResult) *FuncResultView { +// ViewFromResult constructs the nested-processing view from a full result. +func ViewFromResult(r *FuncResult) *FuncAnalysisView { if r == nil { return nil } - return &FuncResultView{ + return &FuncAnalysisView{ Graph: r.Graph, Scopes: r.Scopes, Facts: r.Facts, FlowSolution: r.FlowSolution, + Evidence: r.Evidence, NarrowSynth: r.NarrowSynth, } } diff --git a/compiler/check/api/session.go b/compiler/check/api/session.go index c2502c88..4099dead 100644 --- a/compiler/check/api/session.go +++ b/compiler/check/api/session.go @@ -15,6 +15,7 @@ type AnalysisSession interface { StoreHandle() IterationStore GetOrBuildCFG(fn *ast.FunctionExpr) *cfg.Graph + EvidenceForGraph(graph *cfg.Graph) FlowEvidence RegisterGraphHierarchy(root *cfg.Graph) ResultsMap() map[*ast.FunctionExpr]*FuncResult diff --git a/compiler/check/api/store.go b/compiler/check/api/store.go index 51343247..e96958fc 100644 --- a/compiler/check/api/store.go +++ b/compiler/check/api/store.go @@ -9,10 +9,10 @@ // GraphStore - CFG graph lookup by ID // ParentScopes - Parent scope lookup for nested functions // NestedMetaStore - Nested function metadata -// SnapshotStore - Stable interproc fact snapshots +// InterprocFactReader - Visible interproc fact products // FunctionRefs - Symbol/function bidirectional lookup -// StoreView - Read-only combination of above -// NestedStore - StoreView + constructor field storage +// StoreReader - Read-only combination of above +// NestedStore - StoreReader + canonical fact product writes // IterationStore - Full mutation capability for fixpoint package api @@ -21,8 +21,6 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/types/constraint" - "github.com/wippyai/go-lua/types/typ" ) // FunctionRef is the canonical mapping for a function symbol. @@ -43,9 +41,11 @@ type NestedMeta struct { DefPoint cfg.Point } -// GraphProvider maps function literals to CFGs. +// GraphProvider maps function literals to CFGs and exposes the canonical +// abstract-interpreter evidence for each graph. type GraphProvider interface { GetOrBuildCFG(fn *ast.FunctionExpr) *cfg.Graph + EvidenceForGraph(graph *cfg.Graph) FlowEvidence } // ModuleStore provides module-level bindings and alias maps. @@ -71,16 +71,10 @@ type NestedMetaStore interface { NestedMetaFor(graphID uint64) (NestedMeta, bool) } -// SnapshotStore exposes stable interproc fact snapshots. -type SnapshotStore interface { - GetParamHintsSnapshot(graph *cfg.Graph, parent *scope.State) ParamHints - GetReturnSummariesSnapshot(graph *cfg.Graph, parent *scope.State) ReturnSummaries - GetNarrowReturnSummariesSnapshot(graph *cfg.Graph, parent *scope.State) NarrowReturnSummaries - GetCapturedTypesSnapshot(graph *cfg.Graph, parent *scope.State) CapturedTypes - GetCapturedFieldAssignsSnapshot(graph *cfg.Graph, parent *scope.State) CapturedFieldAssigns - GetCapturedContainerMutationsSnapshot(graph *cfg.Graph, parent *scope.State) CapturedContainerMutations - GetLocalFuncTypesSnapshot(graph *cfg.Graph, parent *scope.State) FuncTypes - GetLiteralSigsSnapshot(graph *cfg.Graph, parent *scope.State) LiteralSigs +// InterprocFactReader exposes visible interproc fact products. +type InterprocFactReader interface { + GetModuleFacts() Facts + GetInterprocFacts(graph *cfg.Graph, parent *scope.State) Facts } // FunctionRefs provides symbol/function lookup for function graphs. @@ -92,63 +86,41 @@ type FunctionRefs interface { SymbolForFunc(fn *ast.FunctionExpr) (cfg.SymbolID, bool) } -// StoreView is the minimal interface required by pre-flow return inference. -type StoreView interface { +// StoreReader is the read contract shared by checker phases. +type StoreReader interface { ModuleStore GraphStore + EvidenceForGraph(graph *cfg.Graph) FlowEvidence ParentScopes NestedMetaStore - SnapshotStore + InterprocFactReader FunctionRefs } -// ConstructorFieldStore provides constructor field storage. -type ConstructorFieldStore interface { - StoreConstructorFields(classSym cfg.SymbolID, fields map[string]typ.Type) - LookupConstructorFields(classSym cfg.SymbolID) map[string]typ.Type -} - // InterprocFactSink provides write access to per-iteration interproc facts. type InterprocFactSink interface { - UpdateInterprocFactsNext(key GraphKey, update func(*Facts)) + MergeInterprocFactsNext(key GraphKey, delta Facts) } // NestedStore is the store interface required by nested processing. type NestedStore interface { - StoreView - ConstructorFieldStore + StoreReader InterprocFactSink } -// LiteralSigSource is used by phase runners to supply literal signatures. -type LiteralSigSource interface { - GetLiteralSigsSnapshot(graph *cfg.Graph, parent *scope.State) LiteralSigs -} - -// LiteralSigSink accepts literal signature results from analysis. -type LiteralSigSink interface { - StoreLiteralSigs(graphID uint64, sigs map[*ast.FunctionExpr]*typ.Function) -} - // IterationStore provides mutation operations required by the fixpoint driver. type IterationStore interface { NestedStore - LiteralSigSink - ClearIterationChannels() + ClearInterprocState() FixpointSwap() bool - FixpointChannelDiffs() []string - Revision() uint64 - BumpRevision() - - RefinementStore() RefinementStore - StoreFunctionRefinement(sym cfg.SymbolID, eff *constraint.FunctionRefinement) + FixpointDiffs() []string SetModuleBindings(bindings *bind.BindingTable) SetModuleAliases(aliases map[cfg.SymbolID]string) SetParentScope(parentHash uint64, parent *scope.State) SetGraphParentHash(graphID, parentHash uint64) - UpdateInterprocFactsNext(key GraphKey, update func(*Facts)) + MergeInterprocFactsNext(key GraphKey, delta Facts) ParentGraphKeyForSymbol(sym cfg.SymbolID) (GraphKey, bool) } diff --git a/compiler/check/api/store_attach.go b/compiler/check/api/store_attach.go index 1eed8e4b..5c04b478 100644 --- a/compiler/check/api/store_attach.go +++ b/compiler/check/api/store_attach.go @@ -2,19 +2,19 @@ package api import "github.com/wippyai/go-lua/types/db" -// StoreKey is the typed attachment key for StoreView. -var StoreKey = db.NewAttachmentKey[StoreView]("check.StoreView") +// StoreKey is the typed attachment key for StoreReader. +var StoreKey = db.NewAttachmentKey[StoreReader]("check.StoreReader") // AttachStore attaches a store to the query context for lookup. -func AttachStore(ctx *db.QueryContext, store StoreView) { +func AttachStore(ctx *db.QueryContext, store StoreReader) { if ctx == nil || store == nil { return } db.Attach(ctx, StoreKey, store) } -// StoreFrom retrieves the StoreView from a db.QueryContext. -func StoreFrom(ctx *db.QueryContext) StoreView { +// StoreFrom retrieves the StoreReader from a db.QueryContext. +func StoreFrom(ctx *db.QueryContext) StoreReader { store, _ := db.Attached(ctx, StoreKey) return store } diff --git a/compiler/check/api/synth.go b/compiler/check/api/synth.go index fe724007..e9515f89 100644 --- a/compiler/check/api/synth.go +++ b/compiler/check/api/synth.go @@ -1,6 +1,6 @@ // Package api defines canonical interfaces for the type synthesis subsystem. // These interfaces decouple the synthesis engine implementation (synth.Engine) -// from its consumers (hooks, flowbuild, phase runners, etc.). +// from its consumers (hooks, abstract interpreter, phase runners, etc.). // // # INTERFACE HIERARCHY // @@ -179,6 +179,9 @@ type FlowOps interface { // varName <= len(array) + offset. ArrayLenBoundWithOffsetAt(p cfg.Point, varName string) (arrKey string, offset int64, ok bool) + // LengthBoundsAt returns numeric bounds for len(path) at a point. + LengthBoundsAt(p cfg.Point, path constraint.Path) (lower, upper int64, ok bool) + // IsPointDead returns whether a CFG point is unreachable. IsPointDead(p cfg.Point) bool diff --git a/compiler/check/api/synth_test.go b/compiler/check/api/synth_test.go index dffb9f91..cf5562e2 100644 --- a/compiler/check/api/synth_test.go +++ b/compiler/check/api/synth_test.go @@ -84,6 +84,9 @@ func (m *mockFlowOps) ArrayLenBoundAt(cfg.Point, string) (string, bool) { retu func (m *mockFlowOps) ArrayLenBoundWithOffsetAt(cfg.Point, string) (string, int64, bool) { return "", 0, false } +func (m *mockFlowOps) LengthBoundsAt(cfg.Point, constraint.Path) (int64, int64, bool) { + return 0, 0, false +} func (m *mockFlowOps) IsPointDead(cfg.Point) bool { return false } func (m *mockFlowOps) HasKeyOf(cfg.Point, constraint.Path, constraint.Path) bool { return false } diff --git a/compiler/check/callsite/callee_symbols.go b/compiler/check/callsite/callee_symbols.go index bd152843..99093e71 100644 --- a/compiler/check/callsite/callee_symbols.go +++ b/compiler/check/callsite/callee_symbols.go @@ -10,23 +10,21 @@ import ( // Candidate order: // 1. raw call callee symbol // 2. symbol resolved from primary bindings using call expression -// 3. symbol resolved from fallback bindings using call expression +// 3. symbol resolved from secondary bindings using call expression // 4. method symbol resolved from primary bindings (receiver + method) -// 5. method symbol resolved from fallback bindings (receiver + method) -// 6. binding symbols with matching callee name (primary, then fallback) -func CalleeSymbolCandidates(info *cfg.CallInfo, primary, fallback *bind.BindingTable) []cfg.SymbolID { +// 5. method symbol resolved from secondary bindings (receiver + method) +// 6. binding symbols with matching callee name (primary, then secondary) +func CalleeSymbolCandidates(info *cfg.CallInfo, primary, secondary *bind.BindingTable) []cfg.SymbolID { if info == nil { return nil } set := newSymbolSet(4) - for _, sym := range exprSymbolCandidates(info.Callee, info.CalleeSymbol, primary, fallback) { - set.Add(sym) - } + addExprSymbolCandidates(set, info.Callee, info.CalleeSymbol, primary, secondary) if methodSym, ok := methodCalleeSymbolFromCall(primary, nil, info); ok { set.Add(methodSym) } - if fallback != nil && fallback != primary { - if methodSym, ok := methodCalleeSymbolFromCall(fallback, nil, info); ok { + if secondary != nil && secondary != primary { + if methodSym, ok := methodCalleeSymbolFromCall(secondary, nil, info); ok { set.Add(methodSym) } } @@ -36,8 +34,8 @@ func CalleeSymbolCandidates(info *cfg.CallInfo, primary, fallback *bind.BindingT set.Add(sym) } } - if fallback != nil && fallback != primary { - for _, sym := range fallback.SymbolsByNameReadOnly(info.CalleeName) { + if secondary != nil && secondary != primary { + for _, sym := range secondary.SymbolsByNameReadOnly(info.CalleeName) { set.Add(sym) } } @@ -52,15 +50,15 @@ func CalleeSymbolCandidates(info *cfg.CallInfo, primary, fallback *bind.BindingT func CallableCalleeSymbolCandidates( info *cfg.CallInfo, graph *cfg.Graph, - primary, fallback *bind.BindingTable, + primary, secondary *bind.BindingTable, ) []cfg.SymbolID { - base := CalleeSymbolCandidates(info, primary, fallback) + base := CalleeSymbolCandidates(info, primary, secondary) if graph == nil { return base } set := newSymbolSet(len(base)*2 + 2) - for _, sym := range expandAliasCandidates(base, graph) { - set.Add(sym) + for _, sym := range base { + addAliasExpansion(set, graph, sym) } // Method calls may resolve method symbol only through an alias receiver base @@ -68,8 +66,8 @@ func CallableCalleeSymbolCandidates( if methodSym, ok := methodCalleeSymbolFromCall(primary, graph, info); ok { addAliasExpansion(set, graph, methodSym) } - if fallback != nil && fallback != primary { - if methodSym, ok := methodCalleeSymbolFromCall(fallback, graph, info); ok { + if secondary != nil && secondary != primary { + if methodSym, ok := methodCalleeSymbolFromCall(secondary, graph, info); ok { addAliasExpansion(set, graph, methodSym) } } @@ -91,20 +89,20 @@ func CallableCalleeSymbolCandidates( // Order is deterministic and candidates are deduplicated. // // NOTE: this intentionally includes the base callee-path symbol (often receiver -// identity for method calls). Use this for resolver-style fallback lookups that +// identity for method calls). Use this for resolver-style symbol lookups that // can tolerate non-callable intermediate symbols; for strict callable lookup // paths, prefer CallableCalleeSymbolCandidates. func ResolverCalleeSymbolCandidates( info *cfg.CallInfo, graph *cfg.Graph, - primary, fallback *bind.BindingTable, + primary, secondary *bind.BindingTable, ) []cfg.SymbolID { if info == nil { return nil } set := newSymbolSet(4) set.Add(info.CalleePath.Symbol) - for _, sym := range CallableCalleeSymbolCandidates(info, graph, primary, fallback) { + for _, sym := range CallableCalleeSymbolCandidates(info, graph, primary, secondary) { set.Add(sym) } return set.Slice() diff --git a/compiler/check/callsite/candidates.go b/compiler/check/callsite/candidates.go index e293f6a1..f41ca717 100644 --- a/compiler/check/callsite/candidates.go +++ b/compiler/check/callsite/candidates.go @@ -66,6 +66,61 @@ func (s *symbolSet) Slice() []cfg.SymbolID { return s.order } +type symbolDeduper struct { + small [symbolSetMapThreshold]cfg.SymbolID + count int + seen map[cfg.SymbolID]struct{} +} + +func (d *symbolDeduper) Add(sym cfg.SymbolID) bool { + if sym == 0 { + return false + } + if d.seen != nil { + if _, ok := d.seen[sym]; ok { + return false + } + d.seen[sym] = struct{}{} + return true + } + for i := 0; i < d.count; i++ { + if d.small[i] == sym { + return false + } + } + if d.count < len(d.small) { + d.small[d.count] = sym + d.count++ + return true + } + d.seen = make(map[cfg.SymbolID]struct{}, len(d.small)+1) + for i := 0; i < d.count; i++ { + d.seen[d.small[i]] = struct{}{} + } + d.seen[sym] = struct{}{} + return true +} + +type preferredSymbolSelector struct { + prefer func(cfg.SymbolID) bool + selected cfg.SymbolID + seen symbolDeduper +} + +func (s *preferredSymbolSelector) Add(sym cfg.SymbolID) bool { + if !s.seen.Add(sym) { + return false + } + if s.selected == 0 { + s.selected = sym + } + if s.prefer != nil && s.prefer(sym) { + s.selected = sym + return true + } + return false +} + // SelectPreferredSymbol returns the first candidate and, if prefer is non-nil, returns // the first candidate that satisfies the predicate. func SelectPreferredSymbol(candidates []cfg.SymbolID, prefer func(cfg.SymbolID) bool) cfg.SymbolID { @@ -81,42 +136,86 @@ func SelectPreferredSymbol(candidates []cfg.SymbolID, prefer func(cfg.SymbolID) return selected } +func visitExprSymbolCandidates( + expr ast.Expr, + raw cfg.SymbolID, + primary *bind.BindingTable, + secondary *bind.BindingTable, + visit func(cfg.SymbolID) bool, +) bool { + if visit == nil { + return false + } + if visit(raw) { + return true + } + if visit(SymbolFromExpr(expr, primary)) { + return true + } + if secondary != primary { + return visit(SymbolFromExpr(expr, secondary)) + } + return false +} + +func addExprSymbolCandidates( + set *symbolSet, + expr ast.Expr, + raw cfg.SymbolID, + primary *bind.BindingTable, + secondary *bind.BindingTable, +) { + if set == nil { + return + } + visitExprSymbolCandidates(expr, raw, primary, secondary, func(sym cfg.SymbolID) bool { + set.Add(sym) + return false + }) +} + func addAliasExpansion(set *symbolSet, graph *cfg.Graph, sym cfg.SymbolID) { if set == nil || graph == nil || sym == 0 { return } - graph.EachAliasSymbol(sym, func(candidate cfg.SymbolID) bool { + visitAliasExpansion(graph, sym, func(candidate cfg.SymbolID) bool { set.Add(candidate) return false }) } -func expandAliasCandidates(base []cfg.SymbolID, graph *cfg.Graph) []cfg.SymbolID { - if graph == nil || len(base) == 0 { - return base +func visitAliasExpansion(graph *cfg.Graph, sym cfg.SymbolID, visit func(cfg.SymbolID) bool) bool { + if sym == 0 || visit == nil { + return false } - set := newSymbolSet(len(base) * 2) - for _, sym := range base { - addAliasExpansion(set, graph, sym) + if graph == nil { + return visit(sym) } - candidates := set.Slice() - if len(candidates) == 0 { - return base + var chain symbolDeduper + current := sym + for current != 0 { + if !chain.Add(current) { + return false + } + if visit(current) { + return true + } + next := graph.DirectAliasSymbol(current) + if next == 0 || next == current { + return false + } + current = next } - return candidates + return false } func exprSymbolCandidates( expr ast.Expr, raw cfg.SymbolID, primary *bind.BindingTable, - fallback *bind.BindingTable, + secondary *bind.BindingTable, ) []cfg.SymbolID { set := newSymbolSet(3) - set.Add(raw) - set.Add(SymbolFromExpr(expr, primary)) - if fallback != primary { - set.Add(SymbolFromExpr(expr, fallback)) - } + addExprSymbolCandidates(set, expr, raw, primary, secondary) return set.Slice() } diff --git a/compiler/check/callsite/canonical_symbol.go b/compiler/check/callsite/canonical_symbol.go index 6b367133..2a3ae313 100644 --- a/compiler/check/callsite/canonical_symbol.go +++ b/compiler/check/callsite/canonical_symbol.go @@ -14,16 +14,12 @@ func CanonicalSymbolFromExprWithAliases( raw cfg.SymbolID, graph *cfg.Graph, primary *bind.BindingTable, - fallback *bind.BindingTable, + secondary *bind.BindingTable, prefer func(cfg.SymbolID) bool, ) cfg.SymbolID { - base := exprSymbolCandidates(expr, raw, primary, fallback) - if len(base) == 0 { - return 0 - } - if graph == nil { - return SelectPreferredSymbol(base, prefer) - } - candidates := expandAliasCandidates(base, graph) - return SelectPreferredSymbol(candidates, prefer) + selector := preferredSymbolSelector{prefer: prefer} + visitExprSymbolCandidates(expr, raw, primary, secondary, func(sym cfg.SymbolID) bool { + return visitAliasExpansion(graph, sym, selector.Add) + }) + return selector.selected } diff --git a/compiler/check/callsite/effect_resolution.go b/compiler/check/callsite/effect_resolution.go index 90fa0158..94582085 100644 --- a/compiler/check/callsite/effect_resolution.go +++ b/compiler/check/callsite/effect_resolution.go @@ -61,7 +61,7 @@ func ResolveCalleeEffect( info *cfg.CallInfo, p cfg.Point, graph *cfg.Graph, - primary, fallback *bind.BindingTable, + primary, secondary *bind.BindingTable, lookup func(sym cfg.SymbolID) *constraint.FunctionRefinement, synth func(expr ast.Expr, p cfg.Point) typ.Type, resolveBySym func(p cfg.Point, sym cfg.SymbolID) (typ.Type, bool), @@ -70,7 +70,7 @@ func ResolveCalleeEffect( if info == nil { return nil } - candidates := ResolverCalleeSymbolCandidates(info, graph, primary, fallback) + candidates := ResolverCalleeSymbolCandidates(info, graph, primary, secondary) if lookup != nil { for _, sym := range candidates { if eff := lookup(sym); eff != nil { @@ -101,7 +101,7 @@ func ResolveCalleeType( info *cfg.CallInfo, p cfg.Point, graph *cfg.Graph, - primary, fallback *bind.BindingTable, + primary, secondary *bind.BindingTable, synth func(expr ast.Expr, p cfg.Point) typ.Type, resolveBySym func(p cfg.Point, sym cfg.SymbolID) (typ.Type, bool), ) typ.Type { @@ -112,7 +112,7 @@ func ResolveCalleeType( return t } return resolveCalleeTypeBySymbolCandidates( - ResolverCalleeSymbolCandidates(info, graph, primary, fallback), + ResolverCalleeSymbolCandidates(info, graph, primary, secondary), p, resolveBySym, ) diff --git a/compiler/check/callsite/function_literal.go b/compiler/check/callsite/function_literal.go index f3a1fddb..e9e30e32 100644 --- a/compiler/check/callsite/function_literal.go +++ b/compiler/check/callsite/function_literal.go @@ -4,17 +4,16 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" ) // FunctionLiteralForSymbol resolves a function symbol to its function literal. // // Resolution order: // 1. Binding table reverse lookup for literal symbols. -// 2. Function definition nodes in the graph. -// 3. Assignment sources in the graph (target symbol = function literal). func FunctionLiteralForSymbol( - graph *cfg.Graph, bindings *bind.BindingTable, + evidence api.FlowEvidence, sym cfg.SymbolID, ) *ast.FunctionExpr { if sym == 0 { @@ -25,41 +24,10 @@ func FunctionLiteralForSymbol( return fn } } - if graph == nil { - return nil + if fn := functionLiteralFromDefinitions(evidence, sym, true); fn != nil { + return fn } - - var found *ast.FunctionExpr - graph.EachFuncDef(func(_ cfg.Point, info *cfg.FuncDefInfo) { - if found != nil || info == nil { - return - } - if info.Symbol == sym { - found = info.FuncExpr - } - }) - if found != nil { - return found - } - - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if found != nil || info == nil { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if found != nil { - return - } - if target.Symbol != sym { - return - } - if fn, ok := source.(*ast.FunctionExpr); ok { - found = fn - } - }) - }) - - return found + return nil } // FunctionLiteralForGraphSymbol resolves only graph-local stable function @@ -70,38 +38,54 @@ func FunctionLiteralForSymbol( // - include local identifier assignments of function literals // - exclude mutable field-path symbols, whose current callable type must come // from value flow at the call site rather than binder symbol backtracking -func FunctionLiteralForGraphSymbol(graph *cfg.Graph, sym cfg.SymbolID) *ast.FunctionExpr { - if sym == 0 || graph == nil { +func FunctionLiteralForGraphSymbol(evidence api.FlowEvidence, sym cfg.SymbolID) *ast.FunctionExpr { + if sym == 0 { return nil } - var found *ast.FunctionExpr - graph.EachFuncDef(func(_ cfg.Point, info *cfg.FuncDefInfo) { - if found != nil || info == nil || info.Symbol != sym { - return - } - found = info.FuncExpr - }) - if found != nil { - return found - } + return functionLiteralFromDefinitions(evidence, sym, false) +} - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if found != nil || info == nil { - return +func functionLiteralFromDefinitions(evidence api.FlowEvidence, sym cfg.SymbolID, includeMutableTargets bool) *ast.FunctionExpr { + for _, def := range evidence.FunctionDefinitions { + if def.Nested.Func == nil { + continue } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if found != nil { - return - } - if target.Kind != cfg.TargetIdent || target.Symbol != sym { - return + if includeMutableTargets && def.Nested.Symbol == sym { + return def.Nested.Func + } + if def.Symbol == sym { + if includeMutableTargets || stableGraphFunctionDefinition(def) { + return def.Nested.Func } - if fn, ok := source.(*ast.FunctionExpr); ok { - found = fn + } + if def.FuncDef != nil && def.FuncDef.Symbol == sym { + if includeMutableTargets || def.FuncDef.TargetKind == cfg.FuncDefGlobal { + return def.FuncDef.FuncExpr } - }) - }) + } + } + return nil +} - return found +func stableGraphFunctionDefinition(def api.FunctionDefinitionEvidence) bool { + if def.FuncDef == nil { + return def.IsLocal && def.Name != "" + } + return true +} + +// AllowsDiscardedExtraArgs reports whether the source function has unannotated +// positional parameters, where Lua accepts and discards surplus call arguments. +// Explicit source varargs are represented by typ.Function.Variadic instead. +func AllowsDiscardedExtraArgs(fn *ast.FunctionExpr) bool { + if fn == nil || fn.ParList == nil || fn.ParList.HasVargs { + return false + } + for i := range fn.ParList.Names { + if i >= len(fn.ParList.Types) || fn.ParList.Types[i] == nil { + return true + } + } + return false } diff --git a/compiler/check/callsite/function_literal_test.go b/compiler/check/callsite/function_literal_test.go index cc8a9e7c..5b75000c 100644 --- a/compiler/check/callsite/function_literal_test.go +++ b/compiler/check/callsite/function_literal_test.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/parse" ) @@ -15,7 +16,7 @@ func TestFunctionLiteralForSymbol_BindingTableLiteral(t *testing.T) { sym := cfg.SymbolID(41) bindings.SetFuncLitSymbol(fn, sym) - got := FunctionLiteralForSymbol(nil, bindings, sym) + got := FunctionLiteralForSymbol(bindings, api.FlowEvidence{}, sym) if got != fn { t.Fatalf("FunctionLiteralForSymbol() = %v, want %v", got, fn) } @@ -49,7 +50,8 @@ func TestFunctionLiteralForSymbol_FuncDefSymbol(t *testing.T) { t.Fatal("expected callsite callee symbol") } - fn := FunctionLiteralForSymbol(graph, graph.Bindings(), calleeSym) + evidence := testFunctionEvidence(graph) + fn := FunctionLiteralForSymbol(graph.Bindings(), evidence, calleeSym) if fn == nil { t.Fatal("expected function literal for local function symbol") } @@ -83,7 +85,8 @@ func TestFunctionLiteralForSymbol_AssignedFunctionLiteral(t *testing.T) { t.Fatal("expected callsite callee symbol") } - fn := FunctionLiteralForSymbol(graph, graph.Bindings(), calleeSym) + evidence := testFunctionEvidence(graph) + fn := FunctionLiteralForSymbol(graph.Bindings(), evidence, calleeSym) if fn == nil { t.Fatal("expected function literal for assigned symbol") } @@ -118,7 +121,8 @@ func TestFunctionLiteralForGraphSymbol_FuncDefSymbol(t *testing.T) { t.Fatal("expected callsite callee symbol") } - fn := FunctionLiteralForGraphSymbol(graph, calleeSym) + evidence := testFunctionEvidence(graph) + fn := FunctionLiteralForGraphSymbol(evidence, calleeSym) if fn == nil { t.Fatal("expected graph-local function literal for field definition") } @@ -161,10 +165,34 @@ func TestFunctionLiteralForGraphSymbol_IgnoresMutableFieldPathBinding(t *testing t.Fatal("expected callsite callee symbol") } - if fn := FunctionLiteralForGraphSymbol(graph, calleeSym); fn != nil { + evidence := testFunctionEvidence(graph) + if fn := FunctionLiteralForGraphSymbol(evidence, calleeSym); fn != nil { t.Fatalf("expected mutable field-path symbol to stay unresolved in graph-local resolver, got %v", fn) } - if fn := FunctionLiteralForSymbol(graph, graph.Bindings(), calleeSym); fn == nil { + if fn := FunctionLiteralForSymbol(graph.Bindings(), evidence, calleeSym); fn == nil { t.Fatal("expected binder-level symbol resolver to still find a literal for the shared field symbol") } } + +func testFunctionEvidence(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + var defs []api.FunctionDefinitionEvidence + for _, nf := range graph.NestedFunctions() { + if nf.Func == nil { + continue + } + info := graph.FuncDef(nf.Point) + sym := nf.Symbol + if info != nil && info.Symbol != 0 { + sym = info.Symbol + } + defs = append(defs, api.FunctionDefinitionEvidence{ + Nested: nf, + FuncDef: info, + Symbol: sym, + }) + } + return api.FlowEvidence{FunctionDefinitions: defs} +} diff --git a/compiler/check/callsite/preassign.go b/compiler/check/callsite/preassign.go index 78c970c0..cfedce65 100644 --- a/compiler/check/callsite/preassign.go +++ b/compiler/check/callsite/preassign.go @@ -2,6 +2,7 @@ package callsite import ( "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/types/typ" ) @@ -12,14 +13,15 @@ type SymbolTypeAtPoint func(cfg.Point, cfg.SymbolID) (typ.Type, bool) // For calls used as assignment RHS at point p (x = f(...)), the returned target // set captures symbols that must be typed from predecessor state when computing // argument evidence at that call site. -func PreAssignmentTargetsByCall(graph *cfg.Graph) map[*cfg.CallInfo]map[cfg.SymbolID]bool { - if graph == nil { +func PreAssignmentTargetsByCall(assignments []api.AssignmentEvidence) map[*cfg.CallInfo]map[cfg.SymbolID]bool { + if len(assignments) == 0 { return nil } out := make(map[*cfg.CallInfo]map[cfg.SymbolID]bool) - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + info := assign.Info if info == nil || len(info.Targets) == 0 || len(info.SourceCalls) == 0 { - return + continue } targets := make(map[cfg.SymbolID]bool, len(info.Targets)) for _, target := range info.Targets { @@ -28,7 +30,7 @@ func PreAssignmentTargetsByCall(graph *cfg.Graph) map[*cfg.CallInfo]map[cfg.Symb } } if len(targets) == 0 { - return + continue } for _, call := range info.SourceCalls { if call == nil { @@ -43,7 +45,7 @@ func PreAssignmentTargetsByCall(graph *cfg.Graph) map[*cfg.CallInfo]map[cfg.Symb existing[sym] = true } } - }) + } return out } diff --git a/compiler/check/callsite/receiver.go b/compiler/check/callsite/receiver.go index d5a4656a..178c3b27 100644 --- a/compiler/check/callsite/receiver.go +++ b/compiler/check/callsite/receiver.go @@ -4,6 +4,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" compcfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" typecfg "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/constraint" ) @@ -15,7 +16,7 @@ import ( // - only method syntax calls (`obj:method(...)`) are considered // - calls to statically known function-literal-backed symbols force receiver // - function definition symbols with field/method targets force receiver -func ForceMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Graph, info *compcfg.CallInfo) bool { +func ForceMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Graph, evidence api.FlowEvidence, info *compcfg.CallInfo) bool { if info == nil || info.Method == "" { return false } @@ -23,11 +24,11 @@ func ForceMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Graph, info sym := SelectPreferredSymbol( CallableCalleeSymbolCandidates(info, graph, bindings, nil), func(candidate typecfg.SymbolID) bool { - return symbolForcesMethodReceiver(bindings, graph, candidate) + return symbolForcesMethodReceiver(bindings, evidence, candidate) }, ) - if sym != 0 && symbolForcesMethodReceiver(bindings, graph, sym) { + if sym != 0 && symbolForcesMethodReceiver(bindings, evidence, sym) { return true } @@ -40,19 +41,19 @@ func ForceMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Graph, info return false } - return symbolForcesMethodReceiver(bindings, graph, sym) + return symbolForcesMethodReceiver(bindings, evidence, sym) } // ForceMethodReceiverAtPoint resolves callsite info at a CFG point and applies // the canonical receiver-forcing policy. -func ForceMethodReceiverAtPoint(bindings *bind.BindingTable, graph *compcfg.Graph, p typecfg.Point, ex *ast.FuncCallExpr) bool { +func ForceMethodReceiverAtPoint(bindings *bind.BindingTable, graph *compcfg.Graph, evidence api.FlowEvidence, p typecfg.Point, ex *ast.FuncCallExpr) bool { if ex == nil || ex.Method == "" { return false } if graph != nil { if info := graph.CallSiteAt(p, ex); info != nil { - return ForceMethodReceiver(bindings, graph, info) + return ForceMethodReceiver(bindings, graph, evidence, info) } } @@ -63,10 +64,10 @@ func ForceMethodReceiverAtPoint(bindings *bind.BindingTable, graph *compcfg.Grap if !ok || sym == 0 { return false } - return symbolForcesMethodReceiver(bindings, graph, sym) + return symbolForcesMethodReceiver(bindings, evidence, sym) } -func symbolForcesMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Graph, sym typecfg.SymbolID) bool { +func symbolForcesMethodReceiver(bindings *bind.BindingTable, evidence api.FlowEvidence, sym typecfg.SymbolID) bool { if sym == 0 { return false } @@ -75,22 +76,14 @@ func symbolForcesMethodReceiver(bindings *bind.BindingTable, graph *compcfg.Grap return true } } - if graph == nil { - return false - } - - forced := false - graph.EachFuncDef(func(_ typecfg.Point, info *compcfg.FuncDefInfo) { - if forced || info == nil || info.Symbol == 0 { - return + for _, def := range evidence.FunctionDefinitions { + info := def.FuncDef + if info == nil || info.Symbol == 0 || info.Symbol != sym { + continue } - if info.Symbol != sym { - return - } - forced = info.TargetKind == compcfg.FuncDefField || info.TargetKind == compcfg.FuncDefMethod - }) - - return forced + return info.TargetKind == compcfg.FuncDefField || info.TargetKind == compcfg.FuncDefMethod + } + return false } func methodCalleeSymbolFromCall(bindings *bind.BindingTable, graph *compcfg.Graph, info *compcfg.CallInfo) (typecfg.SymbolID, bool) { diff --git a/compiler/check/callsite/receiver_test.go b/compiler/check/callsite/receiver_test.go index 73504cec..32c4f59b 100644 --- a/compiler/check/callsite/receiver_test.go +++ b/compiler/check/callsite/receiver_test.go @@ -7,6 +7,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" ccfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/parse" typecfg "github.com/wippyai/go-lua/types/cfg" ) @@ -21,10 +22,11 @@ func TestForceMethodReceiver_DotDefinedFieldFunction(t *testing.T) { ` graph, bindings, call, point := parseGraphAndMethodCall(t, src) - if !ForceMethodReceiver(bindings, graph, call) { + evidence := testReceiverEvidence(graph) + if !ForceMethodReceiver(bindings, graph, evidence, call) { t.Fatal("expected ForceMethodReceiver to be true for dot-defined field function") } - if !ForceMethodReceiverAtPoint(bindings, graph, point, call.Call) { + if !ForceMethodReceiverAtPoint(bindings, graph, evidence, point, call.Call) { t.Fatal("expected ForceMethodReceiverAtPoint to be true for dot-defined field function") } } @@ -39,10 +41,11 @@ func TestForceMethodReceiver_FieldAssignedFunctionLiteral(t *testing.T) { ` graph, bindings, call, point := parseGraphAndMethodCall(t, src) - if !ForceMethodReceiver(bindings, graph, call) { + evidence := testReceiverEvidence(graph) + if !ForceMethodReceiver(bindings, graph, evidence, call) { t.Fatal("expected ForceMethodReceiver to be true for field-assigned function literal") } - if !ForceMethodReceiverAtPoint(bindings, graph, point, call.Call) { + if !ForceMethodReceiverAtPoint(bindings, graph, evidence, point, call.Call) { t.Fatal("expected ForceMethodReceiverAtPoint to be true for field-assigned function literal") } } @@ -60,7 +63,8 @@ func TestForceMethodReceiver_UsesCalleePathWhenReceiverExprMissing(t *testing.T) callCopy.Receiver = nil callCopy.CalleeSymbol = 0 - if !ForceMethodReceiver(bindings, graph, &callCopy) { + evidence := testReceiverEvidence(graph) + if !ForceMethodReceiver(bindings, graph, evidence, &callCopy) { t.Fatal("expected ForceMethodReceiver to resolve method symbol via CalleePath") } } @@ -83,7 +87,8 @@ func TestForceMethodReceiver_PrefersCanonicalCandidateOverStaleRawSymbol(t *test callCopy := *call callCopy.CalleeSymbol = staleSym - if !ForceMethodReceiver(bindings, graph, &callCopy) { + evidence := testReceiverEvidence(graph) + if !ForceMethodReceiver(bindings, graph, evidence, &callCopy) { t.Fatal("expected ForceMethodReceiver to ignore stale raw symbol and use canonical method candidate") } } @@ -99,14 +104,38 @@ func TestForceMethodReceiver_UsesAliasReceiverBase(t *testing.T) { ` graph, bindings, call, point := parseGraphAndMethodCall(t, src) - if !ForceMethodReceiver(bindings, graph, call) { + evidence := testReceiverEvidence(graph) + if !ForceMethodReceiver(bindings, graph, evidence, call) { t.Fatal("expected ForceMethodReceiver to resolve method symbol through alias receiver base") } - if !ForceMethodReceiverAtPoint(bindings, graph, point, call.Call) { + if !ForceMethodReceiverAtPoint(bindings, graph, evidence, point, call.Call) { t.Fatal("expected ForceMethodReceiverAtPoint to resolve method symbol through alias receiver base") } } +func testReceiverEvidence(graph *ccfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + var defs []api.FunctionDefinitionEvidence + for _, nf := range graph.NestedFunctions() { + if nf.Func == nil { + continue + } + info := graph.FuncDef(nf.Point) + sym := nf.Symbol + if info != nil && info.Symbol != 0 { + sym = info.Symbol + } + defs = append(defs, api.FunctionDefinitionEvidence{ + Nested: nf, + FuncDef: info, + Symbol: sym, + }) + } + return api.FlowEvidence{FunctionDefinitions: defs} +} + func parseGraphAndMethodCall(t *testing.T, src string) (*ccfg.Graph, *bind.BindingTable, *ccfg.CallInfo, typecfg.Point) { t.Helper() stmts, err := parse.Parse(strings.NewReader(src), "test") diff --git a/compiler/check/checker.go b/compiler/check/checker.go index 8f81acca..1f71be06 100644 --- a/compiler/check/checker.go +++ b/compiler/check/checker.go @@ -4,9 +4,8 @@ // // The checker performs interprocedural type analysis through a fixpoint iteration loop // that processes all functions until inter-function information stabilizes. Interproc -// facts are produced during analysis and captured in a stable snapshot at iteration -// boundaries. Effect facts are accumulated during analysis and swapped into a stable -// snapshot at the boundary alongside constructor field facts. +// facts are produced during analysis and merged into one canonical product at +// iteration boundaries. // // # PHASE PIPELINE // @@ -27,13 +26,11 @@ // // # INTERPROCEDURAL ANALYSIS // -// The checker supports interprocedural analysis through a unified interproc snapshot: +// The checker supports interprocedural analysis through a unified interproc product: // -// - ReturnSummaries: Inferred return types for local functions -// - ParamHints: Inferred parameter types from call sites -// - FuncTypes: Canonical local function types for sibling lookups +// - FunctionFacts: Canonical parameter/return/narrow/signature facts // - LiteralSigs: Synthesized signatures for function literals -// - Refinements: Function refinement summaries, stored per symbol +// - Function refinements, captured writes, and constructor fields as product lanes // // # DETERMINISTIC ORDERING // @@ -44,14 +41,13 @@ // // # MEMOIZATION // -// Function analysis results are memoized by (GraphID, ParentHash, StoreRevision). -// The memoization cache is cleared at each iteration boundary to force recomputation -// with updated inter-function summaries. +// Function analysis results are memoized by (GraphID, ParentHash). Interprocedural +// fact products are tracked as query inputs, so cached results are revalidated +// precisely when the products they read change. // // # CONVERGENCE // -// The fixpoint loop terminates when interproc facts and effect/constructor snapshots -// stabilize. Maximum iteration count is bounded to detect non-convergent analysis. +// The fixpoint loop terminates when the interproc product stabilizes. package check import ( @@ -128,8 +124,8 @@ func WithComputePass(p api.ComputePass) Option { // for analyzing multiple files in sequence or parallel. // // MEMOIZATION: Function analysis is memoized through funcResultQ keyed by FuncKey. -// The cache is cleared at each fixpoint iteration boundary to ensure fresh -// computation with updated inter-function summaries. +// Inter-function inputs are tracked through the query database, so unchanged +// functions can be reused across fixpoint iterations without a coarse revision key. // // EXTENSION POINTS: Checker supports two extension mechanisms: // - Pass: Diagnostic generators that run after fixpoint convergence @@ -139,7 +135,6 @@ type Checker struct { deps Deps passes []Pass computePasses []api.ComputePass - maxIterations int maxScopeDepth int emitScopeDepthDiagnostics bool } @@ -168,7 +163,6 @@ func NewChecker(database *db.DB, deps Deps, opts ...Option) *Checker { c := &Checker{ db: database, deps: deps, - maxIterations: 10, maxScopeDepth: 0, } @@ -195,24 +189,12 @@ func (c *Checker) newPipeline() *pipeline.Driver { GlobalTypes: c.deps.GlobalTypes, Stdlib: c.deps.Stdlib, Manifests: c.db, - MaxIterations: c.maxIterations, MaxScopeDepth: c.maxScopeDepth, EmitScopeDiag: c.emitScopeDepthDiagnostics, FuncResultQ: funcResultQ, }) } -// WithMaxIterations configures the maximum number of fixpoint iterations. -// Values less than 1 are clamped to 1. -func WithMaxIterations(n int) Option { - return func(c *Checker) { - if n < 1 { - n = 1 - } - c.maxIterations = n - } -} - // WithMaxScopeDepth configures a maximum lexical scope nesting depth. // A value <= 0 disables the limit. func WithMaxScopeDepth(n int) Option { @@ -249,14 +231,14 @@ func WithScopeDepthDiagnostics(enabled bool) Option { // The returned Session contains: // - Results: Per-function analysis results (types, flow facts, effects) // - Diagnostics: Type errors, warnings, and suggestions -// - Store: Inter-function channel data for advanced introspection +// - Store: Interprocedural fact products for advanced introspection func (c *Checker) Check(source, name string) *Session { ctx := db.NewQueryContext(c.db) sess := New(ctx, name) - // Ensure each top-level Check starts from clean inter-function channel state. + // Ensure each top-level Check starts from clean interprocedural fact state. // These are iteration-stable caches and must not persist across separate runs. if sess.Store != nil { - sess.Store.ClearIterationChannels() + sess.Store.ClearInterprocState() } chunk, err := parse.ParseString(source, name) @@ -303,7 +285,7 @@ func (c *Checker) CheckChunk(chunk []ast.Stmt, name string) *Session { sess := New(ctx, name) // Attach store accessor and compute context for interproc queries if sess.Store != nil { - sess.Store.ClearIterationChannels() + sess.Store.ClearInterprocState() } c.checkChunk(sess, chunk) return sess @@ -329,34 +311,24 @@ func (c *Checker) runPasses(sess *Session) { diags := p(sess, fn, result) sess.Diagnostics = append(sess.Diagnostics, diags...) } - - // Emit widening diagnostics for preflow inference precision loss - sess.Diagnostics = append(sess.Diagnostics, pipeline.WideningDiagnostics(sess.SourceName, fn, result)...) } } func funcResultEqual(a, b *api.FuncResult) bool { - if a == b { - return true - } - if a == nil || b == nil { - return false - } - if a.Graph != nil && b.Graph != nil { - return a.Graph.ID() == b.Graph.ID() - } - return false + return a == b } -// ClearCache removes all memoized function analysis results from the query cache. +// ClearCache establishes a fresh incremental-query revision boundary. // -// Call this between Check calls when analyzing unrelated files to prevent stale -// cache entries and reduce memory usage. The cache is automatically cleared at -// each fixpoint iteration boundary, so this is primarily useful for batch -// processing scenarios where the checker analyzes many independent files. +// Function analysis memoization is session-local and discarded at the end of +// each Check call. Advancing the database revision keeps the public cache-clear +// operation meaningful for hosts that retain query contexts around a reused +// Checker without reintroducing a checker-owned cache. func (c *Checker) ClearCache() { - // Function-result memoization is session-local and discarded at the end of Check. - // Kept for API compatibility. + if c == nil || c.db == nil { + return + } + c.db.Bump() } // Database returns the checker's type database for connecting external manifests. diff --git a/compiler/check/checker_test.go b/compiler/check/checker_test.go index 44051dd4..24229aa3 100644 --- a/compiler/check/checker_test.go +++ b/compiler/check/checker_test.go @@ -26,9 +26,6 @@ func TestNewChecker(t *testing.T) { if c.db != database { t.Error("db not set") } - if c.maxIterations != 10 { - t.Fatalf("default maxIterations = %d, want 10", c.maxIterations) - } } func TestChecker_WithPass(t *testing.T) { @@ -44,20 +41,6 @@ func TestChecker_WithPass(t *testing.T) { } } -func TestChecker_WithMaxIterations(t *testing.T) { - c := NewChecker(db.New(), Deps{Types: core.NewEngine()}, WithMaxIterations(3)) - if c.maxIterations != 3 { - t.Fatalf("maxIterations = %d, want 3", c.maxIterations) - } -} - -func TestChecker_WithMaxIterationsClamp(t *testing.T) { - c := NewChecker(db.New(), Deps{Types: core.NewEngine()}, WithMaxIterations(0)) - if c.maxIterations != 1 { - t.Fatalf("maxIterations = %d, want 1", c.maxIterations) - } -} - func TestChecker_WithMaxScopeDepth(t *testing.T) { c := NewChecker(db.New(), Deps{Types: core.NewEngine()}, WithMaxScopeDepth(4)) if c.maxScopeDepth != 4 { @@ -65,6 +48,18 @@ func TestChecker_WithMaxScopeDepth(t *testing.T) { } } +func TestChecker_ClearCacheBumpsRevision(t *testing.T) { + database := db.New() + c := NewChecker(database, Deps{Types: core.NewEngine()}) + before := database.Revision() + + c.ClearCache() + + if after := database.Revision(); after <= before { + t.Fatalf("revision after ClearCache = %d, want > %d", after, before) + } +} + func TestChecker_ScopeDepthDiagnostic(t *testing.T) { c := NewChecker(db.New(), Deps{Types: core.NewEngine()}, WithMaxScopeDepth(1), WithScopeDepthDiagnostics(true)) source := ` diff --git a/compiler/check/flowbuild/mutator/collect.go b/compiler/check/domain/calleffect/collect.go similarity index 58% rename from compiler/check/flowbuild/mutator/collect.go rename to compiler/check/domain/calleffect/collect.go index ad2fc9e4..00b901f2 100644 --- a/compiler/check/flowbuild/mutator/collect.go +++ b/compiler/check/domain/calleffect/collect.go @@ -1,61 +1,58 @@ -package mutator +package calleffect import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/types/narrow" "github.com/wippyai/go-lua/types/typ" ) -// IndexerInfo holds key and value types for dynamic index assignments. -type IndexerInfo struct { - KeyType typ.Type - ValType typ.Type -} - -// CollectTableInsertMutations scans the graph for table mutator calls on indexed expressions. -// For table.insert(t[k], v), returns mutations grouped by the base symbol of t. -// Uses the effect-based detection via TableMutatorFromCall. +// CollectTableInsertMutations reduces call evidence for table mutator calls on +// indexed expressions. For table.insert(t[k], v), it returns mutations grouped +// by the base symbol of t. func CollectTableInsertMutations( + calls []api.CallEvidence, graph *cfg.Graph, synth func(ast.Expr, cfg.Point) typ.Type, bindings *bind.BindingTable, -) map[cfg.SymbolID][]IndexerInfo { - result := make(map[cfg.SymbolID][]IndexerInfo) - if graph == nil { +) map[cfg.SymbolID][]overlaymut.IndexerInfo { + result := make(map[cfg.SymbolID][]overlaymut.IndexerInfo) + if len(calls) == 0 || graph == nil { return result } - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range calls { + p := call.Point + info := call.Info if info == nil { - return + continue } tm := TableMutatorFromCall(info, p, synth, nil, graph, bindings, nil) if tm == nil { - return + continue } targetExpr := callsite.RuntimeArgAt(info, tm.Target.Index) valueExpr := callsite.RuntimeArgAt(info, tm.Value.Index) if targetExpr == nil || valueExpr == nil { - return + continue } - // Check if target is an indexed expression: t[k] targetAttr, ok := targetExpr.(*ast.AttrGetExpr) if !ok { - return + continue } baseSym := callsite.SymbolOrCreateFieldFromExpr(targetAttr.Object, bindings) if baseSym == 0 { - return + continue } - // Get key type from the index key var keyType typ.Type switch k := targetAttr.Key.(type) { case *ast.IdentExpr: @@ -75,13 +72,11 @@ func CollectTableInsertMutations( keyType = typ.String } - // Strip falsy types from key types keyType = narrow.ToTruthy(keyType) if keyType == nil { keyType = typ.String } - // Get value type from the inserted element var elemType typ.Type if synth != nil && valueExpr != nil { elemType = synth(valueExpr, p) @@ -90,54 +85,52 @@ func CollectTableInsertMutations( elemType = typ.Unknown } - // The value type is an array of the element type - valType := typ.NewArray(elemType) - - result[baseSym] = append(result[baseSym], IndexerInfo{ + result[baseSym] = append(result[baseSym], overlaymut.IndexerInfo{ KeyType: keyType, - ValType: valType, + ValType: typ.NewArray(elemType), }) - }) + } return result } -// CollectTableInsertOnDirect scans for table mutator calls on direct variables. -// For table.insert(t, v), returns mutations grouped by the symbol of t. -// Uses the effect-based detection via TableMutatorFromCall. +// CollectTableInsertOnDirect reduces call evidence for table mutator calls on +// direct variables. For table.insert(t, v), it returns element mutations grouped +// by the symbol of t. func CollectTableInsertOnDirect( + calls []api.CallEvidence, graph *cfg.Graph, synth func(ast.Expr, cfg.Point) typ.Type, bindings *bind.BindingTable, ) map[cfg.SymbolID]typ.Type { result := make(map[cfg.SymbolID]typ.Type) - if graph == nil { + if len(calls) == 0 || graph == nil { return result } - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range calls { + p := call.Point + info := call.Info if info == nil { - return + continue } tm := TableMutatorFromCall(info, p, synth, nil, graph, bindings, nil) if tm == nil { - return + continue } targetExpr := callsite.RuntimeArgAt(info, tm.Target.Index) valueExpr := callsite.RuntimeArgAt(info, tm.Value.Index) if targetExpr == nil || valueExpr == nil { - return + continue } - // Check if target resolves to a direct symbol (identifier or static field path). sym := callsite.SymbolOrCreateFieldFromExpr(targetExpr, bindings) if sym == 0 { - return + continue } - // Get value type from the inserted element var elemType typ.Type if synth != nil && valueExpr != nil { elemType = synth(valueExpr, p) @@ -146,23 +139,12 @@ func CollectTableInsertOnDirect( elemType = typ.Unknown } - // Join with existing element type if existing := result[sym]; existing != nil { result[sym] = typ.JoinPreferNonSoft(existing, elemType) } else { result[sym] = elemType } - }) + } return result } - -// MergeIndexerMutations merges table mutator mutations into indexer assignments. -func MergeIndexerMutations( - indexers map[cfg.SymbolID][]IndexerInfo, - mutations map[cfg.SymbolID][]IndexerInfo, -) { - for sym, infos := range mutations { - indexers[sym] = append(indexers[sym], infos...) - } -} diff --git a/compiler/check/flowbuild/mutator/collect_test.go b/compiler/check/domain/calleffect/collect_test.go similarity index 78% rename from compiler/check/flowbuild/mutator/collect_test.go rename to compiler/check/domain/calleffect/collect_test.go index dd693418..956eac3a 100644 --- a/compiler/check/flowbuild/mutator/collect_test.go +++ b/compiler/check/domain/calleffect/collect_test.go @@ -1,10 +1,12 @@ -package mutator +package calleffect import ( "testing" "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/effect" @@ -12,7 +14,7 @@ import ( ) func TestIndexerInfo(t *testing.T) { - info := IndexerInfo{ + info := overlaymut.IndexerInfo{ KeyType: typ.String, ValType: typ.Integer, } @@ -25,7 +27,7 @@ func TestIndexerInfo(t *testing.T) { } func TestCollectTableInsertMutations_NilGraph(t *testing.T) { - result := CollectTableInsertMutations(nil, nil, nil) + result := CollectTableInsertMutations(nil, nil, nil, nil) if result == nil { t.Error("expected non-nil map for nil graph") } @@ -35,7 +37,7 @@ func TestCollectTableInsertMutations_NilGraph(t *testing.T) { } func TestCollectTableInsertOnDirect_NilGraph(t *testing.T) { - result := CollectTableInsertOnDirect(nil, nil, nil) + result := CollectTableInsertOnDirect(nil, nil, nil, nil) if result == nil { t.Error("expected non-nil map for nil graph") } @@ -44,35 +46,6 @@ func TestCollectTableInsertOnDirect_NilGraph(t *testing.T) { } } -func TestMergeIndexerMutations_EmptyInputs(t *testing.T) { - indexers := make(map[cfg.SymbolID][]IndexerInfo) - mutations := make(map[cfg.SymbolID][]IndexerInfo) - - MergeIndexerMutations(indexers, mutations) - - if len(indexers) != 0 { - t.Errorf("expected empty indexers after merging empty mutations, got %d", len(indexers)) - } -} - -func TestMergeIndexerMutations_MergesCorrectly(t *testing.T) { - indexers := make(map[cfg.SymbolID][]IndexerInfo) - indexers[1] = []IndexerInfo{{KeyType: typ.String, ValType: typ.Integer}} - - mutations := make(map[cfg.SymbolID][]IndexerInfo) - mutations[1] = []IndexerInfo{{KeyType: typ.Integer, ValType: typ.String}} - mutations[2] = []IndexerInfo{{KeyType: typ.Number, ValType: typ.Boolean}} - - MergeIndexerMutations(indexers, mutations) - - if len(indexers[1]) != 2 { - t.Errorf("expected 2 infos for symbol 1, got %d", len(indexers[1])) - } - if len(indexers[2]) != 1 { - t.Errorf("expected 1 info for symbol 2, got %d", len(indexers[2])) - } -} - func TestCollectTableInsertOnDirect_AssignmentCallSite(t *testing.T) { code := ` local t = {} @@ -81,7 +54,7 @@ func TestCollectTableInsertOnDirect_AssignmentCallSite(t *testing.T) { graph := buildGraph(t, code, "table") bindings := graph.Bindings() - result := CollectTableInsertOnDirect(graph, tableInsertSynth(), bindings) + result := CollectTableInsertOnDirect(callsFromGraph(graph), graph, tableInsertSynth(), bindings) if len(result) == 0 { t.Fatal("expected direct table mutation from assignment call site") } @@ -105,7 +78,7 @@ func TestCollectTableInsertMutations_AssignmentCallSite(t *testing.T) { graph := buildGraph(t, code, "table") bindings := graph.Bindings() - result := CollectTableInsertMutations(graph, tableInsertSynth(), bindings) + result := CollectTableInsertMutations(callsFromGraph(graph), graph, tableInsertSynth(), bindings) if len(result) == 0 { t.Fatal("expected indexed table mutation from assignment call site") } @@ -136,7 +109,7 @@ func TestCollectTableInsertMutations_NestedBasePath(t *testing.T) { graph := buildGraph(t, code, "table") bindings := graph.Bindings() - result := CollectTableInsertMutations(graph, tableInsertSynth(), bindings) + result := CollectTableInsertMutations(callsFromGraph(graph), graph, tableInsertSynth(), bindings) if len(result) == 0 { t.Fatal("expected indexed table mutation from nested base path") } @@ -167,7 +140,7 @@ func TestCollectTableInsertOnDirect_NestedBasePath(t *testing.T) { graph := buildGraph(t, code, "table") bindings := graph.Bindings() - result := CollectTableInsertOnDirect(graph, tableInsertSynth(), bindings) + result := CollectTableInsertOnDirect(callsFromGraph(graph), graph, tableInsertSynth(), bindings) if len(result) == 0 { t.Fatal("expected direct table mutation for nested base path") } @@ -197,6 +170,19 @@ func buildGraph(t *testing.T, code string, globals ...string) *cfg.Graph { return graph } +func callsFromGraph(graph *cfg.Graph) []api.CallEvidence { + if graph == nil { + return nil + } + var calls []api.CallEvidence + graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + if info != nil { + calls = append(calls, api.CallEvidence{Point: p, Info: info}) + } + }) + return calls +} + func tableInsertSynth() func(ast.Expr, cfg.Point) typ.Type { spec := contract.NewSpec().WithEffects(effect.TableMutator{ Target: effect.ParamRef{Index: 0}, diff --git a/compiler/check/domain/calleffect/container.go b/compiler/check/domain/calleffect/container.go new file mode 100644 index 00000000..8dca6fdd --- /dev/null +++ b/compiler/check/domain/calleffect/container.go @@ -0,0 +1,100 @@ +package calleffect + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/effect" + "github.com/wippyai/go-lua/types/typ" +) + +// ContainerElementReturnInfo holds info about a method that returns container element types. +type ContainerElementReturnInfo struct { + ReturnIndex int + SourceRef effect.ParamRef +} + +// ContainerElementReturnFromCall detects if a call returns a container's element type. +func ContainerElementReturnFromCall( + info *cfg.CallInfo, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, + symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), + assignmentTypes func(cfg.SymbolID) typ.Type, + graph *cfg.Graph, + bindings *bind.BindingTable, + moduleBindings *bind.BindingTable, +) *ContainerElementReturnInfo { + if info == nil { + return nil + } + + fnType := resolve.CalleeType(info, p, synth, symResolver, assignmentTypes, graph, bindings, moduleBindings) + if fnType == nil { + return nil + } + + spec := contract.ExtractSpec(fnType) + if spec == nil { + return nil + } + + for _, label := range spec.Effects.Labels { + ret, ok := label.(effect.Return) + if !ok { + continue + } + elemOf, ok := ret.Transform.(effect.ElementOf) + if !ok { + continue + } + return &ContainerElementReturnInfo{ + ReturnIndex: ret.ReturnIndex, + SourceRef: elemOf.Source, + } + } + + return nil +} + +// ContainerMutatorFromCall extracts the container mutation effect from a call site. +func ContainerMutatorFromCall( + info *cfg.CallInfo, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, + symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), + assignmentTypes func(cfg.SymbolID) typ.Type, + graph *cfg.Graph, + bindings *bind.BindingTable, + moduleBindings *bind.BindingTable, +) *effect.ContainerElementUnion { + if info == nil { + return nil + } + + fnType := resolve.CalleeType(info, p, synth, symResolver, assignmentTypes, graph, bindings, moduleBindings) + if fnType == nil { + return nil + } + + spec := contract.ExtractSpec(fnType) + if spec == nil { + return nil + } + + for _, label := range spec.Effects.Labels { + mut, ok := label.(effect.Mutate) + if !ok { + continue + } + ceu, ok := mut.Transform.(effect.ContainerElementUnion) + if !ok { + continue + } + return &ceu + } + + return nil +} diff --git a/compiler/check/domain/calleffect/doc.go b/compiler/check/domain/calleffect/doc.go new file mode 100644 index 00000000..75b1be77 --- /dev/null +++ b/compiler/check/domain/calleffect/doc.go @@ -0,0 +1,8 @@ +// Package calleffect owns call-effect projection over transfer evidence. +// +// It reduces call evidence plus callee contracts into abstract-interpreter +// effects such as table mutations, container mutations, callback invocation, and +// captured nested-function mutation replay. Callers provide the solved type +// query they want to use; this package owns how call evidence is interpreted as +// effect payloads. +package calleffect diff --git a/compiler/check/returns/callsite.go b/compiler/check/domain/calleffect/nested.go similarity index 55% rename from compiler/check/returns/callsite.go rename to compiler/check/domain/calleffect/nested.go index a0f73d3e..26d03dc4 100644 --- a/compiler/check/returns/callsite.go +++ b/compiler/check/domain/calleffect/nested.go @@ -1,4 +1,4 @@ -package returns +package calleffect import ( "sort" @@ -7,7 +7,7 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/flow" @@ -18,9 +18,9 @@ import ( // called nested functions that target symbols from the parent graph (captured variables). // // When a nested function assigns fields to a captured variable, those assignments -// affect the type of the variable in the parent scope. This function scans nested -// functions that are called from the parent graph and collects field assignments -// that target parent-scope variables. +// affect the type of the variable in the parent scope. This function consumes +// transfer call evidence and reduces the already-recorded captured assignments +// for callees that are proven to run from the parent graph. // // Example: // @@ -35,6 +35,7 @@ import ( func CollectCalledNestedFieldAssignments( parent *cfg.Graph, bindings *bind.BindingTable, + calls []api.CallEvidence, capturedByCallee map[cfg.SymbolID]map[cfg.SymbolID]map[string]typ.Type, resolveCalleeType func(*cfg.CallInfo, cfg.Point) typ.Type, ) map[cfg.SymbolID]map[string]typ.Type { @@ -46,19 +47,19 @@ func CollectCalledNestedFieldAssignments( // Gather all symbols known in the parent graph (avoid per-point merges). parentSymbols := parent.AllSymbolIDs() - // Find which local functions are called in the parent graph. + // Find which local functions are called according to transfer evidence. trackedCallees := make(map[cfg.SymbolID]bool, len(capturedByCallee)) for calleeSym := range capturedByCallee { trackedCallees[calleeSym] = true } calledSyms := make(map[cfg.SymbolID]bool) - parent.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { - for sym := range calledSymbolsFromCall(info, p, parent, bindings, resolveCalleeType, func(sym cfg.SymbolID) bool { + for _, call := range calls { + for sym := range calledSymbolsFromCall(call.Info, call.Point, parent, bindings, resolveCalleeType, func(sym cfg.SymbolID) bool { return trackedCallees[sym] }) { calledSyms[sym] = true } - }) + } // Collect field assignments from called nested functions and merge into result. if len(calledSyms) == 0 { @@ -91,20 +92,34 @@ func CollectCalledNestedFieldAssignments( return result } -// CollectCalledNestedContainerMutatorAssignments collects container mutations recorded for -// called nested functions that target symbols from the parent graph (captured variables). +// CalledNestedMutatorAssignments is the flow replay payload for captured +// mutations made by called nested functions. +type CalledNestedMutatorAssignments struct { + Indexer []flow.IndexerAssignment + Table []flow.TableMutatorAssignment + Container []flow.ContainerMutatorAssignment +} + +// CollectNestedMutatorAssignments collects captured mutations recorded for +// parent-visible nested functions and replays them through the matching flow +// operator. Direct invocation is driven by transfer call evidence. // -// This supports cases where a nested function mutates a captured container (e.g., channel.send) -// and the nested function is invoked directly or passed as a callback to a function with a -// callback spec (e.g., coroutine.spawn). -func CollectCalledNestedContainerMutatorAssignments( +// This supports cases where a nested function mutates a captured table +// (table.insert) or generic container (channel.send) and the nested function is: +// - invoked directly, +// - passed as a callback to a function with a callback spec, or +// - stored in a field/global position that can be called outside the parent +// graph before another exported function reads the captured state. +func CollectNestedMutatorAssignments( parent *cfg.Graph, bindings *bind.BindingTable, + calls []api.CallEvidence, + escapes []api.FunctionEscapeEvidence, capturedByCallee api.CapturedContainerMutations, resolveCalleeType func(*cfg.CallInfo, cfg.Point) typ.Type, -) []flow.ContainerMutatorAssignment { +) CalledNestedMutatorAssignments { if parent == nil || len(capturedByCallee) == 0 { - return nil + return CalledNestedMutatorAssignments{} } parentSymbols := parent.AllSymbolIDs() @@ -112,51 +127,93 @@ func CollectCalledNestedContainerMutatorAssignments( for calleeSym := range capturedByCallee { trackedCallees[calleeSym] = true } - assignments := make([]flow.ContainerMutatorAssignment, 0) - - parent.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { - if info == nil { + assignments := CalledNestedMutatorAssignments{} + emitForCallee := func(p cfg.Point, sym cfg.SymbolID) { + nestedMutations := capturedByCallee[sym] + if len(nestedMutations) == 0 { return } + for _, targetSym := range cfg.SortedSymbolIDs(nestedMutations) { + mutations := nestedMutations[targetSym] + if !parentSymbols[targetSym] { + continue + } + root := resolve.RootNameFromGraphAndBindings(parent, bindings, targetSym, "") + appendNestedMutatorAssignments(&assignments, p, root, targetSym, mutations) + } + } + + for _, call := range calls { + if call.Info == nil { + continue + } - calledSyms := calledSymbolsFromCall(info, p, parent, bindings, resolveCalleeType, func(sym cfg.SymbolID) bool { + calledSyms := calledSymbolsFromCall(call.Info, call.Point, parent, bindings, resolveCalleeType, func(sym cfg.SymbolID) bool { return trackedCallees[sym] }) if len(calledSyms) == 0 { - return + continue } for _, sym := range cfg.SortedSymbolIDs(calledSyms) { - nestedMutations := capturedByCallee[sym] - if len(nestedMutations) == 0 { - continue - } - for _, targetSym := range cfg.SortedSymbolIDs(nestedMutations) { - mutations := nestedMutations[targetSym] - if !parentSymbols[targetSym] { - continue - } - root := resolve.RootNameFromGraphAndBindings(parent, bindings, targetSym, "") - for _, mutation := range mutations { - segs := make([]constraint.Segment, len(mutation.Segments)) - copy(segs, mutation.Segments) - assignments = append(assignments, flow.ContainerMutatorAssignment{ - Point: p, - Target: constraint.Path{ - Root: root, - Symbol: targetSym, - Segments: segs, - }, - ValueType: mutation.ValueType, - }) - } - } + emitForCallee(call.Point, sym) + } + } + + for _, escape := range escapes { + if escape.Symbol == 0 || !trackedCallees[escape.Symbol] { + continue } - }) + emitForCallee(escape.Point, escape.Symbol) + } return assignments } +func appendNestedMutatorAssignments( + assignments *CalledNestedMutatorAssignments, + p cfg.Point, + root string, + targetSym cfg.SymbolID, + mutations []api.ContainerMutation, +) { + if assignments == nil || targetSym == 0 || len(mutations) == 0 { + return + } + for _, mutation := range mutations { + segs := make([]constraint.Segment, len(mutation.Segments)) + copy(segs, mutation.Segments) + target := constraint.Path{ + Root: root, + Symbol: targetSym, + Segments: segs, + } + switch mutation.Kind { + case api.ContainerMutationMapElement: + assignments.Indexer = append(assignments.Indexer, flow.IndexerAssignment{ + Point: p, + Root: root, + Symbol: targetSym, + Segments: segs, + KeyType: mutation.KeyType, + ValType: mutation.ValueType, + }) + case api.ContainerMutationTableElement: + assignments.Table = append(assignments.Table, flow.TableMutatorAssignment{ + Point: p, + Target: target, + ValueType: mutation.ValueType, + }) + default: + assignments.Container = append(assignments.Container, flow.ContainerMutatorAssignment{ + Point: p, + Target: target, + ValueType: mutation.ValueType, + }) + } + } +} + func calledSymbolsFromCall( info *cfg.CallInfo, p cfg.Point, diff --git a/compiler/check/domain/calleffect/nested_test.go b/compiler/check/domain/calleffect/nested_test.go new file mode 100644 index 00000000..85a0ea23 --- /dev/null +++ b/compiler/check/domain/calleffect/nested_test.go @@ -0,0 +1,289 @@ +package calleffect + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/parse" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +func TestCollectCalledNestedFieldAssignments(t *testing.T) { + t.Run("nil graph returns empty map", func(t *testing.T) { + result := CollectCalledNestedFieldAssignments(nil, nil, nil, nil, nil) + if len(result) != 0 { + t.Error("expected empty result") + } + }) +} + +func TestCollectNestedMutatorAssignments(t *testing.T) { + t.Run("nil graph returns empty slice", func(t *testing.T) { + result := CollectNestedMutatorAssignments(nil, nil, nil, nil, nil, nil) + if len(result.Table) != 0 || len(result.Container) != 0 { + t.Error("expected empty result") + } + }) +} + +func TestCollectNestedMutatorAssignments_SplitsOperatorKinds(t *testing.T) { + stmts, err := parse.ParseString(` + local state = {} + local function setup() + return nil + end + setup() + `, "test.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{Stmts: stmts}) + if graph == nil { + t.Fatal("expected graph") + } + bindings := graph.Bindings() + if bindings == nil { + t.Fatal("expected bindings") + } + stateSym, ok := graph.SymbolAt(graph.Exit(), "state") + if !ok || stateSym == 0 { + t.Fatal("expected symbol for state") + } + setupSym, ok := graph.SymbolAt(graph.Exit(), "setup") + if !ok || setupSym == 0 { + t.Fatal("expected symbol for setup") + } + + captured := api.CapturedContainerMutations{ + setupSym: { + stateSym: { + { + Kind: api.ContainerMutationTableElement, + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "items"}}, + ValueType: typ.String, + }, + { + Kind: api.ContainerMutationContainerElement, + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "channel"}}, + ValueType: typ.Number, + }, + }, + }, + } + + calls := callEvidenceForGraph(graph) + got := CollectNestedMutatorAssignments(graph, bindings, calls, nil, captured, nil) + if len(got.Table) != 1 { + t.Fatalf("table assignments = %d, want 1", len(got.Table)) + } + if len(got.Container) != 1 { + t.Fatalf("container assignments = %d, want 1", len(got.Container)) + } + if got.Table[0].Target.Symbol != stateSym || len(got.Table[0].Target.Segments) != 1 || got.Table[0].Target.Segments[0].Name != "items" { + t.Fatalf("unexpected table target: %#v", got.Table[0].Target) + } + if got.Container[0].Target.Symbol != stateSym || len(got.Container[0].Target.Segments) != 1 || got.Container[0].Target.Segments[0].Name != "channel" { + t.Fatalf("unexpected container target: %#v", got.Container[0].Target) + } +} + +func callEvidenceForGraph(graph *cfg.Graph) []api.CallEvidence { + if graph == nil { + return nil + } + var calls []api.CallEvidence + graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + calls = append(calls, api.CallEvidence{Point: p, Info: info}) + }) + return calls +} + +func TestCollectNestedMutatorAssignments_ReplaysExportedFieldFunction(t *testing.T) { + stmts, err := parse.ParseString(` + local api = {} + local state = {} + function api.add() + return nil + end + `, "test.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{Stmts: stmts}) + if graph == nil { + t.Fatal("expected graph") + } + bindings := graph.Bindings() + if bindings == nil { + t.Fatal("expected bindings") + } + stateSym, ok := graph.SymbolAt(graph.Exit(), "state") + if !ok || stateSym == 0 { + t.Fatal("expected symbol for state") + } + + var addSym cfg.SymbolID + var addPoint cfg.Point + graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + if info != nil && info.Name == "add" { + addSym = info.Symbol + addPoint = p + } + }) + if addSym == 0 { + t.Fatal("expected symbol for api.add") + } + + captured := api.CapturedContainerMutations{ + addSym: { + stateSym: { + { + Kind: api.ContainerMutationTableElement, + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "items"}}, + ValueType: typ.String, + }, + }, + }, + } + + escapes := []api.FunctionEscapeEvidence{{Point: addPoint, Symbol: addSym}} + got := CollectNestedMutatorAssignments(graph, bindings, nil, escapes, captured, nil) + if len(got.Table) != 1 { + t.Fatalf("table assignments = %d, want 1", len(got.Table)) + } + if got.Table[0].Point != addPoint { + t.Fatalf("assignment point = %d, want exported definition point %d", got.Table[0].Point, addPoint) + } + if got.Table[0].Target.Symbol != stateSym || got.Table[0].Target.Segments[0].Name != "items" { + t.Fatalf("unexpected exported table target: %#v", got.Table[0].Target) + } +} + +func TestRuntimeArgAt(t *testing.T) { + t.Run("direct call positional mapping", func(t *testing.T) { + a := &ast.NumberExpr{Value: "1"} + b := &ast.NumberExpr{Value: "2"} + info := &cfg.CallInfo{Args: []ast.Expr{a, b}} + if got := checkcallsite.RuntimeArgAt(info, 0); got != a { + t.Fatal("expected first arg at index 0") + } + if got := checkcallsite.RuntimeArgAt(info, -1); got != b { + t.Fatal("expected last arg at index -1") + } + }) + + t.Run("method call runtime mapping", func(t *testing.T) { + recv := &ast.IdentExpr{Value: "self"} + a := &ast.NumberExpr{Value: "1"} + b := &ast.NumberExpr{Value: "2"} + info := &cfg.CallInfo{ + Method: "m", + Receiver: recv, + Args: []ast.Expr{a, b}, + } + if got := checkcallsite.RuntimeArgAt(info, 0); got != recv { + t.Fatal("expected receiver at index 0 for method call") + } + if got := checkcallsite.RuntimeArgAt(info, 1); got != a { + t.Fatal("expected first positional arg at runtime index 1") + } + if got := checkcallsite.RuntimeArgAt(info, -3); got != recv { + t.Fatal("expected receiver from negative runtime index") + } + }) +} + +func TestCalledSymbolsFromCall_PrefersTrackedCanonicalSymbol(t *testing.T) { + bindings := bind.NewBindingTable() + ident := &ast.IdentExpr{Value: "f"} + const ( + rawSym cfg.SymbolID = 101 + trackedSym cfg.SymbolID = 202 + ) + bindings.Bind(ident, trackedSym) + + info := &cfg.CallInfo{ + Callee: ident, + CalleeSymbol: rawSym, + } + + got := calledSymbolsFromCall(info, 0, nil, bindings, nil, func(sym cfg.SymbolID) bool { + return sym == trackedSym + }) + + if !got[trackedSym] { + t.Fatalf("expected tracked canonical symbol %d to be selected, got %v", trackedSym, got) + } + if got[rawSym] { + t.Fatalf("expected raw symbol %d to be excluded when tracked symbol is preferred, got %v", rawSym, got) + } +} + +func TestCalledSymbolsFromCall_UsesCalleeNameCandidatesWhenRawAndExprMissing(t *testing.T) { + bindings := bind.NewBindingTable() + ident := &ast.IdentExpr{Value: "f"} + const trackedSym cfg.SymbolID = 303 + bindings.Bind(ident, trackedSym) + bindings.SetName(trackedSym, "f") + + info := &cfg.CallInfo{ + Callee: nil, + CalleeSymbol: 0, + CalleeName: "f", + } + + got := calledSymbolsFromCall(info, 0, nil, bindings, nil, func(sym cfg.SymbolID) bool { + return sym == trackedSym + }) + if !got[trackedSym] { + t.Fatalf("expected tracked symbol %d via callee-name candidates, got %v", trackedSym, got) + } +} + +func TestCalledSymbolsFromCall_UsesAliasExpandedCandidates(t *testing.T) { + stmts, err := parse.ParseString(` + local function runner() + return 1 + end + local f = runner + local _ = f() + `, "test.lua") + if err != nil { + t.Fatalf("parse failed: %v", err) + } + graph := cfg.Build(&ast.FunctionExpr{Stmts: stmts}) + if graph == nil { + t.Fatal("expected graph") + } + bindings := graph.Bindings() + if bindings == nil { + t.Fatal("expected bindings") + } + runnerSym, ok := graph.SymbolAt(graph.Exit(), "runner") + if !ok || runnerSym == 0 { + t.Fatal("expected symbol for runner") + } + + var info *cfg.CallInfo + graph.EachCallSite(func(_ cfg.Point, ci *cfg.CallInfo) { + if ci == nil || ci.CalleeName != "f" { + return + } + info = ci + }) + if info == nil { + t.Fatal("expected f() call site") + } + + got := calledSymbolsFromCall(info, 0, graph, bindings, nil, func(sym cfg.SymbolID) bool { + return sym == runnerSym + }) + if !got[runnerSym] { + t.Fatalf("expected tracked runner symbol %d via alias-expanded candidates, got %v", runnerSym, got) + } +} diff --git a/compiler/check/domain/calleffect/table.go b/compiler/check/domain/calleffect/table.go new file mode 100644 index 00000000..73c1b160 --- /dev/null +++ b/compiler/check/domain/calleffect/table.go @@ -0,0 +1,38 @@ +package calleffect + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/effect" + "github.com/wippyai/go-lua/types/typ" +) + +// TableMutatorFromCall resolves a call's callee contract and returns its +// table-mutator effect, when present. +func TableMutatorFromCall( + info *cfg.CallInfo, + p cfg.Point, + synth func(ast.Expr, cfg.Point) typ.Type, + symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), + graph *cfg.Graph, + bindings *bind.BindingTable, + moduleBindings *bind.BindingTable, +) *effect.TableMutator { + if info == nil { + return nil + } + + fnType := resolve.CalleeType(info, p, synth, symResolver, nil, graph, bindings, moduleBindings) + if fnType == nil { + return nil + } + + spec := contract.ExtractSpec(fnType) + if spec == nil { + return nil + } + return spec.GetTableMutator() +} diff --git a/compiler/check/domain/functionfact/call_projection.go b/compiler/check/domain/functionfact/call_projection.go new file mode 100644 index 00000000..c8996b35 --- /dev/null +++ b/compiler/check/domain/functionfact/call_projection.go @@ -0,0 +1,243 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// CallProjectionInput describes the local evidence needed to project a stable +// function fact into the effective call signature at one call site. +type CallProjectionInput struct { + Store api.StoreReader + Info *cfg.CallInfo + Graph *cfg.Graph + Evidence api.FlowEvidence + Bindings *bind.BindingTable + Results map[*ast.FunctionExpr]*api.FuncResult + Args []typ.Type + Current typ.Type + UnobservedLocalParams map[cfg.SymbolID][]bool +} + +// CallProjection is the function-fact contribution to call checking. +type CallProjection struct { + Callee typ.Type + AllowExtraArgs bool +} + +// ProjectCall projects canonical function facts into the effective call +// signature for one call site. +func ProjectCall(input CallProjectionInput) (CallProjection, bool) { + if input.Store == nil || input.Info == nil { + return CallProjection{}, false + } + moduleBindings := input.Store.ModuleBindings() + for _, sym := range callsite.CallableCalleeSymbolCandidates(input.Info, input.Graph, input.Bindings, moduleBindings) { + ff, ok := ForSymbol(input.Store, sym, nil) + if !ok || ff.Type == nil { + continue + } + + localFn := callsite.FunctionLiteralForGraphSymbol(input.Evidence, sym) + refinementFn := localFn + if refinementFn == nil { + refinementFn = sourceFunctionForSymbol(input.Store, sym) + } + + var unobservedParams []bool + allowExtraArgs := false + if localFn != nil { + unobservedParams = unobservedLocalParamMask(input.Store, sym, localFn, input.Results, input.UnobservedLocalParams) + allowExtraArgs = callsite.AllowsDiscardedExtraArgs(localFn) + } + + factType := projectRefinementProvenDynamicParams(ff.Type, input.Args, refinementFn, refinementFromFact(ff)) + callee := input.Current + if typ.IsUnknownOrNil(callee) || hasWiderParams(callee, factType) { + callee = factType + } else if len(unobservedParams) > 0 { + callee = projectUnobservedDynamicParams(callee, input.Args, unobservedParams) + } + return CallProjection{Callee: callee, AllowExtraArgs: allowExtraArgs}, true + } + return CallProjection{}, false +} + +func refinementFromFact(ff api.FunctionFact) *constraint.FunctionRefinement { + if ff.Refinement != nil { + return ff.Refinement + } + fn := unwrap.Function(ff.Type) + if fn == nil { + return nil + } + refinement, _ := fn.Refinement.(*constraint.FunctionRefinement) + return refinement +} + +func sourceFunctionForSymbol(store api.StoreReader, sym cfg.SymbolID) *ast.FunctionExpr { + if store == nil || sym == 0 { + return nil + } + ref := store.FunctionRefBySym(sym) + if ref == nil || ref.GraphID == 0 { + return nil + } + graph := store.Graphs()[ref.GraphID] + if graph == nil { + return nil + } + return graph.Func() +} + +func unobservedLocalParamMask( + store api.StoreReader, + sym cfg.SymbolID, + fn *ast.FunctionExpr, + results map[*ast.FunctionExpr]*api.FuncResult, + cache map[cfg.SymbolID][]bool, +) []bool { + if store == nil || sym == 0 || fn == nil { + return nil + } + if cache != nil { + if mask, ok := cache[sym]; ok { + return mask + } + } + ref := store.FunctionRefBySym(sym) + if ref == nil || ref.GraphID == 0 { + return nil + } + graph := store.Graphs()[ref.GraphID] + if graph == nil { + return nil + } + result := results[fn] + if result == nil { + return nil + } + mask := paramevidence.UnobservedParameterMask(graph.ParamSlotsReadOnly(), result.Evidence.ParameterUses) + if cache != nil { + cache[sym] = mask + } + return mask +} + +func projectRefinementProvenDynamicParams( + callee typ.Type, + args []typ.Type, + fn *ast.FunctionExpr, + refinement *constraint.FunctionRefinement, +) typ.Type { + if refinement == nil || fn == nil || fn.ParList == nil || len(args) == 0 { + return callee + } + return rewriteFunctionParams(callee, func(i int, p typ.Param) typ.Type { + if i < len(args) && + typ.IsAny(args[i]) && + sourceParamUnannotated(fn, i) && + RefinementGuaranteesParamType(refinement, i, p.Type) { + return typ.Any + } + return p.Type + }) +} + +func sourceParamUnannotated(fn *ast.FunctionExpr, idx int) bool { + if fn == nil || fn.ParList == nil || idx < 0 || idx >= len(fn.ParList.Names) { + return false + } + return fn.ParList.Types == nil || idx >= len(fn.ParList.Types) || fn.ParList.Types[idx] == nil +} + +func projectUnobservedDynamicParams(callee typ.Type, args []typ.Type, unobservedParams []bool) typ.Type { + if len(args) == 0 || len(unobservedParams) == 0 { + return callee + } + return rewriteFunctionParams(callee, func(i int, p typ.Param) typ.Type { + if i < len(args) && i < len(unobservedParams) && unobservedParams[i] && typ.IsAny(args[i]) && !typ.IsAny(p.Type) { + return typ.Any + } + return p.Type + }) +} + +func rewriteFunctionParams(callee typ.Type, rewrite func(int, typ.Param) typ.Type) typ.Type { + fn := unwrap.Function(callee) + if fn == nil || len(fn.Params) == 0 { + return callee + } + changed := false + builder := typ.Func().ReserveParams(len(fn.Params)) + for _, tp := range fn.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for i, p := range fn.Params { + paramType := rewrite(i, p) + if !typ.TypeEquals(paramType, p.Type) { + changed = true + } + if p.Optional { + builder = builder.OptParam(p.Name, paramType) + } else { + builder = builder.Param(p.Name, paramType) + } + } + if !changed { + return callee + } + if fn.Variadic != nil { + builder = builder.Variadic(fn.Variadic) + } + if len(fn.Returns) > 0 { + builder = builder.Returns(fn.Returns...) + } + if fn.Effects != nil { + builder = builder.Effects(fn.Effects) + } + if fn.Spec != nil { + builder = builder.Spec(fn.Spec) + } + if fn.Refinement != nil { + builder = builder.WithRefinement(fn.Refinement) + } + return builder.Build() +} + +func hasWiderParams(current, fact typ.Type) bool { + currentFn := unwrap.Function(current) + factFn := unwrap.Function(fact) + if currentFn == nil || factFn == nil || len(currentFn.Params) != len(factFn.Params) { + return false + } + wider := false + for i, currentParam := range currentFn.Params { + factParam := factFn.Params[i] + if currentParam.Optional != factParam.Optional { + if currentParam.Optional && !factParam.Optional { + return false + } + wider = true + } + if typ.TypeEquals(currentParam.Type, factParam.Type) { + continue + } + if typ.IsAny(factParam.Type) || typ.IsAny(unwrap.Optional(factParam.Type)) { + wider = true + continue + } + if subtype.IsSubtype(currentParam.Type, factParam.Type) { + wider = true + } + } + return wider +} diff --git a/compiler/check/domain/functionfact/doc.go b/compiler/check/domain/functionfact/doc.go new file mode 100644 index 00000000..34f83eec --- /dev/null +++ b/compiler/check/domain/functionfact/doc.go @@ -0,0 +1,8 @@ +// Package functionfact owns the per-function fact abstract domain. +// +// It canonicalizes and joins api.FunctionFact values, constructs canonical +// FunctionFacts maps from per-symbol evidence, and owns store-backed projection +// of function-fact types, parameter evidence, and return summaries. Product-level +// packages decide when facts are produced; this package decides what the fact +// product means. +package functionfact diff --git a/compiler/check/domain/functionfact/fact.go b/compiler/check/domain/functionfact/fact.go new file mode 100644 index 00000000..b213711d --- /dev/null +++ b/compiler/check/domain/functionfact/fact.go @@ -0,0 +1,823 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/effect" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + typjoin "github.com/wippyai/go-lua/types/typ/join" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// Normalize canonicalizes one stored function fact. +func Normalize(ff api.FunctionFact) api.FunctionFact { + return api.FunctionFact{ + Params: paramevidence.FilterEmptyVector(ff.Params), + Summary: returnsummary.Canonical(ff.Summary), + Narrow: returnsummary.Canonical(ff.Narrow), + Type: value.NormalizeFactType(ff.Type), + Refinement: NormalizeRefinement(ff.Refinement), + } +} + +// Empty reports whether a canonical function fact contains no information. +func Empty(ff api.FunctionFact) bool { + return len(ff.Params) == 0 && + len(ff.Summary) == 0 && + len(ff.Narrow) == 0 && + ff.Type == nil && + NormalizeRefinement(ff.Refinement) == nil +} + +// Join precisely merges two observations for one local function during a single +// analysis iteration. +func Join(existing, candidate api.FunctionFact) api.FunctionFact { + existing = Normalize(existing) + candidate = Normalize(candidate) + out := existing + + if len(candidate.Params) > 0 { + out.Params = paramevidence.JoinVectors(out.Params, candidate.Params) + } + if len(candidate.Summary) > 0 { + out.Summary = returnsummary.Merge(out.Summary, candidate.Summary) + } + if len(candidate.Narrow) > 0 { + out.Narrow = returnsummary.Merge(out.Narrow, candidate.Narrow) + } + if candidate.Type != nil { + out.Type = MergeType(out.Type, candidate.Type) + } + out.Refinement = MergeRefinement(out.Refinement, candidate.Refinement) + out.Params = preserveDynamicParamsProvenByRefinement(existing.Params, candidate.Params, out.Params, out.Refinement) + + summaryBeforeNarrow := out.Summary + if len(out.Narrow) > 0 { + if len(out.Summary) == 0 { + out.Summary = returnsummary.Canonical(out.Narrow) + } else { + out.Summary = returnsummary.Merge(out.Summary, out.Narrow) + } + } + + if fn := unwrap.Function(out.Type); fn != nil { + alignedReturns := out.Summary + usingNarrow := len(out.Narrow) > 0 && !returnsummary.AllNil(out.Narrow) + if usingNarrow { + repairBase := summaryBeforeNarrow + if len(repairBase) == 0 { + repairBase = out.Summary + } + alignedReturns = repairSummaryWithNarrow(repairBase, out.Narrow) + } + if len(alignedReturns) > 0 { + if usingNarrow { + if aligned := typjoin.WithReturns(fn, alignedReturns); aligned != nil { + out.Type = aligned + fn = aligned + } + } else { + if aligned, changed := returnsummary.AlignFunction(fn, alignedReturns); changed { + out.Type = aligned + fn = aligned + } + } + } + if len(out.Summary) == 0 && fn != nil && len(fn.Returns) > 0 { + out.Summary = returnsummary.Canonical(fn.Returns) + } + } + + return out +} + +// MergeType merges function-type facts through the canonical per-function fact +// policy. +func MergeType(existing, candidate typ.Type) typ.Type { + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + + existingFn := unwrap.Function(existing) + candidateFn := unwrap.Function(candidate) + if mergedFromVariants, ok := mergeVariants(existing, candidate); ok { + return mergedFromVariants + } + if existingFn != nil && candidateFn != nil { + if SameShape(existingFn, candidateFn) { + return mergeByShape(existingFn, candidateFn) + } + } + + if subtype.IsSubtype(existing, candidate) { + return candidate + } + if subtype.IsSubtype(candidate, existing) { + return existing + } + return typ.JoinPreferNonSoft(existing, candidate) +} + +// WidenForConvergence merges one function fact at a recursive fixpoint +// boundary. +func WidenForConvergence(prev, next api.FunctionFact) api.FunctionFact { + out := api.FunctionFact{ + Params: paramevidence.JoinVectors(prev.Params, next.Params), + Summary: returnsummary.WidenForConvergence(prev.Summary, next.Summary), + Narrow: returnsummary.WidenForConvergence(prev.Narrow, next.Narrow), + Type: WidenTypeForConvergence(prev.Type, next.Type), + Refinement: MergeRefinement(prev.Refinement, next.Refinement), + } + out.Params = preserveDynamicParamsProvenByRefinement(prev.Params, next.Params, out.Params, out.Refinement) + + summaryBeforeNarrow := out.Summary + // Narrow summaries can refine optional/non-nil returns, but a nil-only + // narrow observation must not erase an already-informative summary. + if len(out.Narrow) > 0 && !returnsummary.AllNil(out.Narrow) { + if len(out.Summary) == 0 { + out.Summary = returnsummary.Canonical(out.Narrow) + } else { + out.Summary = returnsummary.WidenForConvergence(out.Summary, out.Narrow) + } + } + + if fn := unwrap.Function(out.Type); fn != nil { + alignedReturns := out.Summary + usingNarrow := len(out.Narrow) > 0 && !returnsummary.AllNil(out.Narrow) + if usingNarrow { + repairBase := summaryBeforeNarrow + if len(repairBase) == 0 { + repairBase = out.Summary + } + alignedReturns = repairSummaryWithNarrow(repairBase, out.Narrow) + } + if len(alignedReturns) > 0 { + if usingNarrow { + if aligned := typjoin.WithReturns(fn, alignedReturns); aligned != nil { + out.Type = value.WidenForConvergence(aligned) + } + } else { + if aligned, changed := returnsummary.AlignFunction(fn, alignedReturns); changed { + out.Type = WidenTypeForConvergence(fn, aligned) + } + } + } else if len(fn.Returns) > 0 { + out.Summary = returnsummary.WidenForConvergence(nil, fn.Returns) + } + } + + return out +} + +// NormalizeRefinement canonicalizes empty function refinements away. +func NormalizeRefinement(refinement *constraint.FunctionRefinement) *constraint.FunctionRefinement { + if refinement == nil || refinement.IsEmpty() { + return nil + } + return refinement +} + +// MergeRefinement returns the least imprecise sound fact that covers both +// refinement observations. +func MergeRefinement(existing, candidate *constraint.FunctionRefinement) *constraint.FunctionRefinement { + existing = NormalizeRefinement(existing) + candidate = NormalizeRefinement(candidate) + switch { + case existing == nil: + return candidate + case candidate == nil: + return existing + case existing.Equals(candidate): + return existing + } + + merged := &constraint.FunctionRefinement{ + Row: mergeEffectRows(existing.Row, candidate.Row), + OnReturn: mergeGuaranteeCondition(existing.OnReturn, candidate.OnReturn), + OnTrue: mergeGuaranteeCondition(existing.OnTrue, candidate.OnTrue), + OnFalse: mergeGuaranteeCondition(existing.OnFalse, candidate.OnFalse), + Terminates: existing.Terminates && candidate.Terminates, + } + return NormalizeRefinement(merged) +} + +func mergeGuaranteeCondition(existing, candidate constraint.Condition) constraint.Condition { + if existing.Equals(candidate) { + return existing + } + if !existing.HasConstraints() || !candidate.HasConstraints() { + return constraint.Condition{} + } + return constraint.Or(existing, candidate) +} + +func mergeEffectRows(existing, candidate typ.EffectInfo) typ.EffectInfo { + switch { + case existing == nil: + return candidate + case candidate == nil: + return existing + case effectInfoEqual(existing, candidate): + return existing + } + left, leftOK := existing.(effect.Row) + right, rightOK := candidate.(effect.Row) + if leftOK && rightOK { + row := effect.Union(left, right) + if row.Pure() && !row.IsOpen() { + return nil + } + return row + } + return effect.Unknown +} + +func effectInfoEqual(a, b typ.EffectInfo) bool { + if a == nil || b == nil { + return a == b + } + return a.Equals(b) +} + +func repairSummaryWithNarrow(summary, narrow []typ.Type) []typ.Type { + if len(narrow) == 0 { + return summary + } + if len(summary) != len(narrow) || len(summary) == 0 { + return narrow + } + out := make([]typ.Type, len(summary)) + for i := range summary { + out[i] = repairTypeWithNarrow(summary[i], narrow[i], 0) + } + return out +} + +func repairTypeWithNarrow(summary, narrow typ.Type, depth int) typ.Type { + if summary == nil || narrow == nil || depth > typ.DefaultRecursionDepth { + return narrow + } + if typ.IsAny(summary) && !typ.IsAny(narrow) { + return narrow + } + summary = unwrap.Alias(summary) + narrow = unwrap.Alias(narrow) + switch s := summary.(type) { + case *typ.Union: + n, ok := narrow.(*typ.Union) + if !ok { + members := make([]typ.Type, len(s.Members)) + for i, member := range s.Members { + members[i] = repairTypeWithNarrow(member, narrow, depth+1) + } + return typ.NewUnion(members...) + } + if len(s.Members) != len(n.Members) { + return summary + } + members := make([]typ.Type, len(s.Members)) + for i, member := range s.Members { + members[i] = repairTypeWithNarrow(member, bestNarrowUnionMember(member, n.Members), depth+1) + } + return typ.NewUnion(members...) + case *typ.Record: + n, ok := narrow.(*typ.Record) + if !ok { + return narrow + } + builder := typ.NewRecord().SetOpen(s.Open) + if s.HasMapComponent() { + mapValue := s.MapValue + if n.HasMapComponent() { + mapValue = repairTypeWithNarrow(s.MapValue, n.MapValue, depth+1) + } + builder.MapComponent(s.MapKey, mapValue) + } + if s.Metatable != nil { + builder.Metatable(s.Metatable) + } + for _, field := range s.Fields { + fieldType := field.Type + if nf := n.GetField(field.Name); nf != nil { + fieldType = repairTypeWithNarrow(field.Type, nf.Type, depth+1) + } + switch { + case field.Optional && field.Readonly: + builder.OptReadonlyField(field.Name, fieldType) + case field.Optional: + builder.OptField(field.Name, fieldType) + case field.Readonly: + builder.ReadonlyField(field.Name, fieldType) + default: + builder.Field(field.Name, fieldType) + } + } + return builder.Build() + default: + return narrow + } +} + +func bestNarrowUnionMember(summary typ.Type, members []typ.Type) typ.Type { + for _, member := range members { + if subtype.IsSubtype(member, summary) || subtype.IsSubtype(summary, member) { + return member + } + } + if len(members) > 0 { + return members[0] + } + return summary +} + +// WidenTypeForConvergence merges function-type facts at a recursive fixpoint +// boundary. +func WidenTypeForConvergence(existing, candidate typ.Type) typ.Type { + existing = value.NormalizeFactType(existing) + candidate = value.NormalizeFactType(candidate) + if existing == nil { + return value.WidenForConvergence(candidate) + } + if candidate == nil { + return value.WidenForConvergence(existing) + } + existingFn := unwrap.Function(existing) + candidateFn := unwrap.Function(candidate) + if existingFn != nil && candidateFn != nil && SameShape(existingFn, candidateFn) { + return value.WidenForConvergence(widenByShapeForConvergence(existingFn, candidateFn)) + } + return value.MergeForConvergence(existing, candidate) +} + +type variants struct { + funcs []*typ.Function + residuals []typ.Type +} + +func mergeVariants(existing, candidate typ.Type) (typ.Type, bool) { + existingVariants := splitVariants(existing) + candidateVariants := splitVariants(candidate) + if len(existingVariants.funcs) == 0 || len(candidateVariants.funcs) == 0 { + return nil, false + } + + all := make([]*typ.Function, 0, len(existingVariants.funcs)+len(candidateVariants.funcs)) + all = append(all, existingVariants.funcs...) + all = append(all, candidateVariants.funcs...) + for i := 1; i < len(all); i++ { + if !SameShape(all[0], all[i]) { + return nil, false + } + } + + merged := all[0] + for i := 1; i < len(all); i++ { + next, _ := mergeByShape(merged, all[i]).(*typ.Function) + if next == nil { + return nil, false + } + merged = next + } + + residuals := make([]typ.Type, 0, len(existingVariants.residuals)+len(candidateVariants.residuals)+1) + residuals = append(residuals, existingVariants.residuals...) + residuals = append(residuals, candidateVariants.residuals...) + if len(residuals) == 0 { + return merged, true + } + residuals = append(residuals, merged) + return typ.NewUnion(residuals...), true +} + +func splitVariants(t typ.Type) variants { + var out variants + collectVariants(t, &out) + return out +} + +func collectVariants(t typ.Type, out *variants) { + if t == nil || out == nil { + return + } + switch v := unwrap.Alias(t).(type) { + case *typ.Union: + for _, member := range v.Members { + collectVariants(member, out) + } + return + } + if fn := unwrap.Function(t); fn != nil { + out.funcs = append(out.funcs, fn) + return + } + out.residuals = append(out.residuals, t) +} + +// SameShape reports whether two function fact types can be merged slot-wise. +func SameShape(a, b *typ.Function) bool { + if a == nil || b == nil { + return false + } + if len(a.TypeParams) != len(b.TypeParams) { + return false + } + if !typeParamsEqual(a.TypeParams, b.TypeParams) { + return false + } + return len(a.Params) == len(b.Params) +} + +func mergeByShape(existing, candidate *typ.Function) typ.Type { + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + + builder := typ.Func() + for _, tp := range existing.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + + for i, p := range existing.Params { + paramType := mergeParamType(p.Type, candidate.Params[i].Type) + name := p.Name + if name == "" { + name = candidate.Params[i].Name + } + optional := p.Optional || candidate.Params[i].Optional + if optional { + builder = builder.OptParam(name, paramType) + } else { + builder = builder.Param(name, paramType) + } + } + + if existing.Variadic != nil || candidate.Variadic != nil { + builder = builder.Variadic(mergeParamType(existing.Variadic, candidate.Variadic)) + } + + if mergedReturns := returnsummary.Merge(existing.Returns, candidate.Returns); len(mergedReturns) > 0 { + builder = builder.Returns(mergedReturns...) + } + + effects := existing.Effects + if effects == nil { + effects = candidate.Effects + } + if effects != nil { + builder = builder.Effects(effects) + } + spec := existing.Spec + if spec == nil { + spec = candidate.Spec + } + if spec != nil { + builder = builder.Spec(spec) + } + refinement := existing.Refinement + if refinement == nil { + refinement = candidate.Refinement + } + if refinement != nil { + builder = builder.WithRefinement(refinement) + } + + return builder.Build() +} + +func widenByShapeForConvergence(existing, candidate *typ.Function) typ.Type { + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + + builder := typ.Func() + for _, tp := range existing.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for i, p := range existing.Params { + paramType := widenParamTypeForConvergence(p.Type, candidate.Params[i].Type) + name := p.Name + if name == "" { + name = candidate.Params[i].Name + } + if p.Optional || candidate.Params[i].Optional { + builder = builder.OptParam(name, paramType) + } else { + builder = builder.Param(name, paramType) + } + } + if existing.Variadic != nil || candidate.Variadic != nil { + builder = builder.Variadic(widenParamTypeForConvergence(existing.Variadic, candidate.Variadic)) + } + if returns := returnsummary.WidenForConvergence(existing.Returns, candidate.Returns); len(returns) > 0 { + builder = builder.Returns(returns...) + } + + effects := existing.Effects + if effects == nil { + effects = candidate.Effects + } + if effects != nil { + builder = builder.Effects(effects) + } + spec := existing.Spec + if spec == nil { + spec = candidate.Spec + } + if spec != nil { + builder = builder.Spec(spec) + } + refinement := existing.Refinement + if refinement == nil { + refinement = candidate.Refinement + } + if refinement != nil { + builder = builder.WithRefinement(refinement) + } + return builder.Build() +} + +// MergeParamType merges one function parameter type using the same evidence +// policy as canonical function-fact merging. +func MergeParamType(existing, candidate typ.Type) typ.Type { + return mergeParamType(existing, candidate) +} + +func mergeParamType(existing, candidate typ.Type) typ.Type { + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + + existing = typ.PruneSoftUnionMembers(existing) + candidate = typ.PruneSoftUnionMembers(candidate) + if unwrap.IsNilType(existing) && !unwrap.IsNilType(candidate) { + return candidate + } + if unwrap.IsNilType(candidate) && !unwrap.IsNilType(existing) { + return existing + } + if preferred, ok := preferStructuredRecord(existing, candidate); ok { + return preferred + } + if preferred, ok := value.PreferConcreteOverSoft(existing, candidate); ok { + return preferred + } + if typ.IsUnknown(existing) { + return candidate + } + if typ.IsUnknown(candidate) { + return existing + } + if typ.IsAny(existing) && typ.IsAny(candidate) { + return typ.Any + } + if typ.IsAny(existing) { + return candidate + } + if typ.IsAny(candidate) { + return existing + } + if value.FactTypeEqual(existing, candidate) { + return existing + } + if unwrap.Function(existing) != nil || unwrap.Function(candidate) != nil { + return MergeType(existing, candidate) + } + if typ.TypeEquals(existing, candidate) { + return existing + } + if paramevidence.RefinesFunctionParam(candidate, existing) { + return candidate + } + if paramevidence.RefinesFunctionParam(existing, candidate) { + return existing + } + if subtype.IsSubtype(existing, candidate) && !subtype.IsSubtype(candidate, existing) { + return candidate + } + if subtype.IsSubtype(candidate, existing) && !subtype.IsSubtype(existing, candidate) { + return existing + } + return typ.JoinPreferNonSoft(existing, candidate) +} + +func widenParamTypeForConvergence(existing, candidate typ.Type) typ.Type { + existing = value.NormalizeFactType(existing) + candidate = value.NormalizeFactType(candidate) + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + if value.FactTypeEqual(existing, candidate) { + return existing + } + if unwrap.Function(existing) != nil || unwrap.Function(candidate) != nil { + return WidenTypeForConvergence(existing, candidate) + } + if typ.TypeEquals(existing, candidate) { + return existing + } + if typ.IsAny(existing) || typ.IsUnknown(existing) { + return existing + } + if typ.IsAny(candidate) || typ.IsUnknown(candidate) { + return candidate + } + if preferred, ok := value.PreferConcreteOverSoft(existing, candidate); ok { + return preferred + } + if paramevidence.RefinesFunctionParam(candidate, existing) { + return candidate + } + if paramevidence.RefinesFunctionParam(existing, candidate) { + return existing + } + if subtype.IsSubtype(candidate, existing) && !subtype.IsSubtype(existing, candidate) { + return existing + } + if subtype.IsSubtype(existing, candidate) && !subtype.IsSubtype(candidate, existing) { + return candidate + } + return typ.JoinPreferNonSoft(existing, candidate) +} + +func preferStructuredRecord(existing, candidate typ.Type) (typ.Type, bool) { + existingRec, okExisting := unwrap.Alias(existing).(*typ.Record) + candidateRec, okCandidate := unwrap.Alias(candidate).(*typ.Record) + if !okExisting || !okCandidate { + return nil, false + } + + existingOpenTop := existingRec.Open && len(existingRec.Fields) == 0 && !existingRec.HasMapComponent() + candidateOpenTop := candidateRec.Open && len(candidateRec.Fields) == 0 && !candidateRec.HasMapComponent() + if existingOpenTop == candidateOpenTop { + return nil, false + } + if existingOpenTop { + if candidateRec.HasMapComponent() || len(candidateRec.Fields) > 0 { + return candidate, true + } + } + if candidateOpenTop { + if existingRec.HasMapComponent() || len(existingRec.Fields) > 0 { + return existing, true + } + } + return nil, false +} + +// MergeReturnsForSameSignature merges return slots for function signatures that +// already have identical call shapes. +func MergeReturnsForSameSignature(prevFn, nextFn *typ.Function) (typ.Type, bool) { + if prevFn == nil || nextFn == nil { + return nil, false + } + if len(prevFn.TypeParams) != len(nextFn.TypeParams) { + return nil, false + } + if !typeParamsEqual(prevFn.TypeParams, nextFn.TypeParams) { + return nil, false + } + if len(prevFn.Params) != len(nextFn.Params) { + return nil, false + } + if (prevFn.Variadic == nil) != (nextFn.Variadic == nil) { + return nil, false + } + if prevFn.Variadic != nil && !typ.TypeEquals(prevFn.Variadic, nextFn.Variadic) { + return nil, false + } + for i := range prevFn.Params { + if prevFn.Params[i].Optional != nextFn.Params[i].Optional { + return nil, false + } + if !typ.TypeEquals(prevFn.Params[i].Type, nextFn.Params[i].Type) { + return nil, false + } + } + if len(prevFn.Returns) == 0 && len(nextFn.Returns) == 0 { + return prevFn, true + } + if len(prevFn.Returns) != len(nextFn.Returns) || len(prevFn.Returns) == 0 { + return nil, false + } + + allowedTypeParams := make(map[string]bool, len(prevFn.TypeParams)) + for _, tp := range prevFn.TypeParams { + if tp != nil && tp.Name != "" { + allowedTypeParams[tp.Name] = true + } + } + normalizeReturn := func(t typ.Type) (typ.Type, bool) { + if t == nil { + return nil, false + } + leaked := false + return typ.Rewrite(t, func(node typ.Type) (typ.Type, bool) { + tp, ok := node.(*typ.TypeParam) + if !ok { + return node, false + } + if allowedTypeParams[tp.Name] { + return node, false + } + // Free type params in non-generic function returns are unstable placeholders. + leaked = true + return typ.Unknown, true + }), leaked + } + normalizedPrev := make([]typ.Type, len(prevFn.Returns)) + normalizedNext := make([]typ.Type, len(nextFn.Returns)) + leakedPrev := make([]bool, len(prevFn.Returns)) + leakedNext := make([]bool, len(nextFn.Returns)) + for i := range prevFn.Returns { + normalizedPrev[i], leakedPrev[i] = normalizeReturn(prevFn.Returns[i]) + normalizedNext[i], leakedNext[i] = normalizeReturn(nextFn.Returns[i]) + } + + mergedReturns := make([]typ.Type, len(normalizedPrev)) + for i := range mergedReturns { + switch { + case leakedPrev[i] && !leakedNext[i]: + mergedReturns[i] = normalizedNext[i] + case leakedNext[i] && !leakedPrev[i]: + mergedReturns[i] = normalizedPrev[i] + default: + mergedReturns[i] = typ.JoinReturnSlot(normalizedPrev[i], normalizedNext[i]) + } + } + if returnsummary.Equal(prevFn.Returns, mergedReturns) { + return prevFn, true + } + if returnsummary.Equal(nextFn.Returns, mergedReturns) { + return nextFn, true + } + + effects := prevFn.Effects + if effects == nil { + effects = nextFn.Effects + } + spec := prevFn.Spec + if spec == nil { + spec = nextFn.Spec + } + refinement := prevFn.Refinement + if refinement == nil { + refinement = nextFn.Refinement + } + + builder := typ.Func(). + Effects(effects). + Spec(spec). + WithRefinement(refinement) + for _, tp := range prevFn.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for _, p := range prevFn.Params { + if p.Optional { + builder = builder.OptParam(p.Name, p.Type) + } else { + builder = builder.Param(p.Name, p.Type) + } + } + if prevFn.Variadic != nil { + builder = builder.Variadic(prevFn.Variadic) + } + builder = builder.Returns(mergedReturns...) + return builder.Build(), true +} + +func typeParamsEqual(a, b []*typ.TypeParam) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] == nil || b[i] == nil { + if a[i] != b[i] { + return false + } + continue + } + if !a[i].Equals(b[i]) { + return false + } + } + return true +} diff --git a/compiler/check/domain/functionfact/fact_test.go b/compiler/check/domain/functionfact/fact_test.go new file mode 100644 index 00000000..251dccb4 --- /dev/null +++ b/compiler/check/domain/functionfact/fact_test.go @@ -0,0 +1,617 @@ +package functionfact + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/narrow" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +func TestJoin_InitialObservation(t *testing.T) { + fn := typ.Func().Returns(typ.String).Build() + + got := Join(api.FunctionFact{}, api.FunctionFact{ + Summary: []typ.Type{typ.String}, + Narrow: []typ.Type{typ.String}, + Type: fn, + }) + + if !returnsummary.Equal(got.Summary, []typ.Type{typ.String}) { + t.Fatalf("summary mismatch: got %v", got.Summary) + } + if !returnsummary.Equal(got.Narrow, []typ.Type{typ.String}) { + t.Fatalf("narrow mismatch: got %v", got.Narrow) + } + if !typ.TypeEquals(got.Type, fn) { + t.Fatalf("func mismatch: got %v", got.Type) + } +} + +func TestJoin_RefinementProvenParamDoesNotBecomePrecondition(t *testing.T) { + refinement := constraint.NewRefinement([]constraint.Constraint{ + constraint.HasType{Path: constraint.ParamPath(1), Type: narrow.BuiltinTypeKey("string")}, + }, nil, nil) + out := Join( + api.FunctionFact{Params: []typ.Type{typ.String, typ.Any}}, + api.FunctionFact{ + Params: []typ.Type{typ.String, typ.String}, + Refinement: refinement, + Type: typ.Func(). + Param("label", typ.String). + Param("msg", typ.String). + Returns(typ.Unknown). + Build(), + }, + ) + + if len(out.Params) != 2 || !typ.TypeEquals(out.Params[1], typ.Any) { + t.Fatalf("expected dynamic call evidence to survive refinement-proven body use, got %v", out.Params) + } +} + +func TestJoin_UnprovenDynamicParamUseRemainsPrecondition(t *testing.T) { + out := Join( + api.FunctionFact{Params: []typ.Type{typ.Any}}, + api.FunctionFact{ + Params: []typ.Type{typ.String}, + Type: typ.Func().Param("value", typ.String).Returns(typ.Unknown).Build(), + }, + ) + + if len(out.Params) != 1 || !typ.TypeEquals(out.Params[0], typ.String) { + t.Fatalf("expected unproven string demand to remain a precondition, got %v", out.Params) + } +} + +func TestJoin_NarrowSummaryReplacesOpenTopPlaceholder(t *testing.T) { + openTop := typ.NewRecord().SetOpen(true).Build() + existingFunc := typ.Func().Returns(openTop).Build() + candidateFunc := typ.Func().Returns(openTop).Build() + narrow := []typ.Type{typ.NewArray(typ.Unknown)} + + out := Join( + api.FunctionFact{Summary: []typ.Type{openTop}, Type: existingFunc}, + api.FunctionFact{Summary: []typ.Type{openTop}, Narrow: narrow, Type: candidateFunc}, + ) + + if !returnsummary.Equal(returnsummary.NormalizeAndPrune(out.Summary), returnsummary.NormalizeAndPrune(narrow)) { + t.Fatalf("summary mismatch: got %v want %v", out.Summary, narrow) + } + fn, ok := out.Type.(*typ.Function) + if !ok { + t.Fatalf("expected function fact, got %T", out.Type) + } + if !returnsummary.Equal(returnsummary.NormalizeAndPrune(fn.Returns), returnsummary.NormalizeAndPrune(narrow)) { + t.Fatalf("func returns mismatch: got %v want %v", fn.Returns, narrow) + } +} + +func TestJoin_NarrowSummaryRepairsNeverArtifact(t *testing.T) { + bad := []typ.Type{ + typ.NewUnion( + typ.NewRecord(). + Field("success", typ.True). + Field("result", typ.NewRecord().OptField("data", typ.Never).Build()). + Build(), + typ.NewRecord(). + Field("success", typ.False). + Field("error", typ.LiteralString("missing")). + Build(), + ), + } + good := []typ.Type{ + typ.NewUnion( + typ.NewRecord(). + Field("success", typ.True). + Field("result", typ.NewRecord().OptField("data", typ.Unknown).Build()). + Build(), + typ.NewRecord(). + Field("success", typ.False). + Field("error", typ.LiteralString("missing")). + Build(), + ), + } + + out := Join( + api.FunctionFact{Summary: bad, Type: typ.Func().Returns(bad...).Build()}, + api.FunctionFact{Narrow: good}, + ) + + if !returnsummary.Equal(out.Summary, good) { + t.Fatalf("summary mismatch: got %v want %v", out.Summary, good) + } + if !returnsummary.Equal(out.Narrow, good) { + t.Fatalf("narrow mismatch: got %v want %v", out.Narrow, good) + } + fn, ok := out.Type.(*typ.Function) + if !ok { + t.Fatalf("expected function fact, got %T", out.Type) + } + if !returnsummary.Equal(fn.Returns, good) { + t.Fatalf("func returns mismatch: got %v want %v", fn.Returns, good) + } +} + +func TestJoin_MergesExistingAndCandidate(t *testing.T) { + existingFn := typ.Func().Returns(typ.Number).Build() + candidateFn := typ.Func().Returns(typ.String).Build() + existing := api.FunctionFact{ + Summary: []typ.Type{typ.Number}, + Narrow: []typ.Type{typ.Number}, + Type: existingFn, + } + candidate := api.FunctionFact{ + Summary: []typ.Type{typ.String}, + Narrow: []typ.Type{typ.String}, + Type: candidateFn, + } + got := Join(existing, candidate) + + if !returnsummary.Equal(got.Summary, []typ.Type{typ.NewUnion(typ.Number, typ.String)}) { + t.Fatalf("summary mismatch: got %v", got.Summary) + } + if !returnsummary.Equal(got.Narrow, []typ.Type{typ.NewUnion(typ.Number, typ.String)}) { + t.Fatalf("narrow mismatch: got %v", got.Narrow) + } + if got.Type == nil { + t.Fatal("expected merged function type") + } +} + +func TestJoin_DoesNotAlignFunctionToNarrowFieldRegression(t *testing.T) { + withCapturedMethod := typ.NewRecord(). + Field("x", typ.Integer). + Field("get_x", typ.Func().Param("self", typ.Unknown).Returns(typ.Number).Build()). + Build() + flowOnly := typ.NewRecord(). + Field("x", typ.Integer). + Build() + existingFunc := typ.Func().Returns(flowOnly).Build() + + out := Join( + api.FunctionFact{Summary: []typ.Type{withCapturedMethod}, Narrow: []typ.Type{flowOnly}, Type: existingFunc}, + api.FunctionFact{Summary: []typ.Type{withCapturedMethod}, Narrow: []typ.Type{flowOnly}, Type: existingFunc}, + ) + + if !returnsummary.Equal(out.Summary, []typ.Type{withCapturedMethod}) { + t.Fatalf("summary mismatch: got %v want %v", out.Summary, []typ.Type{withCapturedMethod}) + } + fn, ok := out.Type.(*typ.Function) + if !ok { + t.Fatalf("expected function fact, got %T", out.Type) + } + if !returnsummary.Equal(fn.Returns, []typ.Type{withCapturedMethod}) { + t.Fatalf("func returns should preserve captured method summary, got %v", fn.Returns) + } +} + +func TestMergeType_MergesSameShapeReturnsCanonically(t *testing.T) { + existing := typ.Func(). + Param("x", typ.String). + Returns(typ.NewOptional(typ.Integer)). + Build() + candidate := typ.Func(). + Param("x", typ.String). + Returns(typ.Integer). + Build() + + merged := MergeType(existing, candidate) + fn, ok := merged.(*typ.Function) + if !ok || len(fn.Returns) != 1 { + t.Fatalf("expected merged function, got %T", merged) + } + if !typ.TypeEquals(fn.Returns[0], typ.Integer) { + t.Fatalf("expected refined return integer, got %v", fn.Returns[0]) + } +} + +func TestMergeType_WidensParamToCoverObservedCallsites(t *testing.T) { + existing := typ.Func(). + Param("t", typ.NewArray(typ.Any)). + Returns(typ.String). + Build() + candidate := typ.Func(). + Param("t", typ.NewMap(typ.String, typ.Any)). + Returns(typ.String). + Build() + + merged := MergeType(existing, candidate) + fn, ok := merged.(*typ.Function) + if !ok { + t.Fatalf("expected merged function, got %T", merged) + } + if len(fn.Params) != 1 { + t.Fatalf("expected one param, got %+v", fn.Params) + } + if typ.TypeEquals(fn.Params[0].Type, typ.NewArray(typ.Any)) { + t.Fatalf("expected param widening beyond array-only shape, got %v", fn.Params[0].Type) + } + wantMap := typ.NewMap(typ.String, typ.Any) + if !subtype.IsSubtype(wantMap, fn.Params[0].Type) { + t.Fatalf("expected merged param to admit map callsite evidence, got %v", fn.Params[0].Type) + } +} + +func TestMergeType_PrefersConcreteParamOverTopObservation(t *testing.T) { + existing := typ.Func(). + Param("x", typ.Any). + Returns(typ.String). + Build() + candidate := typ.Func(). + Param("x", typ.String). + Returns(typ.String). + Build() + + merged := MergeType(existing, candidate) + fn, ok := merged.(*typ.Function) + if !ok { + t.Fatalf("expected merged function, got %T", merged) + } + if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, typ.String) { + t.Fatalf("expected param refined to string, got %+v", fn.Params) + } +} + +func TestMergeType_KeepsBaselineOverNestedNilOnlyRegression(t *testing.T) { + baselineReturn := typ.NewRecord(). + Field("full_path", typ.String). + Field("parent", typ.Unknown). + OptField("after_all", typ.Nil). + SetOpen(true). + Build() + candidateReturn := typ.NewRecord(). + Field("full_path", typ.String). + Field("parent", typ.Nil). + Field("after_all", typ.Nil). + SetOpen(true). + Build() + + baseline := typ.Func().Param("name", typ.Unknown).Returns(baselineReturn).Build() + candidate := typ.Func().Param("name", typ.Unknown).Returns(candidateReturn).Build() + + merged := MergeType(baseline, candidate) + fn, ok := merged.(*typ.Function) + if !ok || len(fn.Returns) != 1 { + t.Fatalf("expected merged function return, got %v", merged) + } + if !typ.TypeEquals(fn.Returns[0], baselineReturn) { + t.Fatalf("expected baseline record to survive nil-only refinement, got %v", fn.Returns[0]) + } +} + +func TestMergeType_CollapsesMixedFunctionUnionVariants(t *testing.T) { + base := typ.Func(). + Param("name", typ.Unknown). + Returns(typ.NewRecord().Field("full_path", typ.String).SetOpen(true).Build()). + Build() + withChildren := typ.Func(). + Param("name", typ.Unknown). + Returns(typ.NewRecord(). + Field("full_path", typ.String). + Field("children", typ.NewArray(typ.Unknown)). + SetOpen(true). + Build()). + Build() + withTests := typ.Func(). + Param("name", typ.Unknown). + Returns(typ.NewRecord(). + Field("full_path", typ.String). + Field("tests", typ.NewArray(typ.Unknown)). + SetOpen(true). + Build()). + Build() + + merged := MergeType(typ.NewUnion(typ.Nil, base, withChildren), withTests) + if merged == nil { + t.Fatal("expected merged type") + } + fn := unwrap.Function(merged) + if fn == nil || len(fn.Returns) != 1 { + t.Fatalf("expected merged function variant, got %v", merged) + } + rec, ok := fn.Returns[0].(*typ.Record) + if !ok { + t.Fatalf("expected record return, got %T", fn.Returns[0]) + } + for _, field := range []string{"full_path", "children", "tests"} { + if rec.GetField(field) == nil { + t.Fatalf("expected merged field %q in %v", field, rec) + } + } + if merged.Kind() != kind.Optional { + t.Fatalf("expected nil residual to be preserved as optional, got %v", merged) + } +} + +func TestMergeType_DoesNotDropNonFunctionUnionMembers(t *testing.T) { + fn := typ.Func().Param("x", typ.String).Returns(typ.String).Build() + existing := typ.NewUnion(fn, typ.Number) + candidate := typ.Func().Param("x", typ.String).Returns(typ.String).Build() + + merged := MergeType(existing, candidate) + u, ok := merged.(*typ.Union) + if !ok { + t.Fatalf("expected union to be preserved, got %T", merged) + } + hasNumber := false + for _, m := range u.Members { + if typ.TypeEquals(m, typ.Number) { + hasNumber = true + break + } + } + if !hasNumber { + t.Fatalf("expected merged union to retain non-function member, got %v", merged) + } +} + +func TestMergeType_CollapsesCompatibleFunctionVariants(t *testing.T) { + base := typ.Func(). + OptParam("entries", typ.Any). + Returns(typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown))). + Build() + refinedEntry := typ.NewRecord().Field("id", typ.String).Build() + refined := typ.Func(). + OptParam("entries", typ.NewArray(refinedEntry)). + Returns(typ.NewMap(typ.String, typ.NewArray(refinedEntry))). + Build() + + merged := MergeType(base, refined) + fn, ok := merged.(*typ.Function) + if !ok { + t.Fatalf("expected function after compatible-variant collapse, got %T", merged) + } + if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, typ.NewArray(refinedEntry)) { + t.Fatalf("expected refined param type to win, got %+v", fn.Params) + } + if len(fn.Returns) != 1 || !typ.TypeEquals(fn.Returns[0], typ.NewMap(typ.String, typ.NewArray(refinedEntry))) { + t.Fatalf("expected refined return map, got %v", fn.Returns) + } +} + +func TestMergeType_DoesNotCollapseParamToNilWhenOptionalInfoExists(t *testing.T) { + existing := typ.Func(). + OptParam("tests", typ.Nil). + Returns(typ.Integer). + Build() + candidate := typ.Func(). + OptParam("tests", typ.NewOptional(typ.NewArray(typ.Any))). + Returns(typ.Integer). + Build() + + merged := MergeType(existing, candidate) + fn, ok := merged.(*typ.Function) + if !ok { + t.Fatalf("expected function, got %T", merged) + } + want := typ.NewOptional(typ.NewArray(typ.Any)) + if len(fn.Params) != 1 || !fn.Params[0].Optional || !typ.TypeEquals(fn.Params[0].Type, want) { + t.Fatalf("expected optional param slot with type %v, got %+v", want, fn.Params) + } +} + +func TestMergeType_NilDoesNotDominateSoftOptionalParamShape(t *testing.T) { + softArray := typ.NewOptional(typ.NewUnion(typ.NewArray(typ.Any), typ.NewRecord().SetOpen(true).Build())) + preciseArray := typ.NewOptional(typ.NewArray(typ.String)) + + merged := MergeType( + typ.Func().OptParam("tests", typ.Nil).Returns(typ.Integer).Build(), + typ.Func().OptParam("tests", softArray).Returns(typ.Integer).Build(), + ) + fn, ok := merged.(*typ.Function) + if !ok || len(fn.Params) != 1 { + t.Fatalf("expected merged function, got %T", merged) + } + if !typ.TypeEquals(fn.Params[0].Type, softArray) { + t.Fatalf("expected nil observation not to replace soft optional table shape, got %v", fn.Params[0].Type) + } + + merged = MergeType( + typ.Func().OptParam("tests", softArray).Returns(typ.Integer).Build(), + typ.Func().OptParam("tests", preciseArray).Returns(typ.Integer).Build(), + ) + fn, ok = merged.(*typ.Function) + if !ok || len(fn.Params) != 1 { + t.Fatalf("expected merged function, got %T", merged) + } + if !typ.TypeEquals(fn.Params[0].Type, preciseArray) { + t.Fatalf("expected precise optional array evidence to replace soft shape, got %v", fn.Params[0].Type) + } +} + +func TestMergeType_ReplacesStaleFalsyMapKeyWithTruthyRefinement(t *testing.T) { + entry := typ.NewRecord().Field("id", typ.String).Build() + stale := typ.NewRecord(). + MapComponent(typ.NewUnion(typ.Boolean, typ.String), typ.NewArray(entry)). + SetOpen(true). + Build() + current := typ.NewRecord(). + MapComponent(typ.String, typ.NewArray(entry)). + SetOpen(true). + Build() + + merged := MergeType( + typ.Func().OptParam("t", stale).Returns(typ.NewArray(typ.NewUnion(typ.Boolean, typ.String))).Build(), + typ.Func().OptParam("t", current).Returns(typ.NewArray(typ.String)).Build(), + ) + fn, ok := merged.(*typ.Function) + if !ok || len(fn.Params) != 1 { + t.Fatalf("expected merged function, got %T", merged) + } + if !typ.TypeEquals(fn.Params[0].Type, current) { + t.Fatalf("expected truthy-refined map key param %v, got %v", current, fn.Params[0].Type) + } +} + +func TestMergeType_DoesNotRegressToNarrowerNilReturn(t *testing.T) { + prev := typ.Func(). + Returns(typ.NewOptional(typ.Integer)). + Build() + next := typ.Func(). + Returns(typ.Nil). + Build() + + merged := MergeType(prev, next) + fn, ok := merged.(*typ.Function) + if !ok || len(fn.Returns) != 1 { + t.Fatalf("expected merged function return, got %T", merged) + } + if !typ.TypeEquals(fn.Returns[0], typ.NewOptional(typ.Integer)) { + t.Fatalf("expected integer? return after merge, got %v", fn.Returns[0]) + } +} + +func TestMergeType_PrefersWiderSupertypeOnSubtypeRelation(t *testing.T) { + merged := MergeType(typ.Integer, typ.Number) + if !typ.TypeEquals(merged, typ.Number) { + t.Fatalf("expected wider supertype number, got %v", merged) + } + + merged = MergeType(typ.Number, typ.Integer) + if !typ.TypeEquals(merged, typ.Number) { + t.Fatalf("expected wider supertype number, got %v", merged) + } +} + +func TestMergeType_IsCommutativeForIncomparableSignatures(t *testing.T) { + coarse := typ.Func(). + Param("entries", typ.Any). + Returns(typ.Integer). + Build() + refined := typ.Func(). + Param("entries", typ.NewArray(typ.String)). + Returns(typ.Integer). + Build() + + forward := MergeType(coarse, refined) + reverse := MergeType(refined, coarse) + if !typ.TypeEquals(forward, reverse) { + t.Fatalf("expected commutative merge result, got forward=%v reverse=%v", forward, reverse) + } +} + +func TestMergeType_AliasInputsUseCanonicalJoin(t *testing.T) { + coarse := typ.NewAlias("CoarseFn", typ.Func(). + Param("entries", typ.Any). + Returns(typ.Integer). + Build()) + refined := typ.NewAlias("RefinedFn", typ.Func(). + Param("entries", typ.NewArray(typ.String)). + Returns(typ.Integer). + Build()) + + forward := MergeType(coarse, refined) + reverse := MergeType(refined, coarse) + if !typ.TypeEquals(forward, reverse) { + t.Fatalf("expected commutative alias merge result, got forward=%v reverse=%v", forward, reverse) + } +} + +func TestMergeType_MapVsOpenRecordUsesCanonicalJoin(t *testing.T) { + coarse := typ.Func(). + Param("t", typ.NewRecord().SetOpen(true).Build()). + Returns(typ.String). + Build() + refined := typ.Func(). + Param("t", typ.NewMap(typ.String, typ.NewArray(typ.String))). + Returns(typ.String). + Build() + + forward := MergeType(coarse, refined) + reverse := MergeType(refined, coarse) + if !typ.TypeEquals(forward, reverse) { + t.Fatalf("expected commutative map/open-record merge result, got forward=%v reverse=%v", forward, reverse) + } +} + +func TestMergeReturnsForSameSignature_GenericFunctions(t *testing.T) { + prev := typ.Func(). + TypeParam("T", nil). + Returns(typ.String). + Build() + next := typ.Func(). + TypeParam("T", nil). + Returns(typ.Integer). + Build() + + mergedType, ok := MergeReturnsForSameSignature(prev, next) + if !ok { + t.Fatal("expected generic same-shape functions to merge") + } + merged, ok := mergedType.(*typ.Function) + if !ok { + t.Fatalf("expected merged function type, got %T", mergedType) + } + if len(merged.TypeParams) != 1 || merged.TypeParams[0] == nil || merged.TypeParams[0].Name != "T" { + t.Fatalf("expected merged generic type parameter T, got %+v", merged.TypeParams) + } + if len(merged.Returns) != 1 { + t.Fatalf("expected one return, got %d", len(merged.Returns)) + } + want := typ.NewUnion(typ.String, typ.Integer) + if !typ.TypeEquals(merged.Returns[0], want) { + t.Fatalf("expected merged return %v, got %v", want, merged.Returns[0]) + } +} + +func TestMergeReturnsForSameSignature_GenericTypeParamsMustMatch(t *testing.T) { + prev := typ.Func(). + TypeParam("T", nil). + Returns(typ.String). + Build() + next := typ.Func(). + TypeParam("U", nil). + Returns(typ.Integer). + Build() + + _, ok := MergeReturnsForSameSignature(prev, next) + if ok { + t.Fatal("expected mismatched generic params not to merge") + } +} + +func TestMergeReturnsForSameSignature_NormalizesLeakedTypeParams(t *testing.T) { + prev := typ.Func(). + Returns(typ.NewTypeParam("T", nil)). + Build() + next := typ.Func(). + Returns(typ.Integer). + Build() + + mergedType, ok := MergeReturnsForSameSignature(prev, next) + if !ok { + t.Fatal("expected same-shape functions to merge") + } + merged, ok := mergedType.(*typ.Function) + if !ok || len(merged.Returns) != 1 { + t.Fatalf("expected merged function return, got %T", mergedType) + } + if !typ.TypeEquals(merged.Returns[0], typ.Integer) { + t.Fatalf("expected leaked type param to normalize to integer, got %v", merged.Returns[0]) + } +} + +func TestNormalize_CanonicalizesStoredFunctionFact(t *testing.T) { + fn := typ.Func().Returns(typ.Number).Build() + got := Normalize(api.FunctionFact{ + Summary: []typ.Type{nil}, + Narrow: []typ.Type{typ.Number}, + Type: fn, + }) + + if !returnsummary.Equal(got.Summary, []typ.Type{typ.Nil}) { + t.Fatalf("summary mismatch: got %v", got.Summary) + } + if !returnsummary.Equal(got.Narrow, []typ.Type{typ.Number}) { + t.Fatalf("narrow mismatch: got %v", got.Narrow) + } + if !typ.TypeEquals(got.Type, fn) { + t.Fatalf("func mismatch: got %v", got.Type) + } +} diff --git a/compiler/check/domain/functionfact/map.go b/compiler/check/domain/functionfact/map.go new file mode 100644 index 00000000..fa8aa885 --- /dev/null +++ b/compiler/check/domain/functionfact/map.go @@ -0,0 +1,121 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +// Parts is the per-symbol evidence used to publish canonical function facts. +type Parts struct { + Params []typ.Type + Summary []typ.Type + Narrow []typ.Type + Type typ.Type + Refinement *constraint.FunctionRefinement +} + +func fromFact(sym cfg.SymbolID, fact api.FunctionFact) api.FunctionFacts { + if sym == 0 { + return nil + } + ff := Join(api.FunctionFact{}, fact) + if Empty(ff) { + return nil + } + return api.FunctionFacts{sym: ff} +} + +// FromPart builds canonical function facts from one per-symbol evidence part. +func FromPart(sym cfg.SymbolID, part Parts) api.FunctionFacts { + return fromFact(sym, factFromPart(part)) +} + +// FromParts builds canonical function facts from per-symbol evidence. +func FromParts(parts map[cfg.SymbolID]Parts) api.FunctionFacts { + if len(parts) == 0 { + return nil + } + out := make(api.FunctionFacts, len(parts)) + for _, sym := range cfg.SortedSymbolIDs(parts) { + if sym == 0 { + continue + } + ff := Join(api.FunctionFact{}, factFromPart(parts[sym])) + if Empty(ff) { + continue + } + out[sym] = ff + } + if len(out) == 0 { + return nil + } + return out +} + +// FromMaps builds canonical function facts from parallel evidence maps. +func FromMaps( + params map[cfg.SymbolID][]typ.Type, + summaries map[cfg.SymbolID][]typ.Type, + types map[cfg.SymbolID]typ.Type, +) api.FunctionFacts { + total := len(params) + len(summaries) + len(types) + if total == 0 { + return nil + } + parts := make(map[cfg.SymbolID]Parts, total) + addParts(params, parts, func(part *Parts, v []typ.Type) { part.Params = v }) + addParts(summaries, parts, func(part *Parts, v []typ.Type) { part.Summary = v }) + for sym, t := range types { + if sym == 0 { + continue + } + part := parts[sym] + part.Type = t + parts[sym] = part + } + return FromParts(parts) +} + +// FromSummaries builds canonical function facts from return summaries. +func FromSummaries(summaries map[cfg.SymbolID][]typ.Type) api.FunctionFacts { + return FromSummariesExcept(summaries, 0) +} + +// FromSummariesExcept builds canonical function facts from return summaries, +// excluding one symbol when exclude is nonzero. +func FromSummariesExcept(summaries map[cfg.SymbolID][]typ.Type, exclude cfg.SymbolID) api.FunctionFacts { + if len(summaries) == 0 { + return nil + } + parts := make(map[cfg.SymbolID]Parts, len(summaries)) + for sym, summary := range summaries { + if sym == 0 || sym == exclude { + continue + } + parts[sym] = Parts{Summary: summary} + } + return FromParts(parts) +} + +func addParts(src map[cfg.SymbolID][]typ.Type, dst map[cfg.SymbolID]Parts, set func(*Parts, []typ.Type)) { + for sym, value := range src { + if sym == 0 { + continue + } + part := dst[sym] + set(&part, value) + dst[sym] = part + } +} + +func factFromPart(part Parts) api.FunctionFact { + return api.FunctionFact{ + Params: part.Params, + Summary: part.Summary, + Narrow: part.Narrow, + Type: part.Type, + Refinement: part.Refinement, + } +} diff --git a/compiler/check/domain/functionfact/map_test.go b/compiler/check/domain/functionfact/map_test.go new file mode 100644 index 00000000..13bd8585 --- /dev/null +++ b/compiler/check/domain/functionfact/map_test.go @@ -0,0 +1,91 @@ +package functionfact + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +func TestFromSummaries_NormalizesAndSkipsEmpty(t *testing.T) { + facts := FromSummaries(map[cfg.SymbolID][]typ.Type{ + 0: {typ.String}, + 1: {typ.String, typ.Nil}, + 2: nil, + }) + + if len(facts) != 1 { + t.Fatalf("facts len = %d, want 1: %#v", len(facts), facts) + } + if got := ReturnSummaryFromMap(facts, 1); !returnsummary.Equal(got, []typ.Type{typ.String, typ.Nil}) { + t.Fatalf("summary = %v, want string,nil", got) + } +} + +func TestFromSummariesExcept_ExcludesCurrent(t *testing.T) { + facts := FromSummariesExcept(map[cfg.SymbolID][]typ.Type{ + 1: {typ.String}, + 2: {typ.Number}, + }, 1) + + if _, ok := FactFromMap(facts, 1); ok { + t.Fatalf("excluded symbol was published: %#v", facts) + } + if got := ReturnSummaryFromMap(facts, 2); !returnsummary.Equal(got, []typ.Type{typ.Number}) { + t.Fatalf("summary = %v, want number", got) + } +} + +func TestFromMaps_JoinsParamSummaryAndTypeEvidence(t *testing.T) { + fn := typ.Func().Param("x", typ.String).Returns(typ.String).Build() + facts := FromMaps( + map[cfg.SymbolID][]typ.Type{1: {typ.String}}, + map[cfg.SymbolID][]typ.Type{1: {typ.String}}, + map[cfg.SymbolID]typ.Type{1: fn}, + ) + + ff, ok := FactFromMap(facts, 1) + if !ok { + t.Fatal("expected function fact for symbol 1") + } + if len(ff.Params) != 1 || !typ.TypeEquals(ff.Params[0], typ.String) { + t.Fatalf("params = %v, want string", ff.Params) + } + if !returnsummary.Equal(ff.Summary, []typ.Type{typ.String}) { + t.Fatalf("summary = %v, want string", ff.Summary) + } + if !typ.TypeEquals(ff.Type, fn) { + t.Fatalf("type = %v, want %v", ff.Type, fn) + } +} + +func TestFromPart_CanonicalizesAllFunctionFactSlots(t *testing.T) { + refinement := &constraint.FunctionRefinement{Terminates: true} + fn := typ.Func().Returns(typ.String).Build() + facts := FromPart(1, Parts{ + Params: []typ.Type{typ.String}, + Summary: []typ.Type{typ.Nil}, + Narrow: []typ.Type{typ.String}, + Type: fn, + Refinement: refinement, + }) + + ff, ok := FactFromMap(facts, 1) + if !ok { + t.Fatal("expected function fact for symbol 1") + } + if len(ff.Params) != 1 || !typ.TypeEquals(ff.Params[0], typ.String) { + t.Fatalf("params = %v, want string", ff.Params) + } + if !returnsummary.Equal(ff.Narrow, []typ.Type{typ.String}) { + t.Fatalf("narrow = %v, want string", ff.Narrow) + } + if !typ.TypeEquals(ff.Type, fn) { + t.Fatalf("type = %v, want %v", ff.Type, fn) + } + if ff.Refinement == nil || !ff.Refinement.Terminates { + t.Fatalf("refinement = %#v, want terminating refinement", ff.Refinement) + } +} diff --git a/compiler/check/domain/functionfact/projection.go b/compiler/check/domain/functionfact/projection.go new file mode 100644 index 00000000..54bc6572 --- /dev/null +++ b/compiler/check/domain/functionfact/projection.go @@ -0,0 +1,320 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +// Cache memoizes canonical function-fact projections for one check. +type Cache struct { + facts map[CacheKey]cachedFact +} + +// CacheKey identifies a canonical function-fact projection. +type CacheKey struct { + GraphID uint64 + Parent *scope.State + Sym cfg.SymbolID + Phase api.Phase +} + +type cachedFact struct { + Fact api.FunctionFact + Found bool +} + +// NewCache creates an empty function-fact projection cache. +func NewCache() *Cache { + return &Cache{facts: make(map[CacheKey]cachedFact)} +} + +// ForSymbol returns the canonical stored function fact for sym. +func ForSymbol(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State) (api.FunctionFact, bool) { + if store == nil || sym == 0 { + return api.FunctionFact{}, false + } + ref := store.FunctionRefBySym(sym) + if ref == nil { + return api.FunctionFact{}, false + } + return FactForGraph(store, graphForRef(store, ref), sym, defaultParent, nil) +} + +// TypeForSymbol returns the canonical stored function type fact for sym. +func TypeForSymbol(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) typ.Type { + if store == nil || sym == 0 { + return nil + } + ref := store.FunctionRefBySym(sym) + if ref == nil { + return nil + } + return TypeForGraph(store, graphForRef(store, ref), sym, defaultParent, cache) +} + +// ReturnSummaryForSymbol returns the canonical declared/pre-flow return summary +// for sym from its owning function-fact product. +func ReturnSummaryForSymbol(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) []typ.Type { + ff, ok := factForSymbolInPhase(store, sym, defaultParent, api.PhaseScopeCompute, cache) + if !ok { + return nil + } + return ff.Summary +} + +// NarrowSummaryForSymbol returns the canonical post-flow return summary for sym +// from its owning function-fact product. +func NarrowSummaryForSymbol(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) []typ.Type { + ff, ok := factForSymbolInPhase(store, sym, defaultParent, api.PhaseNarrowing, cache) + if !ok { + return nil + } + return ff.Narrow +} + +// GraphKeyForSymbol returns the canonical parent graph key that owns sym's +// function-fact product. +func GraphKeyForSymbol(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State) (api.GraphKey, bool) { + if store == nil || sym == 0 { + return api.GraphKey{}, false + } + ref := store.FunctionRefBySym(sym) + if ref == nil { + return api.GraphKey{}, false + } + graph := graphForRef(store, ref) + if graph == nil { + return api.GraphKey{}, false + } + parent := api.ParentScopeForGraph(store, graph.ID(), defaultParent) + if parent == nil { + return api.GraphKey{}, false + } + return store.GraphKeyFor(graph, parent) +} + +// TypeForGraph returns the canonical function type fact for sym from graph's +// function-fact product. +func TypeForGraph(store api.StoreReader, graph *cfg.Graph, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) typ.Type { + ff, ok := FactForGraph(store, graph, sym, defaultParent, cache) + if !ok { + return nil + } + return ff.Type +} + +// ReturnSummaryForGraph returns the canonical declared/pre-flow return summary +// for sym from graph's function-fact product. +func ReturnSummaryForGraph(store api.StoreReader, graph *cfg.Graph, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) []typ.Type { + ff, ok := factForGraphInPhase(store, graph, sym, defaultParent, api.PhaseScopeCompute, cache) + if !ok { + return nil + } + return ff.Summary +} + +// NarrowSummaryForGraph returns the canonical post-flow return summary for sym +// from graph's function-fact product. +func NarrowSummaryForGraph(store api.StoreReader, graph *cfg.Graph, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) []typ.Type { + ff, ok := factForGraphInPhase(store, graph, sym, defaultParent, api.PhaseNarrowing, cache) + if !ok { + return nil + } + return ff.Narrow +} + +// ReturnsForPhase returns the return projection visible in phase. +func ReturnsForPhase(facts api.FunctionFacts, sym cfg.SymbolID, phase api.Phase) []typ.Type { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return returnsForPhase(ff, phase) +} + +// TypeFromMap returns the canonical function type projection from a fact map. +func TypeFromMap(facts api.FunctionFacts, sym cfg.SymbolID) typ.Type { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return ff.Type +} + +// TypeLookup returns a canonical type projection function for a fact map. +func TypeLookup(facts api.FunctionFacts) func(cfg.SymbolID) typ.Type { + if len(facts) == 0 { + return nil + } + return func(sym cfg.SymbolID) typ.Type { + if sym == 0 { + return nil + } + ff, ok := facts[sym] + if !ok { + return nil + } + return value.NormalizeFactType(ff.Type) + } +} + +// ParameterEvidenceFromMap returns the canonical parameter evidence projection +// from a fact map. +func ParameterEvidenceFromMap(facts api.FunctionFacts, sym cfg.SymbolID) []typ.Type { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return ff.Params +} + +// ReturnSummaryFromMap returns the canonical declared/pre-flow return summary +// projection from a fact map. +func ReturnSummaryFromMap(facts api.FunctionFacts, sym cfg.SymbolID) []typ.Type { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return ff.Summary +} + +// NarrowSummaryFromMap returns the canonical post-flow return summary +// projection from a fact map. +func NarrowSummaryFromMap(facts api.FunctionFacts, sym cfg.SymbolID) []typ.Type { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return ff.Narrow +} + +// RefinementFromMap returns the canonical refinement projection from a fact map. +func RefinementFromMap(facts api.FunctionFacts, sym cfg.SymbolID) *constraint.FunctionRefinement { + ff, ok := FactFromMap(facts, sym) + if !ok { + return nil + } + return ff.Refinement +} + +// FactFromMap returns the canonical stored function fact for sym from facts. +func FactFromMap(facts api.FunctionFacts, sym cfg.SymbolID) (api.FunctionFact, bool) { + if len(facts) == 0 || sym == 0 { + return api.FunctionFact{}, false + } + ff, ok := facts[sym] + if !ok { + return api.FunctionFact{}, false + } + ff = Normalize(ff) + return ff, !Empty(ff) +} + +// FactForGraph returns the canonical stored function fact for sym from graph's +// function-fact product. +func FactForGraph(store api.StoreReader, graph *cfg.Graph, sym cfg.SymbolID, defaultParent *scope.State, cache *Cache) (api.FunctionFact, bool) { + return factForGraphInPhase(store, graph, sym, defaultParent, api.PhaseScopeCompute, cache) +} + +func factForSymbolInPhase(store api.StoreReader, sym cfg.SymbolID, defaultParent *scope.State, phase api.Phase, cache *Cache) (api.FunctionFact, bool) { + if store == nil || sym == 0 { + return api.FunctionFact{}, false + } + ref := store.FunctionRefBySym(sym) + if ref == nil { + return api.FunctionFact{}, false + } + return factForGraphInPhase(store, graphForRef(store, ref), sym, defaultParent, phase, cache) +} + +func factForGraphInPhase(store api.StoreReader, graph *cfg.Graph, sym cfg.SymbolID, defaultParent *scope.State, phase api.Phase, cache *Cache) (api.FunctionFact, bool) { + if store == nil || graph == nil || sym == 0 { + return api.FunctionFact{}, false + } + parent := api.ParentScopeForGraph(store, graph.ID(), defaultParent) + if parent == nil { + return api.FunctionFact{}, false + } + key := CacheKey{GraphID: graph.ID(), Parent: parent, Sym: sym, Phase: phase} + if cache != nil { + if cached, ok := cache.get(key); ok { + return cached.Fact, cached.Found + } + } + facts := functionFactsForGraph(store, graph, parent, phase) + ff, found := FactFromMap(facts, sym) + if cache != nil { + cache.set(key, ff, found) + } + return ff, found +} + +// RefinementsFromStore projects canonical function facts as refinement facts. +func RefinementsFromStore(store api.StoreReader, defaultParent *scope.State) api.RefinementFacts { + if store == nil { + return nil + } + return api.NewRefinementFacts(func(sym cfg.SymbolID) *constraint.FunctionRefinement { + ff, ok := ForSymbol(store, sym, defaultParent) + if !ok { + return nil + } + return ff.Refinement + }) +} + +func returnsForPhase(ff api.FunctionFact, phase api.Phase) []typ.Type { + if phase == api.PhaseNarrowing && len(ff.Narrow) > 0 { + return ff.Narrow + } + return ff.Summary +} + +func (c *Cache) get(key CacheKey) (cachedFact, bool) { + if c == nil || c.facts == nil { + return cachedFact{}, false + } + cached, ok := c.facts[key] + return cached, ok +} + +func (c *Cache) set(key CacheKey, fact api.FunctionFact, found bool) { + if c == nil { + return + } + if c.facts == nil { + c.facts = make(map[CacheKey]cachedFact) + } + c.facts[key] = cachedFact{Fact: fact, Found: found} +} + +func graphForRef(store api.StoreReader, ref *api.FunctionRef) *cfg.Graph { + if store == nil || ref == nil { + return nil + } + parentGraphID := ref.ParentGraphID + if parentGraphID == 0 { + parentGraphID = ref.GraphID + } + return store.Graphs()[parentGraphID] +} + +func functionFactsForGraph(store api.StoreReader, graph *cfg.Graph, parent *scope.State, phase api.Phase) api.FunctionFacts { + if store == nil || graph == nil || parent == nil { + return nil + } + var facts api.FunctionFacts + load := func() { + facts = store.GetInterprocFacts(graph, parent).FunctionFacts + } + if phaser, ok := store.(interface{ WithPhase(api.Phase, func()) }); ok { + phaser.WithPhase(phase, load) + } else { + load() + } + return facts +} diff --git a/compiler/check/domain/functionfact/projection_test.go b/compiler/check/domain/functionfact/projection_test.go new file mode 100644 index 00000000..bde24e56 --- /dev/null +++ b/compiler/check/domain/functionfact/projection_test.go @@ -0,0 +1,158 @@ +package functionfact_test + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/compiler/check/store" + "github.com/wippyai/go-lua/types/typ" +) + +func TestTypeForGraph_UsesCanonicalParentAndCache(t *testing.T) { + st := store.NewSessionStore() + fn := &ast.FunctionExpr{} + graph := cfg.Build(fn) + st.RegisterGraph(graph, fn) + + storedParent := scope.New().WithType("stored_parent", typ.String) + defaultParent := scope.New().WithType("default_parent", typ.Number) + registerGraphParent(t, st, graph, storedParent) + + sym := cfg.SymbolID(7) + first := typ.Func().Returns(typ.String).Build() + second := typ.Func().Returns(typ.Number).Build() + writeFunctionFactType(st, graph, storedParent, sym, first) + + cache := functionfact.NewCache() + if got := functionfact.TypeForGraph(st, graph, sym, defaultParent, cache); !typ.TypeEquals(got, first) { + t.Fatalf("TypeForGraph() = %v, want %v", got, first) + } + + writeFunctionFactType(st, graph, storedParent, sym, second) + if got := functionfact.TypeForGraph(st, graph, sym, defaultParent, cache); !typ.TypeEquals(got, first) { + t.Fatalf("cached TypeForGraph() = %v, want %v", got, first) + } + if got := functionfact.TypeForGraph(st, graph, sym, defaultParent, nil); !typ.TypeEquals(got, second) { + t.Fatalf("uncached TypeForGraph() = %v, want %v", got, second) + } +} + +func TestTypeForSymbol_ResolvesOwningParentGraph(t *testing.T) { + st := store.NewSessionStore() + parentFn := &ast.FunctionExpr{} + childFn := &ast.FunctionExpr{} + parentGraph := cfg.Build(parentFn) + childGraph := cfg.Build(childFn) + st.RegisterGraph(parentGraph, parentFn) + st.RegisterGraph(childGraph, childFn) + + parent := scope.New().WithType("parent", typ.String) + registerGraphParent(t, st, parentGraph, parent) + + sym := cfg.SymbolID(11) + fnType := typ.Func().Returns(typ.Boolean).Build() + st.RegisterFunctionRef(sym, childFn, childGraph, parentGraph.ID(), 0) + writeFunctionFactType(st, parentGraph, parent, sym, fnType) + + if got := functionfact.TypeForSymbol(st, sym, nil, functionfact.NewCache()); !typ.TypeEquals(got, fnType) { + t.Fatalf("TypeForSymbol() = %v, want %v", got, fnType) + } + key, ok := functionfact.GraphKeyForSymbol(st, sym, nil) + if !ok { + t.Fatal("GraphKeyForSymbol() did not resolve key") + } + if key.GraphID != parentGraph.ID() || key.ParentHash != parent.Hash() { + t.Fatalf("GraphKeyForSymbol() = %#v, want graph %d parent %d", key, parentGraph.ID(), parent.Hash()) + } +} + +func TestReturnsForPhase_SelectsNarrowingProjection(t *testing.T) { + facts := api.FunctionFacts{ + 1: { + Summary: []typ.Type{typ.Nil}, + Narrow: []typ.Type{typ.String}, + }, + } + + if got := functionfact.ReturnsForPhase(facts, 1, api.PhaseScopeCompute); len(got) != 1 || !typ.TypeEquals(got[0], typ.Nil) { + t.Fatalf("scope returns = %v, want nil summary", got) + } + if got := functionfact.ReturnsForPhase(facts, 1, api.PhaseNarrowing); len(got) != 1 || !typ.TypeEquals(got[0], typ.String) { + t.Fatalf("narrow returns = %v, want string narrow summary", got) + } +} + +func TestTypeLookup_ProjectsCanonicalFunctionTypes(t *testing.T) { + sym := cfg.SymbolID(3) + fnType := typ.Func().Returns(typ.String).Build() + facts := api.FunctionFacts{ + sym: {Type: fnType}, + 4: {Params: []typ.Type{typ.String}}, + } + + lookup := functionfact.TypeLookup(facts) + if lookup == nil { + t.Fatal("TypeLookup() returned nil") + } + if got := lookup(sym); !typ.TypeEquals(got, fnType) { + t.Fatalf("lookup(%d) = %v, want %v", sym, got, fnType) + } + if got := lookup(4); got != nil { + t.Fatalf("lookup for param-only fact = %v, want nil", got) + } +} + +func TestParameterEvidenceSignatures_NilInputs(t *testing.T) { + if got := functionfact.ParameterEvidenceSignatures(nil, nil, nil, nil); got != nil { + t.Fatalf("ParameterEvidenceSignatures() = %v, want nil", got) + } +} + +func TestParameterEvidenceSignatures_ProjectsCurrentGraphFacts(t *testing.T) { + st := store.NewSessionStore() + fn := &ast.FunctionExpr{} + graph := cfg.Build(fn) + st.RegisterGraph(graph, fn) + parent := scope.New().WithType("parent", typ.String) + registerGraphParent(t, st, graph, parent) + + sym := cfg.SymbolID(21) + st.RegisterFunctionRef(sym, fn, graph, 0, 0) + key := api.KeyForGraph(graph, parent.Hash()) + st.InterprocPrev.Facts[key] = api.Facts{ + FunctionFacts: functionfact.FromPart(sym, functionfact.Parts{ + Params: []typ.Type{typ.String}, + }), + } + + got := functionfact.ParameterEvidenceSignatures(st, graph, parent, nil) + evidence := got[fn] + if len(evidence) != 1 || !typ.TypeEquals(evidence[0], typ.String) { + t.Fatalf("signature evidence = %v, want string", evidence) + } +} + +func registerGraphParent(t *testing.T, st *store.SessionStore, graph *cfg.Graph, parent *scope.State) { + t.Helper() + if graph == nil || graph.ID() == 0 { + t.Fatal("test graph has no ID") + } + if parent == nil || parent.Hash() == 0 { + t.Fatal("test parent has no hash") + } + st.SetParentScope(parent.Hash(), parent) + st.SetGraphParentHash(graph.ID(), parent.Hash()) +} + +func writeFunctionFactType(st *store.SessionStore, graph *cfg.Graph, parent *scope.State, sym cfg.SymbolID, fnType typ.Type) { + key := api.KeyForGraph(graph, parent.Hash()) + st.InterprocPrev.Facts[key] = api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Type: fnType}, + }, + } +} diff --git a/compiler/check/domain/functionfact/refinement.go b/compiler/check/domain/functionfact/refinement.go new file mode 100644 index 00000000..de0cfe07 --- /dev/null +++ b/compiler/check/domain/functionfact/refinement.go @@ -0,0 +1,118 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/narrow" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +func preserveDynamicParamsProvenByRefinement( + left, right, merged []typ.Type, + refinement *constraint.FunctionRefinement, +) []typ.Type { + if refinement == nil || len(merged) == 0 { + return merged + } + var out []typ.Type + for i, t := range merged { + if t == nil || typ.IsAny(t) { + continue + } + if !slotMergedDynamicWithConcrete(left, right, i, t) { + continue + } + if !RefinementGuaranteesParamType(refinement, i, t) { + continue + } + if out == nil { + out = make([]typ.Type, len(merged)) + copy(out, merged) + } + out[i] = typ.Any + } + if out != nil { + return out + } + return merged +} + +func slotMergedDynamicWithConcrete(left, right []typ.Type, idx int, merged typ.Type) bool { + l := paramAt(left, idx) + r := paramAt(right, idx) + return (typ.IsAny(l) && r != nil && !typ.IsAny(r) && typ.TypeEquals(r, merged)) || + (typ.IsAny(r) && l != nil && !typ.IsAny(l) && typ.TypeEquals(l, merged)) +} + +func paramAt(params []typ.Type, idx int) typ.Type { + if idx < 0 || idx >= len(params) { + return nil + } + return params[idx] +} + +// RefinementGuaranteesParamType reports whether normal return proves parameter idx has type t. +func RefinementGuaranteesParamType(refinement *constraint.FunctionRefinement, idx int, t typ.Type) bool { + if refinement == nil || t == nil { + return false + } + path := constraint.ParamPath(idx) + for _, c := range refinement.OnReturn.MustConstraints() { + has, ok := c.(constraint.HasType) + if !ok || !has.Path.Equal(path) { + continue + } + if typeKeyCoversType(has.Type, t) { + return true + } + } + return false +} + +func typeKeyCoversType(key narrow.TypeKey, t typ.Type) bool { + if key.IsZero() || t == nil { + return false + } + t = unwrap.Alias(t) + if t == nil { + return false + } + switch key.Kind { + case narrow.TypeKeyHash: + return key.Hash == t.Hash() + case narrow.TypeKeyBuiltin: + return builtinTypeKeyCoversType(key, t) + default: + return false + } +} + +func builtinTypeKeyCoversType(key narrow.TypeKey, t typ.Type) bool { + k, ok := key.BuiltinKind() + if !ok { + return false + } + switch k { + case kind.Nil: + return unwrap.IsNilType(t) + case kind.Boolean: + return subtype.IsSubtype(t, typ.Boolean) + case kind.Number: + return subtype.IsSubtype(t, typ.Number) + case kind.String: + return subtype.IsSubtype(t, typ.String) + case kind.Function: + return unwrap.Function(t) != nil + case kind.Record: + switch unwrap.Alias(t).(type) { + case *typ.Array, *typ.Record, *typ.Map: + return true + default: + return false + } + default: + return false + } +} diff --git a/compiler/check/domain/functionfact/signature_map.go b/compiler/check/domain/functionfact/signature_map.go new file mode 100644 index 00000000..0cc3abd5 --- /dev/null +++ b/compiler/check/domain/functionfact/signature_map.go @@ -0,0 +1,82 @@ +package functionfact + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/types/typ" +) + +// ParameterEvidenceSignatures builds a function-expression keyed parameter +// evidence map for graph from canonical FunctionFacts. +func ParameterEvidenceSignatures( + store api.StoreReader, + graph *cfg.Graph, + parent *scope.State, + stdlib *scope.State, +) map[*ast.FunctionExpr][]typ.Type { + if store == nil || graph == nil { + return nil + } + + out := make(map[*ast.FunctionExpr][]typ.Type) + add := func(fn *ast.FunctionExpr, evidence []typ.Type) { + if fn == nil || !hasParameterEvidence(evidence) { + return + } + if _, exists := out[fn]; !exists { + out[fn] = evidence + } + } + + if parent != nil { + functionFacts := store.GetInterprocFacts(graph, parent).FunctionFacts + for _, sym := range cfg.SortedSymbolIDs(functionFacts) { + add(store.FuncForSymbol(sym), ParameterEvidenceFromMap(functionFacts, sym)) + } + } + + if meta, ok := store.NestedMetaFor(graph.ID()); ok { + parentGraph := store.Graphs()[meta.ParentGraphID] + if parentGraph != nil { + defaultScope := (*scope.State)(nil) + if _, isNestedParent := store.NestedMetaFor(parentGraph.ID()); !isNestedParent { + defaultScope = stdlib + } + parentScope := api.ParentScopeForGraph(store, parentGraph.ID(), defaultScope) + if parentScope != nil { + parentFacts := store.GetInterprocFacts(parentGraph, parentScope).FunctionFacts + if fn := graphFunction(store, graph); fn != nil { + if sym, ok := store.SymbolForFunc(fn); ok { + add(fn, ParameterEvidenceFromMap(parentFacts, sym)) + } + } + } + } + } + + if len(out) == 0 { + return nil + } + return out +} + +func graphFunction(store api.StoreReader, graph *cfg.Graph) *ast.FunctionExpr { + if store == nil || graph == nil { + return nil + } + if fn := store.FuncForGraph(graph); fn != nil { + return fn + } + return graph.Func() +} + +func hasParameterEvidence(evidence []typ.Type) bool { + for _, observed := range evidence { + if observed != nil { + return true + } + } + return false +} diff --git a/compiler/check/flowbuild/guard/doc.go b/compiler/check/domain/guard/doc.go similarity index 100% rename from compiler/check/flowbuild/guard/doc.go rename to compiler/check/domain/guard/doc.go diff --git a/compiler/check/flowbuild/guard/guard.go b/compiler/check/domain/guard/guard.go similarity index 81% rename from compiler/check/flowbuild/guard/guard.go rename to compiler/check/domain/guard/guard.go index fc9b8d58..fcf91384 100644 --- a/compiler/check/flowbuild/guard/guard.go +++ b/compiler/check/domain/guard/guard.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow/pathkey" @@ -19,18 +20,58 @@ type TruthyPathKey struct { Field string } +// TypeProbe describes a builtin type(expr) equality check. +type TypeProbe struct { + Expr ast.Expr + Key narrow.TypeKey +} + +// ExtractTypeEqualityProbe extracts the runtime type predicate from a +// `type(expr) == "kind"` comparison. It is intentionally expression-only so +// synthesis, field validation, and flow guard collection share one parser. +func ExtractTypeEqualityProbe(expr ast.Expr) (TypeProbe, bool) { + rel, ok := expr.(*ast.RelationalOpExpr) + if !ok || rel == nil || rel.Operator != "==" { + return TypeProbe{}, false + } + if probe, ok := typeProbeSide(rel.Lhs, rel.Rhs); ok { + return probe, true + } + return typeProbeSide(rel.Rhs, rel.Lhs) +} + +// IsTypeCall reports whether call has builtin type(expr) shape. +func IsTypeCall(call *ast.FuncCallExpr) bool { + if call == nil || callsite.IsMethodLikeExpr(call) || len(call.Args) != 1 { + return false + } + ident, ok := call.Func.(*ast.IdentExpr) + return ok && ident != nil && ident.Value == "type" +} + +// TypeForTypeKey returns the broad runtime type represented by a builtin +// type() result key. +func TypeForTypeKey(key narrow.TypeKey) typ.Type { + if kind, ok := key.BuiltinKind(); ok { + return narrow.TypeForKind(kind) + } + return typ.Unknown +} + // CollectTruthyGuards scans the CFG for conditions that establish truthy guards // and propagates them to dominated points. Used to narrow optional types. -func CollectTruthyGuards(graph *cfg.Graph, bindings *bind.BindingTable) map[cfg.Point]map[TruthyPathKey]bool { +func CollectTruthyGuards(graph *cfg.Graph, branches []api.BranchEvidence, bindings *bind.BindingTable) map[cfg.Point]map[TruthyPathKey]bool { if graph == nil || bindings == nil { return nil } result := make(map[cfg.Point]map[TruthyPathKey]bool) - graph.EachBranch(func(branchPoint cfg.Point, info *cfg.BranchInfo) { + for _, branch := range branches { + branchPoint := branch.Point + info := branch.Info if info == nil || info.Condition == nil { - return + continue } succs := graph.Successors(branchPoint) @@ -42,16 +83,16 @@ func CollectTruthyGuards(graph *cfg.Graph, bindings *bind.BindingTable) map[cfg. } } if trueEdge == 0 { - return + continue } keys := ExtractTruthyPathKeys(info.Condition, bindings) if len(keys) == 0 { - return + continue } propagateTruthyGuards(graph, trueEdge, keys, result) - }) + } return result } @@ -63,20 +104,22 @@ func CollectTruthyGuards(graph *cfg.Graph, bindings *bind.BindingTable) map[cfg. // // if type(x.y) ~= "string" then return end // -- dominated fallthrough points get x.y : string -func CollectTypeGuards(graph *cfg.Graph, bindings *bind.BindingTable) map[cfg.Point]map[TruthyPathKey]narrow.TypeKey { +func CollectTypeGuards(graph *cfg.Graph, branches []api.BranchEvidence, bindings *bind.BindingTable) map[cfg.Point]map[TruthyPathKey]narrow.TypeKey { if graph == nil || bindings == nil { return nil } result := make(map[cfg.Point]map[TruthyPathKey]narrow.TypeKey) - graph.EachBranch(func(branchPoint cfg.Point, info *cfg.BranchInfo) { + for _, branch := range branches { + branchPoint := branch.Point + info := branch.Info if info == nil || info.Condition == nil { - return + continue } key, typeKey, hasTypeOnTrue, ok := extractTypeGuard(info.Condition, bindings) if !ok || key.Field == "" || typeKey.IsZero() { - return + continue } succs := graph.Successors(branchPoint) @@ -99,7 +142,7 @@ func CollectTypeGuards(graph *cfg.Graph, bindings *bind.BindingTable) map[cfg.Po } else if falseEdge != 0 { propagateTypeGuards(graph, falseEdge, key, typeKey, result) } - }) + } return result } @@ -363,29 +406,32 @@ func extractTypeGuard(expr ast.Expr, bindings *bind.BindingTable) (TruthyPathKey } func typeGuardPathAndKey(typeExpr, keyExpr ast.Expr, bindings *bind.BindingTable) (TruthyPathKey, narrow.TypeKey, bool) { - call, ok := typeExpr.(*ast.FuncCallExpr) - if !ok || call == nil || callsite.IsMethodLikeExpr(call) || len(call.Args) != 1 { + probe, ok := typeProbeSide(typeExpr, keyExpr) + if !ok { return TruthyPathKey{}, narrow.TypeKey{}, false } - ident, ok := call.Func.(*ast.IdentExpr) - if !ok || ident.Value != "type" { + + key, ok := TruthyKeyFromExpr(probe.Expr, bindings) + if !ok || key.Field == "" { return TruthyPathKey{}, narrow.TypeKey{}, false } + return key, probe.Key, true +} +func typeProbeSide(typeExpr, keyExpr ast.Expr) (TypeProbe, bool) { + call, ok := typeExpr.(*ast.FuncCallExpr) + if !ok || !IsTypeCall(call) { + return TypeProbe{}, false + } typeName, ok := typeStringLiteral(keyExpr) if !ok { - return TruthyPathKey{}, narrow.TypeKey{}, false + return TypeProbe{}, false } typeKey, ok := narrow.KnownBuiltinTypeKey(typeName) if !ok { - return TruthyPathKey{}, narrow.TypeKey{}, false - } - - key, ok := TruthyKeyFromExpr(call.Args[0], bindings) - if !ok || key.Field == "" { - return TruthyPathKey{}, narrow.TypeKey{}, false + return TypeProbe{}, false } - return key, typeKey, true + return TypeProbe{Expr: call.Args[0], Key: typeKey}, true } func typeStringLiteral(expr ast.Expr) (string, bool) { diff --git a/compiler/check/flowbuild/guard/guard_test.go b/compiler/check/domain/guard/guard_test.go similarity index 89% rename from compiler/check/flowbuild/guard/guard_test.go rename to compiler/check/domain/guard/guard_test.go index 57f2b145..fb1ae8cd 100644 --- a/compiler/check/flowbuild/guard/guard_test.go +++ b/compiler/check/domain/guard/guard_test.go @@ -6,7 +6,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/guard" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/domain/guard" "github.com/wippyai/go-lua/types/narrow" "github.com/wippyai/go-lua/types/typ" ) @@ -29,7 +30,7 @@ func TestTruthyPathKey_Equality(t *testing.T) { } func TestCollectTruthyGuards_NilGraph(t *testing.T) { - result := guard.CollectTruthyGuards(nil, nil) + result := guard.CollectTruthyGuards(nil, nil, nil) if result != nil { t.Error("expected nil for nil graph") } @@ -42,7 +43,7 @@ func TestCollectTruthyGuards_NilBindings(t *testing.T) { }, } graph := cfg.Build(fn) - result := guard.CollectTruthyGuards(graph, nil) + result := guard.CollectTruthyGuards(graph, nil, nil) if result != nil { t.Error("expected nil for nil bindings") } @@ -296,7 +297,8 @@ func TestCollectTypeGuards_TypeNotEqReturnPropagatesFallthrough(t *testing.T) { } graph := cfg.Build(fn) bindings := bind.Bind(fn, nil) - guards := guard.CollectTypeGuards(graph, bindings) + evidence := trace.GraphEvidence(graph, bindings) + guards := guard.CollectTypeGuards(graph, evidence.Branches, bindings) payloadSym, ok := bindings.SymbolOf(condExpr.Lhs.(*ast.FuncCallExpr).Args[0].(*ast.AttrGetExpr).Object.(*ast.IdentExpr)) if !ok || payloadSym == 0 { @@ -317,6 +319,35 @@ func TestCollectTypeGuards_TypeNotEqReturnPropagatesFallthrough(t *testing.T) { } } +func TestExtractTypeEqualityProbe(t *testing.T) { + target := &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "page"}, + Key: &ast.StringExpr{Value: "placement"}, + } + expr := &ast.RelationalOpExpr{ + Operator: "==", + Lhs: &ast.FuncCallExpr{ + Func: &ast.IdentExpr{Value: "type"}, + Args: []ast.Expr{target}, + }, + Rhs: &ast.StringExpr{Value: "string"}, + } + + probe, ok := guard.ExtractTypeEqualityProbe(expr) + if !ok { + t.Fatal("expected type equality probe") + } + if probe.Expr != target { + t.Fatal("expected probe expression to be preserved") + } + if probe.Key != narrow.BuiltinTypeKey("string") { + t.Fatalf("probe key = %v, want string key", probe.Key) + } + if got := guard.TypeForTypeKey(probe.Key); !typ.TypeEquals(got, typ.String) { + t.Fatalf("probe type = %v, want string", got) + } +} + func TestNarrowTableFieldsByGuard_TypeGuardNarrowsAny(t *testing.T) { valueExpr := &ast.AttrGetExpr{ Object: &ast.IdentExpr{Value: "payload"}, diff --git a/compiler/check/returns/captured_field_merge.go b/compiler/check/domain/interproc/captured_field.go similarity index 98% rename from compiler/check/returns/captured_field_merge.go rename to compiler/check/domain/interproc/captured_field.go index 8cdeca2e..5767318f 100644 --- a/compiler/check/returns/captured_field_merge.go +++ b/compiler/check/domain/interproc/captured_field.go @@ -1,4 +1,4 @@ -package returns +package interproc import ( "github.com/wippyai/go-lua/compiler/cfg" diff --git a/compiler/check/returns/container_mutation_merge.go b/compiler/check/domain/interproc/container_mutation.go similarity index 87% rename from compiler/check/returns/container_mutation_merge.go rename to compiler/check/domain/interproc/container_mutation.go index 6fb3943b..872d77d0 100644 --- a/compiler/check/returns/container_mutation_merge.go +++ b/compiler/check/domain/interproc/container_mutation.go @@ -1,4 +1,4 @@ -package returns +package interproc import ( "github.com/wippyai/go-lua/compiler/cfg" @@ -16,11 +16,8 @@ func MergeContainerMutationSlices( next []api.ContainerMutation, merge ContainerMutationMerger, ) []api.ContainerMutation { - if len(existing) == 0 { - return next - } - if len(next) == 0 { - return existing + if len(existing) == 0 && len(next) == 0 { + return nil } mergeFn := merge @@ -29,18 +26,21 @@ func MergeContainerMutationSlices( } byKey := make(map[string]api.ContainerMutation, len(existing)+len(next)) - for _, m := range existing { - byKey[api.ContainerMutationKey(m)] = m - } - for _, m := range next { + add := func(m api.ContainerMutation) { key := api.ContainerMutationKey(m) if prev, ok := byKey[key]; ok { merged := mergeFn(&prev, m) byKey[key] = merged - continue + return } byKey[key] = mergeFn(nil, m) } + for _, m := range existing { + add(m) + } + for _, m := range next { + add(m) + } out := make([]api.ContainerMutation, 0, len(byKey)) for _, key := range cfg.SortedFieldNames(byKey) { @@ -55,15 +55,12 @@ func MergeCapturedContainerMutationMaps( next map[cfg.SymbolID][]api.ContainerMutation, merge ContainerMutationMerger, ) map[cfg.SymbolID][]api.ContainerMutation { - if existing == nil { - return next - } - if next == nil { - return existing + if len(existing) == 0 && len(next) == 0 { + return nil } merged := make(map[cfg.SymbolID][]api.ContainerMutation, len(existing)+len(next)) for _, sym := range cfg.SortedSymbolIDs(existing) { - merged[sym] = existing[sym] + merged[sym] = MergeContainerMutationSlices(nil, existing[sym], merge) } for _, sym := range cfg.SortedSymbolIDs(next) { merged[sym] = MergeContainerMutationSlices(merged[sym], next[sym], merge) diff --git a/compiler/check/domain/interproc/container_mutation_test.go b/compiler/check/domain/interproc/container_mutation_test.go new file mode 100644 index 00000000..4be4e418 --- /dev/null +++ b/compiler/check/domain/interproc/container_mutation_test.go @@ -0,0 +1,168 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +func TestMergeContainerMutationSlices_DedupAndSorted(t *testing.T) { + existing := []api.ContainerMutation{ + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "b"}}, + ValueType: typ.Number, + }, + } + next := []api.ContainerMutation{ + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "a"}}, + ValueType: typ.String, + }, + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "b"}}, + ValueType: typ.Integer, + }, + } + + got := MergeContainerMutationSlices(existing, next, func(prev *api.ContainerMutation, n api.ContainerMutation) api.ContainerMutation { + if prev != nil { + n.ValueType = typ.JoinPreferNonSoft(prev.ValueType, n.ValueType) + } + return n + }) + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if k := api.ContainerMutationKey(got[0]); k != "container:.a" { + t.Fatalf("first key = %q, want container:.a", k) + } + if k := api.ContainerMutationKey(got[1]); k != "container:.b" { + t.Fatalf("second key = %q, want container:.b", k) + } + if !typ.TypeEquals(got[1].ValueType, typ.Number) { + t.Fatalf(".b merged type = %v, want number", got[1].ValueType) + } +} + +func TestMergeCapturedContainerMutationMaps_MergeBySymbol(t *testing.T) { + existing := map[cfg.SymbolID][]api.ContainerMutation{ + 1: { + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "x"}}, + ValueType: typ.String, + }, + }, + } + next := map[cfg.SymbolID][]api.ContainerMutation{ + 1: { + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "x"}}, + ValueType: typ.String, + }, + }, + 2: { + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "y"}}, + ValueType: typ.Boolean, + }, + }, + } + + got := MergeCapturedContainerMutationMaps(existing, next, nil) + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2 symbols", len(got)) + } + if len(got[1]) != 1 || len(got[2]) != 1 { + t.Fatalf("unexpected per-symbol merge sizes: sym1=%d sym2=%d", len(got[1]), len(got[2])) + } + if key := api.ContainerMutationKey(got[2][0]); key != "container:.y" { + t.Fatalf("sym2 key = %q, want container:.y", key) + } +} + +func TestMergeContainerMutationSlices_KeepsOperatorKindsDistinct(t *testing.T) { + existing := []api.ContainerMutation{ + { + Kind: api.ContainerMutationContainerElement, + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "items"}}, + ValueType: typ.Number, + }, + } + next := []api.ContainerMutation{ + { + Kind: api.ContainerMutationTableElement, + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "items"}}, + ValueType: typ.String, + }, + } + + got := MergeContainerMutationSlices(existing, next, nil) + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2 distinct operator facts", len(got)) + } + if got[0].Kind == got[1].Kind { + t.Fatalf("expected separate facts for same path with different operators, got %#v", got) + } +} + +func TestWidenCapturedContainerMutations_JoinsSameContainerElement(t *testing.T) { + prevRecord := typ.NewRecord().Field("name", typ.Any).Build() + nextRecord := typ.NewRecord().Field("error", typ.String).Build() + + prev := api.CapturedContainerMutations{ + 10: { + 20: { + {Kind: api.ContainerMutationContainerElement, ValueType: prevRecord}, + }, + }, + } + next := api.CapturedContainerMutations{ + 10: { + 20: { + {Kind: api.ContainerMutationContainerElement, ValueType: nextRecord}, + }, + }, + } + + got := WidenCapturedContainerMutations(prev, next) + muts := got[10][20] + if len(muts) != 1 { + t.Fatalf("len(muts) = %d, want 1", len(muts)) + } + if typ.TypeEquals(muts[0].ValueType, prevRecord) || typ.TypeEquals(muts[0].ValueType, nextRecord) { + t.Fatalf("expected joined container element type, got %v", muts[0].ValueType) + } + if !typ.TypeEquals(got[10][20][0].ValueType, WidenCapturedContainerMutations(got, next)[10][20][0].ValueType) { + t.Fatalf("widened captured container mutation must be idempotent, got %v then %v", got[10][20][0].ValueType, WidenCapturedContainerMutations(got, next)[10][20][0].ValueType) + } +} + +func TestWidenCapturedContainerMutations_DedupesSameIterationMutations(t *testing.T) { + firstRecord := typ.NewRecord().Field("name", typ.Any).Build() + secondRecord := typ.NewRecord().Field("error", typ.String).Build() + + next := api.CapturedContainerMutations{ + 10: { + 20: { + {Kind: api.ContainerMutationContainerElement, ValueType: firstRecord}, + {Kind: api.ContainerMutationContainerElement, ValueType: secondRecord}, + }, + }, + } + + got := WidenCapturedContainerMutations(nil, next) + muts := got[10][20] + if len(muts) != 1 { + t.Fatalf("len(muts) = %d, want 1 canonical mutation per path", len(muts)) + } + if typ.TypeEquals(muts[0].ValueType, firstRecord) || typ.TypeEquals(muts[0].ValueType, secondRecord) { + t.Fatalf("expected same-iteration container writes to join, got %v", muts[0].ValueType) + } + if !CapturedContainerMutationsEqual(got, WidenCapturedContainerMutations(got, next)) { + t.Fatalf("widened captured container mutations must be idempotent") + } +} diff --git a/compiler/check/domain/interproc/delta.go b/compiler/check/domain/interproc/delta.go new file mode 100644 index 00000000..7d9eb012 --- /dev/null +++ b/compiler/check/domain/interproc/delta.go @@ -0,0 +1,69 @@ +package interproc + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/typ" +) + +// FunctionFactsDelta returns a canonical product delta for function facts. +func FunctionFactsDelta(facts api.FunctionFacts) api.Facts { + if len(facts) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{FunctionFacts: facts}) +} + +// LiteralSigsDelta returns a canonical product delta for literal signatures. +func LiteralSigsDelta(sigs api.LiteralSigs) api.Facts { + if len(sigs) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{LiteralSigs: sigs}) +} + +// CapturedTypesDelta returns a canonical product delta for captured symbol types. +func CapturedTypesDelta(types api.CapturedTypes) api.Facts { + if len(types) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{CapturedTypes: types}) +} + +// CapturedFieldAssignsDelta returns a canonical product delta for field writes +// performed by one nested function. +func CapturedFieldAssignsDelta( + fnSym cfg.SymbolID, + fields map[cfg.SymbolID]map[string]typ.Type, +) api.Facts { + if fnSym == 0 || len(fields) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{ + CapturedFields: api.CapturedFieldAssigns{fnSym: fields}, + }) +} + +// CapturedContainerMutationsDelta returns a canonical product delta for +// container writes performed by one nested function. +func CapturedContainerMutationsDelta( + fnSym cfg.SymbolID, + mutations map[cfg.SymbolID][]api.ContainerMutation, +) api.Facts { + if fnSym == 0 || len(mutations) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{ + CapturedContainers: api.CapturedContainerMutations{fnSym: mutations}, + }) +} + +// ConstructorFieldsDelta returns a canonical module product delta for one class. +func ConstructorFieldsDelta(classSym cfg.SymbolID, fields map[string]typ.Type) api.Facts { + if classSym == 0 || len(fields) == 0 { + return api.Facts{} + } + return JoinFacts(api.Facts{}, api.Facts{ + ConstructorFields: api.ConstructorFields{classSym: fields}, + }) +} diff --git a/compiler/check/domain/interproc/doc.go b/compiler/check/domain/interproc/doc.go new file mode 100644 index 00000000..d480f87e --- /dev/null +++ b/compiler/check/domain/interproc/doc.go @@ -0,0 +1,9 @@ +// Package interproc owns the interprocedural facts product domain. +// +// It canonicalizes, joins, widens, and compares api.Facts bundles. Lower-level +// domains own individual slots: functionfact for one FunctionFact, +// returnsummary for return vectors, paramevidence for parameter evidence, and +// value for structural value relations. This package owns the product-level +// shape across graph facts, captured types, captured field writes, captured +// container mutations, constructor fields, and literal signatures. +package interproc diff --git a/compiler/check/domain/interproc/domain_law_test.go b/compiler/check/domain/interproc/domain_law_test.go new file mode 100644 index 00000000..147ee59c --- /dev/null +++ b/compiler/check/domain/interproc/domain_law_test.go @@ -0,0 +1,209 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +func TestFactsDomain_ProductOperatorsAreIdempotentAcrossAllDomains(t *testing.T) { + fnSym := cfg.SymbolID(1) + capturedSym := cfg.SymbolID(2) + classSym := cfg.SymbolID(3) + lit := &ast.FunctionExpr{} + callback := typ.Func().Param("self", typ.Unknown).Returns(typ.Boolean).Build() + fn := typ.Func().Param("name", typ.String).Returns(typ.String).Build() + raw := api.Facts{ + FunctionFacts: api.FunctionFacts{ + fnSym: {Params: []typ.Type{typ.String}, Summary: []typ.Type{typ.String}, Narrow: []typ.Type{typ.String}, Type: fn}, + }, + LiteralSigs: api.LiteralSigs{ + lit: typ.Func().Param("name", typ.String).Returns(typ.String).Build(), + }, + CapturedTypes: api.CapturedTypes{ + capturedSym: typ.NewRecord().Field("name", typ.String).Build(), + }, + CapturedFields: api.CapturedFieldAssigns{ + fnSym: { + capturedSym: { + "callback": typ.NewOptional(callback), + }, + }, + }, + CapturedContainers: api.CapturedContainerMutations{ + fnSym: { + capturedSym: { + { + Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "items"}}, + ValueType: typ.NewArray(typ.String), + }, + }, + }, + }, + ConstructorFields: api.ConstructorFields{ + classSym: { + "name": typ.String, + }, + }, + } + + normalized := WidenFacts(api.Facts{}, raw) + if !FactsEqual(normalized, WidenFacts(normalized, normalized)) { + t.Fatalf("Widen must be idempotent across the product domain") + } + if !FactsEqual(normalized, JoinFacts(normalized, normalized)) { + t.Fatalf("Join must be idempotent across the product domain") + } + + if got := functionfact.ReturnSummaryFromMap(normalized.FunctionFacts, fnSym); !returnsummary.Equal(got, []typ.Type{typ.String}) { + t.Fatalf("summary must come from canonical FunctionFacts, got %v", got) + } + if got := functionfact.NarrowSummaryFromMap(normalized.FunctionFacts, fnSym); !returnsummary.Equal(got, []typ.Type{typ.String}) { + t.Fatalf("narrow summary must come from canonical FunctionFacts, got %v", got) + } + if got := functionfact.TypeFromMap(normalized.FunctionFacts, fnSym); got == nil { + t.Fatalf("function type must come from canonical FunctionFacts") + } +} + +func TestFactsDomain_WidenIdempotentForLiteralUnknownVsConcreteReturn(t *testing.T) { + lit := &ast.FunctionExpr{} + prev := api.Facts{ + LiteralSigs: api.LiteralSigs{ + lit: typ.Func().Param("name", typ.Unknown).Returns(typ.Unknown, typ.NewOptional(typ.String)).Build(), + }, + } + next := api.Facts{ + LiteralSigs: api.LiteralSigs{ + lit: typ.Func().Param("name", typ.Unknown).Returns( + typ.NewOptional(typ.NewRecord(). + Field("id", typ.String). + Field("priority", typ.Integer). + SetOpen(true). + Build()), + typ.NewOptional(typ.String), + ).Build(), + }, + } + + widened := WidenFacts(prev, next) + widenedAgain := WidenFacts(widened, next) + if !FactsEqual(widened, widenedAgain) { + t.Fatalf("Widen must be idempotent for literal signatures:\nfirst=%#v\nsecond=%#v", widened, widenedAgain) + } + + got := widened.LiteralSigs[lit] + if got == nil || len(got.Returns) != 2 || !typ.TypeEquals(got.Returns[0], typ.Unknown) { + t.Fatalf("expected unresolved literal return to remain the stable upper bound, got %v", got) + } +} + +func TestFactsDomain_WidenFunctionParamsIsVarianceAware(t *testing.T) { + sym := cfg.SymbolID(1) + prev := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Type: typ.Func().Param("path", typ.Any).Returns(typ.NewArray(typ.Unknown)).Build()}, + }, + } + next := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Type: typ.Func().Param("path", typ.String).Returns(typ.NewArray(typ.Unknown)).Build()}, + }, + } + + widened := WidenFacts(prev, next) + widenedAgain := WidenFacts(widened, next) + if !FactsEqual(widened, widenedAgain) { + t.Fatalf("Widen must be idempotent for function param facts") + } + + fn := unwrapFunctionForDomainTest(t, functionfact.TypeFromMap(widened.FunctionFacts, sym)) + if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, typ.Any) { + t.Fatalf("expected widening to preserve broad parameter upper bound, got %v", fn) + } +} + +func TestFactsDomain_PreservesArityAndNilabilityAsSeparateParamAxes(t *testing.T) { + sym := cfg.SymbolID(1) + context := typ.NewRecord(). + MapComponent(typ.String, typ.Any). + SetOpen(true). + Build() + raw := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Type: typ.Func().OptParam("context", typ.NewOptional(context)).Build()}, + }, + } + + widened := WidenFacts(api.Facts{}, raw) + fn := unwrapFunctionForDomainTest(t, functionfact.TypeFromMap(widened.FunctionFacts, sym)) + if len(fn.Params) != 1 || !fn.Params[0].Optional { + t.Fatalf("expected optional parameter slot, got %v", fn) + } + want := typ.NewOptional(context) + if !typ.TypeEquals(fn.Params[0].Type, want) { + t.Fatalf("expected explicit nilability to remain in the value type, got %v", fn.Params[0].Type) + } + if !FactsEqual(widened, WidenFacts(widened, raw)) { + t.Fatalf("expected optional parameter product-domain representation to be idempotent") + } +} + +func TestFactsDomain_WidenPreservesCapturedCallbackUnionMembers(t *testing.T) { + sym := cfg.SymbolID(9) + withPending := typ.NewUnion( + typ.LiteralString("pass"), + typ.LiteralString("pending"), + typ.LiteralString("fail"), + typ.LiteralString("skip"), + ) + withoutPending := typ.NewUnion( + typ.LiteralString("pass"), + typ.LiteralString("fail"), + typ.LiteralString("skip"), + ) + prevFn := typ.Func(). + Param("suite", typ.Any). + Param("test_case", typ.Any). + Returns(typ.NewRecord().Field("status", withPending).Field("suite", typ.Unknown).Build()). + Build() + nextFn := typ.Func(). + Param("suite", typ.Any). + Param("test_case", typ.Any). + Returns(typ.NewRecord().Field("status", withoutPending).Field("suite", typ.Unknown).Build()). + Build() + + widened := WidenFacts( + api.Facts{CapturedTypes: api.CapturedTypes{sym: prevFn}}, + api.Facts{CapturedTypes: api.CapturedTypes{sym: nextFn}}, + ) + widenedAgain := WidenFacts(widened, api.Facts{CapturedTypes: api.CapturedTypes{sym: nextFn}}) + if !FactsEqual(widened, widenedAgain) { + t.Fatalf("Widen must be idempotent for captured callback union members") + } + + fn := unwrapFunctionForDomainTest(t, widened.CapturedTypes[sym]) + rec, ok := fn.Returns[0].(*typ.Record) + if !ok { + t.Fatalf("expected callback record return, got %T", fn.Returns[0]) + } + status := rec.GetField("status") + if status == nil || !typ.TypeEquals(status.Type, withPending) { + t.Fatalf("expected status union to preserve pending member, got %v", status) + } +} + +func unwrapFunctionForDomainTest(t *testing.T, got typ.Type) *typ.Function { + t.Helper() + fn, ok := got.(*typ.Function) + if !ok { + t.Fatalf("expected function type, got %T %v", got, got) + } + return fn +} diff --git a/compiler/check/returns/equal.go b/compiler/check/domain/interproc/equal.go similarity index 72% rename from compiler/check/returns/equal.go rename to compiler/check/domain/interproc/equal.go index 2ada7665..1355f5fe 100644 --- a/compiler/check/returns/equal.go +++ b/compiler/check/domain/interproc/equal.go @@ -1,17 +1,17 @@ -package returns +package interproc import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/compiler/check/domain/value" "github.com/wippyai/go-lua/types/typ" ) -// FactsEqual checks if two interproc fact bundles are equal. +// FactsEqual checks if two canonical interproc fact bundles are equal. func FactsEqual(a, b api.Facts) bool { - if !FunctionFactsEqual(canonicalFunctionFacts(a), canonicalFunctionFacts(b)) { - return false - } - if !symbolTypeVectorMapEqual(a.ParamHints, b.ParamHints) { + if !FunctionFactsEqual(a.FunctionFacts, b.FunctionFacts) { return false } if !LiteralSigsEqual(a.LiteralSigs, b.LiteralSigs) { @@ -43,13 +43,19 @@ func FunctionFactsEqual(a, b api.FunctionFacts) bool { if !ok { return false } - if !ReturnTypesEqual(af.Summary, bf.Summary) { + if !paramevidence.EqualVectors(af.Params, bf.Params) { return false } - if !ReturnTypesEqual(af.Narrow, bf.Narrow) { + if !returnsummary.Equal(af.Summary, bf.Summary) { return false } - if !typ.TypeEquals(af.Func, bf.Func) { + if !returnsummary.Equal(af.Narrow, bf.Narrow) { + return false + } + if !value.FactTypeEqual(af.Type, bf.Type) { + return false + } + if !RefinementEqual(af.Refinement, bf.Refinement) { return false } } @@ -63,21 +69,7 @@ func LiteralSigsEqual(a, b api.LiteralSigs) bool { } for fn, sig := range a { other, ok := b[fn] - if !ok || !typ.TypeEquals(sig, other) { - return false - } - } - return true -} - -func symbolTypeVectorMapEqual(a map[cfg.SymbolID][]typ.Type, b map[cfg.SymbolID][]typ.Type) bool { - if len(a) != len(b) { - return false - } - for _, sym := range cfg.SortedSymbolIDs(a) { - left := a[sym] - right, ok := b[sym] - if !ok || !ReturnTypesEqual(left, right) { + if !ok || !value.FactTypeEqual(sig, other) { return false } } @@ -89,9 +81,10 @@ func symbolTypeMapEqual(a map[cfg.SymbolID]typ.Type, b map[cfg.SymbolID]typ.Type return false } for _, sym := range cfg.SortedSymbolIDs(a) { - left := a[sym] + left := canonicalInterprocValueType(a[sym]) right, ok := b[sym] - if !ok || !typ.TypeEquals(left, right) { + right = canonicalInterprocValueType(right) + if !ok || !value.FactTypeEqual(left, right) { return false } } @@ -116,7 +109,9 @@ func CapturedFieldAssignsEqual(a, b api.CapturedFieldAssigns) bool { return false } for _, name := range cfg.SortedFieldNames(fields) { - if !typ.TypeEquals(fields[name], otherFields[name]) { + left := canonicalInterprocValueType(fields[name]) + right := canonicalInterprocValueType(otherFields[name]) + if !value.FactTypeEqual(left, right) { return false } } @@ -161,7 +156,9 @@ func containerMutationSlicesEqual(a, b []api.ContainerMutation) bool { for _, m := range b { key := api.ContainerMutationKey(m) other, ok := index[key] - if !ok || !typ.TypeEquals(other.ValueType, m.ValueType) { + if !ok || + !value.FactTypeEqual(canonicalInterprocValueType(other.KeyType), canonicalInterprocValueType(m.KeyType)) || + !value.FactTypeEqual(canonicalInterprocValueType(other.ValueType), canonicalInterprocValueType(m.ValueType)) { return false } } @@ -180,7 +177,9 @@ func ConstructorFieldsEqual(a, b api.ConstructorFields) bool { return false } for _, name := range cfg.SortedFieldNames(fields) { - if !typ.TypeEquals(fields[name], other[name]) { + left := canonicalInterprocValueType(fields[name]) + right := canonicalInterprocValueType(other[name]) + if !value.FactTypeEqual(left, right) { return false } } diff --git a/compiler/check/domain/interproc/equal_test.go b/compiler/check/domain/interproc/equal_test.go new file mode 100644 index 00000000..0002fa98 --- /dev/null +++ b/compiler/check/domain/interproc/equal_test.go @@ -0,0 +1,256 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/typ" +) + +func TestFactsEqual_Empty(t *testing.T) { + a := api.Facts{} + b := api.Facts{} + if !FactsEqual(a, b) { + t.Error("empty facts should be equal") + } +} + +func TestFactsEqual_FunctionFactSummary(t *testing.T) { + a := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.String}}, + }, + } + b := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.String}}, + }, + } + if !FactsEqual(a, b) { + t.Error("facts with same return summaries should be equal") + } +} + +func TestFactsEqual_DifferentFunctionFactSummary(t *testing.T) { + a := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.String}}, + }, + } + b := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.Number}}, + }, + } + if FactsEqual(a, b) { + t.Error("facts with different return summaries should not be equal") + } +} + +func TestFactsEqual_UsesCanonicalFunctionFactsOnly(t *testing.T) { + sym := cfg.SymbolID(77) + fn := typ.Func().Returns(typ.String).Build() + + a := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: { + Summary: []typ.Type{typ.String}, + Narrow: []typ.Type{typ.String}, + Type: fn, + }, + }, + } + b := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: { + Summary: []typ.Type{typ.String}, + Narrow: []typ.Type{typ.String}, + Type: fn, + }, + }, + } + + if !FactsEqual(a, b) { + t.Fatal("expected facts to be equal by canonical function facts") + } +} + +func TestFactsEqual_DifferentCanonicalFunctionFacts(t *testing.T) { + sym := cfg.SymbolID(91) + + a := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Summary: []typ.Type{typ.String}, Type: typ.Func().Returns(typ.String).Build()}, + }, + } + b := api.Facts{ + FunctionFacts: api.FunctionFacts{ + sym: {Summary: []typ.Type{typ.Number}, Type: typ.Func().Returns(typ.Number).Build()}, + }, + } + + if FactsEqual(a, b) { + t.Fatal("different canonical function facts should not be equal") + } +} + +func TestSymbolTypeMapEqual_Empty(t *testing.T) { + if !symbolTypeMapEqual(nil, nil) { + t.Error("nil func types should be equal") + } +} + +func TestFunctionFactsEqual_Params(t *testing.T) { + a := api.FunctionFacts{1: {Params: []typ.Type{typ.String}}} + b := api.FunctionFacts{1: {Params: []typ.Type{typ.String}}} + if !FunctionFactsEqual(a, b) { + t.Error("same canonical parameter evidence should be equal") + } +} + +func TestFunctionFactsEqual_DifferentParams(t *testing.T) { + a := api.FunctionFacts{1: {Params: []typ.Type{typ.String}}} + b := api.FunctionFacts{1: {Params: []typ.Type{typ.Number}}} + if FunctionFactsEqual(a, b) { + t.Error("different canonical parameter evidence should not be equal") + } +} + +func TestFunctionFactsEqual_FunctionSpecIsCanonicalFactState(t *testing.T) { + callback := typ.Func().Param("value", typ.String).Build() + spec := contract.NewSpec().WithCallback(0, (&contract.CallbackSpec{}).WithEnvOverlay(map[string]typ.Type{ + "up": callback, + })) + withoutSpec := typ.Func().Param("fn", callback).Build() + withSpec := typ.Func().Param("fn", callback).Spec(spec).Build() + + if !typ.TypeEquals(withoutSpec, withSpec) { + t.Fatal("ordinary type equality should ignore function specs") + } + + a := api.FunctionFacts{1: {Type: withoutSpec}} + b := api.FunctionFacts{1: {Type: withSpec}} + if FunctionFactsEqual(a, b) { + t.Fatal("function fact equality must include function specs") + } +} + +func TestWidenFacts_PreservesFunctionSpecChange(t *testing.T) { + callback := typ.Func().Param("value", typ.String).Build() + spec := contract.NewSpec().WithCallback(0, (&contract.CallbackSpec{}).WithEnvOverlay(map[string]typ.Type{ + "up": callback, + })) + withoutSpec := typ.Func().Param("fn", callback).Build() + withSpec := typ.Func().Param("fn", callback).Spec(spec).Build() + sym := cfg.SymbolID(7) + prev := api.Facts{FunctionFacts: api.FunctionFacts{sym: {Type: withoutSpec}}} + next := api.Facts{FunctionFacts: api.FunctionFacts{sym: {Type: withSpec}}} + + widened := WidenFacts(prev, next) + got := widened.FunctionFacts[sym].Type + gotSpec := contract.ExtractSpec(got) + if gotSpec == nil || !gotSpec.Equals(spec) { + t.Fatalf("expected widened fact type to preserve callback spec, got %v", got) + } + if FactsEqual(prev, widened) { + t.Fatal("fact equality must observe a newly inferred function spec") + } +} + +func TestSymbolTypeMapEqual_Same(t *testing.T) { + fn := typ.Func().Returns(typ.String).Build() + a := map[cfg.SymbolID]typ.Type{1: fn} + b := map[cfg.SymbolID]typ.Type{1: fn} + if !symbolTypeMapEqual(a, b) { + t.Error("same func types should be equal") + } +} + +func TestLiteralSigsEqual_Empty(t *testing.T) { + if !LiteralSigsEqual(nil, nil) { + t.Error("nil literal sigs should be equal") + } +} + +func TestCapturedTypesEqual_Empty(t *testing.T) { + if !symbolTypeMapEqual(nil, nil) { + t.Error("nil captured types should be equal") + } +} + +func TestCapturedTypesEqual_Same(t *testing.T) { + a := api.CapturedTypes{cfg.SymbolID(1): typ.String} + b := api.CapturedTypes{cfg.SymbolID(1): typ.String} + if !symbolTypeMapEqual(a, b) { + t.Error("same captured types should be equal") + } +} + +func TestCapturedFieldAssignsEqual_Empty(t *testing.T) { + if !CapturedFieldAssignsEqual(nil, nil) { + t.Error("nil captured field assigns should be equal") + } +} + +func TestCapturedFieldAssignsEqual_DifferentCallee(t *testing.T) { + a := api.CapturedFieldAssigns{ + cfg.SymbolID(1): {cfg.SymbolID(2): {"foo": typ.String}}, + } + b := api.CapturedFieldAssigns{ + cfg.SymbolID(3): {cfg.SymbolID(2): {"foo": typ.String}}, + } + if CapturedFieldAssignsEqual(a, b) { + t.Error("different callee symbols should not be equal") + } +} + +func TestCapturedContainerMutationsEqual_Basic(t *testing.T) { + a := api.CapturedContainerMutations{ + cfg.SymbolID(1): { + cfg.SymbolID(2): { + {Segments: nil, ValueType: typ.Number}, + }, + }, + } + b := api.CapturedContainerMutations{ + cfg.SymbolID(1): { + cfg.SymbolID(2): { + {Segments: nil, ValueType: typ.Number}, + }, + }, + } + if !CapturedContainerMutationsEqual(a, b) { + t.Error("same container mutations should be equal") + } +} + +func TestCapturedContainerMutationsEqual_DifferentOperatorKind(t *testing.T) { + a := api.CapturedContainerMutations{ + cfg.SymbolID(1): { + cfg.SymbolID(2): { + {Kind: api.ContainerMutationContainerElement, ValueType: typ.Number}, + }, + }, + } + b := api.CapturedContainerMutations{ + cfg.SymbolID(1): { + cfg.SymbolID(2): { + {Kind: api.ContainerMutationTableElement, ValueType: typ.Number}, + }, + }, + } + if CapturedContainerMutationsEqual(a, b) { + t.Error("same path with different mutation operators should not be equal") + } +} + +func TestCapturedFieldAssignsEqual_CanonicalizesOptionalFunctionValues(t *testing.T) { + fn := typ.Func().Param("fn", typ.Unknown).Build() + left := api.CapturedFieldAssigns{1: {2: {"after_all": typ.NewOptional(fn)}}} + right := api.CapturedFieldAssigns{1: {2: {"after_all": fn}}} + if !CapturedFieldAssignsEqual(left, right) { + t.Fatal("expected optional function captured field to equal canonical function value") + } +} diff --git a/compiler/check/domain/interproc/function_fact_product_test.go b/compiler/check/domain/interproc/function_fact_product_test.go new file mode 100644 index 00000000..5e3a69d4 --- /dev/null +++ b/compiler/check/domain/interproc/function_fact_product_test.go @@ -0,0 +1,42 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/types/typ" +) + +func TestJoinFacts_BatchMergeFunctionFacts(t *testing.T) { + symSummary := cfg.SymbolID(21) + symNarrow := cfg.SymbolID(22) + symFunc := cfg.SymbolID(23) + funcType := typ.Func().Returns(typ.Boolean).Build() + + facts := JoinFacts( + api.Facts{ + FunctionFacts: api.FunctionFacts{ + symSummary: {Summary: []typ.Type{typ.String}}, + symNarrow: {Narrow: []typ.Type{typ.Number}}, + }, + }, + api.Facts{ + FunctionFacts: api.FunctionFacts{ + symFunc: {Type: funcType}, + }, + }, + ) + + if got := functionfact.ReturnSummaryFromMap(facts.FunctionFacts, symSummary); !returnsummary.Equal(got, []typ.Type{typ.String}) { + t.Fatalf("summary mismatch: got %v", got) + } + if got := functionfact.NarrowSummaryFromMap(facts.FunctionFacts, symNarrow); !returnsummary.Equal(got, []typ.Type{typ.Number}) { + t.Fatalf("narrow mismatch: got %v", got) + } + if got := functionfact.TypeFromMap(facts.FunctionFacts, symFunc); !typ.TypeEquals(got, funcType) { + t.Fatalf("func mismatch: got %v", got) + } +} diff --git a/compiler/check/domain/interproc/function_facts.go b/compiler/check/domain/interproc/function_facts.go new file mode 100644 index 00000000..3faa7808 --- /dev/null +++ b/compiler/check/domain/interproc/function_facts.go @@ -0,0 +1,63 @@ +package interproc + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" +) + +func collectCanonicalFunctionFactSymbols(factSets ...api.FunctionFacts) []cfg.SymbolID { + total := 0 + for _, facts := range factSets { + total += len(facts) + } + symbols := make(map[cfg.SymbolID]bool, total) + for _, facts := range factSets { + markFunctionFactSymbols(symbols, facts) + } + return cfg.SortedSymbolIDs(symbols) +} + +func markFunctionFactSymbols[T any](dst map[cfg.SymbolID]bool, src map[cfg.SymbolID]T) { + for sym := range src { + dst[sym] = true + } +} + +func readFunctionFactFromFacts(facts *api.Facts, sym cfg.SymbolID) api.FunctionFact { + if facts == nil || sym == 0 { + return api.FunctionFact{} + } + if facts.FunctionFacts == nil { + return api.FunctionFact{} + } + ff, ok := facts.FunctionFacts[sym] + if !ok { + return api.FunctionFact{} + } + canonical := functionfact.Normalize(ff) + if !functionfact.Empty(canonical) { + return canonical + } + return api.FunctionFact{} +} + +func writeNormalizedFunctionFactToFacts(facts *api.Facts, sym cfg.SymbolID, ff api.FunctionFact) { + if facts == nil || sym == 0 { + return + } + + if functionfact.Empty(ff) { + if facts.FunctionFacts != nil { + delete(facts.FunctionFacts, sym) + if len(facts.FunctionFacts) == 0 { + facts.FunctionFacts = nil + } + } + } else { + if facts.FunctionFacts == nil { + facts.FunctionFacts = make(api.FunctionFacts) + } + facts.FunctionFacts[sym] = ff + } +} diff --git a/compiler/check/domain/interproc/product.go b/compiler/check/domain/interproc/product.go new file mode 100644 index 00000000..cadc1652 --- /dev/null +++ b/compiler/check/domain/interproc/product.go @@ -0,0 +1,684 @@ +package interproc + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// WidenFacts merges two interproc fact bundles. +func WidenFacts(prev, next api.Facts) api.Facts { + out := api.Facts{ + LiteralSigs: WidenLiteralSigs(prev.LiteralSigs, next.LiteralSigs), + CapturedTypes: WidenCapturedTypes(prev.CapturedTypes, next.CapturedTypes), + CapturedFields: WidenCapturedFieldAssigns(prev.CapturedFields, next.CapturedFields), + CapturedContainers: WidenCapturedContainerMutations(prev.CapturedContainers, next.CapturedContainers), + ConstructorFields: WidenConstructorFields(prev.ConstructorFields, next.ConstructorFields), + } + + symbols := collectCanonicalFunctionFactSymbols(prev.FunctionFacts, next.FunctionFacts) + if len(symbols) == 0 { + return out + } + + out.FunctionFacts = make(api.FunctionFacts, len(symbols)) + for _, sym := range symbols { + prevFact := readFunctionFactFromFacts(&prev, sym) + nextFact := readFunctionFactFromFacts(&next, sym) + writeNormalizedFunctionFactToFacts(&out, sym, functionfact.WidenForConvergence(prevFact, nextFact)) + } + if len(out.FunctionFacts) == 0 { + out.FunctionFacts = nil + } + return out +} + +// JoinFacts performs a precise same-iteration merge of interproc facts. +// Unlike WidenFacts, this may keep directional refinements that are useful +// inside one analysis round. Recursive fixpoint boundaries must use WidenFacts. +func JoinFacts(prev, next api.Facts) api.Facts { + out := api.Facts{ + LiteralSigs: JoinLiteralSigs(prev.LiteralSigs, next.LiteralSigs), + CapturedTypes: JoinCapturedTypes(prev.CapturedTypes, next.CapturedTypes), + CapturedFields: JoinCapturedFieldAssigns(prev.CapturedFields, next.CapturedFields), + CapturedContainers: JoinCapturedContainerMutations(prev.CapturedContainers, next.CapturedContainers), + ConstructorFields: JoinConstructorFields(prev.ConstructorFields, next.ConstructorFields), + } + + symbols := collectCanonicalFunctionFactSymbols(prev.FunctionFacts, next.FunctionFacts) + if len(symbols) > 0 { + out.FunctionFacts = make(api.FunctionFacts, len(symbols)) + } + for _, sym := range symbols { + prevFact := readFunctionFactFromFacts(&prev, sym) + nextFact := readFunctionFactFromFacts(&next, sym) + writeNormalizedFunctionFactToFacts(&out, sym, functionfact.Join(prevFact, nextFact)) + } + return out +} + +func canonicalInterprocValueType(t typ.Type) typ.Type { + if t == nil { + return nil + } + if fn := unwrap.Function(t); fn != nil { + return value.WidenForConvergence(fn) + } + return value.WidenForConvergence(t) +} + +func mergeInterprocValueType(existing, candidate typ.Type) typ.Type { + existing = canonicalInterprocValueType(existing) + candidate = canonicalInterprocValueType(candidate) + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + if unwrap.Function(existing) != nil || unwrap.Function(candidate) != nil { + return value.WidenForConvergence(functionfact.WidenTypeForConvergence(existing, candidate)) + } + return value.WidenForConvergence(value.MergeForConvergence(existing, candidate)) +} + +func normalizeInterprocValueType(t typ.Type) typ.Type { + return value.NormalizeFactType(t) +} + +func joinInterprocValueType(existing, candidate typ.Type) typ.Type { + existing = normalizeInterprocValueType(existing) + candidate = normalizeInterprocValueType(candidate) + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + if unwrap.Function(existing) != nil || unwrap.Function(candidate) != nil { + return functionfact.MergeType(existing, candidate) + } + return value.JoinPrecise(existing, candidate) +} + +// WidenLiteralSigs merges two literal signature maps. +func WidenLiteralSigs(prev, next api.LiteralSigs) api.LiteralSigs { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeLiteralSigs(next) + } + if next == nil { + return normalizeLiteralSigs(prev) + } + merged := make(api.LiteralSigs, len(prev)+len(next)) + for fn, sig := range prev { + merged[fn] = value.WidenFunctionForConvergence(sig) + } + for fn, sig := range next { + if existing := merged[fn]; existing != nil { + merged[fn] = value.WidenFunctionForConvergence(mergeLiteralSigForConvergence(existing, sig)) + } else { + merged[fn] = value.WidenFunctionForConvergence(sig) + } + } + return merged +} + +func normalizeLiteralSigs(sigs api.LiteralSigs) api.LiteralSigs { + if sigs == nil { + return nil + } + out := make(api.LiteralSigs, len(sigs)) + for fn, sig := range sigs { + out[fn] = value.WidenFunctionForConvergence(sig) + } + return out +} + +// JoinLiteralSigs merges literal signatures precisely inside one iteration. +func JoinLiteralSigs(prev, next api.LiteralSigs) api.LiteralSigs { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeLiteralSigs(next) + } + if next == nil { + return normalizeLiteralSigs(prev) + } + merged := make(api.LiteralSigs, len(prev)+len(next)) + for fn, sig := range prev { + merged[fn] = value.WidenFunctionForConvergence(sig) + } + for fn, sig := range next { + if existing := merged[fn]; existing != nil { + merged[fn] = value.WidenFunctionForConvergence(mergeLiteralSig(existing, sig)) + } else { + merged[fn] = value.WidenFunctionForConvergence(sig) + } + } + return merged +} + +func mergeLiteralSig(prev, next *typ.Function) *typ.Function { + if prev == nil { + return next + } + if next == nil { + return prev + } + if merged, ok := functionfact.MergeReturnsForSameSignature(prev, next); ok { + if fn, ok := merged.(*typ.Function); ok { + return fn + } + } + if subtype.IsSubtype(prev, next) { + return next + } + if subtype.IsSubtype(next, prev) { + return prev + } + // Literal signatures are constrained to *typ.Function. For incomparable + // function shapes, keep the prior stable signature instead of narrowing. + return prev +} + +func mergeLiteralSigForConvergence(prev, next *typ.Function) *typ.Function { + merged := functionfact.WidenTypeForConvergence(prev, next) + if fn := unwrap.Function(merged); fn != nil { + return fn + } + return mergeLiteralSig(prev, next) +} + +// WidenCapturedTypes merges two captured type maps using monotone join. +func WidenCapturedTypes(prev, next api.CapturedTypes) api.CapturedTypes { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedTypes(next) + } + if next == nil { + return normalizeCapturedTypes(prev) + } + merged := make(api.CapturedTypes, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = canonicalInterprocValueType(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + t := next[sym] + if existing := merged[sym]; existing != nil { + merged[sym] = mergeInterprocValueType(existing, t) + } else { + merged[sym] = canonicalInterprocValueType(t) + } + } + return merged +} + +// JoinCapturedTypes merges captured types precisely inside one iteration. +func JoinCapturedTypes(prev, next api.CapturedTypes) api.CapturedTypes { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedTypesForJoin(next) + } + if next == nil { + return normalizeCapturedTypesForJoin(prev) + } + merged := make(api.CapturedTypes, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = normalizeInterprocValueType(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + t := next[sym] + if existing := merged[sym]; existing != nil { + merged[sym] = joinInterprocValueType(existing, t) + } else { + merged[sym] = normalizeInterprocValueType(t) + } + } + return merged +} + +// WidenCapturedFieldAssigns merges captured field assignment maps using monotone union. +func WidenCapturedFieldAssigns(prev, next api.CapturedFieldAssigns) api.CapturedFieldAssigns { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedFieldAssigns(next) + } + if next == nil { + return normalizeCapturedFieldAssigns(prev) + } + merged := make(api.CapturedFieldAssigns, len(prev)+len(next)) + for _, callee := range cfg.SortedSymbolIDs(prev) { + merged[callee] = normalizeCapturedFieldSymbolMap(prev[callee]) + } + for _, callee := range cfg.SortedSymbolIDs(next) { + captured := next[callee] + existing := merged[callee] + if existing == nil { + merged[callee] = normalizeCapturedFieldSymbolMap(captured) + continue + } + merged[callee] = MergeCapturedFieldSymbolMaps(existing, captured, func(prev typ.Type, next typ.Type) typ.Type { + if prev != nil { + return mergeInterprocValueType(prev, next) + } + return canonicalInterprocValueType(next) + }) + } + return merged +} + +func normalizeCapturedTypes(types api.CapturedTypes) api.CapturedTypes { + if types == nil { + return nil + } + out := make(api.CapturedTypes, len(types)) + for _, sym := range cfg.SortedSymbolIDs(types) { + out[sym] = canonicalInterprocValueType(types[sym]) + } + return out +} + +func normalizeCapturedTypesForJoin(types api.CapturedTypes) api.CapturedTypes { + if types == nil { + return nil + } + out := make(api.CapturedTypes, len(types)) + for _, sym := range cfg.SortedSymbolIDs(types) { + out[sym] = normalizeInterprocValueType(types[sym]) + } + return out +} + +func normalizeCapturedFieldAssigns(fields api.CapturedFieldAssigns) api.CapturedFieldAssigns { + if fields == nil { + return nil + } + out := make(api.CapturedFieldAssigns, len(fields)) + for _, callee := range cfg.SortedSymbolIDs(fields) { + out[callee] = normalizeCapturedFieldSymbolMap(fields[callee]) + } + return out +} + +func normalizeCapturedFieldSymbolMap(fieldsBySym map[cfg.SymbolID]map[string]typ.Type) map[cfg.SymbolID]map[string]typ.Type { + if fieldsBySym == nil { + return nil + } + out := make(map[cfg.SymbolID]map[string]typ.Type, len(fieldsBySym)) + for _, sym := range cfg.SortedSymbolIDs(fieldsBySym) { + fields := fieldsBySym[sym] + fieldOut := make(map[string]typ.Type, len(fields)) + for _, name := range cfg.SortedFieldNames(fields) { + fieldOut[name] = canonicalInterprocValueType(fields[name]) + } + out[sym] = fieldOut + } + return out +} + +// JoinCapturedFieldAssigns merges captured field assignments inside one iteration. +func JoinCapturedFieldAssigns(prev, next api.CapturedFieldAssigns) api.CapturedFieldAssigns { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedFieldAssignsForJoin(next) + } + if next == nil { + return normalizeCapturedFieldAssignsForJoin(prev) + } + merged := make(api.CapturedFieldAssigns, len(prev)+len(next)) + for _, callee := range cfg.SortedSymbolIDs(prev) { + merged[callee] = normalizeCapturedFieldSymbolMapForJoin(prev[callee]) + } + for _, callee := range cfg.SortedSymbolIDs(next) { + captured := next[callee] + existing := merged[callee] + if existing == nil { + merged[callee] = normalizeCapturedFieldSymbolMapForJoin(captured) + continue + } + merged[callee] = MergeCapturedFieldSymbolMaps(existing, captured, func(prev typ.Type, next typ.Type) typ.Type { + if prev != nil { + return joinInterprocValueType(prev, next) + } + return normalizeInterprocValueType(next) + }) + } + return merged +} + +func normalizeCapturedFieldAssignsForJoin(fields api.CapturedFieldAssigns) api.CapturedFieldAssigns { + if fields == nil { + return nil + } + out := make(api.CapturedFieldAssigns, len(fields)) + for _, callee := range cfg.SortedSymbolIDs(fields) { + out[callee] = normalizeCapturedFieldSymbolMapForJoin(fields[callee]) + } + return out +} + +func normalizeCapturedFieldSymbolMapForJoin(fieldsBySym map[cfg.SymbolID]map[string]typ.Type) map[cfg.SymbolID]map[string]typ.Type { + if fieldsBySym == nil { + return nil + } + out := make(map[cfg.SymbolID]map[string]typ.Type, len(fieldsBySym)) + for _, sym := range cfg.SortedSymbolIDs(fieldsBySym) { + fields := fieldsBySym[sym] + fieldOut := make(map[string]typ.Type, len(fields)) + for _, name := range cfg.SortedFieldNames(fields) { + fieldOut[name] = normalizeInterprocValueType(fields[name]) + } + out[sym] = fieldOut + } + return out +} + +// WidenCapturedContainerMutations merges captured container mutation maps using monotone union. +func WidenCapturedContainerMutations(prev, next api.CapturedContainerMutations) api.CapturedContainerMutations { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedContainerMutations(next) + } + if next == nil { + return normalizeCapturedContainerMutations(prev) + } + merged := make(api.CapturedContainerMutations, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = normalizeCapturedContainerMutationMap(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + muts := next[sym] + existing := merged[sym] + merged[sym] = MergeCapturedContainerMutationMaps(existing, muts, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { + if prev != nil { + next.KeyType = widenContainerMutationValueType(prev.KeyType, next.KeyType) + next.ValueType = widenContainerMutationValueType(prev.ValueType, next.ValueType) + } else { + next.KeyType = value.WidenForConvergence(next.KeyType) + next.ValueType = value.WidenForConvergence(next.ValueType) + } + return next + }) + } + return merged +} + +func normalizeCapturedContainerMutations(muts api.CapturedContainerMutations) api.CapturedContainerMutations { + if muts == nil { + return nil + } + out := make(api.CapturedContainerMutations, len(muts)) + for _, sym := range cfg.SortedSymbolIDs(muts) { + out[sym] = normalizeCapturedContainerMutationMap(muts[sym]) + } + return out +} + +func normalizeCapturedContainerMutationMap(muts map[cfg.SymbolID][]api.ContainerMutation) map[cfg.SymbolID][]api.ContainerMutation { + if muts == nil { + return nil + } + out := make(map[cfg.SymbolID][]api.ContainerMutation, len(muts)) + for _, sym := range cfg.SortedSymbolIDs(muts) { + entries := muts[sym] + if len(entries) == 0 { + continue + } + normalized := MergeContainerMutationSlices(nil, entries, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { + if prev != nil { + next.KeyType = widenContainerMutationValueType(prev.KeyType, next.KeyType) + next.ValueType = widenContainerMutationValueType(prev.ValueType, next.ValueType) + } else { + next.KeyType = value.WidenForConvergence(next.KeyType) + next.ValueType = value.WidenForConvergence(next.ValueType) + } + return next + }) + out[sym] = normalized + } + if len(out) == 0 { + return nil + } + return out +} + +// JoinCapturedContainerMutations merges captured container mutations inside one iteration. +func JoinCapturedContainerMutations(prev, next api.CapturedContainerMutations) api.CapturedContainerMutations { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeCapturedContainerMutationsForJoin(next) + } + if next == nil { + return normalizeCapturedContainerMutationsForJoin(prev) + } + merged := make(api.CapturedContainerMutations, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = normalizeCapturedContainerMutationMapForJoin(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + muts := next[sym] + existing := merged[sym] + merged[sym] = MergeCapturedContainerMutationMaps(existing, muts, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { + if prev != nil { + next.KeyType = joinContainerMutationValueType(prev.KeyType, next.KeyType) + next.ValueType = joinContainerMutationValueType(prev.ValueType, next.ValueType) + } else { + next.KeyType = normalizeInterprocValueType(next.KeyType) + next.ValueType = normalizeInterprocValueType(next.ValueType) + } + return next + }) + } + return merged +} + +func normalizeCapturedContainerMutationsForJoin(muts api.CapturedContainerMutations) api.CapturedContainerMutations { + if muts == nil { + return nil + } + out := make(api.CapturedContainerMutations, len(muts)) + for _, sym := range cfg.SortedSymbolIDs(muts) { + out[sym] = normalizeCapturedContainerMutationMapForJoin(muts[sym]) + } + return out +} + +func normalizeCapturedContainerMutationMapForJoin(muts map[cfg.SymbolID][]api.ContainerMutation) map[cfg.SymbolID][]api.ContainerMutation { + if muts == nil { + return nil + } + out := make(map[cfg.SymbolID][]api.ContainerMutation, len(muts)) + for _, sym := range cfg.SortedSymbolIDs(muts) { + entries := muts[sym] + if len(entries) == 0 { + continue + } + normalized := MergeContainerMutationSlices(nil, entries, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { + if prev != nil { + next.KeyType = joinContainerMutationValueType(prev.KeyType, next.KeyType) + next.ValueType = joinContainerMutationValueType(prev.ValueType, next.ValueType) + } else { + next.KeyType = normalizeInterprocValueType(next.KeyType) + next.ValueType = normalizeInterprocValueType(next.ValueType) + } + return next + }) + out[sym] = normalized + } + if len(out) == 0 { + return nil + } + return out +} + +func widenContainerMutationValueType(prev, next typ.Type) typ.Type { + prev = canonicalInterprocValueType(prev) + next = canonicalInterprocValueType(next) + if prev == nil { + return value.WidenForConvergence(next) + } + if next == nil { + return value.WidenForConvergence(prev) + } + if typ.TypeEquals(prev, next) { + return prev + } + return value.WidenForConvergence(typ.JoinReturnSlot(prev, next)) +} + +func joinContainerMutationValueType(prev, next typ.Type) typ.Type { + prev = normalizeInterprocValueType(prev) + next = normalizeInterprocValueType(next) + if prev == nil { + return next + } + if next == nil { + return prev + } + if typ.TypeEquals(prev, next) { + return prev + } + return normalizeInterprocValueType(typ.JoinReturnSlot(prev, next)) +} + +// WidenConstructorFields merges constructor field maps using monotone join. +func WidenConstructorFields(prev, next api.ConstructorFields) api.ConstructorFields { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeConstructorFields(next) + } + if next == nil { + return normalizeConstructorFields(prev) + } + merged := make(api.ConstructorFields, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = normalizeConstructorFieldMap(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + fields := next[sym] + existing := merged[sym] + if existing == nil { + merged[sym] = normalizeConstructorFieldMap(fields) + continue + } + out := make(map[string]typ.Type, len(existing)+len(fields)) + for _, name := range cfg.SortedFieldNames(existing) { + out[name] = existing[name] + } + for _, name := range cfg.SortedFieldNames(fields) { + t := fields[name] + if prevType := out[name]; prevType != nil { + out[name] = mergeInterprocValueType(prevType, t) + } else { + out[name] = value.WidenForConvergence(t) + } + } + merged[sym] = out + } + return merged +} + +func normalizeConstructorFields(fields api.ConstructorFields) api.ConstructorFields { + if fields == nil { + return nil + } + out := make(api.ConstructorFields, len(fields)) + for _, sym := range cfg.SortedSymbolIDs(fields) { + out[sym] = normalizeConstructorFieldMap(fields[sym]) + } + return out +} + +func normalizeConstructorFieldMap(fields map[string]typ.Type) map[string]typ.Type { + if fields == nil { + return nil + } + out := make(map[string]typ.Type, len(fields)) + for _, name := range cfg.SortedFieldNames(fields) { + out[name] = canonicalInterprocValueType(fields[name]) + } + return out +} + +// JoinConstructorFields merges constructor field maps inside one iteration. +func JoinConstructorFields(prev, next api.ConstructorFields) api.ConstructorFields { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return normalizeConstructorFieldsForJoin(next) + } + if next == nil { + return normalizeConstructorFieldsForJoin(prev) + } + merged := make(api.ConstructorFields, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + merged[sym] = normalizeConstructorFieldMapForJoin(prev[sym]) + } + for _, sym := range cfg.SortedSymbolIDs(next) { + fields := next[sym] + existing := merged[sym] + if existing == nil { + merged[sym] = normalizeConstructorFieldMapForJoin(fields) + continue + } + out := make(map[string]typ.Type, len(existing)+len(fields)) + for _, name := range cfg.SortedFieldNames(existing) { + out[name] = existing[name] + } + for _, name := range cfg.SortedFieldNames(fields) { + t := fields[name] + if prevType := out[name]; prevType != nil { + out[name] = joinInterprocValueType(prevType, t) + } else { + out[name] = normalizeInterprocValueType(t) + } + } + merged[sym] = out + } + return merged +} + +func normalizeConstructorFieldsForJoin(fields api.ConstructorFields) api.ConstructorFields { + if fields == nil { + return nil + } + out := make(api.ConstructorFields, len(fields)) + for _, sym := range cfg.SortedSymbolIDs(fields) { + out[sym] = normalizeConstructorFieldMapForJoin(fields[sym]) + } + return out +} + +func normalizeConstructorFieldMapForJoin(fields map[string]typ.Type) map[string]typ.Type { + if fields == nil { + return nil + } + out := make(map[string]typ.Type, len(fields)) + for _, name := range cfg.SortedFieldNames(fields) { + out[name] = normalizeInterprocValueType(fields[name]) + } + return out +} diff --git a/compiler/check/domain/interproc/product_test.go b/compiler/check/domain/interproc/product_test.go new file mode 100644 index 00000000..e543a51e --- /dev/null +++ b/compiler/check/domain/interproc/product_test.go @@ -0,0 +1,301 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" +) + +func TestWidenFacts_DoesNotOverrideSummaryWithNilNarrow(t *testing.T) { + prev := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.Integer}}, + }, + } + next := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Narrow: []typ.Type{typ.Nil}}, + }, + } + + merged := WidenFacts(prev, next) + got := functionfact.ReturnSummaryFromMap(merged.FunctionFacts, 1) + if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { + t.Fatalf("expected summary[1]=integer, got %v", got) + } +} + +func TestWidenFacts_ElidesOptionalFromNarrowFunctionFact(t *testing.T) { + prev := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.NewOptional(typ.Integer)}}, + }, + } + next := api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Narrow: []typ.Type{typ.Integer}}, + }, + } + + merged := WidenFacts(prev, next) + got := functionfact.ReturnSummaryFromMap(merged.FunctionFacts, 1) + if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { + t.Fatalf("expected summary[1]=integer, got %v", got) + } +} + +func TestWidenFacts_RefinesOptionalForFirstOrderFunctionSummary(t *testing.T) { + prev := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.NewOptional(typ.Integer)}}, + }} + next := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.Integer}}, + }} + + merged := WidenFacts(prev, next) + got := functionfact.ReturnSummaryFromMap(merged.FunctionFacts, 1) + if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { + t.Fatalf("expected integer after first-order refinement, got %v", got) + } +} + +func TestWidenFacts_UsesMonotoneJoinForHigherOrderFunctionSummary(t *testing.T) { + nestedUnknown := typ.NewRecord(). + Field("next", typ.Func().Returns(typ.Unknown).Build()). + Build() + nestedString := typ.NewRecord(). + Field("next", typ.Func().Returns(typ.String).Build()). + Build() + + base := typ.NewRecord(). + Field("build", typ.Func().Returns(nestedUnknown).Build()). + Build() + refined := typ.NewRecord(). + Field("build", typ.Func().Returns(nestedString).Build()). + Build() + + prev := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{base}}, + }} + next := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{refined}}, + }} + + merged := WidenFacts(prev, next) + got := functionfact.ReturnSummaryFromMap(merged.FunctionFacts, 1) + if len(got) != 1 || !typ.TypeEquals(got[0], base) { + t.Fatalf("expected stable upper bound for higher-order return, got %v", got) + } +} + +func TestWidenFacts_InterfaceMethodsDoNotBlockOptionalElision(t *testing.T) { + dbType := typ.NewInterface("sql.DB", []typ.Method{ + { + Name: "release", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). + Build(), + }, + }) + + prev := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.NewOptional(dbType)}}, + }} + next := api.Facts{FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{dbType}}, + }} + + merged := WidenFacts(prev, next) + got := functionfact.ReturnSummaryFromMap(merged.FunctionFacts, 1) + if len(got) != 1 || !typ.TypeEquals(got[0], dbType) { + t.Fatalf("expected optional elision for interface return, got %v", got) + } +} + +func TestReturnSummaryMerge_StopsRecursiveContainerReturnGrowth(t *testing.T) { + recordMap := func(value typ.Type) typ.Type { + return typ.NewRecord().MapComponent(typ.String, value).Build() + } + recordField := func(value typ.Type) typ.Type { + return typ.NewRecord().Field("value", value).SetOpen(true).Build() + } + + tests := []struct { + name string + stable typ.Type + growth typ.Type + }{ + { + name: "map", + stable: typ.NewMap(typ.String, typ.Any), + growth: typ.NewMap(typ.String, typ.NewMap(typ.String, typ.Nil)), + }, + { + name: "record map component", + stable: recordMap(typ.Any), + growth: recordMap(recordMap(typ.Nil)), + }, + { + name: "record field", + stable: recordField(typ.Any), + growth: recordField(recordField(typ.Nil)), + }, + { + name: "array", + stable: typ.NewArray(typ.Any), + growth: typ.NewArray(typ.NewArray(typ.Nil)), + }, + { + name: "tuple", + stable: typ.NewTuple(typ.Any), + growth: typ.NewTuple(typ.NewTuple(typ.Nil)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged := returnsummary.Merge([]typ.Type{tt.stable}, []typ.Type{tt.growth}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], tt.stable) { + t.Fatalf("expected stable recursive return shape, got %v", merged) + } + }) + } +} + +func TestReturnSummaryMerge_KeepsNonRecursiveContainerRefinement(t *testing.T) { + stable := typ.NewMap(typ.String, typ.Any) + refined := typ.NewMap(typ.String, typ.String) + + merged := returnsummary.Merge([]typ.Type{stable}, []typ.Type{refined}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], refined) { + t.Fatalf("expected non-recursive map refinement to survive, got %v", merged) + } +} + +func TestWidenCapturedFieldAssigns_NormalizesOptionalFunctionValues(t *testing.T) { + fn := typ.Func().Param("fn", typ.Unknown).Build() + merged := WidenCapturedFieldAssigns(nil, api.CapturedFieldAssigns{ + 1: {2: {"after_all": typ.NewOptional(fn)}}, + }) + + got := merged[1][2]["after_all"] + if !typ.TypeEquals(got, fn) { + t.Fatalf("expected optional function value to canonicalize to function, got %v", got) + } +} + +func TestWidenCapturedFieldAssigns_MergesSameShapeFunctionValues(t *testing.T) { + prevFn := typ.Func(). + Param("name", typ.Unknown). + Returns(typ.NewRecord(). + Field("full_path", typ.String). + SetOpen(true). + Build()). + Build() + nextFn := typ.Func(). + Param("name", typ.Unknown). + Returns(typ.NewRecord(). + Field("full_path", typ.String). + Field("children", typ.NewArray(typ.Unknown)). + SetOpen(true). + Build()). + Build() + + merged := WidenCapturedFieldAssigns( + api.CapturedFieldAssigns{1: {2: {"describe": prevFn}}}, + api.CapturedFieldAssigns{1: {2: {"describe": nextFn}}}, + ) + + got := merged[1][2]["describe"] + if _, ok := got.(*typ.Union); ok { + t.Fatalf("expected function observations to merge, got union %v", got) + } + fn, ok := got.(*typ.Function) + if !ok { + t.Fatalf("expected merged function, got %T", got) + } + if len(fn.Returns) != 1 { + t.Fatalf("expected one return, got %d", len(fn.Returns)) + } + rec, ok := fn.Returns[0].(*typ.Record) + if !ok { + t.Fatalf("expected record return, got %T", fn.Returns[0]) + } + if rec.GetField("full_path") == nil || rec.GetField("children") == nil { + t.Fatalf("expected merged return fields, got %v", rec) + } +} + +func TestWidenLiteralSigs_DoesNotNarrowComparableSignature(t *testing.T) { + lit := &ast.FunctionExpr{} + + prev := api.LiteralSigs{ + lit: typ.Func().Returns(typ.Number).Build(), + } + next := api.LiteralSigs{ + lit: typ.Func().Returns(typ.Integer).Build(), + } + + merged := WidenLiteralSigs(prev, next) + got := merged[lit] + if got == nil { + t.Fatal("expected merged literal signature") + } + if len(got.Returns) != 1 { + t.Fatalf("expected one return, got %d", len(got.Returns)) + } + if !subtype.IsSubtype(prev[lit].Returns[0], got.Returns[0]) { + t.Fatalf("expected merged return to be supertype of prev (%v), got %v", prev[lit].Returns[0], got.Returns[0]) + } + if !subtype.IsSubtype(next[lit].Returns[0], got.Returns[0]) { + t.Fatalf("expected merged return to be supertype of next (%v), got %v", next[lit].Returns[0], got.Returns[0]) + } + if typ.TypeEquals(got.Returns[0], next[lit].Returns[0]) { + t.Fatalf("expected merged return not to regress to narrower next-only type %v", got.Returns[0]) + } +} + +func TestWidenLiteralSigs_PrefersMergedSameShapeSignature(t *testing.T) { + lit := &ast.FunctionExpr{} + + prev := api.LiteralSigs{ + lit: typ.Func().Returns(typ.String).Build(), + } + next := api.LiteralSigs{ + lit: typ.Func().Returns(typ.Integer).Build(), + } + + merged := WidenLiteralSigs(prev, next) + got := merged[lit] + if got == nil { + t.Fatal("expected merged literal signature") + } + if len(got.Returns) != 1 { + t.Fatalf("expected one return, got %d", len(got.Returns)) + } + want := typ.NewUnion(typ.String, typ.Integer) + if !typ.TypeEquals(got.Returns[0], want) { + t.Fatalf("expected merged return %v, got %v", want, got.Returns[0]) + } +} + +func TestWidenLiteralSigs_NormalizesNilBranch(t *testing.T) { + lit := &ast.FunctionExpr{} + sig := typ.Func(). + Returns(typ.NewUnion(typ.NewRecord().Build(), typ.String)). + Build() + + merged := WidenLiteralSigs(nil, api.LiteralSigs{lit: sig}) + got := merged[lit] + want := value.WidenFunctionForConvergence(sig) + if got == nil || !typ.TypeEquals(got, want) { + t.Fatalf("expected nil-branch literal signature %v to be normalized to %v, got %v", sig, want, got) + } +} diff --git a/compiler/check/domain/interproc/state.go b/compiler/check/domain/interproc/state.go new file mode 100644 index 00000000..ad62c70a --- /dev/null +++ b/compiler/check/domain/interproc/state.go @@ -0,0 +1,87 @@ +package interproc + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +// Empty reports whether a canonical interprocedural fact product carries no +// semantic evidence. +func Empty(f api.Facts) bool { + return len(f.FunctionFacts) == 0 && + len(f.LiteralSigs) == 0 && + len(f.CapturedTypes) == 0 && + len(f.CapturedFields) == 0 && + len(f.CapturedContainers) == 0 && + len(f.ConstructorFields) == 0 +} + +// FactMapEqual compares graph-keyed interprocedural fact products. +func FactMapEqual(a, b map[api.GraphKey]api.Facts) bool { + if len(a) != len(b) { + return false + } + for _, key := range api.SortedGraphKeys(a) { + if !FactsEqual(a[key], b[key]) { + return false + } + } + return true +} + +// WidenFactMap merges a next iteration fact map into the stable product using +// the interprocedural widening policy for each graph key. +func WidenFactMap(prev, next map[api.GraphKey]api.Facts) map[api.GraphKey]api.Facts { + if len(prev) == 0 && len(next) == 0 { + return make(map[api.GraphKey]api.Facts) + } + out := make(map[api.GraphKey]api.Facts, len(prev)+len(next)) + for _, key := range api.SortedGraphKeys(prev) { + out[key] = prev[key] + } + for _, key := range api.SortedGraphKeys(next) { + if existing, ok := out[key]; ok { + out[key] = WidenFacts(existing, next[key]) + } else { + out[key] = WidenFacts(api.Facts{}, next[key]) + } + } + return out +} + +// OverlayFacts returns the canonical facts visible during an iteration from a +// stable previous product and same-iteration next facts. +func OverlayFacts(prev, next api.Facts) api.Facts { + switch { + case Empty(prev): + return next + case Empty(next): + return prev + default: + return JoinFacts(prev, next) + } +} + +// RefinementEqual compares two refinement summaries. +func RefinementEqual(a, b *constraint.FunctionRefinement) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return a.Equals(b) +} + +// ConstructorFieldMapEqual compares one class-symbol constructor field map. +func ConstructorFieldMapEqual(sym cfg.SymbolID, a, b map[string]typ.Type) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return ConstructorFieldsEqual( + api.ConstructorFields{sym: a}, + api.ConstructorFields{sym: b}, + ) +} diff --git a/compiler/check/domain/interproc/state_test.go b/compiler/check/domain/interproc/state_test.go new file mode 100644 index 00000000..1796fe9b --- /dev/null +++ b/compiler/check/domain/interproc/state_test.go @@ -0,0 +1,118 @@ +package interproc + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/typ" +) + +func TestRefinementEqual(t *testing.T) { + if !RefinementEqual(nil, nil) { + t.Fatal("two nil refinements should be equal") + } + eff := &constraint.FunctionRefinement{Terminates: true} + if RefinementEqual(eff, nil) || RefinementEqual(nil, eff) { + t.Fatal("nil and non-nil refinements should differ") + } + if !RefinementEqual(eff, eff) { + t.Fatal("same refinement pointer should be equal") + } +} + +func TestFactMapEqual(t *testing.T) { + if !FactMapEqual(nil, nil) { + t.Fatal("two nil fact maps should be equal") + } + if !FactMapEqual(map[api.GraphKey]api.Facts{}, map[api.GraphKey]api.Facts{}) { + t.Fatal("two empty fact maps should be equal") + } + a := map[api.GraphKey]api.Facts{{GraphID: 1}: {}} + b := map[api.GraphKey]api.Facts{} + if FactMapEqual(a, b) { + t.Fatal("maps of different length should differ") + } +} + +func TestWidenFactMap_Empty(t *testing.T) { + result := WidenFactMap(nil, nil) + if result == nil { + t.Fatal("expected non-nil result") + } + if len(result) != 0 { + t.Fatal("expected empty map") + } +} + +func TestWidenFactMap_OnlyPrev(t *testing.T) { + prev := map[api.GraphKey]api.Facts{ + {GraphID: 1}: { + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.String}}, + }, + }, + } + result := WidenFactMap(prev, nil) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result)) + } +} + +func TestWidenFactMap_OnlyNext(t *testing.T) { + next := map[api.GraphKey]api.Facts{ + {GraphID: 1}: { + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.Number}}, + }, + }, + } + result := WidenFactMap(nil, next) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result)) + } +} + +func TestWidenFactMap_NormalizesNewFacts(t *testing.T) { + fn := typ.Func().Param("value", typ.Unknown).Build() + key := api.GraphKey{GraphID: 1, ParentHash: 2} + next := map[api.GraphKey]api.Facts{ + key: { + CapturedFields: api.CapturedFieldAssigns{ + cfg.SymbolID(10): { + cfg.SymbolID(20): { + "after_all": typ.NewOptional(fn), + }, + }, + }, + }, + } + + result := WidenFactMap(nil, next) + got := result[key].CapturedFields[cfg.SymbolID(10)][cfg.SymbolID(20)]["after_all"] + if !typ.TypeEquals(got, fn) { + t.Fatalf("expected new facts to be normalized through WidenFacts, got %v", got) + } +} + +func TestWidenFactMap_Merge(t *testing.T) { + prev := map[api.GraphKey]api.Facts{ + {GraphID: 1}: { + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.String}}, + }, + }, + } + next := map[api.GraphKey]api.Facts{ + {GraphID: 2}: { + FunctionFacts: api.FunctionFacts{ + 1: {Summary: []typ.Type{typ.Number}}, + }, + }, + } + result := WidenFactMap(prev, next) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } +} diff --git a/compiler/check/domain/iteration/pairs.go b/compiler/check/domain/iteration/pairs.go new file mode 100644 index 00000000..5e081eb6 --- /dev/null +++ b/compiler/check/domain/iteration/pairs.go @@ -0,0 +1,101 @@ +package iteration + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/types/constraint" +) + +// KeyedPairValue identifies the value variable paired with a pairs() key. +type KeyedPairValue struct { + TablePath constraint.Path + KeyPath constraint.Path + ValuePath constraint.Path +} + +// FindKeyedPairValue returns the table/key/value relation introduced by: +// +// for key, value in pairs(table) do +// +// The relation is pure provenance. Callers decide how much type evidence to +// take from the paired value; this query only proves that the key symbol was +// introduced by the same iterator as the value symbol. +func FindKeyedPairValue(graph *cfg.Graph, assignments []api.AssignmentEvidence, table ast.Expr, key *ast.IdentExpr) (KeyedPairValue, bool) { + if graph == nil || len(assignments) == 0 || table == nil || key == nil { + return KeyedPairValue{}, false + } + bindings := graph.Bindings() + if bindings == nil { + return KeyedPairValue{}, false + } + + keySym, ok := bindings.SymbolOf(key) + if !ok || keySym == 0 { + return KeyedPairValue{}, false + } + tablePath := path.FromExprWithBindings(table, nil, bindings) + if tablePath.IsEmpty() { + return KeyedPairValue{}, false + } + + var result KeyedPairValue + found := false + for _, assign := range assignments { + info := assign.Info + if found || info == nil || len(info.IterExprs) == 0 || len(info.Targets) < 2 { + continue + } + keyTarget := info.Targets[0] + valueTarget := info.Targets[1] + if keyTarget.Kind != cfg.TargetIdent || keyTarget.Symbol != keySym || + valueTarget.Kind != cfg.TargetIdent || valueTarget.Symbol == 0 { + continue + } + source, ok := builtinPairsSource(info.IterExprs[0], bindings) + if !ok { + continue + } + sourcePath := path.FromExprWithBindings(source, nil, bindings) + if !sourcePath.Equal(tablePath) { + continue + } + result = KeyedPairValue{ + TablePath: tablePath, + KeyPath: constraint.Path{ + Root: key.Value, + Symbol: keySym, + }, + ValuePath: constraint.Path{ + Root: valueTarget.Name, + Symbol: valueTarget.Symbol, + }, + } + found = true + } + + return result, found +} + +func builtinPairsSource(expr ast.Expr, bindings interface { + SymbolOf(*ast.IdentExpr) (cfg.SymbolID, bool) + Kind(cfg.SymbolID) (cfg.SymbolKind, bool) +}) (ast.Expr, bool) { + call, ok := expr.(*ast.FuncCallExpr) + if !ok || call == nil || len(call.Args) == 0 || call.Method != "" || call.Receiver != nil { + return nil, false + } + ident, ok := call.Func.(*ast.IdentExpr) + if !ok || ident == nil || ident.Value != "pairs" { + return nil, false + } + if bindings != nil { + if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { + if kind, ok := bindings.Kind(sym); ok && kind != cfg.SymbolGlobal { + return nil, false + } + } + } + return call.Args[0], true +} diff --git a/compiler/check/flowbuild/keyscoll/doc.go b/compiler/check/domain/keyscoll/doc.go similarity index 100% rename from compiler/check/flowbuild/keyscoll/doc.go rename to compiler/check/domain/keyscoll/doc.go diff --git a/compiler/check/flowbuild/keyscoll/keyscoll.go b/compiler/check/domain/keyscoll/keyscoll.go similarity index 74% rename from compiler/check/flowbuild/keyscoll/keyscoll.go rename to compiler/check/domain/keyscoll/keyscoll.go index 04aa2c12..fe3e4680 100644 --- a/compiler/check/flowbuild/keyscoll/keyscoll.go +++ b/compiler/check/domain/keyscoll/keyscoll.go @@ -4,8 +4,9 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" ) // KeysCollectorInfo tracks that a function returns keys of one of its parameters. @@ -14,7 +15,7 @@ type KeysCollectorInfo struct { ReturnIndex int // Which return slot carries the keys table (0-based) } -// DetectKeysCollector analyzes a function body to detect if it follows the +// DetectKeysCollector analyzes a function graph to detect if it follows the // "keys collector" pattern: creates a table, iterates with pairs over a param, // inserts keys into the table, and returns it. // @@ -25,20 +26,16 @@ type KeysCollectorInfo struct { // table.insert(keys, k) // end // return keys -func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { - if fn == nil || fn.Stmts == nil || len(fn.Stmts) == 0 { +func DetectKeysCollector(graph *cfg.Graph, evidence api.FlowEvidence) *KeysCollectorInfo { + if graph == nil { return nil } - - graph := cfg.Build(fn) - if graph == nil { + fn := graph.Func() + if fn == nil || fn.Stmts == nil || len(fn.Stmts) == 0 { return nil } - // Use graph's own bindings since we build a fresh CFG. - // Passed-in bindings may have different symbol IDs. bindings := graph.Bindings() - // Track: which local symbol is the "keys" table // Track: which param symbol is being iterated with pairs var keysTableSym cfg.SymbolID @@ -48,12 +45,13 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { insertedKeyIntoTable := false keysReturnIndex := -1 - paramSymbols := graph.ParamSymbols() + paramSlots := graph.ParamSlotsReadOnly() // Scan for local keys = {} pattern and generic for loop with pairs - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range evidence.Assignments { + info := assign.Info if info == nil || len(info.Targets) == 0 { - return + continue } // Check for local keys = {} pattern @@ -73,19 +71,19 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { if len(info.IterExprs) > 0 { call, ok := info.IterExprs[0].(*ast.FuncCallExpr) if !ok || call == nil { - return + continue } // Check if it's pairs(something) if !isPairsCall(call) { - return + continue } if len(call.Args) == 0 { - return + continue } // Check if the argument is a parameter argIdent, ok := call.Args[0].(*ast.IdentExpr) if !ok { - return + continue } var argSym cfg.SymbolID if bindings != nil { @@ -97,9 +95,10 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { argSym, _ = gb.SymbolOf(argIdent) } } - // Check if argSym is a parameter - for i, ps := range paramSymbols { - if ps == argSym { + // Check if argSym is a parameter. The slot index is the runtime + // argument index, including implicit self when present. + for i, slot := range paramSlots { + if slot.Symbol == argSym { pairsParamSym = argSym pairsParamIndex = i break @@ -110,27 +109,28 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { keyVarSym = target.Symbol } } - }) + } if keysTableSym == 0 || pairsParamSym == 0 || pairsParamIndex < 0 || keyVarSym == 0 { return nil } // Scan for table.insert(keys, k) pattern - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range evidence.Calls { + info := call.Info if info == nil { - return + continue } if !isTableInsertCall(info) { - return + continue } if len(info.Args) < 2 { - return + continue } // Check first arg is the keys table argIdent, ok := info.Args[0].(*ast.IdentExpr) if !ok { - return + continue } var argSym cfg.SymbolID if bindings != nil { @@ -142,12 +142,12 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { } } if argSym != keysTableSym { - return + continue } // Check second arg is the key variable valIdent, ok := info.Args[1].(*ast.IdentExpr) if !ok { - return + continue } var valSym cfg.SymbolID if bindings != nil { @@ -161,7 +161,7 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { if valSym == keyVarSym { insertedKeyIntoTable = true } - }) + } if !insertedKeyIntoTable { return nil @@ -185,9 +185,10 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { } // Scan for return keys pattern with a stable return slot index. - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range evidence.Returns { + info := ret.Info if info == nil || len(info.Exprs) == 0 { - return + continue } foundIdx := -1 @@ -198,24 +199,24 @@ func DetectKeysCollector(fn *ast.FunctionExpr) *KeysCollectorInfo { if foundIdx >= 0 { // Ambiguous: same return statement exposes keys at multiple slots. keysReturnIndex = -2 - return + break } foundIdx = i } if foundIdx < 0 { // Every return statement must carry the keys table for sound provenance. keysReturnIndex = -2 - return + continue } if keysReturnIndex == -1 { keysReturnIndex = foundIdx - return + continue } if keysReturnIndex != foundIdx { // Ambiguous across return statements. keysReturnIndex = -2 } - }) + } if keysReturnIndex < 0 { return nil @@ -254,9 +255,32 @@ func isTableInsertCall(info *cfg.CallInfo) bool { return true } +func functionGraph(fn *ast.FunctionExpr, owner *cfg.Graph, graphs api.GraphProvider) *cfg.Graph { + if fn == nil { + return nil + } + if owner != nil && owner.Func() == fn { + return owner + } + if graphs != nil { + if graph := graphs.GetOrBuildCFG(fn); graph != nil { + return graph + } + } + if owner != nil && owner.Bindings() != nil { + return cfg.BuildWithBindings(fn, owner.Bindings()) + } + return cfg.Build(fn) +} + // BuildKeysCollectorDetector returns a callback that detects if a call is to a // keys collector function and returns the symbol of the table argument. -func BuildKeysCollectorDetector(graph *cfg.Graph, moduleBindings *bind.BindingTable) func(*cfg.CallInfo, cfg.Point, int) cfg.SymbolID { +func BuildKeysCollectorDetector( + graph *cfg.Graph, + evidence api.FlowEvidence, + moduleBindings *bind.BindingTable, + graphs api.GraphProvider, +) func(*cfg.CallInfo, cfg.Point, int) cfg.SymbolID { cache := make(map[cfg.SymbolID]*KeysCollectorInfo) bindings := graph.Bindings() @@ -277,14 +301,27 @@ func BuildKeysCollectorDetector(graph *cfg.Graph, moduleBindings *bind.BindingTa return callsite.SymbolOrCreateFieldFromExpr(callsite.RuntimeArgAt(callInfo, info.ParamIndex), bindings) } - // Try to resolve callee to function literal - fn := resolve.ResolveSymbolToFunctionLiteral(graph, calleeSym) + // Resolve callee to a function literal through the graph evidence + // and the available binding tables before classifying its body. + fn := resolve.ResolveSymbolToFunctionLiteral(evidence, bindings, calleeSym) + if fn == nil && moduleBindings != nil && moduleBindings != bindings { + fn = resolve.ResolveSymbolToFunctionLiteral(evidence, moduleBindings, calleeSym) + } if fn == nil { cache[calleeSym] = nil continue } - info := DetectKeysCollector(fn) + fnGraph := functionGraph(fn, graph, graphs) + fnEvidence := evidence + if fnGraph != graph { + if graphs == nil { + cache[calleeSym] = nil + continue + } + fnEvidence = graphs.EvidenceForGraph(fnGraph) + } + info := DetectKeysCollector(fnGraph, fnEvidence) cache[calleeSym] = info if info == nil { continue diff --git a/compiler/check/flowbuild/keyscoll/keyscoll_test.go b/compiler/check/domain/keyscoll/keyscoll_test.go similarity index 80% rename from compiler/check/flowbuild/keyscoll/keyscoll_test.go rename to compiler/check/domain/keyscoll/keyscoll_test.go index b8f77a04..f8519ff9 100644 --- a/compiler/check/flowbuild/keyscoll/keyscoll_test.go +++ b/compiler/check/domain/keyscoll/keyscoll_test.go @@ -6,8 +6,10 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/keyscoll" + "github.com/wippyai/go-lua/compiler/check/domain/keyscoll" "github.com/wippyai/go-lua/compiler/parse" ) @@ -21,8 +23,53 @@ func TestKeysCollectorInfo_ParamIndex(t *testing.T) { } } +func detectKeysCollector(fn *ast.FunctionExpr) *keyscoll.KeysCollectorInfo { + graph := cfg.Build(fn) + return keyscoll.DetectKeysCollector(graph, evidenceForGraph(graph)) +} + +func evidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + +type testGraphProvider struct { + bindings *bind.BindingTable + cache map[*ast.FunctionExpr]*cfg.Graph +} + +func newTestGraphProvider(bindings *bind.BindingTable) *testGraphProvider { + return &testGraphProvider{ + bindings: bindings, + cache: make(map[*ast.FunctionExpr]*cfg.Graph), + } +} + +func (p *testGraphProvider) GetOrBuildCFG(fn *ast.FunctionExpr) *cfg.Graph { + if fn == nil { + return nil + } + if graph := p.cache[fn]; graph != nil { + return graph + } + var graph *cfg.Graph + if p.bindings != nil { + graph = cfg.BuildWithBindings(fn, p.bindings) + } else { + graph = cfg.Build(fn) + } + p.cache[fn] = graph + return graph +} + +func (p *testGraphProvider) EvidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + return evidenceForGraph(graph) +} + func TestDetectKeysCollector_NilFunction(t *testing.T) { - result := keyscoll.DetectKeysCollector(nil) + result := keyscoll.DetectKeysCollector(nil, api.FlowEvidence{}) if result != nil { t.Error("expected nil for nil function") } @@ -30,7 +77,7 @@ func TestDetectKeysCollector_NilFunction(t *testing.T) { func TestDetectKeysCollector_NilStmts(t *testing.T) { fn := &ast.FunctionExpr{Stmts: nil} - result := keyscoll.DetectKeysCollector(fn) + result := detectKeysCollector(fn) if result != nil { t.Error("expected nil for nil statements") } @@ -38,7 +85,7 @@ func TestDetectKeysCollector_NilStmts(t *testing.T) { func TestDetectKeysCollector_EmptyStmts(t *testing.T) { fn := &ast.FunctionExpr{Stmts: []ast.Stmt{}} - result := keyscoll.DetectKeysCollector(fn) + result := detectKeysCollector(fn) if result != nil { t.Error("expected nil for empty statements") } @@ -50,7 +97,7 @@ func TestDetectKeysCollector_SimpleReturn(t *testing.T) { &ast.ReturnStmt{Exprs: []ast.Expr{&ast.NilExpr{}}}, }, } - result := keyscoll.DetectKeysCollector(fn) + result := detectKeysCollector(fn) if result != nil { t.Error("expected nil for simple return function") } @@ -69,7 +116,7 @@ func TestDetectKeysCollector_NoKeysPattern(t *testing.T) { }, }, } - result := keyscoll.DetectKeysCollector(fn) + result := detectKeysCollector(fn) if result != nil { t.Error("expected nil for function without keys pattern") } @@ -80,7 +127,7 @@ func TestBuildKeysCollectorDetector_NilCallInfo(t *testing.T) { Stmts: []ast.Stmt{&ast.ReturnStmt{}}, } graph := cfg.Build(fn) - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) if detector == nil { t.Fatal("expected non-nil detector") } @@ -95,7 +142,7 @@ func TestBuildKeysCollectorDetector_MethodCall(t *testing.T) { Stmts: []ast.Stmt{&ast.ReturnStmt{}}, } graph := cfg.Build(fn) - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) callInfo := &cfg.CallInfo{ Method: "someMethod", Receiver: &ast.IdentExpr{Value: "obj"}, @@ -111,7 +158,7 @@ func TestBuildKeysCollectorDetector_NoCalleeSymbol(t *testing.T) { Stmts: []ast.Stmt{&ast.ReturnStmt{}}, } graph := cfg.Build(fn) - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) callInfo := &cfg.CallInfo{ Callee: &ast.IdentExpr{Value: "fn"}, CalleeSymbol: 0, @@ -138,7 +185,7 @@ func TestDetectKeysCollector_TableInsertAsAssignmentCallSite(t *testing.T) { ParList: &ast.ParList{Names: []string{"tbl"}}, Stmts: body, } - info := keyscoll.DetectKeysCollector(fn) + info := detectKeysCollector(fn) if info == nil { t.Fatal("expected keys collector to be detected when insert call is in assignment expression") } @@ -185,7 +232,7 @@ func TestBuildKeysCollectorDetector_NestedFieldArgument(t *testing.T) { } want := bindings.GetOrCreateFieldSymbol(stateSym, "users") - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) found := false graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { if info == nil || info.CalleeName != "sorted_keys" { @@ -216,7 +263,7 @@ func TestDetectKeysCollector_MultiReturnKeysIndex(t *testing.T) { ParList: &ast.ParList{Names: []string{"tbl"}}, Stmts: body, } - info := keyscoll.DetectKeysCollector(fn) + info := detectKeysCollector(fn) if info == nil { t.Fatal("expected keys collector info") } @@ -261,7 +308,7 @@ func TestBuildKeysCollectorDetector_RespectsReturnIndex(t *testing.T) { } want := bindings.GetOrCreateFieldSymbol(stateSym, "users") - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) found := false graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { if info == nil || info.CalleeName != "sorted_keys" { @@ -314,7 +361,7 @@ func TestBuildKeysCollectorDetector_UsesCanonicalCandidatesWhenRawSymbolMissing( } want := bindings.GetOrCreateFieldSymbol(stateSym, "users") - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) found := false graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { if info == nil || info.CalleeName != "sorted_keys" { @@ -368,7 +415,7 @@ func TestBuildKeysCollectorDetector_UsesModuleBindingNameFallback(t *testing.T) moduleBindings := bind.NewBindingTable() - detector := keyscoll.BuildKeysCollectorDetector(graph, moduleBindings) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), moduleBindings, newTestGraphProvider(graph.Bindings())) found := false graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { if info == nil || info.CalleeName != "sorted_keys" { @@ -427,7 +474,7 @@ func TestBuildKeysCollectorDetector_UsesDirectAliasCandidate(t *testing.T) { } want := bindings.GetOrCreateFieldSymbol(stateSym, "users") - detector := keyscoll.BuildKeysCollectorDetector(graph, nil) + detector := keyscoll.BuildKeysCollectorDetector(graph, evidenceForGraph(graph), nil, newTestGraphProvider(graph.Bindings())) found := false graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { if info == nil || info.CalleeName != "sk" { diff --git a/compiler/check/domain/paramevidence/doc.go b/compiler/check/domain/paramevidence/doc.go new file mode 100644 index 00000000..51f15cfa --- /dev/null +++ b/compiler/check/domain/paramevidence/doc.go @@ -0,0 +1,30 @@ +// Package paramevidence owns the parameter-evidence domain. +// +// The domain canonicalizes, joins, and widens observations from call sites, +// body-derived contracts, and signature facts. Orchestration packages decide +// when an observation is produced; this package decides what that observation +// means and how it combines with prior evidence. +// +// # Evidence Collection +// +// For each call site: +// +// foo(123, "bar") -- evidence: param1=number, param2=string +// +// Call-site analysis collects argument types and associates them with parameter +// positions. Multiple call sites contribute evidence that is joined here. +// +// # Evidence Merging +// +// When multiple calls provide conflicting evidence: +// +// foo(1) -- evidence: param1=number +// foo("a") -- evidence: param1=string +// +// The evidence is joined to produce: param1 = number | string +// +// # Integration +// +// Parameter evidence feeds into function signature inference, providing types +// for parameters that lack explicit annotations. +package paramevidence diff --git a/compiler/check/domain/paramevidence/merge.go b/compiler/check/domain/paramevidence/merge.go new file mode 100644 index 00000000..c05e34e6 --- /dev/null +++ b/compiler/check/domain/paramevidence/merge.go @@ -0,0 +1,237 @@ +package paramevidence + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// WidenMap merges two parameter evidence maps with the same vector law used by +// canonical FunctionFacts. +func WidenMap(prev, next map[cfg.SymbolID][]typ.Type) map[cfg.SymbolID][]typ.Type { + if prev == nil && next == nil { + return nil + } + if prev == nil { + return FilterEmptyMap(next) + } + if next == nil { + return FilterEmptyMap(prev) + } + merged := make(map[cfg.SymbolID][]typ.Type, len(prev)+len(next)) + for _, sym := range cfg.SortedSymbolIDs(prev) { + evidence := NormalizeVector(prev[sym]) + if hasNonNilEvidence(evidence) { + merged[sym] = evidence + } + } + for _, sym := range cfg.SortedSymbolIDs(next) { + evidence := NormalizeVector(next[sym]) + if !hasNonNilEvidence(evidence) { + continue + } + if existing := merged[sym]; existing != nil { + merged[sym] = JoinVectors(existing, evidence) + } else { + merged[sym] = evidence + } + } + return merged +} + +// FilterEmptyMap normalizes evidence and drops entries with no informative +// slots. +func FilterEmptyMap(evidence map[cfg.SymbolID][]typ.Type) map[cfg.SymbolID][]typ.Type { + if evidence == nil { + return nil + } + out := make(map[cfg.SymbolID][]typ.Type, len(evidence)) + for _, sym := range cfg.SortedSymbolIDs(evidence) { + v := FilterEmptyVector(evidence[sym]) + if hasNonNilEvidence(v) { + out[sym] = v + } + } + if len(out) == 0 { + return nil + } + return out +} + +// FilterEmptyVector normalizes one evidence vector and returns nil when all +// slots are empty. +func FilterEmptyVector(evidence []typ.Type) []typ.Type { + v := NormalizeVector(evidence) + if !hasNonNilEvidence(v) { + return nil + } + return v +} + +// NormalizeVector canonicalizes all occupied evidence slots. +func NormalizeVector(evidence []typ.Type) []typ.Type { + var out []typ.Type + for i, observed := range evidence { + normalized := NormalizeType(observed) + if out != nil { + out[i] = normalized + continue + } + if !typ.TypeEquals(observed, normalized) { + out = make([]typ.Type, len(evidence)) + copy(out, evidence[:i]) + out[i] = normalized + } + } + if out != nil { + return out + } + return evidence +} + +// EqualVectors reports whether two normalized evidence vectors are structurally +// equal. +func EqualVectors(a, b []typ.Type) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !typ.TypeEquals(a[i], b[i]) { + return false + } + } + return true +} + +func hasNonNilEvidence(evidence []typ.Type) bool { + for _, observed := range evidence { + if observed != nil { + return true + } + } + return false +} + +// JoinVectors joins two parameter evidence vectors element-wise. +func JoinVectors(a, b []typ.Type) []typ.Type { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + maxLen := len(a) + if len(b) > maxLen { + maxLen = len(b) + } + result := make([]typ.Type, maxLen) + for i := 0; i < maxLen; i++ { + var ai, bi typ.Type + if i < len(a) { + ai = a[i] + } + if i < len(b) { + bi = b[i] + } + result[i] = Join(ai, bi) + } + return result +} + +// Join merges two parameter evidence observations. +func Join(a, b typ.Type) typ.Type { + a = NormalizeType(a) + b = NormalizeType(b) + if a == nil { + return b + } + if b == nil { + return a + } + if unwrap.IsNilType(a) && !unwrap.IsNilType(b) { + return b + } + if unwrap.IsNilType(b) && !unwrap.IsNilType(a) { + return a + } + if joined, ok := joinNilable(a, b); ok { + return joined + } + return joinNonNil(a, b) +} + +func joinNilable(a, b typ.Type) (typ.Type, bool) { + ai, anil := value.SplitNilable(a) + bi, bnil := value.SplitNilable(b) + if !anil && !bnil { + return nil, false + } + if ai == nil && bi == nil { + return typ.Nil, true + } + if ai == nil { + return typ.NewOptional(bi), true + } + if bi == nil { + return typ.NewOptional(ai), true + } + return typ.NewOptional(joinNonNil(ai, bi)), true +} + +func joinNonNil(a, b typ.Type) typ.Type { + if upper, ok := value.SelectTableUpperBound(a, b); ok { + return upper + } + if preferred, ok := value.PreferConcreteOverSoft(a, b); ok { + return preferred + } + if value.CanSelfEmbed(a) && value.ContainsEquivalent(b, a) && !typ.IsAbsentOrUnknown(a) { + if value.ContainsUnion(a) { + return a + } + return typ.JoinPreferNonSoft(a, b) + } + if value.CanSelfEmbed(b) && value.ContainsEquivalent(a, b) && !typ.IsAbsentOrUnknown(b) { + if value.ContainsUnion(b) { + return b + } + return typ.JoinPreferNonSoft(a, b) + } + if value.IsTruthyRefinement(a, b) { + return a + } + if value.IsTruthyRefinement(b, a) { + return b + } + if joined, ok := typ.JoinCompatibleRecords(a, b); ok { + return joined + } + if joined, ok := value.JoinMapRecordShape(a, b, joinNonNil); ok { + return joined + } + if value.ExtendsRecord(a, b) { + return a + } + if value.ExtendsRecord(b, a) { + return b + } + if !typ.IsAbsentOrUnknown(a) && !typ.IsAbsentOrUnknown(b) { + if subtype.IsSubtype(a, b) { + return b + } + if subtype.IsSubtype(b, a) { + return a + } + } + return NormalizeType(typ.JoinPreferNonSoft(a, b)) +} + +// RefinesFunctionParam reports whether candidate is a valid directional +// refinement of baseline for parameter-slot facts. +func RefinesFunctionParam(candidate, baseline typ.Type) bool { + return value.ElidesOptional(candidate, baseline) || + value.IsTruthyRefinement(candidate, baseline) || + value.RefinesTableKeyByTruthiness(candidate, baseline) +} diff --git a/compiler/check/domain/paramevidence/merge_test.go b/compiler/check/domain/paramevidence/merge_test.go new file mode 100644 index 00000000..cad42837 --- /dev/null +++ b/compiler/check/domain/paramevidence/merge_test.go @@ -0,0 +1,300 @@ +package paramevidence + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/types/typ" +) + +func TestWidenMap_StopsSelfEmbeddingRecordGrowth(t *testing.T) { + prevHint := typ.NewUnion( + typ.Number, + typ.NewRecord(). + Field("limit", typ.Any). + SetOpen(true). + Build(), + ) + nextHint := typ.NewRecord(). + Field("limit", prevHint). + SetOpen(true). + Build() + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {prevHint}}, + map[cfg.SymbolID][]typ.Type{1: {nextHint}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, prevHint) { + t.Fatalf("expected stable previous evidence, got %v", got) + } +} + +func TestWidenMap_StopsSelfEmbeddingContainerGrowth(t *testing.T) { + prevHint := typ.NewUnion( + typ.Number, + typ.NewRecord(). + Field("limit", typ.Any). + SetOpen(true). + Build(), + ) + + tests := []struct { + name string + next typ.Type + }{ + { + name: "record", + next: typ.NewRecord(). + Field("value", prevHint). + SetOpen(true). + Build(), + }, + { + name: "array", + next: typ.NewArray(prevHint), + }, + { + name: "map", + next: typ.NewMap(typ.String, prevHint), + }, + { + name: "tuple", + next: typ.NewTuple(prevHint), + }, + { + name: "function", + next: typ.Func(). + Param("value", prevHint). + Returns(prevHint). + Build(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {prevHint}}, + map[cfg.SymbolID][]typ.Type{1: {tt.next}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, prevHint) { + t.Fatalf("expected stable previous evidence, got %v", got) + } + }) + } +} + +func TestWidenMap_KeepsFirstRecordWrapperObservation(t *testing.T) { + nextHint := typ.NewRecord(). + Field("limit", typ.Number). + SetOpen(true). + Build() + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {typ.Number}}, + map[cfg.SymbolID][]typ.Type{1: {nextHint}}, + ) + + got := merged[1][0] + if typ.TypeEquals(got, typ.Number) { + t.Fatalf("expected wrapper observation to be preserved, got %v", got) + } + if !typ.TypeEquals(got, typ.NewUnion(typ.Number, nextHint)) { + t.Fatalf("expected number | wrapper evidence, got %v", got) + } +} + +func TestWidenMap_JoinsNestedRecordObservations(t *testing.T) { + nested := typ.NewRecord(). + Field("routes", typ.NewRecord().Field("users", typ.Boolean).SetOpen(true).Build()). + SetOpen(true). + Build() + outer := typ.NewRecord(). + Field("api", nested). + SetOpen(true). + Build() + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {outer}}, + map[cfg.SymbolID][]typ.Type{1: {nested}}, + ) + + got := merged[1][0] + want := typ.NewUnion(outer, nested) + if !typ.TypeEquals(got, want) { + t.Fatalf("expected nested record observations to be joined as %v, got %v", want, got) + } +} + +func TestWidenMap_ReplacesStaleBroadHintWithCurrentRefinement(t *testing.T) { + stale := typ.NewUnion(typ.String, typ.False) + current := typ.String + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {stale}}, + map[cfg.SymbolID][]typ.Type{1: {current}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, current) { + t.Fatalf("expected current refined evidence %v to replace stale broad evidence, got %v", current, got) + } +} + +func TestWidenMap_ReplacesSoftContainerPlaceholderWithConcreteElementShape(t *testing.T) { + entry := typ.NewRecord().Field("id", typ.String).Build() + stale := typ.NewUnion( + typ.NewArray(typ.Any), + typ.NewRecord().SetOpen(true).Build(), + ) + current := typ.NewArray(entry) + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {stale}}, + map[cfg.SymbolID][]typ.Type{1: {current}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, current) { + t.Fatalf("expected concrete array evidence %v to replace soft stale evidence, got %v", current, got) + } +} + +func TestWidenMap_PreservesStructuredHintOverNilOnlyObservation(t *testing.T) { + context := typ.NewMap(typ.String, typ.Any) + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, context}}, + map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, typ.Nil}}, + ) + + got := merged[1][2] + if !typ.TypeEquals(got, context) { + t.Fatalf("expected nil-only observation to preserve structured evidence %v, got %v", context, got) + } + + again := WidenMap(merged, map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, typ.Nil}}) + if !evidenceMapsEqual(merged, again) { + t.Fatalf("expected idempotent nil-only observation widening, got %v then %v", merged, again) + } +} + +func TestWidenMap_PreservesMapHintOverOptionalOpenRecordObservation(t *testing.T) { + context := typ.NewMap(typ.String, typ.Any) + optionalContextRecord := typ.NewOptional(typ.NewRecord(). + MapComponent(typ.String, typ.Any). + SetOpen(true). + Build()) + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, context}}, + map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, optionalContextRecord}}, + ) + + got := merged[1][2] + if got == nil || typ.TypeEquals(got, typ.Nil) { + t.Fatalf("expected optional structured observation to preserve context evidence, got %v", got) + } + if !typ.TypeEquals(got, typ.NewOptional(context)) { + t.Fatalf("expected pure map observation to stay canonical, got %v", got) + } + + again := WidenMap(merged, map[cfg.SymbolID][]typ.Type{1: {typ.String, typ.Any, optionalContextRecord}}) + if !evidenceMapsEqual(merged, again) { + t.Fatalf("expected idempotent optional structured observation widening, got %v then %v", merged, again) + } +} + +func TestWidenMap_CollapsesPureOpenRecordMapToCanonicalMap(t *testing.T) { + entry := typ.NewRecord().Field("id", typ.String).Build() + canonical := typ.NewMap(typ.String, typ.NewArray(entry)) + staleRecordView := typ.NewRecord(). + MapComponent(typ.NewUnion(typ.String, typ.False), typ.NewArray(entry)). + SetOpen(true). + Build() + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {staleRecordView}}, + map[cfg.SymbolID][]typ.Type{1: {canonical}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, canonical) { + t.Fatalf("expected pure keyed table evidence to canonicalize to %v, got %v", canonical, got) + } +} + +func TestWidenMap_TableTopUpperBoundAbsorbsRecordUnion(t *testing.T) { + tableTop := typ.NewOptional(typ.NewInterface("table", nil)) + strategySpec := typ.NewRecord(). + Field("kind", typ.LiteralString("strategy")). + Field("tools", typ.NewTuple(typ.String, typ.String, typ.String)). + Build() + contextSpec := typ.NewRecord(). + Field("kind", typ.LiteralString("context")). + Field("scope", typ.String). + Build() + nextHint := typ.NewUnion(strategySpec, contextSpec) + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {tableTop}}, + map[cfg.SymbolID][]typ.Type{1: {nextHint}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, tableTop) { + t.Fatalf("expected table top upper bound %v, got %v", tableTop, got) + } + + again := WidenMap(merged, map[cfg.SymbolID][]typ.Type{1: {nextHint}}) + if !evidenceMapsEqual(merged, again) { + t.Fatalf("expected idempotent table-top widening, got %v then %v", merged, again) + } +} + +func TestWidenMap_TableTopUpperBoundAbsorbsAnyObservation(t *testing.T) { + tableTop := typ.NewOptional(typ.NewInterface("table", nil)) + + merged := WidenMap( + map[cfg.SymbolID][]typ.Type{1: {tableTop}}, + map[cfg.SymbolID][]typ.Type{1: {typ.Any}}, + ) + + got := merged[1][0] + if !typ.TypeEquals(got, tableTop) { + t.Fatalf("expected dynamic observation to preserve table top upper bound %v, got %v", tableTop, got) + } + + again := WidenMap(merged, map[cfg.SymbolID][]typ.Type{1: {typ.Any}}) + if !evidenceMapsEqual(merged, again) { + t.Fatalf("expected idempotent table-top/any widening, got %v then %v", merged, again) + } +} + +func TestEqualVectors(t *testing.T) { + if !EqualVectors([]typ.Type{typ.String, typ.Nil}, []typ.Type{typ.String, typ.Nil}) { + t.Fatal("expected equal evidence vectors") + } + if EqualVectors([]typ.Type{typ.String}, []typ.Type{typ.String, typ.Nil}) { + t.Fatal("expected different lengths to be unequal") + } + if EqualVectors([]typ.Type{typ.String}, []typ.Type{typ.Number}) { + t.Fatal("expected different evidence slots to be unequal") + } +} + +func evidenceMapsEqual(a, b map[cfg.SymbolID][]typ.Type) bool { + if len(a) != len(b) { + return false + } + for _, sym := range cfg.SortedSymbolIDs(a) { + right, ok := b[sym] + if !ok || !EqualVectors(a[sym], right) { + return false + } + } + return true +} diff --git a/compiler/check/domain/paramevidence/parameter_evidence.go b/compiler/check/domain/paramevidence/parameter_evidence.go new file mode 100644 index 00000000..c5aea60c --- /dev/null +++ b/compiler/check/domain/paramevidence/parameter_evidence.go @@ -0,0 +1,383 @@ +package paramevidence + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +type JoinFn func(prev, next typ.Type) typ.Type + +// MergeIntoSignature merges parameter evidence into a synthesized signature. +// Hard annotations stay authoritative. Soft structural annotations keep their +// container shape while evidence refines their element/value domain. +func MergeIntoSignature(fn *ast.FunctionExpr, evidence []typ.Type, sig *typ.Function) *typ.Function { + if sig == nil || fn == nil || fn.ParList == nil { + return sig + } + modified := false + for i, p := range sig.Params { + paramType, optional := mergeSignatureParam(fn, sig, evidence, i, p) + if !typ.TypeEquals(p.Type, paramType) || p.Optional != optional { + modified = true + } + } + if !modified { + return sig + } + + builder := typ.Func() + for i, p := range sig.Params { + paramType, optional := mergeSignatureParam(fn, sig, evidence, i, p) + if optional { + builder = builder.OptParam(p.Name, paramType) + } else { + builder = builder.Param(p.Name, paramType) + } + } + if sig.Variadic != nil { + builder = builder.Variadic(sig.Variadic) + } + if len(sig.Returns) > 0 { + builder = builder.Returns(sig.Returns...) + } + if sig.Effects != nil { + builder = builder.Effects(sig.Effects) + } + if sig.Spec != nil { + builder = builder.Spec(sig.Spec) + } + if sig.Refinement != nil { + builder = builder.WithRefinement(sig.Refinement) + } + return builder.Build() +} + +func mergeSignatureParam(fn *ast.FunctionExpr, sig *typ.Function, evidence []typ.Type, idx int, param typ.Param) (typ.Type, bool) { + if idx >= len(evidence) || evidence[idx] == nil { + return param.Type, param.Optional + } + srcIdx, hasSource := signatureSourceParamIndex(fn, sig, idx) + if hasSource && srcIdx < len(fn.ParList.Types) && fn.ParList.Types[srcIdx] != nil { + return RefineAnnotationWithEvidence(param.Type, evidence[idx]), param.Optional + } + return MergeUnannotatedParam(param, evidence[idx]) +} + +// MergeUnannotatedParam merges call/body evidence into an unannotated parameter. +// Concrete synthesized demands dominate stale nilable seeds: nil remains a valid +// call-boundary arity concern, but it must not poison the specialized body type +// once all observed/demanded uses require a non-nil value. +func MergeUnannotatedParam(param typ.Param, evidence typ.Type) (typ.Type, bool) { + if evidence == nil { + return param.Type, param.Optional + } + if concreteParamTypeDominatesNilableEvidence(param.Type, evidence) { + return param.Type, false + } + paramType := evidence + optional := param.Optional + if hasConcreteParamType(param.Type) { + paramType = Join(param.Type, evidence) + if concreteParamTypeDominatesNilableEvidence(param.Type, paramType) { + return param.Type, false + } + } + if !unwrap.IsOptionalLike(paramType) { + optional = false + } + return paramType, optional +} + +func concreteParamTypeDominatesNilableEvidence(paramType, evidence typ.Type) bool { + if !hasConcreteParamType(paramType) || evidence == nil { + return false + } + inner, nilable := typ.SplitNilableFieldType(evidence) + if !nilable || inner == nil { + return false + } + return typ.TypeEquals(paramType, inner) || subtype.IsSubtype(paramType, inner) +} + +func hasConcreteParamType(t typ.Type) bool { + return t != nil && + !typ.IsAny(t) && + !typ.IsUnknown(t) && + !t.Kind().IsPlaceholder() && + !unwrap.IsOptionalLike(t) +} + +// RefineAnnotationWithEvidence returns the function-body type produced when a +// soft structural annotation receives harder evidence. Hard annotations and top +// annotations (`any`, `unknown`) remain authoritative. +func RefineAnnotationWithEvidence(annotation, evidence typ.Type) typ.Type { + if annotation == nil || evidence == nil || !typ.IsRefinableAnnotation(annotation) { + return annotation + } + evidence = NormalizeType(evidence) + if !IsInformative(evidence) { + return annotation + } + if refined, changed := value.RefineStructuralAnnotation(annotation, evidence, Join); changed { + return refined + } + return annotation +} + +func signatureSourceParamIndex(fn *ast.FunctionExpr, sig *typ.Function, paramIdx int) (int, bool) { + if fn == nil || fn.ParList == nil || sig == nil || paramIdx < 0 || paramIdx >= len(sig.Params) { + return 0, false + } + if signatureHasImplicitSelf(fn, sig) { + if paramIdx == 0 { + return 0, false + } + srcIdx := paramIdx - 1 + return srcIdx, srcIdx >= 0 && srcIdx < len(fn.ParList.Names) + } + return paramIdx, paramIdx < len(fn.ParList.Names) +} + +func signatureHasImplicitSelf(fn *ast.FunctionExpr, sig *typ.Function) bool { + if fn == nil || fn.ParList == nil || sig == nil || len(sig.Params) == 0 { + return false + } + if sig.Params[0].Name != "self" { + return false + } + if len(fn.ParList.Names) > 0 && fn.ParList.Names[0] == "self" { + return false + } + return len(sig.Params) == len(fn.ParList.Names)+1 +} + +func WidenType(t typ.Type) typ.Type { + if t == nil { + return nil + } + switch v := t.(type) { + case *typ.Literal: + switch v.Base { + case kind.Boolean: + return typ.Boolean + case kind.Integer: + return typ.Integer + case kind.Number: + return typ.Number + case kind.String: + return typ.String + } + case *typ.Optional: + inner := WidenType(v.Inner) + if inner != v.Inner && inner != nil { + return typ.NewOptional(inner) + } + case *typ.Alias: + if v.Target != nil { + return WidenType(v.Target) + } + case *typ.Union: + changed := false + members := make([]typ.Type, 0, len(v.Members)) + for _, m := range v.Members { + wm := WidenType(m) + if wm != m { + changed = true + } + members = append(members, wm) + } + if changed { + return typ.NewUnion(members...) + } + case *typ.Record: + builder := typ.NewRecord() + changed := false + if v.Open { + builder.SetOpen(true) + } + for _, f := range v.Fields { + ft := WidenType(f.Type) + if ft != f.Type { + changed = true + } + if f.Optional { + builder.OptField(f.Name, ft) + } else { + builder.Field(f.Name, ft) + } + } + if v.MapKey != nil && v.MapValue != nil { + k := WidenType(v.MapKey) + val := WidenType(v.MapValue) + if k != v.MapKey || val != v.MapValue { + changed = true + } + builder.MapComponent(k, val) + } + if v.Metatable != nil { + builder.Metatable(v.Metatable) + } + if changed { + return builder.Build() + } + } + return t +} + +// NormalizeType applies canonical widening and soft-member pruning. +func NormalizeType(t typ.Type) typ.Type { + return value.CollapseTableTopEvidence(typ.PruneSoftUnionMembers(WidenType(t))) +} + +// EnsureCapacity grows evidence vector to at least size. +func EnsureCapacity(evidence []typ.Type, size int) []typ.Type { + if size <= len(evidence) { + return evidence + } + expanded := make([]typ.Type, size) + copy(expanded, evidence) + return expanded +} + +// MergeAt normalizes and joins one observation into vector slot idx. +func MergeAt(vec []typ.Type, idx int, observed typ.Type, join JoinFn) ([]typ.Type, bool) { + if idx < 0 { + return vec, false + } + observed = NormalizeType(observed) + if !IsInformative(observed) { + return vec, false + } + vec = EnsureCapacity(vec, idx+1) + + joinFn := join + if joinFn == nil { + joinFn = typ.JoinPreferNonSoft + } + prev := vec[idx] + merged := joinFn(prev, observed) + if typ.TypeEquals(prev, merged) { + return vec, false + } + vec[idx] = merged + return vec, true +} + +// MergeCallArgAt merges a call-argument observation into a parameter evidence +// slot. Unlike MergeAt, unresolved/top-like argument observations are +// preserved as uncertainty evidence so later literal calls cannot over-specialize +// unannotated parameters. +func MergeCallArgAt(evidence []typ.Type, idx int, argType typ.Type, join JoinFn, unknownOnNil bool) ([]typ.Type, bool) { + if idx < 0 { + return evidence, false + } + argType = NormalizeType(argType) + if argType == nil { + if !unknownOnNil { + return evidence, false + } + argType = typ.Unknown + } + evidence = EnsureCapacity(evidence, idx+1) + + joinFn := join + if joinFn == nil { + joinFn = typ.JoinPreferNonSoft + } + + prev := NormalizeType(evidence[idx]) + if prev == nil { + prev = evidence[idx] + } + + mergeTopAware := func(a, b typ.Type) typ.Type { + if a == nil { + return b + } + if b == nil { + return a + } + if typ.IsAny(a) || typ.IsAny(b) { + return typ.Any + } + if typ.IsUnknown(a) { + return b + } + if typ.IsUnknown(b) { + return a + } + return joinFn(a, b) + } + + topLikeArg := typ.IsAny(argType) || typ.IsUnknown(argType) + nilArg := unwrap.IsNilType(argType) + if !topLikeArg && !nilArg && !IsInformative(argType) { + return evidence, false + } + + merged := mergeTopAware(prev, argType) + if typ.TypeEquals(evidence[idx], merged) { + return evidence, false + } + evidence[idx] = merged + return evidence, true +} + +// IsInformative reports whether a type carries useful call-site +// information for parameter evidence propagation. +// +// It intentionally rejects top-like and empty placeholder shapes that tend to +// poison evidence, while preserving structured evidence such as maps/arrays with +// partial information (for example `{[string]: any[]}`). +func IsInformative(t typ.Type) bool { + return isInformativeEvidenceType(t, typ.NewGuard()) +} + +func isInformativeEvidenceType(t typ.Type, guard internal.RecursionGuard) bool { + if t == nil { + return false + } + next, ok := guard.Enter(t) + if !ok { + return false + } + + if t.Kind().IsDeferred() { + return false + } + + k := t.Kind() + if k.IsPlaceholder() || k == kind.Nil || k == kind.Never { + return false + } + + switch v := t.(type) { + case *typ.Optional: + return isInformativeEvidenceType(v.Inner, next) + case *typ.Union: + for _, m := range v.Members { + if isInformativeEvidenceType(m, next) { + return true + } + } + return false + case *typ.Alias: + if v.Target == nil { + return false + } + return isInformativeEvidenceType(v.Target, next) + } + + if r, ok := t.(*typ.Record); ok { + if len(r.Fields) == 0 && !r.HasMapComponent() && !r.Open { + return false + } + } + + return true +} diff --git a/compiler/check/domain/paramevidence/parameter_evidence_test.go b/compiler/check/domain/paramevidence/parameter_evidence_test.go new file mode 100644 index 00000000..26a8a421 --- /dev/null +++ b/compiler/check/domain/paramevidence/parameter_evidence_test.go @@ -0,0 +1,697 @@ +package paramevidence + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/types/typ" +) + +func TestWidenType_Nil(t *testing.T) { + result := WidenType(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestWidenType_BooleanLiteral(t *testing.T) { + lit := typ.LiteralBool(true) + result := WidenType(lit) + if result != typ.Boolean { + t.Errorf("expected Boolean, got %v", result) + } +} + +func TestWidenType_IntegerLiteral(t *testing.T) { + lit := typ.LiteralInt(42) + result := WidenType(lit) + if result != typ.Integer { + t.Errorf("expected Integer, got %v", result) + } +} + +func TestWidenType_NumberLiteral(t *testing.T) { + lit := typ.LiteralNumber(3.14) + result := WidenType(lit) + if result != typ.Number { + t.Errorf("expected Number, got %v", result) + } +} + +func TestWidenType_StringLiteral(t *testing.T) { + lit := typ.LiteralString("hello") + result := WidenType(lit) + if result != typ.String { + t.Errorf("expected String, got %v", result) + } +} + +func TestWidenType_NonLiteral(t *testing.T) { + result := WidenType(typ.String) + if result != typ.String { + t.Errorf("expected String unchanged, got %v", result) + } +} + +func TestWidenType_Alias(t *testing.T) { + alias := typ.NewAlias("NumAlias", typ.Number) + result := WidenType(alias) + if result != typ.Number { + t.Errorf("expected alias to widen to Number, got %v", result) + } +} + +func TestWidenType_Optional(t *testing.T) { + lit := typ.LiteralString("hello") + opt := typ.NewOptional(lit) + result := WidenType(opt) + if result == nil { + t.Fatal("expected non-nil result") + } + optResult, ok := result.(*typ.Optional) + if !ok { + t.Fatalf("expected Optional, got %T", result) + } + if optResult.Inner != typ.String { + t.Errorf("expected inner to be String, got %v", optResult.Inner) + } +} + +func TestWidenType_Union(t *testing.T) { + lit1 := typ.LiteralString("a") + lit2 := typ.LiteralNumber(1.0) + union := typ.NewUnion(lit1, lit2) + result := WidenType(union) + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestNormalizeType_TableTopAbsorbsPreciseTableMembers(t *testing.T) { + tableTop := typ.NewInterface("table", nil) + preciseA := typ.NewRecord(). + Field("name", typ.String). + Field("tools", typ.NewArray(typ.String)). + Build() + preciseB := typ.NewMap(typ.String, typ.Integer) + evidence := typ.NewUnion(typ.NewOptional(tableTop), preciseA, preciseB, typ.String) + + got := NormalizeType(evidence) + want := typ.NewUnion(typ.NewOptional(tableTop), typ.String) + if !typ.TypeEquals(got, want) { + t.Fatalf("expected table top to absorb precise table members as %v, got %v", want, got) + } +} + +func TestWidenType_RecordPreservesClosedShape(t *testing.T) { + rec := typ.NewRecord(). + Field("pid", typ.LiteralString("abc")). + Field("topic", typ.LiteralString("test:update")). + Build() + + result := WidenType(rec) + widened, ok := result.(*typ.Record) + if !ok { + t.Fatalf("expected record result, got %T", result) + } + if widened.Open { + t.Fatalf("expected parameter evidence to preserve closed call-site shape, got open: %v", widened) + } + + pid := widened.GetField("pid") + if pid == nil || !typ.TypeEquals(pid.Type, typ.String) { + t.Fatalf("expected pid field widened to string, got %v", pid) + } + topic := widened.GetField("topic") + if topic == nil || !typ.TypeEquals(topic.Type, typ.String) { + t.Fatalf("expected topic field widened to string, got %v", topic) + } +} + +func TestMergeIntoSignature_ImplicitSelfUsesEffectiveHintSlots(t *testing.T) { + fn := functionWithParams("name") + sig := typ.Func(). + Param("self", typ.Unknown). + Param("name", typ.Unknown). + Build() + selfType := typ.NewRecord().Field("prefix", typ.String).Build() + + got := MergeIntoSignature(fn, []typ.Type{selfType, typ.String}, sig) + if got == nil || len(got.Params) != 2 { + t.Fatalf("unexpected merged signature: %v", got) + } + if !typ.TypeEquals(got.Params[0].Type, selfType) { + t.Fatalf("self evidence should use effective slot 0, got %v", got.Params[0].Type) + } + if !typ.TypeEquals(got.Params[1].Type, typ.String) { + t.Fatalf("source parameter evidence should use effective slot 1, got %v", got.Params[1].Type) + } +} + +func TestMergeIntoSignature_SoftArrayAnnotationPreservesContainerShape(t *testing.T) { + fn := functionWithParams("responses") + fn.ParList.Types = []ast.TypeExpr{&ast.ArrayTypeExpr{ + Element: &ast.PrimitiveTypeExpr{Name: "any"}, + }} + sig := typ.Func(). + Param("responses", typ.NewArray(typ.Any)). + Build() + evidence := typ.NewTuple( + typ.NewRecord().Field("ok", typ.Boolean).Build(), + typ.NewRecord().Field("ok", typ.Boolean).Build(), + ) + + got := MergeIntoSignature(fn, []typ.Type{evidence}, sig) + if got == nil || len(got.Params) != 1 { + t.Fatalf("unexpected merged signature: %v", got) + } + want := typ.NewArray(typ.NewRecord().Field("ok", typ.Boolean).Build()) + if !typ.TypeEquals(got.Params[0].Type, want) { + t.Fatalf("soft {any} parameter annotation must refine element domain without becoming a tuple, got %v", got.Params[0].Type) + } +} + +func TestMergeIntoSignature_HardAnyAnnotationRemainsAuthoritative(t *testing.T) { + fn := functionWithParams("value") + fn.ParList.Types = []ast.TypeExpr{&ast.PrimitiveTypeExpr{Name: "any"}} + sig := typ.Func(). + Param("value", typ.Any). + Build() + evidence := typ.NewRecord().Field("id", typ.String).Build() + + got := MergeIntoSignature(fn, []typ.Type{evidence}, sig) + if got != sig { + t.Fatalf("hard any annotation must stay authoritative, got %v", got) + } +} + +func TestMergeIntoSignature_PreservesExplicitNilabilityOnOptionalSlot(t *testing.T) { + fn := functionWithParams("context") + context := typ.NewRecord(). + MapComponent(typ.String, typ.Any). + SetOpen(true). + Build() + sig := typ.Func().OptParam("context", typ.Any).Build() + + got := MergeIntoSignature(fn, []typ.Type{typ.NewOptional(context)}, sig) + if got == nil || len(got.Params) != 1 { + t.Fatalf("unexpected merged signature: %v", got) + } + if !got.Params[0].Optional { + t.Fatalf("expected parameter slot to remain optional: %v", got) + } + want := typ.NewOptional(context) + if !typ.TypeEquals(got.Params[0].Type, want) { + t.Fatalf("expected nilability to remain in the value type, got %v", got.Params[0].Type) + } +} + +func TestProjectToParameterUse_KeepsDemandedRecordFields(t *testing.T) { + fn := functionWithParams("client", "model_id") + fn.Stmts = []ast.Stmt{ + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.FuncCallExpr{ + Func: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "client"}, + Key: &ast.StringExpr{Value: "invoke"}, + }, + Args: []ast.Expr{ + &ast.IdentExpr{Value: "model_id"}, + &ast.TableExpr{}, + &ast.TableExpr{}, + }, + }, + }}, + } + graph := cfg.Build(fn) + invoke := typ.Func().Param("model_id", typ.String).Returns(typ.Unknown).Build() + client := typ.NewRecord(). + Field("invoke", invoke). + Field("process_converse_stream", typ.Func().Returns(typ.String).Build()). + Field("_credentials", typ.String). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{client, typ.String}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected client evidence = %T, want record (%v)", got[0], got[0]) + } + if rec.GetField("invoke") == nil { + t.Fatalf("projected client evidence lost demanded invoke field: %v", rec) + } + for _, unused := range []string{"process_converse_stream", "_credentials"} { + if rec.GetField(unused) != nil { + t.Fatalf("projected client evidence kept unused field %q: %v", unused, rec) + } + } + if !typ.TypeEquals(got[1], typ.String) { + t.Fatalf("directly used scalar evidence should stay intact, got %v", got[1]) + } +} + +func TestProjectToParameterUse_KeepsDemandedAbsentRecordFieldsAsNil(t *testing.T) { + fn := functionWithParams("options") + fn.Stmts = []ast.Stmt{ + &ast.IfStmt{ + Condition: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "options"}, + Key: &ast.StringExpr{Value: "stream"}, + }, + }, + &ast.AssignStmt{ + Lhs: []ast.Expr{ + &ast.AttrGetExpr{ + Object: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "options"}, + Key: &ast.StringExpr{Value: "headers"}, + }, + Key: &ast.StringExpr{Value: "Accept"}, + }, + }, + Rhs: []ast.Expr{&ast.StringExpr{Value: "application/json"}}, + }, + } + graph := cfg.Build(fn) + evidence := typ.NewRecord(). + Field("headers", typ.NewRecord().Build()). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{evidence}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected options evidence = %T, want record (%v)", got[0], got[0]) + } + stream := rec.GetField("stream") + if stream == nil || !typ.TypeEquals(stream.Type, typ.Nil) { + t.Fatalf("demanded absent stream field should project as nil, got %v in %v", stream, rec) + } + headers := rec.GetField("headers") + if headers == nil { + t.Fatalf("projected options evidence lost demanded headers field: %v", rec) + } +} + +func TestProjectToParameterUse_WholeForwardingCompletesDemandedFields(t *testing.T) { + fn := functionWithParams("options") + fn.Stmts = []ast.Stmt{ + &ast.IfStmt{ + Condition: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "options"}, + Key: &ast.StringExpr{Value: "stream"}, + }, + }, + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.FuncCallExpr{ + Func: &ast.IdentExpr{Value: "forward"}, + Args: []ast.Expr{&ast.IdentExpr{Value: "options"}}, + }, + }}, + } + graph := cfg.Build(fn, "forward") + evidence := typ.NewRecord(). + Field("headers", typ.NewRecord().Build()). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{evidence}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected options evidence = %T, want record (%v)", got[0], got[0]) + } + if rec.GetField("headers") == nil { + t.Fatalf("whole forwarding should retain existing evidence fields: %v", rec) + } + stream := rec.GetField("stream") + if stream == nil || !typ.TypeEquals(stream.Type, typ.Nil) { + t.Fatalf("direct field demand should complete forwarded evidence with stream:nil, got %v in %v", stream, rec) + } +} + +func TestProjectToParameterUse_SingleFieldWriteDoesNotDemandInputField(t *testing.T) { + fn := functionWithParams("schema") + fn.Stmts = []ast.Stmt{ + &ast.AssignStmt{ + Lhs: []ast.Expr{&ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "schema"}, + Key: &ast.StringExpr{Value: "examples"}, + }}, + Rhs: []ast.Expr{&ast.NilExpr{}}, + }, + &ast.ReturnStmt{Exprs: []ast.Expr{&ast.IdentExpr{Value: "schema"}}}, + } + graph := cfg.Build(fn) + evidence := typ.NewRecord().Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{evidence}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected schema evidence = %T, want record (%v)", got[0], got[0]) + } + if field := rec.GetField("examples"); field != nil { + t.Fatalf("single-segment field write must not become a caller requirement, got %v in %v", field, rec) + } +} + +func TestProjectSignatureToParamUse_CompletesDemandedAbsentFields(t *testing.T) { + fn := functionWithParams("info") + fn.Stmts = []ast.Stmt{ + &ast.AssignStmt{ + Lhs: []ast.Expr{&ast.IdentExpr{Value: "info"}}, + Rhs: []ast.Expr{&ast.LogicalOpExpr{ + Operator: "or", + Lhs: &ast.IdentExpr{Value: "info"}, + Rhs: &ast.TableExpr{}, + }}, + }, + &ast.LocalAssignStmt{Exprs: []ast.Expr{ + &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "info"}, + Key: &ast.StringExpr{Value: "message"}, + }, + }}, + &ast.LocalAssignStmt{Exprs: []ast.Expr{ + &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "info"}, + Key: &ast.StringExpr{Value: "status_code"}, + }, + }}, + } + graph := cfg.Build(fn) + info := typ.NewRecord(). + OptField("message", typ.String). + Build() + sig := typ.Func(). + Param("info", info). + Returns(typ.String). + Build() + + got := ProjectSignatureToParamUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), sig) + rec, ok := got.Params[0].Type.(*typ.Record) + if !ok { + t.Fatalf("projected param = %T, want record (%v)", got.Params[0].Type, got.Params[0].Type) + } + if rec.GetField("message") == nil { + t.Fatalf("projected signature lost existing demanded message field: %v", rec) + } + status := rec.GetField("status_code") + if status == nil || !typ.TypeEquals(status.Type, typ.Nil) { + t.Fatalf("projected signature should include demanded absent status_code as nil, got %v in %v", status, rec) + } + if len(got.Returns) != 1 || !typ.TypeEquals(got.Returns[0], typ.String) { + t.Fatalf("projected signature lost returns: %v", got) + } +} + +func TestProjectToParameterUse_TypeGuardDoesNotKeepWholeRecord(t *testing.T) { + fn := functionWithParams("params") + fn.Stmts = []ast.Stmt{ + &ast.IfStmt{ + Condition: &ast.RelationalOpExpr{ + Operator: "~=", + Lhs: &ast.FuncCallExpr{ + Func: &ast.IdentExpr{Value: "type"}, + Args: []ast.Expr{&ast.IdentExpr{Value: "params"}}, + }, + Rhs: &ast.StringExpr{Value: "table"}, + }, + }, + &ast.IfStmt{ + Condition: &ast.UnaryNotOpExpr{ + Expr: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "params"}, + Key: &ast.StringExpr{Value: "agent"}, + }, + }, + }, + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "params"}, + Key: &ast.StringExpr{Value: "kind"}, + }, + }}, + } + graph := cfg.Build(fn) + evidence := typ.NewRecord(). + OptField("kind", typ.String). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{evidence}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected params evidence = %T, want record (%v)", got[0], got[0]) + } + if rec.GetField("kind") == nil { + t.Fatalf("projected params evidence lost demanded kind field: %v", rec) + } + agent := rec.GetField("agent") + if agent == nil || !typ.TypeEquals(agent.Type, typ.Nil) { + t.Fatalf("type(params) should not force whole-record evidence; agent = %v in %v", agent, rec) + } +} + +func TestProjectToParameterUse_SelfDefaultDoesNotKeepWholeRecord(t *testing.T) { + fn := functionWithParams("options") + optionsIdent := &ast.IdentExpr{Value: "options"} + fn.Stmts = []ast.Stmt{ + &ast.AssignStmt{ + Lhs: []ast.Expr{optionsIdent}, + Rhs: []ast.Expr{&ast.LogicalOpExpr{ + Operator: "or", + Lhs: &ast.IdentExpr{Value: "options"}, + Rhs: &ast.TableExpr{}, + }}, + }, + &ast.LocalAssignStmt{ + Names: []string{"method"}, + Exprs: []ast.Expr{&ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "options"}, + Key: &ast.StringExpr{Value: "method"}, + }}, + }, + &ast.LocalAssignStmt{ + Names: []string{"timeout"}, + Exprs: []ast.Expr{&ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "options"}, + Key: &ast.StringExpr{Value: "timeout"}, + }}, + }, + } + graph := cfg.Build(fn) + evidence := typ.NewRecord(). + OptField("method", typ.String). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{evidence}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected options evidence = %T, want record (%v)", got[0], got[0]) + } + if rec.GetField("method") == nil { + t.Fatalf("projected options evidence lost demanded method field: %v", rec) + } + timeout := rec.GetField("timeout") + if timeout == nil || !typ.TypeEquals(timeout.Type, typ.Nil) { + t.Fatalf("self-default assignment should not force whole-record evidence; timeout = %v in %v", timeout, rec) + } +} + +func TestProjectToParameterUse_DedupsUnionAfterProjection(t *testing.T) { + fn := functionWithParams("client") + fn.Stmts = []ast.Stmt{ + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.FuncCallExpr{ + Func: &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "client"}, + Key: &ast.StringExpr{Value: "invoke"}, + }, + }, + }}, + } + graph := cfg.Build(fn) + invoke := typ.Func().Returns(typ.Unknown).Build() + broad := typ.NewRecord(). + Field("invoke", invoke). + Field("stream", typ.Func().Returns(typ.String).Build()). + Build() + narrow := typ.NewRecord(). + Field("invoke", invoke). + Field("stream", typ.Func().Returns(typ.LiteralString("invalid")).Build()). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{typ.NewUnion(broad, narrow)}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected union evidence = %T, want coalesced record (%v)", got[0], got[0]) + } + if rec.GetField("invoke") == nil || rec.GetField("stream") != nil { + t.Fatalf("projected union should keep only invoke, got %v", rec) + } +} + +func TestProjectToParameterUse_WholeParameterUseKeepsEvidence(t *testing.T) { + fn := functionWithParams("client") + fn.Stmts = []ast.Stmt{ + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.FuncCallExpr{ + Func: &ast.IdentExpr{Value: "use_client"}, + Args: []ast.Expr{&ast.IdentExpr{Value: "client"}}, + }, + }}, + } + graph := cfg.Build(fn, "use_client") + client := typ.NewRecord().Field("invoke", typ.Func().Returns(typ.Unknown).Build()).Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{client}) + if !typ.TypeEquals(got[0], client) { + t.Fatalf("whole-parameter use should keep full evidence, got %v", got[0]) + } +} + +func TestProjectToParameterUse_RecursiveForwardingDoesNotKeepWholeEvidence(t *testing.T) { + recursiveIdent := &ast.IdentExpr{Value: "visit"} + selfIdent := &ast.IdentExpr{Value: "self"} + valueIdent := &ast.IdentExpr{Value: "value"} + fn := functionWithParams("self", "value") + fn.Stmts = []ast.Stmt{ + &ast.IfStmt{ + Condition: &ast.AttrGetExpr{ + Object: valueIdent, + Key: &ast.StringExpr{Value: "next"}, + }, + Then: []ast.Stmt{ + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.FuncCallExpr{ + Func: recursiveIdent, + Args: []ast.Expr{ + selfIdent, + &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "value"}, + Key: &ast.StringExpr{Value: "next"}, + }, + }, + }, + }}, + }, + }, + &ast.ReturnStmt{Exprs: []ast.Expr{ + &ast.AttrGetExpr{ + Object: &ast.IdentExpr{Value: "self"}, + Key: &ast.StringExpr{Value: "id"}, + }, + }}, + } + graph := cfg.Build(fn, "visit") + if sym, ok := graph.Bindings().SymbolOf(recursiveIdent); ok { + graph.Bindings().SetFuncLitSymbol(fn, sym) + } + selfEvidence := typ.NewRecord(). + Field("id", typ.String). + Field("command", typ.Func().Returns(typ.Nil, typ.String).Build()). + Build() + + got := ProjectToParameterUse(graph.ParamSlotsReadOnly(), trace.ParameterUses(graph, fn), []typ.Type{selfEvidence, typ.NewRecord().Field("next", typ.Any).Build()}) + rec, ok := got[0].(*typ.Record) + if !ok { + t.Fatalf("projected self evidence = %T, want record (%v)", got[0], got[0]) + } + if rec.GetField("id") == nil { + t.Fatalf("projected self evidence lost demanded id field: %v", rec) + } + if rec.GetField("command") != nil { + t.Fatalf("recursive forwarding should not keep unused command field: %v", rec) + } +} + +func functionWithParams(names ...string) *ast.FunctionExpr { + return &ast.FunctionExpr{ParList: &ast.ParList{Names: names}} +} + +func TestIsInformative(t *testing.T) { + tests := []struct { + name string + in typ.Type + want bool + }{ + {name: "nil", in: nil, want: false}, + {name: "any", in: typ.Any, want: false}, + {name: "unknown", in: typ.Unknown, want: false}, + {name: "never", in: typ.Never, want: false}, + {name: "nil type", in: typ.Nil, want: false}, + {name: "empty record", in: typ.NewRecord().Build(), want: false}, + {name: "map with string key", in: typ.NewMap(typ.String, typ.NewArray(typ.Any)), want: true}, + {name: "record map component", in: typ.NewRecord().MapComponent(typ.String, typ.Any).Build(), want: true}, + {name: "string", in: typ.String, want: true}, + {name: "literal", in: typ.LiteralString("x"), want: true}, + {name: "type param", in: typ.NewTypeParam("T", nil), want: false}, + {name: "ref", in: typ.NewRef("", "Foo"), want: false}, + {name: "optional unknown", in: typ.NewOptional(typ.Unknown), want: false}, + {name: "optional string", in: typ.NewOptional(typ.String), want: true}, + {name: "union placeholders", in: typ.NewUnion(typ.Unknown, typ.Nil), want: false}, + {name: "union with informative member", in: typ.NewUnion(typ.Unknown, typ.String), want: true}, + } + + for _, tt := range tests { + if got := IsInformative(tt.in); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestEnsureCapacity(t *testing.T) { + base := []typ.Type{typ.String} + got := EnsureCapacity(base, 3) + if len(got) != 3 { + t.Fatalf("EnsureCapacity len = %d, want 3", len(got)) + } + if got[0] != typ.String { + t.Fatalf("EnsureCapacity preserved value = %v, want string", got[0]) + } +} + +func TestMergeAt(t *testing.T) { + join := func(prev, next typ.Type) typ.Type { return typ.JoinPreferNonSoft(prev, next) } + + t.Run("filters non-informative", func(t *testing.T) { + evidence := []typ.Type{typ.String} + got, changed := MergeAt(evidence, 1, typ.Unknown, join) + if changed { + t.Fatal("expected no change for unknown evidence") + } + if len(got) != 1 { + t.Fatalf("expected unchanged slice len 1, got %d", len(got)) + } + }) + + t.Run("normalizes literal and merges", func(t *testing.T) { + got, changed := MergeAt(nil, 0, typ.LiteralString("x"), join) + if !changed { + t.Fatal("expected merge change for informative literal") + } + if len(got) != 1 { + t.Fatalf("expected one evidence, got %d", len(got)) + } + if !typ.TypeEquals(got[0], typ.String) { + t.Fatalf("expected normalized string evidence, got %v", got[0]) + } + }) +} + +func TestMergeCallArgAt_PreservesExplicitNilArgument(t *testing.T) { + got, changed := MergeCallArgAt(nil, 0, typ.Nil, typ.JoinPreferNonSoft, true) + if !changed { + t.Fatal("expected nil argument to be recorded") + } + if len(got) != 1 || !typ.TypeEquals(got[0], typ.Nil) { + t.Fatalf("expected nil evidence, got %v", got) + } + + rec := typ.NewRecord().Field("id", typ.String).Build() + got, changed = MergeCallArgAt(got, 0, rec, typ.JoinPreferNonSoft, true) + if !changed { + t.Fatal("expected record call to merge with nil evidence") + } + if !typ.TypeEquals(got[0], typ.NewOptional(rec)) { + t.Fatalf("expected nil plus record calls to produce optional record evidence, got %v", got[0]) + } +} diff --git a/compiler/check/domain/paramevidence/project.go b/compiler/check/domain/paramevidence/project.go new file mode 100644 index 00000000..aad70cb2 --- /dev/null +++ b/compiler/check/domain/paramevidence/project.go @@ -0,0 +1,362 @@ +package paramevidence + +import ( + "sort" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" +) + +type paramUse struct { + whole bool + fields map[string]struct{} +} + +// ProjectToParameterUse trims structured call-site evidence to the surface the +// function body actually reads from each unannotated parameter. It is evidence +// for analyzing a helper, not a promise that every unused field on the +// first argument shape is part of that helper's public contract. +func ProjectToParameterUse(slots []cfg.ParamSlot, evidence []api.ParameterUseEvidence, vec []typ.Type) []typ.Type { + if len(slots) == 0 || len(vec) == 0 { + return vec + } + + uses := parameterUseMap(evidence) + if len(uses) == 0 { + return vec + } + + var out []typ.Type + for idx, slot := range slots { + if slot.Symbol == 0 || idx < 0 || idx >= len(vec) { + continue + } + observed := vec[idx] + if observed == nil { + continue + } + projected := projectEvidenceToUse(observed, uses[slot.Symbol]) + if typ.TypeEquals(observed, projected) { + continue + } + if out == nil { + out = make([]typ.Type, len(vec)) + copy(out, vec) + } + out[idx] = projected + } + + if out == nil { + return vec + } + return out +} + +// ProjectSignatureToParamUse completes a function signature's parameter slots +// against the fields the function body reads. Unlike ProjectToParameterUse it +// does not trim unused fields: a function fact is already a canonical signature +// observation, and same-body analysis only needs to ensure demanded fields are +// present even when the parameter is also used as a whole value. +func ProjectSignatureToParamUse(slots []cfg.ParamSlot, evidence []api.ParameterUseEvidence, sig *typ.Function) *typ.Function { + if sig == nil || len(sig.Params) == 0 { + return sig + } + uses := parameterUseMap(evidence) + if len(uses) == 0 { + return sig + } + projected := make([]typ.Type, len(sig.Params)) + changed := false + for idx, slot := range slots { + if idx < 0 || idx >= len(sig.Params) || slot.Symbol == 0 { + continue + } + use := uses[slot.Symbol] + if len(use.fields) == 0 { + continue + } + completed, ok := completeTypeWithFields(sig.Params[idx].Type, use.fields) + if !ok || completed == nil { + continue + } + projected[idx] = completed + if !typ.TypeEquals(sig.Params[idx].Type, completed) { + changed = true + } + } + if !changed { + return sig + } + + builder := typ.Func().ReserveParams(len(sig.Params)) + for _, tp := range sig.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for i, p := range sig.Params { + paramType := p.Type + if i < len(projected) && projected[i] != nil { + paramType = projected[i] + } + if p.Optional { + builder = builder.OptParam(p.Name, paramType) + } else { + builder = builder.Param(p.Name, paramType) + } + } + if sig.Variadic != nil { + builder = builder.Variadic(sig.Variadic) + } + if len(sig.Returns) > 0 { + builder = builder.Returns(sig.Returns...) + } + if sig.Effects != nil { + builder = builder.Effects(sig.Effects) + } + if sig.Spec != nil { + builder = builder.Spec(sig.Spec) + } + if sig.Refinement != nil { + builder = builder.WithRefinement(sig.Refinement) + } + return builder.Build() +} + +func completeTypeWithFields(t typ.Type, fields map[string]struct{}) (typ.Type, bool) { + if t == nil || len(fields) == 0 { + return t, false + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + completed, ok := completeTypeWithFields(v.Target, fields) + if !ok { + return t, false + } + return completed, true + case *typ.Optional: + inner, ok := completeTypeWithFields(v.Inner, fields) + if !ok { + return t, false + } + return typ.NewOptional(inner), true + case *typ.Union: + members := make([]typ.Type, 0, len(v.Members)) + changed := false + for _, member := range v.Members { + completed, ok := completeTypeWithFields(member, fields) + if !ok { + members = append(members, member) + continue + } + if !typ.TypeEquals(member, completed) { + changed = true + } + members = append(members, completed) + } + if !changed { + return t, false + } + return typ.NewUnion(members...), true + case *typ.Record: + return completeRecordWithFields(v, fields), true + default: + return t, false + } +} + +func completeRecordWithFields(r *typ.Record, fields map[string]struct{}) typ.Type { + builder := typ.NewRecord() + if r.Open { + builder.SetOpen(true) + } + if r.Metatable != nil { + builder.Metatable(r.Metatable) + } + for _, field := range r.Fields { + switch { + case field.Optional && field.Readonly: + builder.OptReadonlyField(field.Name, field.Type) + case field.Optional: + builder.OptField(field.Name, field.Type) + case field.Readonly: + builder.ReadonlyField(field.Name, field.Type) + default: + builder.Field(field.Name, field.Type) + } + } + if r.HasMapComponent() { + builder.MapComponent(r.MapKey, r.MapValue) + } + + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if r.GetField(name) != nil { + continue + } + if r.HasMapComponent() && subtype.IsSubtype(typ.LiteralString(name), r.MapKey) { + mapValue := r.MapValue + if mapValue == nil { + mapValue = typ.Unknown + } + builder.OptField(name, mapValue) + continue + } + if !r.Open { + builder.Field(name, typ.Nil) + } + } + return builder.Build() +} + +// UnobservedParameterMask reports parameter slots whose values are not demanded +// by the function body. Nil means every slot is observed or no parameter-use +// information is available. +func UnobservedParameterMask(slots []cfg.ParamSlot, evidence []api.ParameterUseEvidence) []bool { + if len(slots) == 0 { + return nil + } + uses := parameterUseMap(evidence) + var mask []bool + for i, slot := range slots { + if slot.Symbol == 0 { + continue + } + use, observed := uses[slot.Symbol] + if observed && (use.whole || len(use.fields) > 0) { + continue + } + if mask == nil { + mask = make([]bool, len(slots)) + } + mask[i] = true + } + return mask +} + +func parameterUseMap(evidence []api.ParameterUseEvidence) map[cfg.SymbolID]paramUse { + if len(evidence) == 0 { + return nil + } + out := make(map[cfg.SymbolID]paramUse, len(evidence)) + for _, ev := range evidence { + if ev.Symbol == 0 { + continue + } + use := out[ev.Symbol] + if ev.Whole { + use.whole = true + } + if len(ev.Fields) > 0 { + if use.fields == nil { + use.fields = make(map[string]struct{}, len(ev.Fields)) + } + for _, field := range ev.Fields { + if field != "" { + use.fields[field] = struct{}{} + } + } + } + out[ev.Symbol] = use + } + if len(out) == 0 { + return nil + } + return out +} + +func projectEvidenceToUse(observed typ.Type, use paramUse) typ.Type { + if observed == nil { + return observed + } + if len(use.fields) == 0 { + if use.whole { + return observed + } + return nil + } + if use.whole { + completed, ok := completeTypeWithFields(observed, use.fields) + if !ok { + return observed + } + return completed + } + projected, ok := projectTypeToFields(observed, use.fields) + if !ok { + return observed + } + return projected +} + +func projectTypeToFields(t typ.Type, fields map[string]struct{}) (typ.Type, bool) { + if t == nil { + return nil, false + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return projectTypeToFields(v.Target, fields) + case *typ.Optional: + inner, ok := projectTypeToFields(v.Inner, fields) + if !ok { + return t, false + } + return typ.NewOptional(inner), true + case *typ.Union: + members := make([]typ.Type, 0, len(v.Members)) + for _, member := range v.Members { + projected, ok := projectTypeToFields(member, fields) + if !ok { + return t, false + } + members = append(members, projected) + } + return typ.NewUnion(members...), true + case *typ.Record: + return projectRecordToFields(v, fields), true + default: + return t, false + } +} + +func projectRecordToFields(r *typ.Record, fields map[string]struct{}) typ.Type { + builder := typ.NewRecord().SetOpen(true) + if r.Metatable != nil { + builder.Metatable(r.Metatable) + } + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + field := r.GetField(name) + if field == nil { + if r.HasMapComponent() && subtype.IsSubtype(typ.LiteralString(name), r.MapKey) { + mapValue := r.MapValue + if mapValue == nil { + mapValue = typ.Unknown + } + builder.OptField(name, mapValue) + } else if !r.Open { + builder.Field(name, typ.Nil) + } + continue + } + switch { + case field.Optional && field.Readonly: + builder.OptReadonlyField(field.Name, field.Type) + case field.Optional: + builder.OptField(field.Name, field.Type) + case field.Readonly: + builder.ReadonlyField(field.Name, field.Type) + default: + builder.Field(field.Name, field.Type) + } + } + return builder.Build() +} diff --git a/compiler/check/flowbuild/path/doc.go b/compiler/check/domain/path/doc.go similarity index 100% rename from compiler/check/flowbuild/path/doc.go rename to compiler/check/domain/path/doc.go diff --git a/compiler/check/flowbuild/path/path.go b/compiler/check/domain/path/path.go similarity index 100% rename from compiler/check/flowbuild/path/path.go rename to compiler/check/domain/path/path.go diff --git a/compiler/check/flowbuild/path/path_extract_test.go b/compiler/check/domain/path/path_extract_test.go similarity index 99% rename from compiler/check/flowbuild/path/path_extract_test.go rename to compiler/check/domain/path/path_extract_test.go index 887f979a..021e8328 100644 --- a/compiler/check/flowbuild/path/path_extract_test.go +++ b/compiler/check/domain/path/path_extract_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" ) diff --git a/compiler/check/flowbuild/path/path_test.go b/compiler/check/domain/path/path_test.go similarity index 99% rename from compiler/check/flowbuild/path/path_test.go rename to compiler/check/domain/path/path_test.go index 20d7d704..8ff20678 100644 --- a/compiler/check/flowbuild/path/path_test.go +++ b/compiler/check/domain/path/path_test.go @@ -5,7 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" diff --git a/compiler/check/domain/provenance/provenance.go b/compiler/check/domain/provenance/provenance.go new file mode 100644 index 00000000..0198eacb --- /dev/null +++ b/compiler/check/domain/provenance/provenance.go @@ -0,0 +1,146 @@ +package provenance + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" +) + +// IdentBindingLookup resolves identifier expressions to graph symbols. +type IdentBindingLookup interface { + SymbolOf(ident *ast.IdentExpr) (cfg.SymbolID, bool) +} + +// FreshTableLiteral is a table constructor that still owns the value currently +// read through a local symbol. Point is where the literal assignment occurred. +type FreshTableLiteral struct { + Table *ast.TableExpr + Point cfg.Point +} + +// CurrentFreshTableLiteral returns the transfer-proven fresh table literal for +// source at a point. The freshness proof is produced by trace.GraphEvidence; +// this reducer only matches the identifier use to canonical evidence. +func CurrentFreshTableLiteral( + source ast.Expr, + at cfg.Point, + bindings IdentBindingLookup, + freshTables []api.FreshTableLiteralEvidence, +) (FreshTableLiteral, bool) { + if source == nil || bindings == nil || len(freshTables) == 0 { + return FreshTableLiteral{}, false + } + ident, ok := source.(*ast.IdentExpr) + if !ok { + return FreshTableLiteral{}, false + } + sym, ok := bindings.SymbolOf(ident) + if !ok || sym == 0 { + return FreshTableLiteral{}, false + } + for _, ev := range freshTables { + if ev.Point != at || ev.Symbol != sym || ev.Table == nil { + continue + } + return FreshTableLiteral{Table: ev.Table, Point: ev.AssignmentPoint}, true + } + return FreshTableLiteral{}, false +} + +// ExprMayExposeSymbolValue reports whether evaluating expr may publish the +// symbol's current value as an alias or to a call. Field reads do not expose the +// base object; calls and table constructors do. +func ExprMayExposeSymbolValue(expr ast.Expr, sym cfg.SymbolID, bindings IdentBindingLookup) bool { + if expr == nil || sym == 0 || bindings == nil { + return false + } + switch e := expr.(type) { + case *ast.IdentExpr: + bound, ok := bindings.SymbolOf(e) + return ok && bound == sym + case *ast.CastExpr: + return ExprMayExposeSymbolValue(e.Expr, sym, bindings) + case *ast.NonNilAssertExpr: + return ExprMayExposeSymbolValue(e.Expr, sym, bindings) + case *ast.TableExpr: + for _, field := range e.Fields { + if field == nil { + continue + } + if ExprMayExposeSymbolValue(field.Key, sym, bindings) || ExprMayExposeSymbolValue(field.Value, sym, bindings) { + return true + } + } + return false + case *ast.FuncCallExpr: + return ExprReferencesSymbol(e.Func, sym, bindings) || + ExprReferencesSymbol(e.Receiver, sym, bindings) || + exprsReferenceSymbol(e.Args, sym, bindings) + case *ast.LogicalOpExpr: + return ExprMayExposeSymbolValue(e.Lhs, sym, bindings) || ExprMayExposeSymbolValue(e.Rhs, sym, bindings) + default: + return false + } +} + +// ExprReferencesSymbol reports whether expr reads the given symbol anywhere. +func ExprReferencesSymbol(expr ast.Expr, sym cfg.SymbolID, bindings IdentBindingLookup) bool { + if expr == nil || sym == 0 || bindings == nil { + return false + } + + switch e := expr.(type) { + case *ast.IdentExpr: + if bound, ok := bindings.SymbolOf(e); ok && bound == sym { + return true + } + return false + case *ast.AttrGetExpr: + return ExprReferencesSymbol(e.Object, sym, bindings) || ExprReferencesSymbol(e.Key, sym, bindings) + case *ast.TableExpr: + for _, field := range e.Fields { + if field == nil { + continue + } + if ExprReferencesSymbol(field.Key, sym, bindings) || ExprReferencesSymbol(field.Value, sym, bindings) { + return true + } + } + return false + case *ast.FuncCallExpr: + return ExprReferencesSymbol(e.Func, sym, bindings) || + ExprReferencesSymbol(e.Receiver, sym, bindings) || + exprsReferenceSymbol(e.Args, sym, bindings) + case *ast.LogicalOpExpr: + return ExprReferencesSymbol(e.Lhs, sym, bindings) || ExprReferencesSymbol(e.Rhs, sym, bindings) + case *ast.RelationalOpExpr: + return ExprReferencesSymbol(e.Lhs, sym, bindings) || ExprReferencesSymbol(e.Rhs, sym, bindings) + case *ast.StringConcatOpExpr: + return ExprReferencesSymbol(e.Lhs, sym, bindings) || ExprReferencesSymbol(e.Rhs, sym, bindings) + case *ast.ArithmeticOpExpr: + return ExprReferencesSymbol(e.Lhs, sym, bindings) || ExprReferencesSymbol(e.Rhs, sym, bindings) + case *ast.UnaryMinusOpExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + case *ast.UnaryNotOpExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + case *ast.UnaryLenOpExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + case *ast.UnaryBNotOpExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + case *ast.CastExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + case *ast.NonNilAssertExpr: + return ExprReferencesSymbol(e.Expr, sym, bindings) + default: + return false + } +} + +func exprsReferenceSymbol(exprs []ast.Expr, sym cfg.SymbolID, bindings IdentBindingLookup) bool { + for _, expr := range exprs { + if ExprReferencesSymbol(expr, sym, bindings) { + return true + } + } + return false +} diff --git a/compiler/check/flowbuild/resolve/resolve.go b/compiler/check/domain/resolve/resolve.go similarity index 73% rename from compiler/check/flowbuild/resolve/resolve.go rename to compiler/check/domain/resolve/resolve.go index fb08551a..03e98c83 100644 --- a/compiler/check/flowbuild/resolve/resolve.go +++ b/compiler/check/domain/resolve/resolve.go @@ -1,6 +1,6 @@ // Package resolve provides symbol and type resolution utilities for flow constraint extraction. // -// This package bridges between AST nodes and the constraint system by providing: +// This package maps AST nodes into the constraint system by providing: // - Symbol name resolution (SymbolID -> display name) // - Type resolution from various sources (inputs, overlays, scope) // - Path extraction from expressions (for constraint targeting) @@ -11,10 +11,10 @@ // Type lookups follow a priority order: // 1. SpecTypes overlay (contextually inferred types) // 2. flow.Inputs.DeclaredTypes (explicit annotations) -// 3. Synthesizer fallback (structural inference) +// 3. Synthesizer-derived structural inference // // This allows inferred types to override base declarations while still -// falling back to synthesis for unannotated expressions. +// using synthesis for unannotated expressions. // // # SYMBOL VS NAME RESOLUTION // @@ -35,9 +35,8 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/compiler/pathseg" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/effect" @@ -51,38 +50,38 @@ type symbolNamer interface { NameOf(sym cfg.SymbolID) string } -func rootNameFromSymbolSource(source symbolNamer, sym cfg.SymbolID, fallback string) string { +func rootNameFromSymbolSource(source symbolNamer, sym cfg.SymbolID, displayName string) string { if sym == 0 || source == nil { - return fallback + return displayName } if name := source.NameOf(sym); name != "" { return name } - return fallback + return displayName } // RootName returns the display name for a symbol, using NameOf when available. -func RootName(graph *cfg.Graph, sym cfg.SymbolID, fallback string) string { - return rootNameFromSymbolSource(graph, sym, fallback) +func RootName(graph *cfg.Graph, sym cfg.SymbolID, displayName string) string { + return rootNameFromSymbolSource(graph, sym, displayName) } // RootNameFromBindings returns the display name for a symbol using bindings. -func RootNameFromBindings(bindings *bind.BindingTable, sym cfg.SymbolID, fallback string) string { +func RootNameFromBindings(bindings *bind.BindingTable, sym cfg.SymbolID, displayName string) string { if sym != 0 && bindings != nil { if name := bindings.Name(sym); name != "" { return name } } - return fallback + return displayName } -// RootNameFromGraphAndBindings resolves display name with binding-first, graph-second fallback. -func RootNameFromGraphAndBindings(graph *cfg.Graph, bindings *bind.BindingTable, sym cfg.SymbolID, fallback string) string { +// RootNameFromGraphAndBindings resolves display name with binding-first, then graph. +func RootNameFromGraphAndBindings(graph *cfg.Graph, bindings *bind.BindingTable, sym cfg.SymbolID, displayName string) string { name := RootNameFromBindings(bindings, sym, "") if name != "" { return name } - return RootName(graph, sym, fallback) + return RootName(graph, sym, displayName) } // GetBindings returns the binding table from inputs. @@ -97,12 +96,12 @@ func GetBindings(inputs *flow.Inputs) *bind.BindingTable { return nil } -// RootFromSymbol returns the display name for a symbol, falling back to the provided name. -func RootFromSymbol(inputs *flow.Inputs, sym cfg.SymbolID, fallback string) string { +// RootFromSymbol returns the display name for a symbol. +func RootFromSymbol(inputs *flow.Inputs, sym cfg.SymbolID, displayName string) string { if inputs == nil { - return fallback + return displayName } - return rootNameFromSymbolSource(inputs.Graph, sym, fallback) + return rootNameFromSymbolSource(inputs.Graph, sym, displayName) } // ClassifyReturnExpr determines if a return expression returns true, false, or unknown. @@ -125,101 +124,11 @@ func ClassifyReturnExpr(expr ast.Expr) flow.ReturnKind { // ResolveSymbolToFunctionLiteral resolves a symbol to a function literal defined // in the current graph (local/global function definitions or assignments). -func ResolveSymbolToFunctionLiteral(graph *cfg.Graph, sym cfg.SymbolID) *ast.FunctionExpr { +func ResolveSymbolToFunctionLiteral(evidence api.FlowEvidence, bindings *bind.BindingTable, sym cfg.SymbolID) *ast.FunctionExpr { if sym == 0 { return nil } - var bindings *bind.BindingTable - if graph != nil { - bindings = graph.Bindings() - } - return callsite.FunctionLiteralForSymbol(graph, bindings, sym) -} - -// ResolveExprToTableLiteral resolves expression to a table literal when possible. -// Supports direct table expressions and identifier references to local table literals. -func ResolveExprToTableLiteral(expr ast.Expr, graph *cfg.Graph) *ast.TableExpr { - if expr == nil || graph == nil { - return nil - } - - if tbl, ok := expr.(*ast.TableExpr); ok { - return tbl - } - - ident, ok := expr.(*ast.IdentExpr) - if !ok { - return nil - } - - bindings := graph.Bindings() - if bindings == nil { - return nil - } - - sym, found := bindings.SymbolOf(ident) - if !found || sym == 0 { - return nil - } - - var tableLit *ast.TableExpr - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if tableLit != nil || info == nil || !info.IsLocal { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if target.Symbol == sym && target.Kind == cfg.TargetIdent { - if tbl, ok := source.(*ast.TableExpr); ok { - tableLit = tbl - } - } - }) - }) - - return tableLit -} - -// ResolveCalleeToFunctionLiteral resolves a callee expression to a function literal. -// -// Supported forms: -// - direct function literal: function(...) ... end -// - table field function via static table literal: -// local t = { f = function(...) ... end }; t.f(...) -func ResolveCalleeToFunctionLiteral(callee ast.Expr, graph *cfg.Graph) *ast.FunctionExpr { - if callee == nil { - return nil - } - - if fn, ok := callee.(*ast.FunctionExpr); ok { - return fn - } - - attr, ok := callee.(*ast.AttrGetExpr) - if !ok || graph == nil { - return nil - } - - calleeSeg, ok := pathseg.StaticAttrKeySegment(attr.Key) - if !ok { - return nil - } - - tableLit := ResolveExprToTableLiteral(attr.Object, graph) - if tableLit == nil { - return nil - } - - for _, field := range tableLit.Fields { - fieldSeg, ok := pathseg.StaticTableFieldKeySegment(field.Key) - if !ok || fieldSeg != calleeSeg { - continue - } - if fn, ok := field.Value.(*ast.FunctionExpr); ok { - return fn - } - } - - return nil + return callsite.FunctionLiteralForSymbol(bindings, evidence, sym) } // Ref resolves typ.Ref to its actual type using scope type lookup. @@ -237,21 +146,22 @@ func Ref(t typ.Type, sc *scope.State) typ.Type { } // selectConcreteOrPlaceholder applies canonical symbol-resolver preference: -// return concrete types immediately, and keep placeholders as fallback. -func selectConcreteOrPlaceholder(candidate typ.Type, fallback *typ.Type) (typ.Type, bool) { +// return concrete types immediately, and remember placeholders for unresolved +// global symbols. +func selectConcreteOrPlaceholder(candidate typ.Type, placeholder *typ.Type) (typ.Type, bool) { if candidate == nil { return nil, false } if candidate.Kind().IsPlaceholder() { - if fallback != nil { - *fallback = candidate + if placeholder != nil { + *placeholder = candidate } return nil, false } return candidate, true } -func selectFromTypeMap(types map[cfg.SymbolID]typ.Type, sym cfg.SymbolID, fallback *typ.Type) (typ.Type, bool) { +func selectFromTypeMap(types map[cfg.SymbolID]typ.Type, sym cfg.SymbolID, placeholder *typ.Type) (typ.Type, bool) { if types == nil { return nil, false } @@ -259,30 +169,30 @@ func selectFromTypeMap(types map[cfg.SymbolID]typ.Type, sym cfg.SymbolID, fallba if !ok { return nil, false } - return selectConcreteOrPlaceholder(candidate, fallback) + return selectConcreteOrPlaceholder(candidate, placeholder) } // selectFromTypeMaps returns the first non-placeholder type for sym across maps, -// preserving the last placeholder as fallback when no concrete type exists. -func selectFromTypeMaps(sym cfg.SymbolID, fallback *typ.Type, maps ...map[cfg.SymbolID]typ.Type) (typ.Type, bool) { +// preserving the last placeholder for unresolved global-symbol lookup. +func selectFromTypeMaps(sym cfg.SymbolID, placeholder *typ.Type, maps ...map[cfg.SymbolID]typ.Type) (typ.Type, bool) { for _, m := range maps { - if selected, ok := selectFromTypeMap(m, sym, fallback); ok { + if selected, ok := selectFromTypeMap(m, sym, placeholder); ok { return selected, true } } return nil, false } -// resolveGlobalOrFallback finalizes symbol resolution by preferring global type -// bindings and otherwise returning the placeholder fallback (if any). -func resolveGlobalOrFallback(ctx api.BaseEnv, sym cfg.SymbolID, fallback typ.Type) (typ.Type, bool) { +// resolveGlobalOrPlaceholder finalizes symbol resolution by preferring global +// type bindings and otherwise returning the remembered placeholder, if any. +func resolveGlobalOrPlaceholder(ctx api.BaseEnv, sym cfg.SymbolID, placeholder typ.Type) (typ.Type, bool) { if ctx != nil { if t, ok := ctx.GlobalType(sym); ok && t != nil { return t, true } } - if fallback != nil { - return fallback, true + if placeholder != nil { + return placeholder, true } return nil, false } @@ -295,35 +205,35 @@ func BuildContextSymbolResolver(ctx api.BaseEnv) func(cfg.Point, cfg.SymbolID) ( } } return func(p cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { - var fallback typ.Type + var placeholder typ.Type tv := ctx.Types().EffectiveTypeAt(p, sym) if tv.State == flow.StateResolved { - if selected, ok := selectConcreteOrPlaceholder(tv.Type, &fallback); ok { + if selected, ok := selectConcreteOrPlaceholder(tv.Type, &placeholder); ok { return selected, true } } - return resolveGlobalOrFallback(ctx, sym, fallback) + return resolveGlobalOrPlaceholder(ctx, sym, placeholder) } } // BuildInputSymbolResolver creates a symbol type resolver that prefers flow inputs -// (literal/sibling/declared types) before falling back to globals. +// (literal/sibling/declared types) before querying globals. func BuildInputSymbolResolver(ctx api.BaseEnv, inputs *flow.Inputs) func(cfg.Point, cfg.SymbolID) (typ.Type, bool) { if inputs == nil { return BuildContextSymbolResolver(ctx) } return func(p cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { - var fallback typ.Type + var placeholder typ.Type if selected, ok := selectFromTypeMaps( sym, - &fallback, + &placeholder, inputs.LiteralTypes, inputs.SiblingTypes, inputs.DeclaredTypes, ); ok { return selected, true } - return resolveGlobalOrFallback(ctx, sym, fallback) + return resolveGlobalOrPlaceholder(ctx, sym, placeholder) } } @@ -512,7 +422,7 @@ func ExtractIteratorSource( // For method calls, resolves the receiver type (via CalleePath.Symbol, assignmentTypes, // symResolver, synth) and looks up the method. For non-method calls, synthesizes the // callee directly. Symbol resolver lookup uses canonical callsite candidates with -// binding-table fallback. +// secondary binding table. func CalleeType( info *cfg.CallInfo, p cfg.Point, diff --git a/compiler/check/flowbuild/resolve/resolve_test.go b/compiler/check/domain/resolve/resolve_test.go similarity index 74% rename from compiler/check/flowbuild/resolve/resolve_test.go rename to compiler/check/domain/resolve/resolve_test.go index ef0ea529..62e4c10d 100644 --- a/compiler/check/flowbuild/resolve/resolve_test.go +++ b/compiler/check/domain/resolve/resolve_test.go @@ -6,7 +6,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/constraint" @@ -205,242 +206,13 @@ func TestResolveSymbolToFunctionLiteral_LocalAssign(t *testing.T) { t.Fatal("expected non-zero symbol for local function assignment") } - got := resolve.ResolveSymbolToFunctionLiteral(graph, sym) + evidence := trace.GraphEvidence(graph, graph.Bindings()) + got := resolve.ResolveSymbolToFunctionLiteral(evidence, graph.Bindings(), sym) if got != fnLit { t.Fatalf("ResolveSymbolToFunctionLiteral mismatch: got %p want %p", got, fnLit) } } -func TestResolveExprToTableLiteral_IdentRef(t *testing.T) { - tbl := &ast.TableExpr{} - retIdent := &ast.IdentExpr{Value: "t"} - root := &ast.FunctionExpr{ - ParList: &ast.ParList{}, - Stmts: []ast.Stmt{ - &ast.LocalAssignStmt{ - Names: []string{"t"}, - Exprs: []ast.Expr{tbl}, - }, - &ast.ReturnStmt{ - Exprs: []ast.Expr{retIdent}, - }, - }, - } - graph := cfg.Build(root) - if graph == nil { - t.Fatal("expected non-nil graph") - } - - got := resolve.ResolveExprToTableLiteral(retIdent, graph) - if got != tbl { - t.Fatalf("ResolveExprToTableLiteral mismatch: got %p want %p", got, tbl) - } -} - -func TestResolveCalleeToFunctionLiteral_TableFieldFunction(t *testing.T) { - stmts, err := parse.ParseString(` - local t = { - f = function(x) - return x - end - } - t.f(1) - `, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - graph := cfg.Build(&ast.FunctionExpr{ - ParList: &ast.ParList{}, - Stmts: stmts, - }) - if graph == nil { - t.Fatal("expected graph") - } - - var ( - callee ast.Expr - want *ast.FunctionExpr - ) - graph.EachCallSite(func(_ cfg.Point, info *cfg.CallInfo) { - if callee != nil || info == nil { - return - } - callee = info.Callee - }) - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if want != nil || info == nil { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if want != nil || target.Name != "t" { - return - } - tbl, ok := source.(*ast.TableExpr) - if !ok || len(tbl.Fields) == 0 { - return - } - if fn, ok := tbl.Fields[0].Value.(*ast.FunctionExpr); ok { - want = fn - } - }) - }) - if callee == nil || want == nil { - t.Fatal("expected callee and table field function literal") - } - - got := resolve.ResolveCalleeToFunctionLiteral(callee, graph) - if got != want { - t.Fatalf("ResolveCalleeToFunctionLiteral mismatch: got %p want %p", got, want) - } -} - -func TestResolveCalleeToFunctionLiteral_TableIndexStringFunction(t *testing.T) { - stmts, err := parse.ParseString(` - local t = { - ["x-y"] = function(x) - return x - end - } - t["x-y"](1) - `, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - graph := cfg.Build(&ast.FunctionExpr{ - ParList: &ast.ParList{}, - Stmts: stmts, - }) - if graph == nil { - t.Fatal("expected graph") - } - - var ( - callee ast.Expr - want *ast.FunctionExpr - ) - graph.EachCallSite(func(_ cfg.Point, info *cfg.CallInfo) { - if callee != nil || info == nil { - return - } - callee = info.Callee - }) - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if want != nil || info == nil { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if want != nil || target.Name != "t" { - return - } - tbl, ok := source.(*ast.TableExpr) - if !ok || len(tbl.Fields) == 0 { - return - } - if fn, ok := tbl.Fields[0].Value.(*ast.FunctionExpr); ok { - want = fn - } - }) - }) - if callee == nil || want == nil { - t.Fatal("expected callee and table field function literal") - } - - got := resolve.ResolveCalleeToFunctionLiteral(callee, graph) - if got != want { - t.Fatalf("ResolveCalleeToFunctionLiteral mismatch: got %p want %p", got, want) - } -} - -func TestResolveCalleeToFunctionLiteral_TableIndexIntFunction(t *testing.T) { - stmts, err := parse.ParseString(` - local t = { - [1] = function(x) - return x - end - } - t[1](1) - `, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - graph := cfg.Build(&ast.FunctionExpr{ - ParList: &ast.ParList{}, - Stmts: stmts, - }) - if graph == nil { - t.Fatal("expected graph") - } - - var ( - callee ast.Expr - want *ast.FunctionExpr - ) - graph.EachCallSite(func(_ cfg.Point, info *cfg.CallInfo) { - if callee != nil || info == nil { - return - } - callee = info.Callee - }) - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if want != nil || info == nil { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if want != nil || target.Name != "t" { - return - } - tbl, ok := source.(*ast.TableExpr) - if !ok || len(tbl.Fields) == 0 { - return - } - if fn, ok := tbl.Fields[0].Value.(*ast.FunctionExpr); ok { - want = fn - } - }) - }) - if callee == nil || want == nil { - t.Fatal("expected callee and table field function literal") - } - - got := resolve.ResolveCalleeToFunctionLiteral(callee, graph) - if got != want { - t.Fatalf("ResolveCalleeToFunctionLiteral mismatch: got %p want %p", got, want) - } -} - -func TestResolveCalleeToFunctionLiteral_TableFieldNotFunction(t *testing.T) { - stmts, err := parse.ParseString(` - local t = { f = 1 } - t.f() - `, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - graph := cfg.Build(&ast.FunctionExpr{ - ParList: &ast.ParList{}, - Stmts: stmts, - }) - if graph == nil { - t.Fatal("expected graph") - } - - var callee ast.Expr - graph.EachCallSite(func(_ cfg.Point, info *cfg.CallInfo) { - if callee != nil || info == nil { - return - } - callee = info.Callee - }) - if callee == nil { - t.Fatal("expected callee expression") - } - - got := resolve.ResolveCalleeToFunctionLiteral(callee, graph) - if got != nil { - t.Fatalf("expected nil for non-function table field, got %v", got) - } -} - func TestRef_NilType(t *testing.T) { result := resolve.Ref(nil, nil) if result != nil { diff --git a/compiler/check/domain/returnsummary/doc.go b/compiler/check/domain/returnsummary/doc.go new file mode 100644 index 00000000..736c3eb4 --- /dev/null +++ b/compiler/check/domain/returnsummary/doc.go @@ -0,0 +1,8 @@ +// Package returnsummary owns the return-vector abstract domain. +// +// It canonicalizes, compares, joins, and widens return summaries produced by +// local return inference and interprocedural fact propagation. Orchestration +// packages decide when candidate summaries are produced; this package decides +// how those summaries normalize, refine, merge, and align back to function +// types. +package returnsummary diff --git a/compiler/check/returns/join_test.go b/compiler/check/domain/returnsummary/join_test.go similarity index 58% rename from compiler/check/returns/join_test.go rename to compiler/check/domain/returnsummary/join_test.go index e41facdc..8849a6b6 100644 --- a/compiler/check/returns/join_test.go +++ b/compiler/check/domain/returnsummary/join_test.go @@ -1,11 +1,9 @@ -package returns +package returnsummary import ( - "testing" - - "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" typjoin "github.com/wippyai/go-lua/types/typ/join" + "testing" ) func TestJoinReturnVectors_Empty(t *testing.T) { @@ -52,14 +50,14 @@ func TestTypJoinReturnSlot_PreservesUnknownOverNil(t *testing.T) { } } -func TestReturnTypesAllNil(t *testing.T) { - if !ReturnTypesAllNil([]typ.Type{typ.Nil}) { +func TestReturnSummaryAllNil(t *testing.T) { + if !AllNil([]typ.Type{typ.Nil}) { t.Fatal("expected [nil] to be nil-only") } - if ReturnTypesAllNil([]typ.Type{typ.Nil, typ.Unknown}) { + if AllNil([]typ.Type{typ.Nil, typ.Unknown}) { t.Fatal("expected [nil, unknown] to not be nil-only") } - if ReturnTypesAllNil(nil) { + if AllNil(nil) { t.Fatal("expected empty return vector to not be nil-only") } } @@ -73,98 +71,108 @@ func TestJoinReturnVectors_DifferentLengths(t *testing.T) { } } -func TestReturnTypesEqual_Empty(t *testing.T) { - if !ReturnTypesEqual(nil, nil) { +func TestReturnSummaryEqual_Empty(t *testing.T) { + if !Equal(nil, nil) { t.Error("nil slices should be equal") } } -func TestReturnTypesEqual_DifferentLength(t *testing.T) { +func TestReturnSummaryEqual_DifferentLength(t *testing.T) { a := []typ.Type{typ.String} b := []typ.Type{typ.String, typ.Number} - if ReturnTypesEqual(a, b) { + if Equal(a, b) { t.Error("different lengths should not be equal") } } -func TestReturnTypesEqual_Same(t *testing.T) { +func TestReturnSummaryEqual_Same(t *testing.T) { a := []typ.Type{typ.String, typ.Number} b := []typ.Type{typ.String, typ.Number} - if !ReturnTypesEqual(a, b) { + if !Equal(a, b) { t.Error("same types should be equal") } } -func TestReturnTypesEqual_Different(t *testing.T) { +func TestReturnSummaryEqual_Different(t *testing.T) { a := []typ.Type{typ.String} b := []typ.Type{typ.Number} - if ReturnTypesEqual(a, b) { + if Equal(a, b) { t.Error("different types should not be equal") } } -func TestReturnTypesRefine_EmptyA(t *testing.T) { +func TestReturnSummaryRefines_EmptyA(t *testing.T) { b := []typ.Type{typ.String} - if ReturnTypesRefine(nil, b) { + if Refines(nil, b) { t.Error("empty a should not refine b") } } -func TestReturnTypesRefine_EmptyB(t *testing.T) { +func TestReturnSummaryRefines_EmptyB(t *testing.T) { a := []typ.Type{typ.String} - if !ReturnTypesRefine(a, nil) { + if !Refines(a, nil) { t.Error("a should refine empty b") } } -func TestReturnTypesRefine_Same(t *testing.T) { +func TestReturnSummaryRefines_Same(t *testing.T) { a := []typ.Type{typ.String} b := []typ.Type{typ.String} - if !ReturnTypesRefine(a, b) { + if !Refines(a, b) { t.Error("same types should refine") } } -func TestReturnTypesRefine_DifferentLength(t *testing.T) { +func TestReturnSummaryRefines_DifferentLength(t *testing.T) { a := []typ.Type{typ.String, typ.Number} b := []typ.Type{typ.String} - if ReturnTypesRefine(a, b) { + if Refines(a, b) { t.Error("different lengths should not refine") } } -func TestReturnTypesExtendRecord_Empty(t *testing.T) { - if ReturnTypesExtendRecord(nil, nil) { +func TestReturnSummaryMerge_ReplacesStaleFalsyKeyArrayElement(t *testing.T) { + stale := []typ.Type{typ.NewArray(typ.NewUnion(typ.Boolean, typ.String))} + current := []typ.Type{typ.NewArray(typ.String)} + + got := Merge(stale, current) + if !Equal(got, current) { + t.Fatalf("expected truthy-refined key array %v, got %v", current, got) + } +} + +func TestReturnSummaryExtendsRecord_Empty(t *testing.T) { + if ExtendsRecord(nil, nil) { t.Error("empty vectors should not extend") } } -func TestReturnTypesExtendRecord_NotRecords(t *testing.T) { +func TestReturnSummaryExtendsRecord_NotRecords(t *testing.T) { a := []typ.Type{typ.String} b := []typ.Type{typ.String} - if ReturnTypesExtendRecord(a, b) { + if ExtendsRecord(a, b) { t.Error("non-records should not extend") } } -func TestReturnTypesExtendRecord_RecordExtends(t *testing.T) { +func TestReturnSummaryExtendsRecord_RecordExtends(t *testing.T) { oldRec := typ.NewRecord().Field("x", typ.Number).Build() newRec := typ.NewRecord().Field("x", typ.Number).Field("y", typ.Number).Build() a := []typ.Type{newRec} b := []typ.Type{oldRec} - if !ReturnTypesExtendRecord(a, b) { + if !ExtendsRecord(a, b) { t.Error("record with more fields should extend") } } -func TestReturnTypesElideOptional_Empty(t *testing.T) { - if ReturnTypesElideOptional(nil, nil) { +func TestReturnSummaryElidesOptional_Empty(t *testing.T) { + if ElidesOptional(nil, nil) { t.Error("empty vectors should not elide") } } -func TestSelectPreferredReturnVector_Refinement(t *testing.T) { - preferred, ok := SelectPreferredReturnVector([]typ.Type{typ.String}, []typ.Type{typ.NewOptional(typ.String)}) +func TestReturnSummarySelectPreferred_Refinement(t *testing.T) { + preferred, ok := SelectPreferred([]typ.Type{typ.String}, []typ.Type{typ.NewOptional(typ.String)}) if !ok { t.Fatal("expected preferred vector") } @@ -173,8 +181,8 @@ func TestSelectPreferredReturnVector_Refinement(t *testing.T) { } } -func TestSelectPreferredReturnVector_AvoidsNilOnlyRegression(t *testing.T) { - preferred, ok := SelectPreferredReturnVector([]typ.Type{typ.Nil}, []typ.Type{typ.NewOptional(typ.String)}) +func TestReturnSummarySelectPreferred_AvoidsNilOnlyRegression(t *testing.T) { + preferred, ok := SelectPreferred([]typ.Type{typ.Nil}, []typ.Type{typ.NewOptional(typ.String)}) if !ok { t.Fatal("expected preferred vector") } @@ -183,8 +191,8 @@ func TestSelectPreferredReturnVector_AvoidsNilOnlyRegression(t *testing.T) { } } -func TestSelectPreferredReturnVector_RejectsStaleNilOnly(t *testing.T) { - preferred, ok := SelectPreferredReturnVector([]typ.Type{typ.NewOptional(typ.String)}, []typ.Type{typ.Nil}) +func TestReturnSummarySelectPreferred_RejectsStaleNilOnly(t *testing.T) { + preferred, ok := SelectPreferred([]typ.Type{typ.NewOptional(typ.String)}, []typ.Type{typ.Nil}) if !ok { t.Fatal("expected preferred vector") } @@ -193,11 +201,11 @@ func TestSelectPreferredReturnVector_RejectsStaleNilOnly(t *testing.T) { } } -func TestSelectPreferredReturnVector_RecordExtension(t *testing.T) { +func TestReturnSummarySelectPreferred_RecordExtension(t *testing.T) { oldRec := typ.NewRecord().Field("x", typ.Number).Build() newRec := typ.NewRecord().Field("x", typ.Number).Field("y", typ.String).Build() - preferred, ok := SelectPreferredReturnVector([]typ.Type{newRec}, []typ.Type{oldRec}) + preferred, ok := SelectPreferred([]typ.Type{newRec}, []typ.Type{oldRec}) if !ok { t.Fatal("expected preferred vector") } @@ -206,11 +214,11 @@ func TestSelectPreferredReturnVector_RecordExtension(t *testing.T) { } } -func TestSelectRefiningReturnVector_Refinement(t *testing.T) { +func TestReturnSummarySelectRefining_Refinement(t *testing.T) { refined := []typ.Type{typ.String} baseline := []typ.Type{typ.NewOptional(typ.String)} - got, ok := SelectRefiningReturnVector(refined, baseline) + got, ok := SelectRefining(refined, baseline) if !ok { t.Fatal("expected refinement to be selected") } @@ -219,35 +227,35 @@ func TestSelectRefiningReturnVector_Refinement(t *testing.T) { } } -func TestSelectRefiningReturnVector_DoesNotSelectOlderNarrowerBaseline(t *testing.T) { +func TestReturnSummarySelectRefining_DoesNotSelectOlderNarrowerBaseline(t *testing.T) { candidate := []typ.Type{typ.Any} baseline := []typ.Type{typ.False} - _, ok := SelectRefiningReturnVector(candidate, baseline) + _, ok := SelectRefining(candidate, baseline) if ok { t.Fatal("did not expect baseline-narrower relation to select candidate") } } -func TestReturnTypesFillNilSlots(t *testing.T) { +func TestReturnSummaryFillsNilSlots(t *testing.T) { candidate := []typ.Type{typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown)), typ.NewArray(typ.Unknown)} baseline := []typ.Type{typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown)), typ.Nil} - if !ReturnTypesFillNilSlots(candidate, baseline) { + if !FillsNilSlots(candidate, baseline) { t.Fatalf("expected candidate to fill nil slot: candidate=%v baseline=%v", candidate, baseline) } } -func TestMergeReturnSummary_PrefersCandidateRefinement(t *testing.T) { +func TestReturnSummaryMerge_PrefersCandidateRefinement(t *testing.T) { existing := []typ.Type{typ.NewOptional(typ.String)} candidate := []typ.Type{typ.String} - merged := MergeReturnSummary(existing, candidate) + merged := Merge(existing, candidate) if len(merged) != 1 || !typ.TypeEquals(merged[0], typ.String) { t.Fatalf("expected refined candidate return, got %v", merged) } } -func TestMergeReturnSummary_FillsNilSlotWithCandidateEvidence(t *testing.T) { +func TestReturnSummaryMerge_FillsNilSlotWithCandidateEvidence(t *testing.T) { existing := []typ.Type{ typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown)), typ.Nil, @@ -257,7 +265,7 @@ func TestMergeReturnSummary_FillsNilSlotWithCandidateEvidence(t *testing.T) { typ.NewArray(typ.Unknown), } - merged := MergeReturnSummary(existing, candidate) + merged := Merge(existing, candidate) if len(merged) != 2 { t.Fatalf("expected two return slots, got %v", merged) } @@ -266,141 +274,58 @@ func TestMergeReturnSummary_FillsNilSlotWithCandidateEvidence(t *testing.T) { } } -func TestMergeFunctionFactType_MergesSameShapeReturnsCanonically(t *testing.T) { - existing := typ.Func(). - Param("x", typ.String). - Returns(typ.NewOptional(typ.Integer)). - Build() - candidate := typ.Func(). - Param("x", typ.String). - Returns(typ.Integer). - Build() - - merged := MergeFunctionFactType(existing, candidate) - fn, ok := merged.(*typ.Function) - if !ok || len(fn.Returns) != 1 { - t.Fatalf("expected merged function, got %T", merged) - } - if !typ.TypeEquals(fn.Returns[0], typ.Integer) { - t.Fatalf("expected refined return integer, got %v", fn.Returns[0]) - } -} - -func TestMergeFunctionFactType_PrefersConcreteParamOverSoftAny(t *testing.T) { - existing := typ.Func(). - Param("x", typ.Any). - Returns(typ.String). - Build() - candidate := typ.Func(). - Param("x", typ.String). - Returns(typ.String). - Build() - - merged := MergeFunctionFactType(existing, candidate) - fn, ok := merged.(*typ.Function) - if !ok { - t.Fatalf("expected merged function, got %T", merged) - } - if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, typ.String) { - t.Fatalf("expected param refined to string, got %+v", fn.Params) - } -} - -func TestMergeFunctionFactType_WidensParamToCoverObservedCallsites(t *testing.T) { - existing := typ.Func(). - Param("t", typ.NewArray(typ.Any)). - Returns(typ.String). - Build() - candidate := typ.Func(). - Param("t", typ.NewMap(typ.String, typ.Any)). - Returns(typ.String). - Build() +func TestReturnSummaryMerge_PrefersCurrentTruthyMapKeyRefinement(t *testing.T) { + baseline := typ.NewMap(typ.NewUnion(typ.String, typ.False), typ.Unknown) + candidate := typ.NewMap(typ.String, typ.Unknown) - merged := MergeFunctionFactType(existing, candidate) - fn, ok := merged.(*typ.Function) - if !ok { - t.Fatalf("expected merged function, got %T", merged) - } - if len(fn.Params) != 1 { - t.Fatalf("expected one param, got %+v", fn.Params) - } - if typ.TypeEquals(fn.Params[0].Type, typ.NewArray(typ.Any)) { - t.Fatalf("expected param widening beyond array-only shape, got %v", fn.Params[0].Type) - } - wantMap := typ.NewMap(typ.String, typ.Any) - if !subtype.IsSubtype(wantMap, fn.Params[0].Type) { - t.Fatalf("expected merged param to admit map callsite evidence, got %v", fn.Params[0].Type) + merged := Merge([]typ.Type{baseline}, []typ.Type{candidate}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], candidate) { + t.Fatalf("expected stale falsy map key to refine to %v, got %v", candidate, merged) } } -func TestMergeFunctionFactType_DoesNotDropNonFunctionUnionMembers(t *testing.T) { - fn := typ.Func().Param("x", typ.String).Returns(typ.String).Build() - existing := typ.NewUnion(fn, typ.Number) - candidate := typ.Func().Param("x", typ.String).Returns(typ.String).Build() +func TestReturnSummaryMerge_PrefersConcreteMapValueOverSoftPlaceholder(t *testing.T) { + entry := typ.NewRecord().Field("id", typ.String).Build() + baseline := typ.NewMap(typ.String, typ.NewArray(typ.Any)) + candidate := typ.NewMap(typ.String, typ.NewArray(entry)) - merged := MergeFunctionFactType(existing, candidate) - u, ok := merged.(*typ.Union) - if !ok { - t.Fatalf("expected union to be preserved, got %T", merged) - } - hasNumber := false - for _, m := range u.Members { - if typ.TypeEquals(m, typ.Number) { - hasNumber = true - break - } - } - if !hasNumber { - t.Fatalf("expected merged union to retain non-function member, got %v", merged) + merged := Merge([]typ.Type{baseline}, []typ.Type{candidate}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], candidate) { + t.Fatalf("expected concrete map value evidence %v, got %v", candidate, merged) } } -func TestMergeFunctionFactType_CollapsesCompatibleFunctionVariants(t *testing.T) { - base := typ.Func(). - OptParam("entries", typ.Any). - Returns(typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown))). +func TestReturnSummaryMerge_PrefersCurrentTruthyRecordMapKeyRefinement(t *testing.T) { + entryArray := typ.NewArray(typ.Unknown) + baseline := typ.NewRecord(). + MapComponent(typ.NewUnion(typ.Nil, typ.String, typ.False), entryArray). + SetOpen(true). Build() - refinedEntry := typ.NewRecord().Field("id", typ.String).Build() - refined := typ.Func(). - OptParam("entries", typ.NewArray(refinedEntry)). - Returns(typ.NewMap(typ.String, typ.NewArray(refinedEntry))). + candidate := typ.NewRecord(). + MapComponent(typ.String, entryArray). Build() - merged := MergeFunctionFactType(base, refined) - fn, ok := merged.(*typ.Function) - if !ok { - t.Fatalf("expected function after compatible-variant collapse, got %T", merged) - } - if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, typ.NewArray(refinedEntry)) { - t.Fatalf("expected refined param type to win, got %+v", fn.Params) - } - if len(fn.Returns) != 1 || !typ.TypeEquals(fn.Returns[0], typ.NewMap(typ.String, typ.NewArray(refinedEntry))) { - t.Fatalf("expected refined return map, got %v", fn.Returns) + merged := Merge([]typ.Type{baseline}, []typ.Type{candidate}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], candidate) { + t.Fatalf("expected stale falsy record map key to refine to %v, got %v", candidate, merged) } } -func TestMergeFunctionFactType_DoesNotCollapseParamToNilWhenOptionalInfoExists(t *testing.T) { - existing := typ.Func(). - OptParam("tests", typ.Nil). - Returns(typ.Integer). - Build() - candidate := typ.Func(). - OptParam("tests", typ.NewOptional(typ.NewArray(typ.Any))). - Returns(typ.Integer). +func TestReturnSummaryMerge_PrefersMapOverStaleOpenRecordMapKeyRefinement(t *testing.T) { + entryArray := typ.NewArray(typ.Unknown) + baseline := typ.NewRecord(). + MapComponent(typ.NewUnion(typ.Nil, typ.String, typ.False), entryArray). + SetOpen(true). Build() + candidate := typ.NewMap(typ.String, entryArray) - merged := MergeFunctionFactType(existing, candidate) - fn, ok := merged.(*typ.Function) - if !ok { - t.Fatalf("expected function, got %T", merged) - } - want := typ.NewOptional(typ.NewArray(typ.Any)) - if len(fn.Params) != 1 || !typ.TypeEquals(fn.Params[0].Type, want) { - t.Fatalf("expected param type %v, got %+v", want, fn.Params) + merged := Merge([]typ.Type{baseline}, []typ.Type{candidate}) + if len(merged) != 1 || !typ.TypeEquals(merged[0], candidate) { + t.Fatalf("expected map to replace stale open record map %v, got %v", candidate, merged) } } -func TestMergeReturnSummary_ElidesOptionalForInterfaceFieldRecords(t *testing.T) { +func TestReturnSummaryMerge_ElidesOptionalForInterfaceFieldRecords(t *testing.T) { txType := typ.NewInterface("sql.Tx", []typ.Method{ {Name: "rollback", Type: typ.Func().Param("self", typ.Self).Build()}, }) @@ -422,20 +347,20 @@ func TestMergeReturnSummary_ElidesOptionalForInterfaceFieldRecords(t *testing.T) Build(), } - merged := MergeReturnSummary(existing, candidate) + merged := Merge(existing, candidate) if len(merged) != 1 || !typ.TypeEquals(merged[0], candidate[0]) { t.Fatalf("expected candidate optional-elision to win, got %v", merged) } } -func TestWithSummaryOrUnknown_AppliesSummaryToPlaceholderReturns(t *testing.T) { +func TestReturnSummaryApplyToFunctionType_AppliesSummaryToPlaceholderReturns(t *testing.T) { fn := typ.Func(). Param("x", typ.String). Returns(typ.Unknown). Build() summary := []typ.Type{typ.Integer} - got := WithSummaryOrUnknown(fn, summary) + got := ApplyToFunctionType(fn, summary) if got == nil || len(got.Returns) != 1 { t.Fatalf("expected function return, got %v", got) } @@ -444,9 +369,9 @@ func TestWithSummaryOrUnknown_AppliesSummaryToPlaceholderReturns(t *testing.T) { } } -func TestWithSummaryOrUnknown_DefaultsToUnknownWhenMissing(t *testing.T) { +func TestReturnSummaryApplyToFunctionType_DefaultsToUnknownWhenMissing(t *testing.T) { fn := typ.Func().Param("x", typ.String).Build() - got := WithSummaryOrUnknown(fn, nil) + got := ApplyToFunctionType(fn, nil) if got == nil || len(got.Returns) != 1 { t.Fatalf("expected one default return, got %v", got) } @@ -455,31 +380,16 @@ func TestWithSummaryOrUnknown_DefaultsToUnknownWhenMissing(t *testing.T) { } } -func TestTypeExtendsRecord_NilTypes(t *testing.T) { - if TypeExtendsRecord(nil, typ.String) { - t.Error("nil a should not extend") - } - if TypeExtendsRecord(typ.String, nil) { - t.Error("nil b should not extend") - } -} - -func TestTypeExtendsRecord_NotRecord(t *testing.T) { - if TypeExtendsRecord(typ.String, typ.String) { - t.Error("non-record should not extend") - } -} - -func TestNormalizeReturnVector_Empty(t *testing.T) { - result := NormalizeReturnVector(nil) +func TestReturnSummaryNormalize_Empty(t *testing.T) { + result := Normalize(nil) if result != nil { t.Errorf("expected nil, got %v", result) } } -func TestNormalizeReturnVector_ReplacesNil(t *testing.T) { +func TestReturnSummaryNormalize_ReplacesNil(t *testing.T) { input := []typ.Type{typ.String, nil, typ.Number} - result := NormalizeReturnVector(input) + result := Normalize(input) if len(result) != 3 { t.Fatalf("expected length 3, got %d", len(result)) } @@ -501,7 +411,7 @@ func TestRecordSuperset_BothHaveMapComponent(t *testing.T) { newRec := typ.NewRecord().MapComponent(typ.String, typ.Number).Field("x", typ.Number).Build() a := []typ.Type{newRec} b := []typ.Type{oldRec} - if !ReturnTypesExtendRecord(a, b) { + if !ExtendsRecord(a, b) { t.Error("record with same map component and additional fields should extend") } } @@ -511,12 +421,12 @@ func TestRecordSuperset_OldHasNoMapComponent(t *testing.T) { newRec := typ.NewRecord().Field("x", typ.Number).Field("y", typ.String).Build() a := []typ.Type{newRec} b := []typ.Type{oldRec} - if !ReturnTypesExtendRecord(a, b) { + if !ExtendsRecord(a, b) { t.Error("record with additional fields should extend record without map component") } } -func TestAlignFunctionTypeWithSummary_AppliesStrictRefinement(t *testing.T) { +func TestReturnSummaryAlignFunction_AppliesStrictRefinement(t *testing.T) { fn := typ.Func(). Param("entries", typ.Any). Returns(typ.NewMap(typ.Unknown, typ.NewArray(typ.Unknown))). @@ -529,7 +439,7 @@ func TestAlignFunctionTypeWithSummary_AppliesStrictRefinement(t *testing.T) { Build(), } - aligned, changed := AlignFunctionTypeWithSummary(fn, summary) + aligned, changed := AlignFunction(fn, summary) if !changed { t.Fatal("expected alignment to apply strict refinement summary") } @@ -546,12 +456,12 @@ func TestAlignFunctionTypeWithSummary_AppliesStrictRefinement(t *testing.T) { } } -func TestAlignFunctionTypeWithSummary_ReplacesOpenTopRecordWithStructuredSummary(t *testing.T) { +func TestReturnSummaryAlignFunction_ReplacesOpenTopRecordWithStructuredSummary(t *testing.T) { openTop := typ.NewRecord().SetOpen(true).Build() fn := typ.Func().Returns(openTop).Build() summary := []typ.Type{typ.NewArray(typ.Unknown)} - aligned, changed := AlignFunctionTypeWithSummary(fn, summary) + aligned, changed := AlignFunction(fn, summary) if !changed { t.Fatal("expected open-top placeholder to be replaced by structured summary") } @@ -563,12 +473,12 @@ func TestAlignFunctionTypeWithSummary_ReplacesOpenTopRecordWithStructuredSummary } } -func TestAlignFunctionTypeWithSummary_DoesNotDowngradeStructuredToPlaceholder(t *testing.T) { +func TestReturnSummaryAlignFunction_DoesNotDowngradeStructuredToPlaceholder(t *testing.T) { structured := typ.NewRecord().Field("get_x", typ.Func().Build()).Build() fn := typ.Func().Returns(structured).Build() summary := []typ.Type{typ.Any} - aligned, changed := AlignFunctionTypeWithSummary(fn, summary) + aligned, changed := AlignFunction(fn, summary) if changed { t.Fatalf("expected no downgrade change, got %v", aligned) } @@ -580,7 +490,7 @@ func TestAlignFunctionTypeWithSummary_DoesNotDowngradeStructuredToPlaceholder(t } } -func TestMergeReturnSummary_PrefersRuntimePossibleSummaryOverNeverArtifact(t *testing.T) { +func TestReturnSummaryMerge_PrefersRuntimePossibleSummaryOverNeverArtifact(t *testing.T) { bad := []typ.Type{ typ.NewUnion( typ.NewRecord(). @@ -606,13 +516,13 @@ func TestMergeReturnSummary_PrefersRuntimePossibleSummaryOverNeverArtifact(t *te ), } - got := MergeReturnSummary(bad, good) - if !ReturnTypesEqual(got, good) { - t.Fatalf("MergeReturnSummary(%v, %v) = %v, want %v", bad, good, got, good) + got := Merge(bad, good) + if !Equal(got, good) { + t.Fatalf("Merge(%v, %v) = %v, want %v", bad, good, got, good) } } -func TestAlignFunctionTypeWithSummary_RepairsNestedNeverArtifact(t *testing.T) { +func TestReturnSummaryAlignFunction_RepairsNestedNeverArtifact(t *testing.T) { bad := typ.NewUnion( typ.NewRecord(). Field("success", typ.True). @@ -635,7 +545,7 @@ func TestAlignFunctionTypeWithSummary_RepairsNestedNeverArtifact(t *testing.T) { ) fn := typ.Func().Returns(bad).Build() - aligned, changed := AlignFunctionTypeWithSummary(fn, []typ.Type{good}) + aligned, changed := AlignFunction(fn, []typ.Type{good}) if !changed { t.Fatal("expected never-artifact repair to update function returns") } @@ -649,7 +559,7 @@ func TestRecordSuperset_NewHasMapComponentOldDoesNot(t *testing.T) { newRec := typ.NewRecord().Field("x", typ.Number).MapComponent(typ.String, typ.Any).Build() a := []typ.Type{newRec} b := []typ.Type{oldRec} - if !ReturnTypesExtendRecord(a, b) { + if !ExtendsRecord(a, b) { t.Error("record with additional map component should extend record without it") } } @@ -659,23 +569,12 @@ func TestRecordSuperset_IncompatibleMapComponent(t *testing.T) { newRec := typ.NewRecord().MapComponent(typ.String, typ.Number).Build() a := []typ.Type{newRec} b := []typ.Type{oldRec} - if ReturnTypesExtendRecord(a, b) { + if ExtendsRecord(a, b) { t.Error("record with incompatible map component should not extend") } } -// Regression: recordSuperset should use && not || for map component check. -// This test verifies the fix by checking that the code uses HasMapComponent semantics. -func TestTypeExtendsRecord_MapComponentConsistency(t *testing.T) { - // When old has map component, new must have compatible map component - oldRec := typ.NewRecord().MapComponent(typ.String, typ.Number).Build() - newRec := typ.NewRecord().Field("x", typ.Number).Build() - if TypeExtendsRecord(newRec, oldRec) { - t.Error("record without map component should not extend record with map component") - } -} - -func TestMergeReturnSummary_PrefersStructuredCollectionOverOpenTopRecordField(t *testing.T) { +func TestReturnSummaryMerge_PrefersStructuredCollectionOverOpenTopRecordField(t *testing.T) { weak := []typ.Type{ typ.NewRecord(). Field("messages", typ.NewRecord().SetOpen(true).Build()). @@ -691,7 +590,7 @@ func TestMergeReturnSummary_PrefersStructuredCollectionOverOpenTopRecordField(t Build(), } - merged := MergeReturnSummary(weak, strong) + merged := Merge(weak, strong) if len(merged) != 1 { t.Fatalf("expected one return slot, got %d", len(merged)) } @@ -709,7 +608,7 @@ func TestMergeReturnSummary_PrefersStructuredCollectionOverOpenTopRecordField(t } } -func TestMergeReturnSummary_PromotesTopLevelStructuredOverOpenTop(t *testing.T) { +func TestReturnSummaryMerge_PromotesTopLevelStructuredOverOpenTop(t *testing.T) { weak := []typ.Type{ typ.NewRecord().SetOpen(true).Build(), } @@ -717,7 +616,7 @@ func TestMergeReturnSummary_PromotesTopLevelStructuredOverOpenTop(t *testing.T) typ.NewArray(typ.Any), } - merged := MergeReturnSummary(weak, strong) + merged := Merge(weak, strong) if len(merged) != 1 { t.Fatalf("expected one return slot, got %d", len(merged)) } diff --git a/compiler/check/domain/returnsummary/summary.go b/compiler/check/domain/returnsummary/summary.go new file mode 100644 index 00000000..ecdbd5ad --- /dev/null +++ b/compiler/check/domain/returnsummary/summary.go @@ -0,0 +1,901 @@ +package returnsummary + +import ( + "github.com/wippyai/go-lua/compiler/check/domain/value" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + typjoin "github.com/wippyai/go-lua/types/typ/join" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// Equal checks whether two return vectors are structurally equal. +func Equal(a, b []typ.Type) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !typ.TypeEquals(a[i], b[i]) { + return false + } + } + return true +} + +// AllNil reports whether every return slot is explicit nil. +func AllNil(rets []typ.Type) bool { + if len(rets) == 0 { + return false + } + for _, t := range rets { + if t == nil || t.Kind() != kind.Nil { + return false + } + } + return true +} + +// Refines reports whether a is an element-wise subtype refinement of b. +func Refines(a, b []typ.Type) bool { + if len(a) == 0 { + return false + } + if len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + ai := a[i] + bi := b[i] + if ai == nil || bi == nil { + if ai == nil && bi == nil { + continue + } + return false + } + if !subtype.IsSubtype(ai, bi) { + return false + } + } + return true +} + +// ExtendsRecord reports whether a refines b by adding record fields. +func ExtendsRecord(a, b []typ.Type) bool { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return false + } + for i := range a { + if _, ok := a[i].(*typ.Record); !ok { + return false + } + if !value.ExtendsRecord(a[i], b[i]) { + return false + } + } + return true +} + +// ElidesOptional reports whether a refines b by removing nil/optional parts. +func ElidesOptional(a, b []typ.Type) bool { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return false + } + for i := range a { + if !value.ElidesOptional(a[i], b[i]) { + return false + } + } + return true +} + +// SelectPreferred picks a canonical winner when one return vector is strictly +// preferable to the other without requiring a join. +func SelectPreferred(a, b []typ.Type) ([]typ.Type, bool) { + if RepairsNever(a, b) { + return a, true + } + if RepairsNever(b, a) { + return b, true + } + if RefinesSoftContainers(a, b) { + return a, true + } + if RefinesSoftContainers(b, a) { + return b, true + } + if StopsRecursiveStructuralGrowth(a, b) { + return a, true + } + if StopsRecursiveStructuralGrowth(b, a) { + return b, true + } + if RefinesFalsyMapKeys(a, b) { + return a, true + } + if RefinesFalsyMapKeys(b, a) { + return b, true + } + if Refines(a, b) { + if AllNil(a) && !AllNil(b) { + return b, true + } + if NestedNilOnlyRegression(a, b) { + return b, true + } + return a, true + } + if Refines(b, a) { + if AllNil(b) && !AllNil(a) { + return a, true + } + if NestedNilOnlyRegression(b, a) { + return a, true + } + return b, true + } + if FillsNilSlots(a, b) { + return a, true + } + if FillsNilSlots(b, a) { + return b, true + } + if (ExtendsRecord(a, b) || ElidesOptional(a, b)) && !NestedNilOnlyRegression(a, b) { + return a, true + } + if (ExtendsRecord(b, a) || ElidesOptional(b, a)) && !NestedNilOnlyRegression(b, a) { + return b, true + } + return nil, false +} + +// RefinesSoftContainers reports whether candidate preserves the same table +// shape while replacing soft placeholder element/value evidence with concrete +// evidence. +func RefinesSoftContainers(candidate, baseline []typ.Type) bool { + if len(candidate) == 0 || len(baseline) == 0 || len(candidate) != len(baseline) { + return false + } + strict := false + for i := range candidate { + refines, changed := value.RefinesSoftContainer(candidate[i], baseline[i]) + if !refines { + return false + } + if changed { + strict = true + } + } + return strict +} + +// RefinesFalsyMapKeys reports whether candidate is the same table-derived shape +// as baseline after removing stale falsy members from baseline. +func RefinesFalsyMapKeys(candidate, baseline []typ.Type) bool { + if len(candidate) == 0 || len(baseline) == 0 || len(candidate) != len(baseline) { + return false + } + strict := false + for i := range candidate { + refines, changed := value.RefinesFalsyMapKey(candidate[i], baseline[i]) + if !refines { + return false + } + if changed { + strict = true + } + } + return strict +} + +// NestedNilOnlyRegression reports whether candidate's apparent refinement only +// adds nested nil facts over a more useful baseline shape. +func NestedNilOnlyRegression(candidate, baseline []typ.Type) bool { + if len(candidate) == 0 || len(baseline) == 0 || len(candidate) != len(baseline) { + return false + } + for i := range candidate { + if value.NestedNilOnlyRegression(candidate[i], baseline[i]) { + return true + } + } + return false +} + +// StopsRecursiveStructuralGrowth reports whether growing embeds the same +// structural container shape as stable beneath its root. +func StopsRecursiveStructuralGrowth(stable, growing []typ.Type) bool { + if len(stable) == 0 || len(growing) == 0 || len(stable) != len(growing) { + return false + } + + strict := false + for i := range stable { + s := stable[i] + g := growing[i] + if s == nil || g == nil { + return false + } + if typ.TypeEquals(s, g) { + continue + } + if typ.IsAbsentOrUnknown(s) || !value.CanSelfEmbed(s) { + return false + } + if !value.ShallowStructuralShapeEquals(g, s) { + return false + } + if !value.ContainsNestedStructuralShape(g, s) { + return false + } + strict = true + } + return strict +} + +// SelectRefining prefers candidate only when it directionally refines baseline. +func SelectRefining(candidate, baseline []typ.Type) ([]typ.Type, bool) { + if Refines(candidate, baseline) { + if AllNil(candidate) && !AllNil(baseline) { + return baseline, true + } + return candidate, true + } + if FillsNilSlots(candidate, baseline) { + return candidate, true + } + if ExtendsRecord(candidate, baseline) || ElidesOptional(candidate, baseline) { + return candidate, true + } + return nil, false +} + +// FillsNilSlots reports whether a improves b by replacing nil-only slots with +// concrete return evidence while staying compatible on other slots. +func FillsNilSlots(a, b []typ.Type) bool { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return false + } + strict := false + for i := range a { + ai := a[i] + bi := b[i] + if ai == nil || bi == nil { + return false + } + if unwrap.IsNilType(bi) && !unwrap.IsNilType(ai) { + strict = true + continue + } + if typ.TypeEquals(ai, bi) { + continue + } + if subtype.IsSubtype(ai, bi) || value.ExtendsRecord(ai, bi) || value.ElidesOptional(ai, bi) { + continue + } + return false + } + return strict +} + +// RepairsNever reports whether candidate is a runtime-possible repair of +// baseline by replacing nested never artifacts while otherwise widening +// compatibly. +func RepairsNever(candidate, baseline []typ.Type) bool { + if len(candidate) == 0 || len(baseline) == 0 || len(candidate) != len(baseline) { + return false + } + strict := false + for i := range candidate { + if candidate[i] == nil || baseline[i] == nil { + return false + } + if typ.TypeEquals(candidate[i], baseline[i]) { + continue + } + if !repairsNever(candidate[i], baseline[i]) { + return false + } + strict = true + } + return strict +} + +func repairsNever(candidate, baseline typ.Type) bool { + if candidate == nil || baseline == nil { + return false + } + if !containsNever(baseline) || containsNever(candidate) { + return false + } + ok, strict := neverRepairRelation(candidate, baseline) + return ok && strict +} + +func neverRepairRelation(candidate, baseline typ.Type) (bool, bool) { + if candidate == nil || baseline == nil { + return false, false + } + if typ.TypeEquals(candidate, baseline) { + return true, false + } + + candidate = unwrap.Alias(candidate) + baseline = unwrap.Alias(baseline) + if candidate == nil || baseline == nil { + return false, false + } + + if typ.IsNever(baseline) { + return !typ.IsNever(candidate), !typ.IsNever(candidate) + } + if !containsNever(baseline) { + return false, false + } + + switch b := baseline.(type) { + case *typ.Optional: + c, ok := candidate.(*typ.Optional) + if !ok { + return false, false + } + return neverRepairRelation(c.Inner, b.Inner) + case *typ.Union: + c, ok := candidate.(*typ.Union) + if !ok || len(c.Members) != len(b.Members) { + return false, false + } + used := make([]bool, len(c.Members)) + strict := false + for _, bm := range b.Members { + matched := false + for j, cm := range c.Members { + if used[j] || !typ.TypeEquals(cm, bm) { + continue + } + used[j] = true + matched = true + break + } + if matched { + continue + } + for j, cm := range c.Members { + if used[j] { + continue + } + ok, repaired := neverRepairRelation(cm, bm) + if !ok { + continue + } + used[j] = true + matched = true + if repaired { + strict = true + } + break + } + if !matched { + return false, false + } + } + return true, strict + case *typ.Record: + c, ok := candidate.(*typ.Record) + if !ok || c.Open != b.Open || c.HasMapComponent() != b.HasMapComponent() || len(c.Fields) != len(b.Fields) { + return false, false + } + strict := false + for _, bf := range b.Fields { + cf := c.GetField(bf.Name) + if cf == nil || cf.Optional != bf.Optional || cf.Readonly != bf.Readonly { + return false, false + } + ok, repaired := neverRepairRelation(cf.Type, bf.Type) + if !ok { + return false, false + } + if repaired { + strict = true + } + } + if b.HasMapComponent() { + ok, repaired := neverRepairRelation(c.MapKey, b.MapKey) + if !ok { + return false, false + } + if repaired { + strict = true + } + ok, repaired = neverRepairRelation(c.MapValue, b.MapValue) + if !ok { + return false, false + } + if repaired { + strict = true + } + } + if b.Metatable != nil || c.Metatable != nil { + if b.Metatable == nil || c.Metatable == nil { + return false, false + } + ok, repaired := neverRepairRelation(c.Metatable, b.Metatable) + if !ok { + return false, false + } + if repaired { + strict = true + } + } + return true, strict + case *typ.Array: + c, ok := candidate.(*typ.Array) + if !ok { + return false, false + } + return neverRepairRelation(c.Element, b.Element) + case *typ.Map: + c, ok := candidate.(*typ.Map) + if !ok { + return false, false + } + keyOK, keyStrict := neverRepairRelation(c.Key, b.Key) + if !keyOK { + return false, false + } + valOK, valStrict := neverRepairRelation(c.Value, b.Value) + if !valOK { + return false, false + } + return true, keyStrict || valStrict + case *typ.Tuple: + c, ok := candidate.(*typ.Tuple) + if !ok || len(c.Elements) != len(b.Elements) { + return false, false + } + strict := false + for i := range b.Elements { + ok, repaired := neverRepairRelation(c.Elements[i], b.Elements[i]) + if !ok { + return false, false + } + if repaired { + strict = true + } + } + return true, strict + case *typ.Function: + c, ok := candidate.(*typ.Function) + if !ok || !sameFunctionShapeForRepair(c, b) || len(c.Returns) != len(b.Returns) { + return false, false + } + for i := range b.Params { + if c.Params[i].Name != b.Params[i].Name || + c.Params[i].Optional != b.Params[i].Optional || + !typ.TypeEquals(c.Params[i].Type, b.Params[i].Type) { + return false, false + } + } + switch { + case (c.Variadic == nil) != (b.Variadic == nil): + return false, false + case c.Variadic != nil && !typ.TypeEquals(c.Variadic, b.Variadic): + return false, false + } + strict := false + for i := range b.Returns { + ok, repaired := neverRepairRelation(c.Returns[i], b.Returns[i]) + if !ok { + return false, false + } + if repaired { + strict = true + } + } + return true, strict + default: + return false, false + } +} + +func sameFunctionShapeForRepair(a, b *typ.Function) bool { + if a == nil || b == nil { + return false + } + if !typeParamsEqual(a.TypeParams, b.TypeParams) { + return false + } + return len(a.Params) == len(b.Params) +} + +func typeParamsEqual(a, b []*typ.TypeParam) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] == nil || b[i] == nil { + if a[i] != b[i] { + return false + } + continue + } + if !a[i].Equals(b[i]) { + return false + } + } + return true +} + +func containsNever(t typ.Type) bool { + seen := make(map[typ.Type]bool) + return containsNeverMemo(t, seen) +} + +func containsNeverMemo(t typ.Type, seen map[typ.Type]bool) bool { + if t == nil { + return false + } + if seen[t] { + return false + } + seen[t] = true + t = unwrap.Alias(t) + if t == nil { + return false + } + if typ.IsNever(t) { + return true + } + return typ.Visit(t, typ.Visitor[bool]{ + Optional: func(o *typ.Optional) bool { + return containsNeverMemo(o.Inner, seen) + }, + Union: func(u *typ.Union) bool { + for _, m := range u.Members { + if containsNeverMemo(m, seen) { + return true + } + } + return false + }, + Intersection: func(in *typ.Intersection) bool { + for _, m := range in.Members { + if containsNeverMemo(m, seen) { + return true + } + } + return false + }, + Tuple: func(tup *typ.Tuple) bool { + for _, e := range tup.Elements { + if containsNeverMemo(e, seen) { + return true + } + } + return false + }, + Array: func(a *typ.Array) bool { + return containsNeverMemo(a.Element, seen) + }, + Map: func(m *typ.Map) bool { + return containsNeverMemo(m.Key, seen) || containsNeverMemo(m.Value, seen) + }, + Record: func(r *typ.Record) bool { + for _, f := range r.Fields { + if containsNeverMemo(f.Type, seen) { + return true + } + } + if r.HasMapComponent() { + return containsNeverMemo(r.MapKey, seen) || containsNeverMemo(r.MapValue, seen) + } + return false + }, + Function: func(fn *typ.Function) bool { + for _, p := range fn.Params { + if containsNeverMemo(p.Type, seen) { + return true + } + } + if fn.Variadic != nil && containsNeverMemo(fn.Variadic, seen) { + return true + } + for _, ret := range fn.Returns { + if containsNeverMemo(ret, seen) { + return true + } + } + return false + }, + Default: func(typ.Type) bool { + return false + }, + }) +} + +// Normalize replaces nil slots with explicit nil types in a copied vector. +func Normalize(rets []typ.Type) []typ.Type { + if len(rets) == 0 { + return nil + } + out := make([]typ.Type, len(rets)) + copy(out, rets) + return NormalizeOwned(out) +} + +// NormalizeOwned replaces nil slots with explicit nil types in an owned vector. +func NormalizeOwned(rets []typ.Type) []typ.Type { + if len(rets) == 0 { + return nil + } + for i, t := range rets { + if t == nil { + rets[i] = typ.Nil + } + } + return rets +} + +// Canonical returns a vector with explicit nil slots, reusing the input when it +// is already canonical. +func Canonical(rets []typ.Type) []typ.Type { + if len(rets) == 0 { + return nil + } + for i, t := range rets { + if t != nil { + continue + } + out := make([]typ.Type, len(rets)) + copy(out, rets) + out[i] = typ.Nil + for j := i + 1; j < len(out); j++ { + if out[j] == nil { + out[j] = typ.Nil + } + } + return out + } + return rets +} + +// NormalizeAndPrune canonicalizes nil slots and removes soft union members. +func NormalizeAndPrune(rets []typ.Type) []typ.Type { + if len(rets) == 0 { + return nil + } + var out []typ.Type + for i, ret := range rets { + normalized := ret + if normalized == nil { + normalized = typ.Nil + } + pruned := typ.PruneSoftUnionMembers(normalized) + if out != nil { + out[i] = pruned + continue + } + if pruned == ret { + continue + } + out = make([]typ.Type, len(rets)) + copy(out, rets[:i]) + out[i] = pruned + } + if out != nil { + return out + } + return rets +} + +// Merge applies the canonical return-summary merge policy shared by iterative +// product facts. +func Merge(existing, candidate []typ.Type) []typ.Type { + existing = NormalizeAndPrune(existing) + candidate = NormalizeAndPrune(candidate) + if len(existing) == 0 { + return candidate + } + if len(candidate) == 0 { + return existing + } + if replaced, ok := replaceOpenTopWithStructured(existing, candidate); ok { + existing = NormalizeAndPrune(replaced) + } + if RepairsNever(existing, candidate) { + return existing + } + if RepairsNever(candidate, existing) { + return candidate + } + if shouldUseMonotoneJoin(existing, candidate) { + return NormalizeAndPrune(joinMonotone(existing, candidate)) + } + if preferred, ok := SelectPreferred(existing, candidate); ok { + return NormalizeAndPrune(preferred) + } + return NormalizeAndPrune(typjoin.ReturnVectors(existing, candidate)) +} + +// WidenForConvergence merges return vectors at a recursive fixpoint boundary. +func WidenForConvergence(prev, next []typ.Type) []typ.Type { + prev = NormalizeAndPrune(prev) + next = NormalizeAndPrune(next) + if len(prev) == 0 { + return WidenVectorForConvergence(next) + } + if len(next) == 0 { + return WidenVectorForConvergence(prev) + } + + merged := Merge(prev, next) + if UnsafePrecisionDrop(prev, merged) { + merged = prev + } + return WidenVectorForConvergence(NormalizeAndPrune(merged)) +} + +// WidenVectorForConvergence applies element-wise convergence widening. +func WidenVectorForConvergence(rets []typ.Type) []typ.Type { + if len(rets) == 0 { + return rets + } + out := make([]typ.Type, len(rets)) + changed := false + for i, t := range rets { + wt := value.WidenForConvergence(t) + out[i] = wt + if wt != t { + changed = true + } + } + if !changed { + return rets + } + return out +} + +// UnsafePrecisionDrop reports whether a merged vector lost prior evidence. +func UnsafePrecisionDrop(prev, merged []typ.Type) bool { + if len(prev) == 0 || len(merged) == 0 || len(prev) != len(merged) { + return false + } + for i := range prev { + if value.UnsafePrecisionDrop(prev[i], merged[i]) { + return true + } + } + return false +} + +func shouldUseMonotoneJoin(a, b []typ.Type) bool { + for _, t := range a { + if value.HasHigherOrderGrowthRisk(t) { + return true + } + } + for _, t := range b { + if value.HasHigherOrderGrowthRisk(t) { + return true + } + } + return false +} + +func joinMonotone(a, b []typ.Type) []typ.Type { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + maxLen := len(a) + if len(b) > maxLen { + maxLen = len(b) + } + out := make([]typ.Type, maxLen) + for i := 0; i < maxLen; i++ { + var ai, bi typ.Type + if i < len(a) { + ai = a[i] + } + if i < len(b) { + bi = b[i] + } + out[i] = joinTypeMonotone(ai, bi) + } + return out +} + +func joinTypeMonotone(a, b typ.Type) typ.Type { + if a == nil { + return b + } + if b == nil { + return a + } + if typ.TypeEquals(a, b) { + return a + } + if subtype.IsSubtype(a, b) || value.ExtendsRecord(a, b) || value.ElidesOptional(a, b) { + return b + } + if subtype.IsSubtype(b, a) || value.ExtendsRecord(b, a) || value.ElidesOptional(b, a) { + return a + } + return typ.JoinPreferNonSoft(a, b) +} + +// AlignFunction applies the canonical return-summary winner to a function type. +func AlignFunction(fn *typ.Function, summary []typ.Type) (*typ.Function, bool) { + if fn == nil { + return nil, false + } + + normalizedSummary := NormalizeAndPrune(summary) + if len(normalizedSummary) == 0 { + return fn, false + } + + current := NormalizeAndPrune(fn.Returns) + if len(current) == 0 { + aligned := typjoin.WithReturns(fn, normalizedSummary) + return aligned, aligned != nil + } + merged := Merge(current, normalizedSummary) + if Equal(current, merged) { + return fn, false + } + + aligned := typjoin.WithReturns(fn, merged) + if aligned == nil { + return fn, false + } + return aligned, true +} + +func replaceOpenTopWithStructured(current, summary []typ.Type) ([]typ.Type, bool) { + if len(current) == 0 || len(summary) == 0 || len(current) != len(summary) { + return nil, false + } + out := append([]typ.Type(nil), current...) + changed := false + for i := range out { + if !value.IsOpenTopRecord(out[i]) { + continue + } + if !value.IsStructuredTableShape(summary[i]) { + continue + } + out[i] = summary[i] + changed = true + } + if !changed { + return nil, false + } + return out, true +} + +// ApplyToFunctionType applies summary-derived returns to a function signature. +// If both summary and signature returns are empty, it attaches unknown to keep +// call-site checking conservative. +func ApplyToFunctionType(fn *typ.Function, summary []typ.Type) *typ.Function { + if fn == nil { + return nil + } + if len(summary) == 0 { + if len(fn.Returns) > 0 { + return fn + } + return typjoin.WithReturns(fn, []typ.Type{typ.Unknown}) + } + if aligned, changed := AlignFunction(fn, summary); changed { + return aligned + } + if len(fn.Returns) > 0 { + return fn + } + return typjoin.WithReturns(fn, NormalizeAndPrune(summary)) +} diff --git a/compiler/check/domain/typefacts/type_facts.go b/compiler/check/domain/typefacts/type_facts.go new file mode 100644 index 00000000..5b71dd21 --- /dev/null +++ b/compiler/check/domain/typefacts/type_facts.go @@ -0,0 +1,120 @@ +// Package typefacts owns the checker product-state query surface. +// +// Synthesis and transfer code should ask this package for semantic facts rather +// than rebuilding precedence rules from stores, product overlays, or phase-local +// snapshots. +package typefacts + +import ( + "github.com/wippyai/go-lua/types/cfg" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/typ" +) + +// FunctionTypeLookup projects canonical function summaries into the type-fact query. +type FunctionTypeLookup func(sym cfg.SymbolID) typ.Type + +// Config contains the immutable and solved inputs visible to a query. +type Config struct { + Declared flow.DeclaredTypes + FunctionType FunctionTypeLookup + Literals flow.DeclaredTypes + AnnotatedVars map[cfg.SymbolID]bool + Solution *flow.Solution +} + +// TypeFacts implements flow.TypeFacts over the checker product state. +type TypeFacts struct { + declared flow.DeclaredTypes + functionType FunctionTypeLookup + literals flow.DeclaredTypes + annotatedVars map[cfg.SymbolID]bool + solution *flow.Solution +} + +var _ flow.TypeFacts = (*TypeFacts)(nil) + +// New returns the canonical type-fact query for a checker phase. +func New(cfg Config) *TypeFacts { + return &TypeFacts{ + declared: cfg.Declared, + functionType: cfg.FunctionType, + literals: cfg.Literals, + annotatedVars: cfg.AnnotatedVars, + solution: cfg.Solution, + } +} + +// DeclaredAt returns the declared product-state type for a symbol at a point. +func (f *TypeFacts) DeclaredAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { + if sym == 0 { + return flow.TypedValue{Type: typ.Unknown, State: flow.StateUnknown} + } + if f != nil && f.annotatedVars != nil && f.annotatedVars[sym] { + if tv, ok := f.declaredTypedValue(sym); ok { + return tv + } + } + if f != nil && f.functionType != nil { + if t := f.functionType(sym); t != nil { + return typedValue(t) + } + } + if f != nil { + if tv, ok := f.declaredTypedValue(sym); ok { + return tv + } + } + if f != nil && f.literals != nil { + if f.annotatedVars == nil || !f.annotatedVars[sym] { + if t, ok := f.literals[sym]; ok && t != nil { + return typedValue(t) + } + } + } + return flow.TypedValue{Type: typ.Unknown, State: flow.StateUnknown} +} + +// RefinedAt returns the flow-refined product-state type for a symbol. +func (f *TypeFacts) RefinedAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { + if f == nil || sym == 0 || f.solution == nil { + return flow.TypedValue{Type: nil, State: flow.StateUnknown} + } + return f.solution.RefinedAt(p, sym) +} + +// EffectiveTypeAt returns the resolved flow type when available, otherwise the +// declared product-state type. +func (f *TypeFacts) EffectiveTypeAt(p cfg.Point, sym cfg.SymbolID) flow.TypedValue { + refined := f.RefinedAt(p, sym) + if refined.Type != nil && refined.State == flow.StateResolved { + return refined + } + return f.DeclaredAt(p, sym) +} + +// IsAnnotated reports whether a symbol has an explicit source annotation. +func (f *TypeFacts) IsAnnotated(sym cfg.SymbolID) bool { + if f == nil || f.annotatedVars == nil { + return false + } + return f.annotatedVars[sym] +} + +func (f *TypeFacts) declaredTypedValue(sym cfg.SymbolID) (flow.TypedValue, bool) { + if f.declared == nil { + return flow.TypedValue{}, false + } + t, ok := f.declared[sym] + if !ok || t == nil { + return flow.TypedValue{}, false + } + return typedValue(t), true +} + +func typedValue(t typ.Type) flow.TypedValue { + if typ.IsUnknown(t) { + return flow.TypedValue{Type: t, State: flow.StateUnknown} + } + return flow.TypedValue{Type: t, State: flow.StateResolved} +} diff --git a/compiler/check/domain/typefacts/type_facts_test.go b/compiler/check/domain/typefacts/type_facts_test.go new file mode 100644 index 00000000..a5ac636a --- /dev/null +++ b/compiler/check/domain/typefacts/type_facts_test.go @@ -0,0 +1,68 @@ +package typefacts + +import ( + "testing" + + "github.com/wippyai/go-lua/types/cfg" + "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/typ" +) + +type functionTypeMap map[cfg.SymbolID]typ.Type + +func (m functionTypeMap) lookup(sym cfg.SymbolID) typ.Type { + return m[sym] +} + +func TestTypeFactsDeclaredAtPrefersAnnotatedDeclaration(t *testing.T) { + const sym cfg.SymbolID = 1 + facts := New(Config{ + Declared: flow.DeclaredTypes{sym: typ.String}, + FunctionType: functionTypeMap{sym: typ.Number}.lookup, + Literals: flow.DeclaredTypes{sym: typ.Boolean}, + AnnotatedVars: map[cfg.SymbolID]bool{sym: true}, + }) + + got := facts.DeclaredAt(0, sym) + if got.State != flow.StateResolved || !typ.TypeEquals(got.Type, typ.String) { + t.Fatalf("DeclaredAt annotated symbol = %v/%v, want string/resolved", got.Type, got.State) + } +} + +func TestTypeFactsDeclaredAtUsesFunctionBeforeDeclaration(t *testing.T) { + const sym cfg.SymbolID = 2 + fn := typ.Func().Returns(typ.String).Build() + facts := New(Config{ + Declared: flow.DeclaredTypes{sym: typ.Number}, + FunctionType: functionTypeMap{sym: fn}.lookup, + }) + + got := facts.DeclaredAt(0, sym) + if got.State != flow.StateResolved || !typ.TypeEquals(got.Type, fn) { + t.Fatalf("DeclaredAt function symbol = %v/%v, want canonical function fact", got.Type, got.State) + } +} + +func TestTypeFactsDeclaredAtUsesLiteralLast(t *testing.T) { + const sym cfg.SymbolID = 3 + facts := New(Config{ + Literals: flow.DeclaredTypes{sym: typ.Boolean}, + }) + + got := facts.DeclaredAt(0, sym) + if got.State != flow.StateResolved || !typ.TypeEquals(got.Type, typ.Boolean) { + t.Fatalf("DeclaredAt literal-only symbol = %v/%v, want boolean/resolved", got.Type, got.State) + } +} + +func TestTypeFactsDeclaredAtUnknownIsUnknownState(t *testing.T) { + const sym cfg.SymbolID = 4 + facts := New(Config{ + Declared: flow.DeclaredTypes{sym: typ.Unknown}, + }) + + got := facts.DeclaredAt(0, sym) + if got.State != flow.StateUnknown || !typ.TypeEquals(got.Type, typ.Unknown) { + t.Fatalf("DeclaredAt unknown = %v/%v, want unknown/unknown", got.Type, got.State) + } +} diff --git a/compiler/check/domain/value/annotation.go b/compiler/check/domain/value/annotation.go new file mode 100644 index 00000000..978aaff5 --- /dev/null +++ b/compiler/check/domain/value/annotation.go @@ -0,0 +1,234 @@ +package value + +import ( + "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// RefineStructuralAnnotation returns a refined structural annotation shape +// using evidence and the caller's slot join law. +func RefineStructuralAnnotation( + annotation, + evidence typ.Type, + join func(typ.Type, typ.Type) typ.Type, +) (typ.Type, bool) { + if join == nil { + join = typ.JoinPreferNonSoft + } + return refineStructuralAnnotation(annotation, evidence, typ.NewGuard(), join) +} + +func refineStructuralAnnotation( + annotation, + evidence typ.Type, + guard internal.RecursionGuard, + join func(typ.Type, typ.Type) typ.Type, +) (typ.Type, bool) { + if annotation == nil || evidence == nil { + return annotation, false + } + next, ok := guard.Enter(annotation) + if !ok { + return annotation, false + } + switch a := unwrap.Alias(annotation).(type) { + case *typ.Optional: + inner, changed := refineStructuralAnnotation(a.Inner, evidence, next, join) + if !changed { + return annotation, false + } + return typ.NewOptional(inner), true + case *typ.Array: + elem := arrayElementEvidence(evidence, next, join) + if elem == nil { + return annotation, false + } + refined := join(a.Element, elem) + if typ.TypeEquals(refined, a.Element) { + return annotation, false + } + return typ.NewArray(refined), true + case *typ.Map: + key, value, ok := mapEvidence(evidence, a.Key, next, join) + if !ok { + return annotation, false + } + refinedKey := a.Key + if typ.IsAny(a.Key) || typ.IsUnknown(a.Key) { + refinedKey = join(a.Key, key) + } + refinedValue := join(a.Value, value) + if typ.TypeEquals(refinedKey, a.Key) && typ.TypeEquals(refinedValue, a.Value) { + return annotation, false + } + return typ.NewMap(refinedKey, refinedValue), true + case *typ.Record: + return refineRecordAnnotation(a, evidence, next, join) + case *typ.Union: + members := make([]typ.Type, len(a.Members)) + changed := false + for i, member := range a.Members { + refined, memberChanged := refineStructuralAnnotation(member, evidence, next, join) + members[i] = refined + changed = changed || memberChanged + } + if !changed { + return annotation, false + } + return typ.NewUnion(members...), true + default: + return annotation, false + } +} + +func refineRecordAnnotation( + annotation *typ.Record, + evidence typ.Type, + guard internal.RecursionGuard, + join func(typ.Type, typ.Type) typ.Type, +) (typ.Type, bool) { + if annotation == nil { + return nil, false + } + if annotation.Open && len(annotation.Fields) == 0 && !annotation.HasMapComponent() && annotation.Metatable == nil { + return evidence, !typ.TypeEquals(annotation, evidence) + } + if !annotation.HasMapComponent() { + return annotation, false + } + key, value, ok := mapEvidence(evidence, annotation.MapKey, guard, join) + if !ok { + return annotation, false + } + refinedKey := annotation.MapKey + if typ.IsAny(annotation.MapKey) || typ.IsUnknown(annotation.MapKey) { + refinedKey = join(annotation.MapKey, key) + } + refinedValue := join(annotation.MapValue, value) + if typ.TypeEquals(refinedKey, annotation.MapKey) && typ.TypeEquals(refinedValue, annotation.MapValue) { + return annotation, false + } + builder := typ.NewRecord().SetOpen(annotation.Open) + if annotation.Metatable != nil { + builder.Metatable(annotation.Metatable) + } + builder.MapComponent(refinedKey, refinedValue) + for _, field := range annotation.Fields { + switch { + case field.Optional && field.Readonly: + builder.OptReadonlyField(field.Name, field.Type) + case field.Optional: + builder.OptField(field.Name, field.Type) + case field.Readonly: + builder.ReadonlyField(field.Name, field.Type) + default: + builder.Field(field.Name, field.Type) + } + } + return builder.Build(), true +} + +func arrayElementEvidence( + evidence typ.Type, + guard internal.RecursionGuard, + join func(typ.Type, typ.Type) typ.Type, +) typ.Type { + if evidence == nil { + return nil + } + next, ok := guard.Enter(evidence) + if !ok { + return nil + } + switch e := unwrap.Alias(evidence).(type) { + case *typ.Array: + return e.Element + case *typ.Tuple: + var elem typ.Type + for _, slot := range e.Elements { + elem = join(elem, slot) + } + return elem + case *typ.Union: + var elem typ.Type + for _, member := range e.Members { + memberElem := arrayElementEvidence(member, next, join) + if memberElem == nil { + continue + } + elem = join(elem, memberElem) + } + return elem + case *typ.Optional: + return arrayElementEvidence(e.Inner, next, join) + default: + return nil + } +} + +func mapEvidence( + evidence, + expectedKey typ.Type, + guard internal.RecursionGuard, + join func(typ.Type, typ.Type) typ.Type, +) (typ.Type, typ.Type, bool) { + if evidence == nil { + return nil, nil, false + } + next, ok := guard.Enter(evidence) + if !ok { + return nil, nil, false + } + switch e := unwrap.Alias(evidence).(type) { + case *typ.Map: + if expectedKey != nil && !keyEvidenceCompatible(e.Key, expectedKey) { + return nil, nil, false + } + return e.Key, e.Value, true + case *typ.Record: + if e.HasMapComponent() { + if expectedKey != nil && !keyEvidenceCompatible(e.MapKey, expectedKey) { + return nil, nil, false + } + return e.MapKey, e.MapValue, true + } + if len(e.Fields) == 0 { + return nil, nil, false + } + var value typ.Type + for _, field := range e.Fields { + if expectedKey != nil && !keyEvidenceCompatible(typ.LiteralString(field.Name), expectedKey) { + return nil, nil, false + } + value = join(value, field.Type) + } + return typ.String, value, true + case *typ.Union: + var key typ.Type + var value typ.Type + seen := false + for _, member := range e.Members { + memberKey, memberValue, ok := mapEvidence(member, expectedKey, next, join) + if !ok { + continue + } + key = join(key, memberKey) + value = join(value, memberValue) + seen = true + } + return key, value, seen + case *typ.Optional: + return mapEvidence(e.Inner, expectedKey, next, join) + default: + return nil, nil, false + } +} + +func keyEvidenceCompatible(candidate, expected typ.Type) bool { + if candidate == nil || expected == nil { + return false + } + return subtype.IsSubtype(candidate, expected) || typ.IsAny(expected) || typ.IsUnknown(expected) +} diff --git a/compiler/check/domain/value/convergence.go b/compiler/check/domain/value/convergence.go new file mode 100644 index 00000000..6fdb4e16 --- /dev/null +++ b/compiler/check/domain/value/convergence.go @@ -0,0 +1,205 @@ +package value + +import ( + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// NormalizeFactType canonicalizes one type before it is stored in an +// interprocedural fact slot. +func NormalizeFactType(t typ.Type) typ.Type { + if t == nil { + return nil + } + if fn := unwrap.Function(t); fn != nil { + return fn + } + return typ.PruneSoftUnionMembers(t) +} + +// WidenForConvergence applies the finite-height approximation needed for +// higher-order structural growth. +func WidenForConvergence(t typ.Type) typ.Type { + if t == nil { + return nil + } + if !HasHigherOrderGrowthRisk(t) { + return t + } + return subtype.WidenForInference(t) +} + +// WidenFunctionForConvergence applies convergence widening to a function type. +func WidenFunctionForConvergence(fn *typ.Function) *typ.Function { + if fn == nil { + return nil + } + if widened, ok := WidenForConvergence(fn).(*typ.Function); ok { + return widened + } + return fn +} + +// JoinPrecise merges non-function value facts inside one analysis iteration. +func JoinPrecise(existing, candidate typ.Type) typ.Type { + existing = NormalizeFactType(existing) + candidate = NormalizeFactType(candidate) + if existing == nil { + return candidate + } + if candidate == nil { + return existing + } + return typ.JoinPreferNonSoft(existing, candidate) +} + +// MergeForConvergence merges non-function value facts at a fixpoint boundary. +func MergeForConvergence(existing, candidate typ.Type) typ.Type { + existing = NormalizeFactType(existing) + candidate = NormalizeFactType(candidate) + if existing == nil { + return WidenForConvergence(candidate) + } + if candidate == nil { + return WidenForConvergence(existing) + } + existing = WidenForConvergence(existing) + candidate = WidenForConvergence(candidate) + if typ.TypeEquals(existing, candidate) { + return existing + } + if unwrap.IsNilType(existing) && !unwrap.IsNilType(candidate) { + return candidate + } + if unwrap.IsNilType(candidate) && !unwrap.IsNilType(existing) { + return existing + } + if typ.IsAny(existing) || typ.IsUnknown(existing) { + return existing + } + if typ.IsAny(candidate) || typ.IsUnknown(candidate) { + return candidate + } + if ElidesOptional(candidate, existing) { + return candidate + } + if ExtendsRecord(candidate, existing) && !ContainsNestedStructuralShape(candidate, existing) { + return candidate + } + if refines, _ := RefinesFalsyMapKey(candidate, existing); refines { + return candidate + } + if subtype.IsSubtype(candidate, existing) && !subtype.IsSubtype(existing, candidate) { + return existing + } + if subtype.IsSubtype(existing, candidate) && !subtype.IsSubtype(candidate, existing) { + return candidate + } + return typ.JoinPreferNonSoft(existing, candidate) +} + +// UnsafePrecisionDrop reports whether merged lost a previously possible branch +// from prev while appearing as a subtype refinement. +func UnsafePrecisionDrop(prev, merged typ.Type) bool { + if prev == nil || merged == nil || typ.TypeEquals(prev, merged) { + return false + } + if ElidesOptional(merged, prev) { + return false + } + if refines, _ := RefinesFalsyMapKey(merged, prev); refines { + return false + } + if typ.IsAny(prev) || typ.IsUnknown(prev) { + return true + } + + switch p := UnwrapStructuralShape(prev).(type) { + case *typ.Union: + if unionStrictMemberSubset(merged, p) { + return true + } + if subtype.IsSubtype(merged, p) && !subtype.IsSubtype(p, merged) { + return true + } + case *typ.Record: + m, ok := UnwrapStructuralShape(merged).(*typ.Record) + if !ok { + break + } + for _, pf := range p.Fields { + mf := m.GetField(pf.Name) + if mf != nil && UnsafePrecisionDrop(pf.Type, mf.Type) { + return true + } + } + if p.HasMapComponent() && m.HasMapComponent() && UnsafePrecisionDrop(p.MapValue, m.MapValue) { + return true + } + case *typ.Array: + if m, ok := UnwrapStructuralShape(merged).(*typ.Array); ok { + return UnsafePrecisionDrop(p.Element, m.Element) + } + case *typ.Map: + if m, ok := UnwrapStructuralShape(merged).(*typ.Map); ok { + return UnsafePrecisionDrop(p.Key, m.Key) || UnsafePrecisionDrop(p.Value, m.Value) + } + case *typ.Tuple: + m, ok := UnwrapStructuralShape(merged).(*typ.Tuple) + if !ok || len(p.Elements) != len(m.Elements) { + break + } + for i := range p.Elements { + if UnsafePrecisionDrop(p.Elements[i], m.Elements[i]) { + return true + } + } + case *typ.Function: + m, ok := UnwrapStructuralShape(merged).(*typ.Function) + if !ok { + break + } + for i := 0; i < len(p.Params) && i < len(m.Params); i++ { + if UnsafePrecisionDrop(p.Params[i].Type, m.Params[i].Type) { + return true + } + } + for i := 0; i < len(p.Returns) && i < len(m.Returns); i++ { + if UnsafePrecisionDrop(p.Returns[i], m.Returns[i]) { + return true + } + } + } + + if subtype.IsSubtype(merged, prev) && !subtype.IsSubtype(prev, merged) && !ExtendsRecord(merged, prev) { + return true + } + return false +} + +func unionStrictMemberSubset(candidate typ.Type, baseline *typ.Union) bool { + if baseline == nil { + return false + } + candidateMembers := UnionMembers(candidate) + if len(candidateMembers) == 0 { + candidateMembers = []typ.Type{candidate} + } + if len(candidateMembers) >= len(baseline.Members) { + return false + } + for _, member := range candidateMembers { + found := false + for _, baseMember := range baseline.Members { + if typ.TypeEquals(member, baseMember) { + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/compiler/check/domain/value/convergence_test.go b/compiler/check/domain/value/convergence_test.go new file mode 100644 index 00000000..f8b20f76 --- /dev/null +++ b/compiler/check/domain/value/convergence_test.go @@ -0,0 +1,26 @@ +package value + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestUnsafePrecisionDrop_DetectsNestedUnionMemberDrop(t *testing.T) { + withPending := typ.NewUnion( + typ.LiteralString("pass"), + typ.LiteralString("pending"), + typ.LiteralString("fail"), + typ.LiteralString("skip"), + ) + withoutPending := typ.NewUnion( + typ.LiteralString("pass"), + typ.LiteralString("fail"), + typ.LiteralString("skip"), + ) + prev := typ.NewRecord().Field("status", withPending).Build() + next := typ.NewRecord().Field("status", withoutPending).Build() + if !UnsafePrecisionDrop(prev, next) { + t.Fatalf("expected nested union member drop to be unsafe: prev=%v next=%v", prev, next) + } +} diff --git a/compiler/check/domain/value/doc.go b/compiler/check/domain/value/doc.go new file mode 100644 index 00000000..4d59037f --- /dev/null +++ b/compiler/check/domain/value/doc.go @@ -0,0 +1,8 @@ +// Package value owns reusable structural relations over typ.Type values. +// +// These relations are below return summaries, function facts, and whole fact +// products: optional elision, soft-placeholder preference, table-key truthiness +// refinement, recursive-growth detection, convergence widening, and unsafe +// precision-drop checks live here so higher domains can compose them without +// reimplementing local helper clusters. +package value diff --git a/compiler/check/domain/value/equality.go b/compiler/check/domain/value/equality.go new file mode 100644 index 00000000..7182e980 --- /dev/null +++ b/compiler/check/domain/value/equality.go @@ -0,0 +1,330 @@ +package value + +import ( + "reflect" + "unsafe" + + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// FactTypeEqual compares stored fact types, including function metadata. +// +// typ.TypeEquals intentionally compares function call shapes and ignores +// effects, specs, and refinements. Interprocedural fact equality needs the +// stronger relation because those fields are part of the abstract value stored +// in a fact slot. +func FactTypeEqual(a, b typ.Type) bool { + if !typ.TypeEquals(a, b) { + return false + } + return factTypeMetadataEqual(a, b, nil) +} + +type factTypePair struct { + a uintptr + b uintptr +} + +func factTypeMetadataEqual(a, b typ.Type, seen map[factTypePair]bool) bool { + a = unwrapFactTransparent(a) + b = unwrapFactTransparent(b) + if a == nil || b == nil { + return a == b + } + if a.Kind() != b.Kind() { + return false + } + if needsFactTypeCycleCheck(a.Kind()) { + ap := factTypePointer(a) + bp := factTypePointer(b) + if ap != 0 && bp != 0 { + pair := factTypePair{a: ap, b: bp} + if seen == nil { + seen = make(map[factTypePair]bool) + } + if seen[pair] { + return true + } + seen[pair] = true + } + } + + switch left := a.(type) { + case *typ.Optional: + right, ok := b.(*typ.Optional) + return ok && factTypeMetadataEqual(left.Inner, right.Inner, seen) + case *typ.Union: + right, ok := b.(*typ.Union) + if !ok || len(left.Members) != len(right.Members) { + return false + } + for i, member := range left.Members { + if !factTypeMetadataEqual(member, right.Members[i], seen) { + return false + } + } + return true + case *typ.Intersection: + right, ok := b.(*typ.Intersection) + if !ok || len(left.Members) != len(right.Members) { + return false + } + for i, member := range left.Members { + if !factTypeMetadataEqual(member, right.Members[i], seen) { + return false + } + } + return true + case *typ.Tuple: + right, ok := b.(*typ.Tuple) + if !ok || len(left.Elements) != len(right.Elements) { + return false + } + for i, elem := range left.Elements { + if !factTypeMetadataEqual(elem, right.Elements[i], seen) { + return false + } + } + return true + case *typ.Array: + right, ok := b.(*typ.Array) + return ok && factTypeMetadataEqual(left.Element, right.Element, seen) + case *typ.Map: + right, ok := b.(*typ.Map) + return ok && + factTypeMetadataEqual(left.Key, right.Key, seen) && + factTypeMetadataEqual(left.Value, right.Value, seen) + case *typ.Record: + right, ok := b.(*typ.Record) + if !ok || left.Open != right.Open || len(left.Fields) != len(right.Fields) { + return false + } + for i, field := range left.Fields { + other := right.Fields[i] + if field.Name != other.Name || field.Optional != other.Optional || field.Readonly != other.Readonly { + return false + } + if !factTypeMetadataEqual(field.Type, other.Type, seen) { + return false + } + } + if left.HasMapComponent() != right.HasMapComponent() { + return false + } + if left.HasMapComponent() { + return factTypeMetadataEqual(left.MapKey, right.MapKey, seen) && + factTypeMetadataEqual(left.MapValue, right.MapValue, seen) + } + return true + case *typ.Function: + right, ok := b.(*typ.Function) + return ok && factFunctionEqual(left, right, seen) + case *typ.Generic: + right, ok := b.(*typ.Generic) + if !ok || left.Name != right.Name || len(left.TypeParams) != len(right.TypeParams) { + return false + } + for i, tp := range left.TypeParams { + if !factTypeParamEqual(tp, right.TypeParams[i], seen) { + return false + } + } + if left.Name != "" { + return true + } + return factTypeMetadataEqual(left.Body, right.Body, seen) + case *typ.Instantiated: + right, ok := b.(*typ.Instantiated) + if !ok || len(left.TypeArgs) != len(right.TypeArgs) { + return false + } + if !factTypeMetadataEqual(left.Generic, right.Generic, seen) { + return false + } + for i, arg := range left.TypeArgs { + if !factTypeMetadataEqual(arg, right.TypeArgs[i], seen) { + return false + } + } + return true + case *typ.Recursive: + right, ok := b.(*typ.Recursive) + if !ok || left.Name != right.Name { + return false + } + if left.ID == right.ID { + return true + } + return factTypeMetadataEqual(left.Body, right.Body, seen) + case *typ.Sum: + right, ok := b.(*typ.Sum) + if !ok || left.Name != right.Name || len(left.Variants) != len(right.Variants) { + return false + } + for i, variant := range left.Variants { + other := right.Variants[i] + if variant.Tag != other.Tag || len(variant.Types) != len(other.Types) { + return false + } + for j, t := range variant.Types { + if !factTypeMetadataEqual(t, other.Types[j], seen) { + return false + } + } + } + return true + case *typ.Interface: + right, ok := b.(*typ.Interface) + if !ok || left.Name != right.Name || len(left.Methods) != len(right.Methods) { + return false + } + for i, method := range left.Methods { + other := right.Methods[i] + if method.Name != other.Name || !factFunctionEqual(method.Type, other.Type, seen) { + return false + } + } + return true + case *typ.FieldAccess: + right, ok := b.(*typ.FieldAccess) + return ok && left.Field == right.Field && + factTypeMetadataEqual(left.Base, right.Base, seen) + case *typ.IndexAccess: + right, ok := b.(*typ.IndexAccess) + return ok && + factTypeMetadataEqual(left.Base, right.Base, seen) && + factTypeMetadataEqual(left.Index, right.Index, seen) + case *typ.Meta: + right, ok := b.(*typ.Meta) + return ok && factTypeMetadataEqual(left.Of, right.Of, seen) + default: + return true + } +} + +func unwrapFactTransparent(t typ.Type) typ.Type { + for depth := 0; depth <= typ.DefaultRecursionDepth; depth++ { + t = unwrap.Alias(t) + annotated, ok := t.(*typ.Annotated) + if !ok { + return t + } + if annotated.Inner == nil || annotated.Inner == t { + return annotated.Inner + } + t = annotated.Inner + } + return nil +} + +func factFunctionEqual(left, right *typ.Function, seen map[factTypePair]bool) bool { + if left == nil || right == nil { + return left == right + } + if !effectInfoEqual(left.Effects, right.Effects) || + !specInfoEqual(left.Spec, right.Spec) || + !refinementInfoEqual(left.Refinement, right.Refinement) { + return false + } + if len(left.TypeParams) != len(right.TypeParams) || + len(left.Params) != len(right.Params) || + len(left.Returns) != len(right.Returns) { + return false + } + for i, tp := range left.TypeParams { + if !factTypeParamEqual(tp, right.TypeParams[i], seen) { + return false + } + } + for i, param := range left.Params { + other := right.Params[i] + if param.Optional != other.Optional { + return false + } + if !factTypeMetadataEqual(param.Type, other.Type, seen) { + return false + } + } + if (left.Variadic == nil) != (right.Variadic == nil) { + return false + } + if left.Variadic != nil && !factTypeMetadataEqual(left.Variadic, right.Variadic, seen) { + return false + } + for i, ret := range left.Returns { + if !factTypeMetadataEqual(ret, right.Returns[i], seen) { + return false + } + } + return true +} + +func factTypeParamEqual(left, right *typ.TypeParam, seen map[factTypePair]bool) bool { + if left == nil || right == nil { + return left == right + } + return left.Name == right.Name && + factTypeMetadataEqual(left.Constraint, right.Constraint, seen) +} + +func effectInfoEqual(left, right typ.EffectInfo) bool { + if left == nil || right == nil { + return left == right + } + return left.Equals(right) +} + +func specInfoEqual(left, right typ.SpecInfo) bool { + if left == nil || right == nil { + return left == right + } + return left.Equals(right) +} + +func refinementInfoEqual(left, right typ.RefinementInfo) bool { + if left == nil || right == nil { + return left == right + } + return left.Equals(right) +} + +func needsFactTypeCycleCheck(k kind.Kind) bool { + switch k { + case kind.Union, kind.Intersection, kind.Record, kind.Function, + kind.Generic, kind.Instantiated, kind.Interface, kind.Recursive, + kind.Sum: + return true + default: + return false + } +} + +func factTypePointer(t typ.Type) uintptr { + switch tt := t.(type) { + case *typ.Union: + return uintptr(unsafe.Pointer(tt)) + case *typ.Intersection: + return uintptr(unsafe.Pointer(tt)) + case *typ.Record: + return uintptr(unsafe.Pointer(tt)) + case *typ.Function: + return uintptr(unsafe.Pointer(tt)) + case *typ.Generic: + return uintptr(unsafe.Pointer(tt)) + case *typ.Instantiated: + return uintptr(unsafe.Pointer(tt)) + case *typ.Interface: + return uintptr(unsafe.Pointer(tt)) + case *typ.Recursive: + return uintptr(unsafe.Pointer(tt)) + case *typ.Sum: + return uintptr(unsafe.Pointer(tt)) + } + v := reflect.ValueOf(t) + if v.Kind() != reflect.Pointer { + return 0 + } + return v.Pointer() +} diff --git a/compiler/check/domain/value/equality_test.go b/compiler/check/domain/value/equality_test.go new file mode 100644 index 00000000..cb36ffcd --- /dev/null +++ b/compiler/check/domain/value/equality_test.go @@ -0,0 +1,41 @@ +package value + +import ( + "testing" + + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/typ" +) + +func TestFactTypeEqual_IncludesNestedFunctionSpec(t *testing.T) { + callback := typ.Func().Param("value", typ.String).Build() + spec := contract.NewSpec().WithCallback(0, (&contract.CallbackSpec{}).WithEnvOverlay(map[string]typ.Type{ + "run": callback, + })) + withoutSpec := typ.NewRecord(). + Field("define", typ.Func().Param("fn", callback).Build()). + Build() + withSpec := typ.NewRecord(). + Field("define", typ.Func().Param("fn", callback).Spec(spec).Build()). + Build() + + if !typ.TypeEquals(withoutSpec, withSpec) { + t.Fatal("ordinary structural equality should ignore function specs") + } + if FactTypeEqual(withoutSpec, withSpec) { + t.Fatal("fact equality must include function specs even when nested") + } +} + +func TestFactTypeEqual_IncludesFunctionSpecThroughAnnotation(t *testing.T) { + spec := contract.NewSpec().WithCallback(0, &contract.CallbackSpec{Cardinality: contract.CardExactlyOnce}) + withoutSpec := typ.NewAnnotated(typ.Func().Param("fn", typ.Func().Build()).Build(), []typ.Annotation{{Name: "checked"}}) + withSpec := typ.NewAnnotated(typ.Func().Param("fn", typ.Func().Build()).Spec(spec).Build(), []typ.Annotation{{Name: "checked"}}) + + if !typ.TypeEquals(withoutSpec, withSpec) { + t.Fatal("ordinary structural equality should ignore function specs through annotations") + } + if FactTypeEqual(withoutSpec, withSpec) { + t.Fatal("fact equality must include function specs through annotations") + } +} diff --git a/compiler/check/domain/value/growth.go b/compiler/check/domain/value/growth.go new file mode 100644 index 00000000..c96fad94 --- /dev/null +++ b/compiler/check/domain/value/growth.go @@ -0,0 +1,322 @@ +package value + +import ( + "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" +) + +// HasHigherOrderGrowthRisk reports whether a type can produce non-monotone +// higher-order structural growth across abstract-interpretation iterations. +func HasHigherOrderGrowthRisk(t typ.Type) bool { + if t == nil { + return false + } + state := newGrowthScanState() + return state.hasHigherOrderGrowthRisk(t, typ.NewGuard()) +} + +type growthScanKey struct { + hash uint64 + kind kind.Kind +} + +type methodScanKey struct { + node growthScanKey + owner growthScanKey +} + +type growthScanState struct { + riskSeen map[growthScanKey]bool + riskValue map[growthScanKey]bool + functionSeen map[growthScanKey]bool + functionValue map[growthScanKey]bool + methodSeen map[methodScanKey]bool + methodValue map[methodScanKey]bool + selfMethodSeen map[growthScanKey]bool + selfMethodVal map[growthScanKey]bool +} + +func newGrowthScanState() *growthScanState { + return &growthScanState{ + riskSeen: make(map[growthScanKey]bool), + riskValue: make(map[growthScanKey]bool), + functionSeen: make(map[growthScanKey]bool), + functionValue: make(map[growthScanKey]bool), + methodSeen: make(map[methodScanKey]bool), + methodValue: make(map[methodScanKey]bool), + selfMethodSeen: make(map[growthScanKey]bool), + selfMethodVal: make(map[growthScanKey]bool), + } +} + +func growthKey(t typ.Type) growthScanKey { + if t == nil { + return growthScanKey{} + } + return growthScanKey{hash: t.Hash(), kind: t.Kind()} +} + +func stripAnnotated(t typ.Type) typ.Type { + for { + ann, ok := t.(*typ.Annotated) + if !ok || ann.Inner == nil || ann.Inner == t { + return t + } + t = ann.Inner + } +} + +func (s *growthScanState) hasHigherOrderGrowthRisk(t typ.Type, guard internal.RecursionGuard) bool { + t = stripAnnotated(t) + if t == nil { + return false + } + key := growthKey(t) + if s.riskSeen[key] { + return s.riskValue[key] + } + next, ok := guard.Enter(t) + if !ok { + return false + } + + result := false + switch n := t.(type) { + case *typ.Optional: + result = s.hasHigherOrderGrowthRisk(n.Inner, next) + case *typ.Union: + result = s.anyTypeHasRisk(n.Members, next) + case *typ.Intersection: + result = s.anyTypeHasRisk(n.Members, next) + case *typ.Array: + result = s.hasHigherOrderGrowthRisk(n.Element, next) + case *typ.Map: + result = s.hasHigherOrderGrowthRisk(n.Key, next) || s.hasHigherOrderGrowthRisk(n.Value, next) + case *typ.Tuple: + result = s.anyTypeHasRisk(n.Elements, next) + case *typ.Function: + for _, ret := range n.Returns { + if s.containsFunction(ret, typ.NewGuard()) { + result = true + break + } + } + if !result { + for _, p := range n.Params { + if s.hasHigherOrderGrowthRisk(p.Type, next) { + result = true + break + } + } + } + if !result { + result = s.anyTypeHasRisk(n.Returns, next) || + (n.Variadic != nil && s.hasHigherOrderGrowthRisk(n.Variadic, next)) + } + case *typ.Record: + result = s.recordHasSelfRecursiveMethod(n) + if !result { + for _, f := range n.Fields { + if s.hasHigherOrderGrowthRisk(f.Type, next) { + result = true + break + } + } + } + if !result { + result = (n.Metatable != nil && s.hasHigherOrderGrowthRisk(n.Metatable, next)) || + (n.HasMapComponent() && (s.hasHigherOrderGrowthRisk(n.MapKey, next) || s.hasHigherOrderGrowthRisk(n.MapValue, next))) + } + case *typ.Alias: + result = s.hasHigherOrderGrowthRisk(n.Target, next) + case *typ.Instantiated: + result = s.anyTypeHasRisk(n.TypeArgs, next) + case *typ.Interface: + for _, m := range n.Methods { + if m.Type != nil && s.hasHigherOrderGrowthRisk(m.Type, next) { + result = true + break + } + } + } + + s.riskSeen[key] = true + s.riskValue[key] = result + return result +} + +func (s *growthScanState) anyTypeHasRisk(types []typ.Type, guard internal.RecursionGuard) bool { + for _, t := range types { + if s.hasHigherOrderGrowthRisk(t, guard) { + return true + } + } + return false +} + +func (s *growthScanState) containsFunction(t typ.Type, guard internal.RecursionGuard) bool { + t = stripAnnotated(t) + if t == nil { + return false + } + key := growthKey(t) + if s.functionSeen[key] { + return s.functionValue[key] + } + next, ok := guard.Enter(t) + if !ok { + return false + } + + result := false + switch n := t.(type) { + case *typ.Function: + result = true + case *typ.Interface: + result = false + case *typ.Optional: + result = s.containsFunction(n.Inner, next) + case *typ.Union: + result = s.anyTypeContainsFunction(n.Members, next) + case *typ.Intersection: + result = s.anyTypeContainsFunction(n.Members, next) + case *typ.Array: + result = s.containsFunction(n.Element, next) + case *typ.Map: + result = s.containsFunction(n.Key, next) || s.containsFunction(n.Value, next) + case *typ.Tuple: + result = s.anyTypeContainsFunction(n.Elements, next) + case *typ.Record: + for _, f := range n.Fields { + if s.containsFunction(f.Type, next) { + result = true + break + } + } + if !result { + result = (n.Metatable != nil && s.containsFunction(n.Metatable, next)) || + (n.HasMapComponent() && (s.containsFunction(n.MapKey, next) || s.containsFunction(n.MapValue, next))) + } + case *typ.Alias: + result = s.containsFunction(n.Target, next) + case *typ.Instantiated: + result = s.anyTypeContainsFunction(n.TypeArgs, next) + } + + s.functionSeen[key] = true + s.functionValue[key] = result + return result +} + +func (s *growthScanState) anyTypeContainsFunction(types []typ.Type, guard internal.RecursionGuard) bool { + for _, t := range types { + if s.containsFunction(t, guard) { + return true + } + } + return false +} + +func (s *growthScanState) recordHasSelfRecursiveMethod(r *typ.Record) bool { + if r == nil { + return false + } + key := growthKey(r) + if s.selfMethodSeen[key] { + return s.selfMethodVal[key] + } + + result := false + for _, f := range r.Fields { + if s.methodTypeHasSelfRecursiveReturn(f.Type, r, typ.NewGuard()) { + result = true + break + } + } + if !result && r.HasMapComponent() { + result = s.methodTypeHasSelfRecursiveReturn(r.MapValue, r, typ.NewGuard()) + } + + s.selfMethodSeen[key] = true + s.selfMethodVal[key] = result + return result +} + +func (s *growthScanState) methodTypeHasSelfRecursiveReturn(t typ.Type, owner *typ.Record, guard internal.RecursionGuard) bool { + t = stripAnnotated(t) + if t == nil || owner == nil { + return false + } + key := methodScanKey{node: growthKey(t), owner: growthKey(owner)} + if _, ok := t.(*typ.Function); !ok && !s.containsFunction(t, typ.NewGuard()) { + return false + } + if s.methodSeen[key] { + return s.methodValue[key] + } + next, ok := guard.Enter(t) + if !ok { + return false + } + + result := false + switch n := t.(type) { + case *typ.Interface: + result = false + case *typ.Function: + for _, ret := range n.Returns { + if ret == nil { + continue + } + if subtype.IsSubtype(ret, owner) || subtype.IsSubtype(owner, ret) || + ExtendsRecord(ret, owner) || ExtendsRecord(owner, ret) { + result = true + break + } + } + case *typ.Optional: + result = s.methodTypeHasSelfRecursiveReturn(n.Inner, owner, next) + case *typ.Union: + result = s.anyMethodTypeHasSelfRecursiveReturn(n.Members, owner, next) + case *typ.Intersection: + result = s.anyMethodTypeHasSelfRecursiveReturn(n.Members, owner, next) + case *typ.Array: + result = s.methodTypeHasSelfRecursiveReturn(n.Element, owner, next) + case *typ.Map: + result = s.methodTypeHasSelfRecursiveReturn(n.Key, owner, next) || + s.methodTypeHasSelfRecursiveReturn(n.Value, owner, next) + case *typ.Tuple: + result = s.anyMethodTypeHasSelfRecursiveReturn(n.Elements, owner, next) + case *typ.Record: + for _, f := range n.Fields { + if s.methodTypeHasSelfRecursiveReturn(f.Type, owner, next) { + result = true + break + } + } + if !result { + result = (n.Metatable != nil && s.methodTypeHasSelfRecursiveReturn(n.Metatable, owner, next)) || + (n.HasMapComponent() && (s.methodTypeHasSelfRecursiveReturn(n.MapKey, owner, next) || + s.methodTypeHasSelfRecursiveReturn(n.MapValue, owner, next))) + } + case *typ.Alias: + result = s.methodTypeHasSelfRecursiveReturn(n.Target, owner, next) + case *typ.Instantiated: + result = s.anyMethodTypeHasSelfRecursiveReturn(n.TypeArgs, owner, next) + } + + s.methodSeen[key] = true + s.methodValue[key] = result + return result +} + +func (s *growthScanState) anyMethodTypeHasSelfRecursiveReturn(types []typ.Type, owner *typ.Record, guard internal.RecursionGuard) bool { + for _, t := range types { + if s.methodTypeHasSelfRecursiveReturn(t, owner, guard) { + return true + } + } + return false +} diff --git a/compiler/check/domain/value/growth_test.go b/compiler/check/domain/value/growth_test.go new file mode 100644 index 00000000..c658b929 --- /dev/null +++ b/compiler/check/domain/value/growth_test.go @@ -0,0 +1,47 @@ +package value + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestHigherOrderGrowthRisk_DetectsFunctionReturningFunction(t *testing.T) { + tp := typ.Func(). + Returns(typ.Func().Returns(typ.String).Build()). + Build() + if !HasHigherOrderGrowthRisk(tp) { + t.Fatalf("expected higher-order growth risk to be detected") + } +} + +func TestContainsFunction_IgnoresInterfaceMethodSignatures(t *testing.T) { + iface := typ.NewInterface("Reader", []typ.Method{ + { + Name: "next", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.Func().Returns(typ.String).Build()). + Build(), + }, + }) + if newGrowthScanState().containsFunction(iface, typ.NewGuard()) { + t.Fatalf("expected interface method signatures to be ignored, got true") + } +} + +func TestMethodTypeHasSelfRecursiveReturn_IgnoresInterfaceMethods(t *testing.T) { + owner := typ.NewRecord().Field("id", typ.String).Build() + methodType := typ.NewInterface("HasBuild", []typ.Method{ + { + Name: "build", + Type: typ.Func(). + Param("self", typ.Self). + Returns(owner). + Build(), + }, + }) + if newGrowthScanState().methodTypeHasSelfRecursiveReturn(methodType, owner, typ.NewGuard()) { + t.Fatalf("expected interface method signatures to be ignored for self-recursive detection") + } +} diff --git a/compiler/check/domain/value/shape.go b/compiler/check/domain/value/shape.go new file mode 100644 index 00000000..4807feea --- /dev/null +++ b/compiler/check/domain/value/shape.go @@ -0,0 +1,839 @@ +package value + +import ( + "github.com/wippyai/go-lua/internal" + "github.com/wippyai/go-lua/types/narrow" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// Equivalent reports structural equality or mutual subtyping. +func Equivalent(a, b typ.Type) bool { + return typ.TypeEquals(a, b) || (subtype.IsSubtype(a, b) && subtype.IsSubtype(b, a)) +} + +// ElidesOptional reports whether candidate is inside baseline after nil is +// removed from baseline. +func ElidesOptional(candidate, baseline typ.Type) bool { + if candidate == nil || baseline == nil { + return false + } + nonNil := narrow.RemoveNil(baseline) + if nonNil == nil || typ.TypeEquals(nonNil, baseline) { + return false + } + return subtype.IsSubtype(candidate, nonNil) +} + +// SplitNilable separates the non-nil component from an optional/nilable type. +func SplitNilable(t typ.Type) (typ.Type, bool) { + t = unwrap.Alias(t) + switch v := t.(type) { + case nil: + return nil, false + case *typ.Optional: + return v.Inner, true + case *typ.Union: + members := make([]typ.Type, 0, len(v.Members)) + nilable := false + for _, member := range v.Members { + member = unwrap.Alias(member) + if unwrap.IsNilType(member) { + nilable = true + continue + } + members = append(members, member) + } + if !nilable { + return t, false + } + return typ.NewUnion(members...), true + default: + if unwrap.IsNilType(t) { + return nil, true + } + return t, false + } +} + +// IsTruthyRefinement reports whether candidate equals or subtypes the truthy +// refinement of baseline. +func IsTruthyRefinement(candidate, baseline typ.Type) bool { + if candidate == nil || baseline == nil || typ.TypeEquals(candidate, baseline) { + return false + } + refined := narrow.ToTruthy(baseline) + if refined == nil || refined.Kind().IsNever() || typ.TypeEquals(refined, baseline) { + return false + } + return typ.TypeEquals(candidate, refined) || subtype.IsSubtype(candidate, refined) +} + +// PreferConcreteOverSoft selects a concrete observation over a soft placeholder +// while preserving explicit nilability. +func PreferConcreteOverSoft(a, b typ.Type) (typ.Type, bool) { + aSoft := typ.IsSoft(a, typ.SoftPlaceholderPolicy) + bSoft := typ.IsSoft(b, typ.SoftPlaceholderPolicy) + switch { + case aSoft && !bSoft && !unwrap.IsNilType(b): + return b, true + case bSoft && !aSoft && !unwrap.IsNilType(a): + return a, true + } + if preferred, ok := preferConcreteOverNilableSoft(a, b); ok { + return preferred, true + } + return nil, false +} + +func preferConcreteOverNilableSoft(a, b typ.Type) (typ.Type, bool) { + if preferred, ok := preferConcreteOverNilableSoftDirected(a, b); ok { + return preferred, true + } + return preferConcreteOverNilableSoftDirected(b, a) +} + +func preferConcreteOverNilableSoftDirected(softMaybeNil, concrete typ.Type) (typ.Type, bool) { + inner, nilable := SplitNilable(softMaybeNil) + if !nilable || inner == nil || !typ.IsSoft(inner, typ.SoftPlaceholderPolicy) { + return nil, false + } + if concrete == nil || unwrap.IsNilType(concrete) { + return nil, false + } + concreteInner, concreteNilable := SplitNilable(concrete) + if concreteInner == nil { + return nil, false + } + if typ.IsSoft(concreteInner, typ.SoftPlaceholderPolicy) { + return nil, false + } + if concreteNilable { + return concrete, true + } + return typ.NewOptional(concrete), true +} + +// CanSelfEmbed reports whether t is a structural type that can recursively +// carry another type value below itself. +func CanSelfEmbed(t typ.Type) bool { + if t == nil { + return false + } + switch v := t.(type) { + case *typ.Annotated: + return CanSelfEmbed(v.Inner) + case *typ.Alias: + return CanSelfEmbed(v.Target) + case *typ.Optional: + return CanSelfEmbed(v.Inner) + case *typ.Union: + for _, member := range v.Members { + if CanSelfEmbed(member) { + return true + } + } + return false + case *typ.Intersection: + for _, member := range v.Members { + if CanSelfEmbed(member) { + return true + } + } + return false + case *typ.Array, *typ.Map, *typ.Tuple, *typ.Record, *typ.Function: + return true + default: + return false + } +} + +// ContainsEquivalent reports whether haystack contains a node equivalent to +// needle while walking structural type children. +func ContainsEquivalent(haystack, needle typ.Type) bool { + if haystack == nil || needle == nil { + return false + } + return Scan(haystack, typ.NewGuard(), func(node typ.Type) (bool, bool) { + if typ.TypeEquals(node, needle) { + return true, false + } + return false, true + }) +} + +// ContainsUnion reports whether t contains any union node. +func ContainsUnion(t typ.Type) bool { + if t == nil { + return false + } + return Scan(t, typ.NewGuard(), func(node typ.Type) (bool, bool) { + if _, ok := node.(*typ.Union); ok { + return true, false + } + return false, true + }) +} + +// Scan walks structural type children until visit stops traversal. +func Scan( + t typ.Type, + guard internal.RecursionGuard, + visit func(node typ.Type) (stop bool, descend bool), +) bool { + if t == nil { + return false + } + next, ok := guard.Enter(t) + if !ok { + return false + } + + node := t + for { + ann, ok := node.(*typ.Annotated) + if !ok || ann.Inner == nil || ann.Inner == node { + break + } + node = ann.Inner + } + + if stop, descend := visit(node); stop { + return true + } else if !descend { + return false + } + + switch n := node.(type) { + case *typ.Optional: + return Scan(n.Inner, next, visit) + case *typ.Union: + for _, m := range n.Members { + if Scan(m, next, visit) { + return true + } + } + return false + case *typ.Intersection: + for _, m := range n.Members { + if Scan(m, next, visit) { + return true + } + } + return false + case *typ.Array: + return Scan(n.Element, next, visit) + case *typ.Map: + return Scan(n.Key, next, visit) || Scan(n.Value, next, visit) + case *typ.Tuple: + for _, e := range n.Elements { + if Scan(e, next, visit) { + return true + } + } + return false + case *typ.Function: + for _, p := range n.Params { + if Scan(p.Type, next, visit) { + return true + } + } + for _, r := range n.Returns { + if Scan(r, next, visit) { + return true + } + } + return n.Variadic != nil && Scan(n.Variadic, next, visit) + case *typ.Record: + for _, f := range n.Fields { + if Scan(f.Type, next, visit) { + return true + } + } + if n.Metatable != nil && Scan(n.Metatable, next, visit) { + return true + } + if n.HasMapComponent() { + return Scan(n.MapKey, next, visit) || Scan(n.MapValue, next, visit) + } + return false + case *typ.Alias: + return Scan(n.Target, next, visit) + case *typ.Instantiated: + for _, a := range n.TypeArgs { + if Scan(a, next, visit) { + return true + } + } + return false + case *typ.Interface: + for _, m := range n.Methods { + if m.Type != nil && Scan(m.Type, next, visit) { + return true + } + } + return false + default: + return false + } +} + +// ExtendsRecord reports whether a extends b by adding record fields. This +// treats record field supersets as refinements when b is a record or union of +// records. +func ExtendsRecord(a, b typ.Type) bool { + if a == nil || b == nil { + return false + } + ar, ok := a.(*typ.Record) + if !ok { + return false + } + switch br := b.(type) { + case *typ.Record: + return RecordSuperset(ar, br) + case *typ.Union: + return recordSupersetUnion(ar, br) + default: + return false + } +} + +// RecordSuperset reports whether newRec preserves oldRec and may add fields. +func RecordSuperset(newRec, oldRec *typ.Record) bool { + if newRec == nil || oldRec == nil { + return false + } + if oldRec.Metatable != nil { + if newRec.Metatable == nil || !subtype.IsSubtype(newRec.Metatable, oldRec.Metatable) { + return false + } + } + if oldRec.HasMapComponent() { + if !newRec.HasMapComponent() { + return false + } + if !subtype.IsSubtype(newRec.MapKey, oldRec.MapKey) || !subtype.IsSubtype(newRec.MapValue, oldRec.MapValue) { + return false + } + } + oldFields := make(map[string]typ.Field, len(oldRec.Fields)) + for _, f := range oldRec.Fields { + oldFields[f.Name] = f + } + for _, nf := range newRec.Fields { + if of, ok := oldFields[nf.Name]; ok { + if of.Optional && !nf.Optional { + // ok: stronger requirement + } else if !of.Optional && nf.Optional { + return false + } + if of.Readonly && !nf.Readonly { + return false + } + if of.Type != nil { + if IsOpenTopRecord(nf.Type) && IsStructuredTableShape(of.Type) { + return false + } + if nf.Type == nil || !subtype.IsSubtype(nf.Type, of.Type) { + return false + } + } + delete(oldFields, nf.Name) + } + } + return len(oldFields) == 0 +} + +func recordSupersetUnion(newRec *typ.Record, oldUnion *typ.Union) bool { + if newRec == nil || oldUnion == nil { + return false + } + if len(oldUnion.Members) == 0 { + return false + } + for _, member := range oldUnion.Members { + oldRec, ok := member.(*typ.Record) + if !ok { + return false + } + if !RecordSuperset(newRec, oldRec) { + return false + } + } + return true +} + +// IsOpenTopRecord reports whether t is an open record with no concrete fields +// or map component. +func IsOpenTopRecord(t typ.Type) bool { + rec, ok := unwrap.Alias(t).(*typ.Record) + if !ok || rec == nil { + return false + } + return rec.Open && len(rec.Fields) == 0 && !rec.HasMapComponent() +} + +// IsStructuredTableShape reports whether t carries table structure beyond an +// open-top placeholder. +func IsStructuredTableShape(t typ.Type) bool { + switch v := unwrap.Alias(t).(type) { + case *typ.Array: + return true + case *typ.Map: + return true + case *typ.Record: + return v.HasMapComponent() || len(v.Fields) > 0 + default: + return false + } +} + +// RefinesSoftContainer reports whether candidate preserves the same table shape +// while replacing a soft placeholder element/value with concrete evidence. +func RefinesSoftContainer(candidate, baseline typ.Type) (bool, bool) { + candidate = UnwrapStructuralShape(candidate) + baseline = UnwrapStructuralShape(baseline) + if candidate == nil || baseline == nil { + return candidate == baseline, false + } + if typ.TypeEquals(candidate, baseline) { + return true, false + } + + switch b := baseline.(type) { + case *typ.Array: + c, ok := candidate.(*typ.Array) + if !ok { + return false, false + } + return refinesSoftContainerSlot(c.Element, b.Element) + case *typ.Map: + c, ok := candidate.(*typ.Map) + if !ok || !Equivalent(c.Key, b.Key) { + return false, false + } + return refinesSoftContainerSlot(c.Value, b.Value) + case *typ.Record: + c, ok := candidate.(*typ.Record) + if !ok || !sameRecordFrame(c, b) { + return false, false + } + if !c.HasMapComponent() && !b.HasMapComponent() { + return true, false + } + if !c.HasMapComponent() || !b.HasMapComponent() || !Equivalent(c.MapKey, b.MapKey) { + return false, false + } + return refinesSoftContainerSlot(c.MapValue, b.MapValue) + default: + return false, false + } +} + +func refinesSoftContainerSlot(candidate, baseline typ.Type) (bool, bool) { + if typ.TypeEquals(candidate, baseline) { + return true, false + } + if (typ.IsAny(baseline) || typ.IsUnknown(baseline)) && CanSelfEmbed(candidate) { + return false, false + } + preferred, ok := PreferConcreteOverSoft(baseline, candidate) + return ok && typ.TypeEquals(preferred, candidate), ok +} + +func sameRecordFrame(a, b *typ.Record) bool { + if a == nil || b == nil || a.Open != b.Open || len(a.Fields) != len(b.Fields) { + return false + } + if (a.Metatable == nil) != (b.Metatable == nil) { + return false + } + if a.Metatable != nil && !typ.TypeEquals(a.Metatable, b.Metatable) { + return false + } + for i, field := range a.Fields { + other := b.Fields[i] + if field.Name != other.Name || field.Optional != other.Optional || field.Readonly != other.Readonly { + return false + } + if !typ.TypeEquals(field.Type, other.Type) { + return false + } + } + return true +} + +// RefinesFalsyMapKey reports whether candidate is the same table-derived shape +// as baseline after removing stale falsy key members from baseline. +func RefinesFalsyMapKey(candidate, baseline typ.Type) (bool, bool) { + candidate = UnwrapStructuralShape(candidate) + baseline = UnwrapStructuralShape(baseline) + if candidate == nil || baseline == nil { + return candidate == baseline, false + } + if typ.TypeEquals(candidate, baseline) { + return true, false + } + + switch b := baseline.(type) { + case *typ.Array: + c, ok := candidate.(*typ.Array) + if !ok { + return false, false + } + return truthyElementRefinement(c.Element, b.Element) + case *typ.Map: + c, ok := candidate.(*typ.Map) + if !ok { + return false, false + } + return mapKeyTruthyRefinement(c.Key, c.Value, b.Key, b.Value) + case *typ.Record: + if c, ok := candidate.(*typ.Map); ok { + if len(b.Fields) != 0 || b.Metatable != nil || !b.HasMapComponent() { + return false, false + } + return mapKeyTruthyRefinement(c.Key, c.Value, b.MapKey, b.MapValue) + } + c, ok := candidate.(*typ.Record) + if !ok || !c.HasMapComponent() || !b.HasMapComponent() { + return false, false + } + if c.Open && !b.Open { + return false, false + } + if len(c.Fields) != len(b.Fields) { + return false, false + } + for _, bf := range b.Fields { + cf := c.GetField(bf.Name) + if cf == nil || cf.Optional != bf.Optional || cf.Readonly != bf.Readonly || !typ.TypeEquals(cf.Type, bf.Type) { + return false, false + } + } + if (c.Metatable == nil) != (b.Metatable == nil) || (c.Metatable != nil && !typ.TypeEquals(c.Metatable, b.Metatable)) { + return false, false + } + return mapKeyTruthyRefinement(c.MapKey, c.MapValue, b.MapKey, b.MapValue) + default: + return false, false + } +} + +func mapKeyTruthyRefinement(candidateKey, candidateValue, baselineKey, baselineValue typ.Type) (bool, bool) { + if !typ.TypeEquals(candidateValue, baselineValue) { + return false, false + } + if IsTruthyRefinement(candidateKey, baselineKey) { + return true, true + } + return false, false +} + +func truthyElementRefinement(candidate, baseline typ.Type) (bool, bool) { + if typ.TypeEquals(candidate, baseline) { + return true, false + } + if IsTruthyRefinement(candidate, baseline) { + return true, true + } + return false, false +} + +// NestedNilOnlyRegression reports whether candidate's apparent refinement only +// adds nested nil facts over a more useful baseline shape. +func NestedNilOnlyRegression(candidate, baseline typ.Type) bool { + candidate = UnwrapStructuralShape(candidate) + baseline = UnwrapStructuralShape(baseline) + if candidate == nil || baseline == nil || typ.TypeEquals(candidate, baseline) { + return false + } + if unwrap.IsNilType(candidate) { + return typ.IsAny(baseline) || typ.IsUnknown(baseline) || unwrap.IsOptionalLike(baseline) + } + + switch c := candidate.(type) { + case *typ.Record: + b, ok := baseline.(*typ.Record) + if !ok { + return false + } + for _, cf := range c.Fields { + bf := b.GetField(cf.Name) + if bf == nil { + continue + } + if unwrap.IsNilType(cf.Type) && (bf.Optional || typ.IsAny(bf.Type) || typ.IsUnknown(bf.Type) || unwrap.IsOptionalLike(bf.Type)) { + return true + } + if NestedNilOnlyRegression(cf.Type, bf.Type) { + return true + } + } + if c.HasMapComponent() && b.HasMapComponent() { + return NestedNilOnlyRegression(c.MapValue, b.MapValue) + } + case *typ.Array: + if b, ok := baseline.(*typ.Array); ok { + return NestedNilOnlyRegression(c.Element, b.Element) + } + case *typ.Map: + if b, ok := baseline.(*typ.Map); ok { + return NestedNilOnlyRegression(c.Value, b.Value) + } + case *typ.Tuple: + b, ok := baseline.(*typ.Tuple) + if !ok || len(c.Elements) != len(b.Elements) { + return false + } + for i := range c.Elements { + if NestedNilOnlyRegression(c.Elements[i], b.Elements[i]) { + return true + } + } + case *typ.Function: + b, ok := baseline.(*typ.Function) + if !ok || len(c.Returns) != len(b.Returns) { + return false + } + for i := range c.Returns { + if NestedNilOnlyRegression(c.Returns[i], b.Returns[i]) { + return true + } + } + } + return false +} + +// ContainsNestedStructuralShape reports whether haystack embeds the same +// shallow structural shape as needle below the root. +func ContainsNestedStructuralShape(haystack, needle typ.Type) bool { + return containsNestedStructuralShapeDepth(haystack, needle, make(map[typ.Type]bool), false) +} + +func containsNestedStructuralShapeDepth(haystack, needle typ.Type, seen map[typ.Type]bool, belowContainer bool) bool { + if haystack == nil || needle == nil { + return false + } + if seen[haystack] { + return false + } + seen[haystack] = true + + node := UnwrapStructuralShape(haystack) + if node == nil { + return false + } + if belowContainer && ShallowStructuralShapeEquals(node, needle) { + return true + } + + descend := func(child typ.Type, childBelowContainer bool) bool { + return containsNestedStructuralShapeDepth(child, needle, seen, childBelowContainer) + } + + switch n := node.(type) { + case *typ.Optional: + return descend(n.Inner, belowContainer) + case *typ.Union: + for _, member := range n.Members { + if descend(member, belowContainer) { + return true + } + } + return false + case *typ.Intersection: + for _, member := range n.Members { + if descend(member, belowContainer) { + return true + } + } + return false + case *typ.Array: + return descend(n.Element, true) + case *typ.Map: + return descend(n.Key, true) || descend(n.Value, true) + case *typ.Tuple: + for _, elem := range n.Elements { + if descend(elem, true) { + return true + } + } + return false + case *typ.Record: + for _, field := range n.Fields { + if descend(field.Type, true) { + return true + } + } + if n.Metatable != nil && descend(n.Metatable, true) { + return true + } + if n.HasMapComponent() { + return descend(n.MapKey, true) || descend(n.MapValue, true) + } + return false + case *typ.Function: + for _, param := range n.Params { + if descend(param.Type, true) { + return true + } + } + if n.Variadic != nil && descend(n.Variadic, true) { + return true + } + for _, ret := range n.Returns { + if descend(ret, true) { + return true + } + } + return false + case *typ.Instantiated: + for _, arg := range n.TypeArgs { + if descend(arg, belowContainer) { + return true + } + } + return false + case *typ.Interface: + for _, method := range n.Methods { + if method.Type != nil && descend(method.Type, true) { + return true + } + } + return false + default: + return false + } +} + +// ShallowStructuralShapeEquals reports whether a and b have the same root +// structural container shape. +func ShallowStructuralShapeEquals(a, b typ.Type) bool { + a = UnwrapStructuralShape(a) + b = UnwrapStructuralShape(b) + if a == nil || b == nil { + return a == b + } + + switch av := a.(type) { + case *typ.Union: + for _, member := range av.Members { + if ShallowStructuralShapeEquals(member, b) { + return true + } + } + return false + case *typ.Intersection: + for _, member := range av.Members { + if ShallowStructuralShapeEquals(member, b) { + return true + } + } + return false + } + switch bv := b.(type) { + case *typ.Union: + for _, member := range bv.Members { + if ShallowStructuralShapeEquals(a, member) { + return true + } + } + return false + case *typ.Intersection: + for _, member := range bv.Members { + if ShallowStructuralShapeEquals(a, member) { + return true + } + } + return false + } + + switch av := a.(type) { + case *typ.Array: + _, ok := b.(*typ.Array) + return ok + case *typ.Map: + bv, ok := b.(*typ.Map) + return ok && shallowMapKeyShapeEquals(av.Key, bv.Key) + case *typ.Tuple: + bv, ok := b.(*typ.Tuple) + return ok && len(av.Elements) == len(bv.Elements) + case *typ.Record: + bv, ok := b.(*typ.Record) + return ok && shallowRecordShapeEquals(av, bv) + default: + return typ.TypeEquals(a, b) + } +} + +// UnwrapStructuralShape strips transparent wrappers for structural comparison. +func UnwrapStructuralShape(t typ.Type) typ.Type { + for t != nil { + switch v := t.(type) { + case *typ.Annotated: + if v.Inner == nil || v.Inner == t { + return t + } + t = v.Inner + case *typ.Alias: + if v.Target == nil || v.Target == t { + return t + } + t = v.Target + case *typ.Optional: + if v.Inner == nil || v.Inner == t { + return t + } + t = v.Inner + default: + return t + } + } + return nil +} + +func shallowMapKeyShapeEquals(a, b typ.Type) bool { + if a == nil || b == nil { + return a == b + } + if typ.TypeEquals(a, b) { + return true + } + return typ.IsAny(a) || typ.IsAny(b) || typ.IsUnknown(a) || typ.IsUnknown(b) +} + +func shallowRecordShapeEquals(a, b *typ.Record) bool { + if a == nil || b == nil { + return a == b + } + if a.HasMapComponent() != b.HasMapComponent() { + return false + } + if a.HasMapComponent() && !shallowMapKeyShapeEquals(a.MapKey, b.MapKey) { + return false + } + if len(a.Fields) != len(b.Fields) { + return false + } + for _, field := range a.Fields { + if b.GetField(field.Name) == nil { + return false + } + } + return true +} + +// UnionMembers returns explicit union members after structural unwrapping. +func UnionMembers(t typ.Type) []typ.Type { + switch v := UnwrapStructuralShape(t).(type) { + case *typ.Union: + return v.Members + case *typ.Optional: + return append([]typ.Type{typ.Nil}, UnionMembers(v.Inner)...) + default: + return nil + } +} diff --git a/compiler/check/domain/value/shape_test.go b/compiler/check/domain/value/shape_test.go new file mode 100644 index 00000000..b1bf196b --- /dev/null +++ b/compiler/check/domain/value/shape_test.go @@ -0,0 +1,171 @@ +package value + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestExtendsRecord_NilTypes(t *testing.T) { + if ExtendsRecord(nil, typ.String) { + t.Error("nil a should not extend") + } + if ExtendsRecord(typ.String, nil) { + t.Error("nil b should not extend") + } +} + +func TestExtendsRecord_NotRecord(t *testing.T) { + if ExtendsRecord(typ.String, typ.String) { + t.Error("non-record should not extend") + } +} + +func TestExtendsRecord_MapComponentConsistency(t *testing.T) { + oldRec := typ.NewRecord().MapComponent(typ.String, typ.Number).Build() + newRec := typ.NewRecord().Field("x", typ.Number).Build() + if ExtendsRecord(newRec, oldRec) { + t.Error("record without map component should not extend record with map component") + } +} + +func TestCollapseTableTopEvidence_AbsorbsPreciseTableMembers(t *testing.T) { + tableTop := typ.NewInterface("table", nil) + preciseRecord := typ.NewRecord(). + Field("name", typ.String). + Field("tools", typ.NewArray(typ.String)). + Build() + preciseMap := typ.NewMap(typ.String, typ.Integer) + evidence := typ.NewUnion(typ.NewOptional(tableTop), preciseRecord, preciseMap, typ.String) + + got := CollapseTableTopEvidence(evidence) + want := typ.NewUnion(typ.NewOptional(tableTop), typ.String) + if !typ.TypeEquals(got, want) { + t.Fatalf("expected table top to absorb precise table members as %v, got %v", want, got) + } +} + +func TestSelectTableUpperBound_AbsorbsTableUnion(t *testing.T) { + tableTop := typ.NewOptional(typ.NewInterface("table", nil)) + strategySpec := typ.NewRecord(). + Field("kind", typ.LiteralString("strategy")). + Field("tools", typ.NewTuple(typ.String, typ.String, typ.String)). + Build() + contextSpec := typ.NewRecord(). + Field("kind", typ.LiteralString("context")). + Field("scope", typ.String). + Build() + nextHint := typ.NewUnion(strategySpec, contextSpec) + + got, ok := SelectTableUpperBound(tableTop, nextHint) + if !ok || !typ.TypeEquals(got, tableTop) { + t.Fatalf("expected table top upper bound %v, got %v ok=%v", tableTop, got, ok) + } +} + +func TestJoinMapRecordShape_PureMapComponentBecomesMap(t *testing.T) { + entry := typ.NewRecord().Field("id", typ.String).Build() + canonical := typ.NewMap(typ.String, typ.NewArray(entry)) + recordView := typ.NewRecord(). + MapComponent(typ.NewUnion(typ.String, typ.False), typ.NewArray(entry)). + SetOpen(true). + Build() + join := func(a, b typ.Type) typ.Type { + if IsTruthyRefinement(a, b) { + return a + } + if IsTruthyRefinement(b, a) { + return b + } + return typ.JoinPreferNonSoft(a, b) + } + + got, ok := JoinMapRecordShape(canonical, recordView, join) + if !ok || !typ.TypeEquals(got, canonical) { + t.Fatalf("expected canonical map %v, got %v ok=%v", canonical, got, ok) + } +} + +func TestRefineStructuralAnnotation_MapValueFromRecordEvidence(t *testing.T) { + annotation := typ.NewMap(typ.String, typ.Any) + evidence := typ.NewRecord(). + Field("name", typ.String). + Field("age", typ.Integer). + Build() + + got, changed := RefineStructuralAnnotation(annotation, evidence, typ.JoinPreferNonSoft) + want := typ.NewMap(typ.String, typ.JoinPreferNonSoft(typ.String, typ.Integer)) + if !changed || !typ.TypeEquals(got, want) { + t.Fatalf("expected refined map annotation %v, got %v changed=%v", want, got, changed) + } +} + +func TestRefinesFalsyMapKey(t *testing.T) { + candidate := typ.NewMap(typ.String, typ.Number) + baseline := typ.NewMap(typ.NewUnion(typ.String, typ.False), typ.Number) + + ok, changed := RefinesFalsyMapKey(candidate, baseline) + if !ok || !changed { + t.Fatalf("expected truthy key refinement, got ok=%v changed=%v", ok, changed) + } +} + +func TestRefinesTableKeyByTruthiness_Map(t *testing.T) { + candidate := typ.NewMap(typ.String, typ.Number) + baseline := typ.NewMap(typ.NewUnion(typ.String, typ.False), typ.Number) + + if !RefinesTableKeyByTruthiness(candidate, baseline) { + t.Fatalf("expected map key truthiness refinement") + } +} + +func TestRefinesTableKeyByTruthiness_RecordMapComponent(t *testing.T) { + candidate := typ.NewRecord(). + Field("name", typ.String). + MapComponent(typ.String, typ.Number). + Build() + baseline := typ.NewRecord(). + Field("name", typ.String). + MapComponent(typ.NewUnion(typ.String, typ.False), typ.Number). + Build() + + if !RefinesTableKeyByTruthiness(candidate, baseline) { + t.Fatalf("expected record map-key truthiness refinement") + } +} + +func TestRefinesTableKeyByTruthiness_RejectsValueChange(t *testing.T) { + candidate := typ.NewMap(typ.String, typ.Integer) + baseline := typ.NewMap(typ.NewUnion(typ.String, typ.False), typ.Number) + + if RefinesTableKeyByTruthiness(candidate, baseline) { + t.Fatalf("value changes are not table-key truthiness refinements") + } +} + +func TestRefinesTableKeyByTruthiness_SplitsNilableUnion(t *testing.T) { + candidate := typ.NewUnion(typ.Nil, typ.NewMap(typ.String, typ.Number)) + baseline := typ.NewUnion(typ.Nil, typ.NewMap(typ.NewUnion(typ.String, typ.False), typ.Number)) + + if !RefinesTableKeyByTruthiness(candidate, baseline) { + t.Fatalf("expected nilable map key truthiness refinement") + } +} + +func TestNestedNilOnlyRegression(t *testing.T) { + candidate := typ.NewRecord().Field("value", typ.Nil).Build() + baseline := typ.NewRecord().OptField("value", typ.String).Build() + + if !NestedNilOnlyRegression(candidate, baseline) { + t.Fatalf("expected nested nil-only regression") + } +} + +func TestContainsNestedStructuralShape(t *testing.T) { + shape := typ.NewMap(typ.String, typ.Any) + growing := typ.NewMap(typ.String, typ.NewMap(typ.String, typ.Nil)) + + if !ContainsNestedStructuralShape(growing, shape) { + t.Fatalf("expected nested structural shape") + } +} diff --git a/compiler/check/domain/value/table.go b/compiler/check/domain/value/table.go new file mode 100644 index 00000000..571ceaf0 --- /dev/null +++ b/compiler/check/domain/value/table.go @@ -0,0 +1,322 @@ +package value + +import ( + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// JoinMapRecordShape joins a map shape with a record carrying a map component. +// The caller supplies the slot join law; this package owns only the structural +// table reconstruction. +func JoinMapRecordShape(a, b typ.Type, join func(typ.Type, typ.Type) typ.Type) (typ.Type, bool) { + if join == nil { + join = typ.JoinPreferNonSoft + } + if joined, ok := joinMapRecordShapeDirected(a, b, join); ok { + return joined, true + } + return joinMapRecordShapeDirected(b, a, join) +} + +func joinMapRecordShapeDirected(mapType, recordType typ.Type, join func(typ.Type, typ.Type) typ.Type) (typ.Type, bool) { + m, ok := unwrap.Alias(mapType).(*typ.Map) + if !ok || m == nil { + return nil, false + } + r, ok := unwrap.Alias(recordType).(*typ.Record) + if !ok || r == nil || !r.HasMapComponent() { + return nil, false + } + + key := join(m.Key, r.MapKey) + value := join(m.Value, r.MapValue) + if len(r.Fields) == 0 && r.Metatable == nil { + return typ.NewMap(key, value), true + } + builder := typ.NewRecord() + if r.Open { + builder.SetOpen(true) + } + if r.Metatable != nil { + builder.Metatable(r.Metatable) + } + builder.MapComponent(key, value) + for _, field := range r.Fields { + fieldType := field.Type + optional := true + if subtype.IsSubtype(typ.LiteralString(field.Name), key) { + fieldType = join(field.Type, value) + } else { + optional = field.Optional + } + switch { + case optional && field.Readonly: + builder.OptReadonlyField(field.Name, fieldType) + case optional: + builder.OptField(field.Name, fieldType) + case field.Readonly: + builder.ReadonlyField(field.Name, fieldType) + default: + builder.Field(field.Name, fieldType) + } + } + return builder.Build(), true +} + +// CollapseTableTopEvidence collapses unions where builtin table top already +// covers concrete table-shaped evidence. +func CollapseTableTopEvidence(t typ.Type) typ.Type { + if t == nil { + return nil + } + switch v := t.(type) { + case *typ.Alias: + target := CollapseTableTopEvidence(v.Target) + if target != nil && !typ.TypeEquals(target, v.Target) { + return typ.NewAlias(v.Name, target) + } + return t + case *typ.Optional: + inner := CollapseTableTopEvidence(v.Inner) + if inner != nil && !typ.TypeEquals(inner, v.Inner) { + return typ.NewOptional(inner) + } + return t + case *typ.Union: + return collapseTableTopUnion(v) + default: + return t + } +} + +func collapseTableTopUnion(u *typ.Union) typ.Type { + if u == nil { + return nil + } + tableTop := firstTableTopMember(u.Members) + members := make([]typ.Type, 0, len(u.Members)) + changed := false + + if tableTop == nil { + for _, member := range u.Members { + collapsed := CollapseTableTopEvidence(member) + if !typ.TypeEquals(collapsed, member) { + changed = true + } + members = append(members, collapsed) + } + if !changed { + return u + } + return typ.NewUnion(members...) + } + + tableAdded := false + for _, member := range u.Members { + if member == nil { + continue + } + if unwrap.IsNilType(typ.UnwrapAnnotated(member)) { + members = append(members, member) + continue + } + collapsed := CollapseTableTopEvidence(member) + if TableTopCovers(collapsed) { + if !tableAdded { + members = append(members, tableTop) + tableAdded = true + } + if !typ.TypeEquals(member, tableTop) { + changed = true + } + continue + } + if !typ.TypeEquals(collapsed, member) { + changed = true + } + members = append(members, collapsed) + } + if !changed { + return u + } + return typ.NewUnion(members...) +} + +func firstTableTopMember(members []typ.Type) typ.Type { + for _, member := range members { + if unwrap.IsBuiltinTableTop(typ.UnwrapAnnotated(member)) { + return member + } + } + return nil +} + +// SelectTableUpperBound returns the table-top upper bound when one candidate +// already covers the other table-shaped candidate. +func SelectTableUpperBound(a, b typ.Type) (typ.Type, bool) { + if isOnlyTableTop(a) && typ.IsAny(b) { + return a, true + } + if isOnlyTableTop(b) && typ.IsAny(a) { + return b, true + } + if containsTableTop(a) && TableTopCovers(b) && subtype.IsSubtype(b, a) { + return a, true + } + if containsTableTop(b) && TableTopCovers(a) && subtype.IsSubtype(a, b) { + return b, true + } + return nil, false +} + +func containsTableTop(t typ.Type) bool { + if t == nil { + return false + } + if unwrap.IsBuiltinTableTop(typ.UnwrapAnnotated(t)) { + return true + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return containsTableTop(v.UnaliasedTarget()) + case *typ.Optional: + return containsTableTop(v.Inner) + case *typ.Union: + for _, member := range v.Members { + if containsTableTop(member) { + return true + } + } + } + return false +} + +func isOnlyTableTop(t typ.Type) bool { + if t == nil { + return false + } + if unwrap.IsBuiltinTableTop(typ.UnwrapAnnotated(t)) { + return true + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return isOnlyTableTop(v.UnaliasedTarget()) + case *typ.Optional: + return isOnlyTableTop(v.Inner) + case *typ.Union: + if len(v.Members) == 0 { + return false + } + hasTableTop := false + for _, member := range v.Members { + if unwrap.IsNilType(member) { + continue + } + if !isOnlyTableTop(member) { + return false + } + hasTableTop = true + } + return hasTableTop + default: + return false + } +} + +// TableTopCovers reports whether builtin table top can cover t. +func TableTopCovers(t typ.Type) bool { + if t == nil { + return false + } + if typ.IsAny(t) { + return true + } + if unwrap.IsNilType(t) || unwrap.IsBuiltinTableTop(typ.UnwrapAnnotated(t)) { + return true + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return TableTopCovers(v.UnaliasedTarget()) + case *typ.Optional: + return TableTopCovers(v.Inner) + case *typ.Recursive: + return v.Body != nil && v.Body != v && TableTopCovers(v.Body) + case *typ.Union: + if len(v.Members) == 0 { + return false + } + for _, member := range v.Members { + if !TableTopCovers(member) { + return false + } + } + return true + case *typ.Record, *typ.Map, *typ.Array, *typ.Tuple, *typ.Interface, *typ.Intersection: + return true + default: + return false + } +} + +// RefinesTableKeyByTruthiness reports whether candidate preserves baseline's +// table shape while replacing a stale falsy table-key component with its truthy +// refinement. +func RefinesTableKeyByTruthiness(candidate, baseline typ.Type) bool { + if candidate == nil || baseline == nil || typ.TypeEquals(candidate, baseline) { + return false + } + candidateInner, _ := SplitNilable(candidate) + baselineInner, _ := SplitNilable(baseline) + if candidateInner == nil || baselineInner == nil { + return false + } + return nonNilRefinesTableKeyByTruthiness(candidateInner, baselineInner) +} + +func nonNilRefinesTableKeyByTruthiness(candidate, baseline typ.Type) bool { + candidate = unwrap.Alias(candidate) + baseline = unwrap.Alias(baseline) + switch b := baseline.(type) { + case *typ.Record: + c, ok := candidate.(*typ.Record) + return ok && recordRefinesTableKeyByTruthiness(c, b) + case *typ.Map: + c, ok := candidate.(*typ.Map) + return ok && mapKeyRefinesByTruthiness(c.Key, c.Value, b.Key, b.Value) + default: + return false + } +} + +func recordRefinesTableKeyByTruthiness(candidate, baseline *typ.Record) bool { + return sameRecordFrameEquivalent(candidate, baseline) && + candidate.HasMapComponent() && baseline.HasMapComponent() && + mapKeyRefinesByTruthiness(candidate.MapKey, candidate.MapValue, baseline.MapKey, baseline.MapValue) +} + +func mapKeyRefinesByTruthiness(candidateKey, candidateValue, baselineKey, baselineValue typ.Type) bool { + return IsTruthyRefinement(candidateKey, baselineKey) && Equivalent(candidateValue, baselineValue) +} + +func sameRecordFrameEquivalent(candidate, baseline *typ.Record) bool { + if candidate == nil || baseline == nil || candidate.Open != baseline.Open || len(candidate.Fields) != len(baseline.Fields) { + return false + } + if (candidate.Metatable == nil) != (baseline.Metatable == nil) { + return false + } + if candidate.Metatable != nil && !typ.TypeEquals(candidate.Metatable, baseline.Metatable) { + return false + } + for i, field := range candidate.Fields { + other := baseline.Fields[i] + if field.Name != other.Name || field.Optional != other.Optional || field.Readonly != other.Readonly { + return false + } + if !Equivalent(field.Type, other.Type) { + return false + } + } + return true +} diff --git a/compiler/check/effects/doc.go b/compiler/check/effects/doc.go index 7435df76..5db72d5f 100644 --- a/compiler/check/effects/doc.go +++ b/compiler/check/effects/doc.go @@ -24,8 +24,8 @@ // // # Effect Lookup // -// [LookupRefinementBySym] resolves effects for called functions: -// - First checks the effect store for analyzed functions +// [ResolveRefinementBySym] resolves effects for called functions: +// - First checks canonical function facts for analyzed functions // - Falls back to global type information for builtins // - Extracts effects from function type annotations // diff --git a/compiler/check/effects/effectops_test.go b/compiler/check/effects/effectops_test.go index 2012d7fe..5d964594 100644 --- a/compiler/check/effects/effectops_test.go +++ b/compiler/check/effects/effectops_test.go @@ -40,15 +40,15 @@ func TestPropagate_WithLocalEffect(t *testing.T) { } } -func TestLookupRefinementBySym_NilStore(t *testing.T) { - result := LookupRefinementBySym(nil, nil, nil, 1) +func TestResolveRefinementBySym_NilFacts(t *testing.T) { + result := ResolveRefinementBySym(nil, nil, nil, 1) if result != nil { - t.Errorf("expected nil for nil store, got %v", result) + t.Errorf("expected nil for nil facts, got %v", result) } } -func TestLookupRefinementBySym_ZeroSym(t *testing.T) { - result := LookupRefinementBySym(nil, nil, nil, 0) +func TestResolveRefinementBySym_ZeroSym(t *testing.T) { + result := ResolveRefinementBySym(nil, nil, nil, 0) if result != nil { t.Errorf("expected nil for zero symbol, got %v", result) } @@ -256,6 +256,7 @@ func TestPropagate_CollectsEffectFromAssignmentCallSite(t *testing.T) { result := Propagate(&api.FuncResult{ Graph: graph, + Evidence: evidenceForEffects(graph), FnRefinement: &constraint.FunctionRefinement{}, }, func(sym cfg.SymbolID) *constraint.FunctionRefinement { if sym == symF { @@ -284,6 +285,7 @@ func TestPropagate_CollectsEffectFromReturnCallSite(t *testing.T) { result := Propagate(&api.FuncResult{ Graph: graph, + Evidence: evidenceForEffects(graph), FnRefinement: &constraint.FunctionRefinement{}, }, func(sym cfg.SymbolID) *constraint.FunctionRefinement { if sym == symF { @@ -321,6 +323,7 @@ func TestPropagate_UsesCanonicalCandidatesWhenRawSymbolMissing(t *testing.T) { result := Propagate(&api.FuncResult{ Graph: graph, + Evidence: evidenceForEffects(graph), FnRefinement: &constraint.FunctionRefinement{}, }, func(sym cfg.SymbolID) *constraint.FunctionRefinement { if sym == symF { @@ -359,6 +362,7 @@ func TestPropagate_UsesModuleBindingNameFallback(t *testing.T) { result := Propagate(&api.FuncResult{ Graph: graph, ModuleBindings: moduleBindings, + Evidence: evidenceForEffects(graph), FnRefinement: &constraint.FunctionRefinement{}, }, func(sym cfg.SymbolID) *constraint.FunctionRefinement { if sym == fallbackSym { @@ -375,6 +379,19 @@ func TestPropagate_UsesModuleBindingNameFallback(t *testing.T) { } } +func evidenceForEffects(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + var evidence api.FlowEvidence + graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + if info != nil { + evidence.Calls = append(evidence.Calls, api.CallEvidence{Point: p, Info: info}) + } + }) + return evidence +} + func buildGraphForEffects(t *testing.T, code string, globals ...string) *cfg.Graph { t.Helper() stmts, err := parse.ParseString(code, "test.lua") diff --git a/compiler/check/effects/propagate.go b/compiler/check/effects/propagate.go index 8fe13da8..29c9a60b 100644 --- a/compiler/check/effects/propagate.go +++ b/compiler/check/effects/propagate.go @@ -42,9 +42,11 @@ func Propagate(result *api.FuncResult, lookup LookupFunc) *constraint.FunctionRe // Start with the function's own Terminates value. terminates := fnEffect.Terminates - result.Graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range result.Evidence.Calls { + p := call.Point + info := call.Info if info == nil { - return + continue } var synthFn func(ast.Expr, cfg.Point) typ.Type if result.NarrowSynth != nil { @@ -63,14 +65,14 @@ func Propagate(result *api.FuncResult, lookup LookupFunc) *constraint.FunctionRe ) if calleeEffect == nil { - return + continue } if calleeEffect.Row != nil { if calleeRow, ok := calleeEffect.Row.(effect.Row); ok { row = effect.Union(row, calleeRow) } } - }) + } // Compute Terminates from flow reachability. if !terminates && result.FlowSolution != nil && result.Graph.CFG() != nil { @@ -91,18 +93,21 @@ func Propagate(result *api.FuncResult, lookup LookupFunc) *constraint.FunctionRe } } -// LookupRefinementBySym resolves effects from the store or global type information. -func LookupRefinementBySym( - store api.RefinementStore, +// ResolveRefinementBySym resolves effects from canonical function facts or global +// type information. +func ResolveRefinementBySym( + facts api.RefinementFacts, bindings *bind.BindingTable, globalTypes map[string]typ.Type, sym cfg.SymbolID, ) *constraint.FunctionRefinement { - if store == nil || sym == 0 { + if sym == 0 { return nil } - if eff := store.LookupRefinementBySym(sym); eff != nil { - return eff + if facts != nil { + if eff := facts.LookupBySym(sym); eff != nil { + return eff + } } if bindings != nil && globalTypes != nil { if name := bindings.Name(sym); name != "" { @@ -117,26 +122,20 @@ func LookupRefinementBySym( // TerminatesFromReachability determines if a function never returns normally // by checking reachability of all return and exit points via flow analysis. func TerminatesFromReachability(result *api.FuncResult) bool { - if result == nil || result.FlowSolution == nil || result.Graph == nil || result.Graph.CFG() == nil { + if result == nil || result.FlowSolution == nil || !result.Evidence.NormalExit.Valid { return false } - g := result.Graph.CFG() - // Check if any return node is reachable. - for _, p := range g.RPO() { - node := g.Node(p) - if node == nil || node.Kind != cfg.NodeReturn { - continue - } - cond := result.FlowSolution.ConditionAt(p) + for _, ret := range result.Evidence.Returns { + cond := result.FlowSolution.ConditionAt(ret.Point) if !cond.IsFalse() { return false } } // Check if exit node is reachable. - exitCond := result.FlowSolution.ConditionAt(g.Exit()) + exitCond := result.FlowSolution.ConditionAt(result.Evidence.NormalExit.Point) return exitCond.IsFalse() } diff --git a/compiler/check/erreffect/error_return_infer.go b/compiler/check/erreffect/error_return_infer.go index 70a650d5..d1fea898 100644 --- a/compiler/check/erreffect/error_return_infer.go +++ b/compiler/check/erreffect/error_return_infer.go @@ -13,15 +13,73 @@ import ( "github.com/wippyai/go-lua/types/typ/unwrap" ) +// ErrorReturnConvention describes a return relation where one slot carries the +// success value and another carries the error. The convention is a pair +// relation, not a complete return-vector shape: extra return slots do not affect +// whether the value/error pair can be proven. +type ErrorReturnConvention struct { + ValueIndex int + ErrorIndex int +} + +// CanonicalLuaValueErrorConvention returns the canonical Lua `(value, err)` layout. +func CanonicalLuaValueErrorConvention() ErrorReturnConvention { + return ErrorReturnConvention{ + ValueIndex: 0, + ErrorIndex: 1, + } +} + +func (c ErrorReturnConvention) valid() bool { + return c.ValueIndex >= 0 && + c.ErrorIndex >= 0 && + c.ValueIndex != c.ErrorIndex +} + +func (c ErrorReturnConvention) requiredReturnSlots() int { + return requiredReturnSlots(c.ValueIndex, c.ErrorIndex) +} + +// CanClassifyReturns reports whether returnTypes contains the slots required by +// this convention before the expensive per-return inverse-pattern proof runs. +func (c ErrorReturnConvention) CanClassifyReturns(returnTypes []typ.Type) bool { + return c.valid() && len(returnTypes) >= c.requiredReturnSlots() +} + +func (c ErrorReturnConvention) canClassifyFunction(fn *typ.Function) bool { + return fn != nil && c.CanClassifyReturns(fn.Returns) +} + +// HasStrictInversePattern proves this convention from the function body. +func (c ErrorReturnConvention) HasStrictInversePattern( + returns []api.ReturnEvidence, + solution *flow.Solution, + synth api.BaseSynth, +) bool { + if !c.valid() { + return false + } + return HasStrictInverseReturnPattern(returns, solution, synth, c.ValueIndex, c.ErrorIndex) +} + +// Attach enriches fn with this convention's ErrorReturn effect. +func (c ErrorReturnConvention) Attach(fn *typ.Function) *typ.Function { + if !c.valid() { + return fn + } + return AttachErrorReturnSpec(fn, c.ValueIndex, c.ErrorIndex) +} + // AttachInferredErrorReturnSpec enriches function types with a canonical // ErrorReturn effect when the function body proves the `(value, err)` pattern. func AttachInferredErrorReturnSpec( fn *typ.Function, - graph *cfg.Graph, + evidence api.FlowEvidence, solution *flow.Solution, synth api.Synth, ) *typ.Function { - if fn == nil || graph == nil || synth == nil || len(fn.Returns) != 2 { + convention := CanonicalLuaValueErrorConvention() + if len(evidence.Returns) == 0 || synth == nil || !convention.canClassifyFunction(fn) { return fn } if HasErrorReturnLabel(fn) { @@ -31,11 +89,11 @@ func AttachInferredErrorReturnSpec( if base == nil { base = synth } - if !HasStrictInverseReturnPattern(graph, solution, base, 0, 1) { + if !convention.HasStrictInversePattern(evidence.Returns, solution, base) { return fn } - return AttachErrorReturnSpec(fn, 0, 1) + return convention.Attach(fn) } func HasErrorReturnLabel(fn *typ.Function) bool { @@ -52,13 +110,17 @@ func HasErrorReturnLabel(fn *typ.Function) bool { } func HasStrictInverseReturnPattern( - graph *cfg.Graph, + returns []api.ReturnEvidence, solution *flow.Solution, synth api.BaseSynth, valueIdx int, errorIdx int, ) bool { - if graph == nil || synth == nil { + if len(returns) == 0 || synth == nil { + return false + } + needed := requiredReturnSlots(valueIdx, errorIdx) + if needed == 0 { return false } var sawSuccess bool @@ -66,23 +128,32 @@ func HasStrictInverseReturnPattern( var incompatible bool var classified bool - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range returns { + p := ret.Point + info := ret.Info if incompatible || info == nil { - return + continue } if solution != nil && solution.IsPointDead(p) { - return + continue } // Skip synthetic implicit return nodes; explicit `return` without values // is a real nil,nil return and should block inference. if len(info.Exprs) == 0 && info.Stmt == nil { - return + continue } - values := synth.ExpandValues(info.Exprs, 2, p) + if delegatesErrorReturn(info, p, synth, valueIdx, errorIdx) { + classified = true + sawSuccess = true + sawFailure = true + continue + } + + values := synth.ExpandValues(info.Exprs, needed, p) if valueIdx >= len(values) || errorIdx >= len(values) { incompatible = true - return + continue } valueState, okValue := classifyNilState(values[valueIdx]) @@ -95,7 +166,7 @@ func HasStrictInverseReturnPattern( } if !okValue || !okError { incompatible = true - return + continue } classified = true @@ -107,11 +178,47 @@ func HasStrictInverseReturnPattern( default: incompatible = true } - }) + } return classified && !incompatible && sawSuccess && sawFailure } +func delegatesErrorReturn( + info *cfg.ReturnInfo, + p cfg.Point, + synth api.BaseSynth, + valueIdx int, + errorIdx int, +) bool { + if info == nil || synth == nil || len(info.Exprs) != 1 { + return false + } + call, ok := info.Exprs[0].(*ast.FuncCallExpr) + if !ok || call == nil || call.Func == nil { + return false + } + fn := unwrap.Function(synth.TypeOf(call.Func, p)) + if fn == nil { + return false + } + spec := contract.ExtractSpec(fn) + if spec == nil { + return false + } + er := spec.Effects.GetErrorReturn(valueIdx) + return er != nil && er.ErrorIndex == errorIdx +} + +func requiredReturnSlots(valueIdx int, errorIdx int) int { + if valueIdx < 0 || errorIdx < 0 || valueIdx == errorIdx { + return 0 + } + if valueIdx > errorIdx { + return valueIdx + 1 + } + return errorIdx + 1 +} + func AttachErrorReturnSpec(fn *typ.Function, valueIndex, errorIndex int) *typ.Function { if fn == nil { return fn diff --git a/compiler/check/erreffect/error_return_infer_test.go b/compiler/check/erreffect/error_return_infer_test.go new file mode 100644 index 00000000..62d26e58 --- /dev/null +++ b/compiler/check/erreffect/error_return_infer_test.go @@ -0,0 +1,34 @@ +package erreffect + +import ( + "testing" + + "github.com/wippyai/go-lua/types/typ" +) + +func TestErrorReturnConventionCanClassifyReturns(t *testing.T) { + t.Parallel() + + convention := CanonicalLuaValueErrorConvention() + if !convention.CanClassifyReturns([]typ.Type{typ.String, typ.Nil}) { + t.Fatal("canonical value/error convention should classify two return slots") + } + if convention.CanClassifyReturns([]typ.Type{typ.String}) { + t.Fatal("canonical value/error convention should reject missing error slot") + } + if !convention.CanClassifyReturns([]typ.Type{typ.String, typ.Nil, typ.Boolean}) { + t.Fatal("canonical value/error convention should allow unrelated extra return slots") + } +} + +func TestErrorReturnConventionRejectsInvalidLayout(t *testing.T) { + t.Parallel() + + convention := ErrorReturnConvention{ + ValueIndex: 0, + ErrorIndex: 0, + } + if convention.CanClassifyReturns([]typ.Type{typ.Nil}) { + t.Fatal("convention with overlapping value/error slots should be invalid") + } +} diff --git a/compiler/check/flowbuild/assign/collect.go b/compiler/check/flowbuild/assign/collect.go deleted file mode 100644 index b5f3686b..00000000 --- a/compiler/check/flowbuild/assign/collect.go +++ /dev/null @@ -1,33 +0,0 @@ -package assign - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/overlaymut" - "github.com/wippyai/go-lua/types/typ" -) - -// CollectFieldAssignments scans the graph for field assignments and groups them by base symbol. -// Returns a map: symbolID -> map[fieldName]typ.Type representing fields assigned to each symbol. -// The synth function is used to synthesize field value types. -// If filterSyms is non-nil, only symbols in the filter are collected. -func CollectFieldAssignments( - graph *cfg.Graph, - synth func(ast.Expr, cfg.Point) typ.Type, - filterSyms map[cfg.SymbolID]bool, -) map[cfg.SymbolID]map[string]typ.Type { - return overlaymut.CollectFieldAssignments(graph, synth, filterSyms) -} - -// CollectIndexerAssignments scans the graph for dynamic index assignments (t[k] = v where k is non-const). -// Returns a map: symbolID -> []IndexerInfo representing index assignments to each symbol. -func CollectIndexerAssignments( - graph *cfg.Graph, - synth func(ast.Expr, cfg.Point) typ.Type, - bindings *bind.BindingTable, - filterSyms map[cfg.SymbolID]bool, -) map[cfg.SymbolID][]mutator.IndexerInfo { - return overlaymut.CollectIndexerAssignments(graph, synth, bindings, filterSyms) -} diff --git a/compiler/check/flowbuild/assign/precision.go b/compiler/check/flowbuild/assign/precision.go deleted file mode 100644 index f7ef8a60..00000000 --- a/compiler/check/flowbuild/assign/precision.go +++ /dev/null @@ -1,71 +0,0 @@ -package assign - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/types/subtype" - "github.com/wippyai/go-lua/types/typ" -) - -// preferPreciseDirectSourceType keeps assignment inference on the canonical -// expression-synthesis path. -// -// Direct synthesis is allowed to repair a slot only when it is strictly more -// informative than the expanded assignment value. This keeps tuple expansion as -// the primary source of truth for assignment slots while still allowing -// canonical single-expression synthesis to repair top-like degradation. -func preferPreciseDirectSourceType( - assignedType typ.Type, - source ast.Expr, - p cfg.Point, - sc *scope.State, - synth func(ast.Expr, cfg.Point) typ.Type, - singleTarget bool, -) typ.Type { - if source == nil || synth == nil { - return assignedType - } - switch source.(type) { - case *ast.Comma3Expr: - return assignedType - } - - precise := resolve.Ref(synth(source, p), sc) - if typ.IsAbsentOrUnknown(precise) { - return assignedType - } - if singleTarget { - if typ.IsAbsentOrUnknown(assignedType) || typ.IsAny(assignedType) { - return precise - } - if subtype.IsSubtype(precise, assignedType) && !subtype.IsSubtype(assignedType, precise) { - return precise - } - if preferNamedEquivalentDirectType(precise, assignedType) { - return precise - } - return assignedType - } - if typ.IsAny(assignedType) && !typ.IsAny(precise) { - return precise - } - return assignedType -} - -func preferNamedEquivalentDirectType(precise, assignedType typ.Type) bool { - if !isNamedIdentityType(precise) || isNamedIdentityType(assignedType) { - return false - } - return subtype.IsSubtype(precise, assignedType) && subtype.IsSubtype(assignedType, precise) -} - -func isNamedIdentityType(t typ.Type) bool { - switch t.(type) { - case *typ.Alias, *typ.Ref: - return true - default: - return false - } -} diff --git a/compiler/check/flowbuild/assign/precision_test.go b/compiler/check/flowbuild/assign/precision_test.go deleted file mode 100644 index b4fdc423..00000000 --- a/compiler/check/flowbuild/assign/precision_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package assign - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/types/typ" -) - -func TestPreferPreciseDirectSourceType_PrefersNamedEquivalentAliasForSingleTarget(t *testing.T) { - record := typ.NewRecord(). - Field("id", typ.String). - Field("count", typ.Integer). - Build() - alias := typ.NewAlias("Counter", record) - - got := preferPreciseDirectSourceType( - record, - &ast.IdentExpr{Value: "x"}, - 0, - nil, - func(ast.Expr, cfg.Point) typ.Type { return alias }, - true, - ) - if got != alias { - t.Fatalf("expected direct named alias to win, got %s", typ.FormatShort(got)) - } -} - -func TestPreferPreciseDirectSourceType_DoesNotReplaceNamedAssignedType(t *testing.T) { - record := typ.NewRecord(). - Field("id", typ.String). - Field("count", typ.Integer). - Build() - alias := typ.NewAlias("Counter", record) - - got := preferPreciseDirectSourceType( - alias, - &ast.IdentExpr{Value: "x"}, - 0, - nil, - func(ast.Expr, cfg.Point) typ.Type { return record }, - true, - ) - if got != alias { - t.Fatalf("expected existing named assigned type to remain, got %s", typ.FormatShort(got)) - } -} diff --git a/compiler/check/flowbuild/assign/preflow_synth.go b/compiler/check/flowbuild/assign/preflow_synth.go deleted file mode 100644 index 5f677926..00000000 --- a/compiler/check/flowbuild/assign/preflow_synth.go +++ /dev/null @@ -1,127 +0,0 @@ -package assign - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/cond" - fbcore "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - fbpath "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/types/db" - "github.com/wippyai/go-lua/types/flow" - "github.com/wippyai/go-lua/types/narrow" - "github.com/wippyai/go-lua/types/query/core" - "github.com/wippyai/go-lua/types/typ" -) - -type narrowResolverAdapter struct { - ctx *db.QueryContext - ops core.TypeOps -} - -var _ narrow.Resolver = (*narrowResolverAdapter)(nil) - -func (r narrowResolverAdapter) Field(t typ.Type, name string) (typ.Type, bool) { - if r.ops == nil { - return nil, false - } - return r.ops.Field(r.ctx, t, name) -} - -func (r narrowResolverAdapter) Index(t typ.Type, key typ.Type) (typ.Type, bool) { - if r.ops == nil { - return nil, false - } - return r.ops.Index(r.ctx, t, key) -} - -// buildPreflowBranchSolution solves only branch/numeric edge facts that are -// already available before assignment extraction completes. -// -// This gives local inference access to canonical branch narrowing such as -// discriminant checks on parameters, without depending on later assignment- -// derived facts or full post-extraction solve. -func buildPreflowBranchSolution(fc *fbcore.FlowContext, inputs *flow.Inputs) *flow.Solution { - if fc == nil || inputs == nil || inputs.Graph == nil || fc.TypeOps == nil { - return nil - } - - temp := *inputs - temp.EdgeConditions = nil - temp.EdgeNumericConstraints = nil - - cond.ExtractEdgeConstraints(fc, &temp) - cond.ExtractNumericConstraints(fc, &temp) - - return flow.Solve(&temp, narrowResolverAdapter{ctx: fc.CallCtx, ops: fc.TypeOps}) -} - -// synthWithOverlayAndPreflow wraps base synthesis with overlay lookup and a -// preflow branch-narrowing view for identifiers and attribute/index reads. -// -// This keeps assignment inference on the canonical synthesis path while letting -// recursive field/index expressions observe already-provable branch facts. -func synthWithOverlayAndPreflow( - overlay map[cfg.SymbolID]typ.Type, - bindings *bind.BindingTable, - inputs *flow.Inputs, - callCtx *db.QueryContext, - typeOps core.TypeOps, - preflow *flow.Solution, - base func(ast.Expr, cfg.Point) typ.Type, -) func(ast.Expr, cfg.Point) typ.Type { - var synth func(ast.Expr, cfg.Point) typ.Type - - synth = func(expr ast.Expr, p cfg.Point) typ.Type { - if expr == nil { - return nil - } - - if ident, ok := expr.(*ast.IdentExpr); ok && bindings != nil { - if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { - if t, exists := overlay[sym]; exists { - return t - } - } - } - - if preflow != nil && bindings != nil && inputs != nil { - constResolver := predicate.BuildConstResolver(inputs, p) - if path := fbpath.FromExprWithBindings(expr, constResolver, bindings); !path.IsEmpty() { - if narrowed := preflow.NarrowedTypeAt(p, path); !typ.IsAbsentOrUnknown(narrowed) { - return narrowed - } - } - } - - if attr, ok := expr.(*ast.AttrGetExpr); ok && typeOps != nil { - objType := synth(attr.Object, p) - if !typ.IsAbsentOrUnknown(objType) { - switch key := attr.Key.(type) { - case *ast.StringExpr: - if ft, ok := typeOps.Field(callCtx, objType, key.Value); ok && !typ.IsAbsentOrUnknown(ft) { - return ft - } - if it, ok := typeOps.Index(callCtx, objType, typ.LiteralString(key.Value)); ok && !typ.IsAbsentOrUnknown(it) { - return it - } - default: - keyType := synth(attr.Key, p) - if !typ.IsAbsentOrUnknown(keyType) { - if it, ok := typeOps.Index(callCtx, objType, keyType); ok && !typ.IsAbsentOrUnknown(it) { - return it - } - } - } - } - } - - if base == nil { - return nil - } - return base(expr, p) - } - - return synth -} diff --git a/compiler/check/flowbuild/mutator/container.go b/compiler/check/flowbuild/mutator/container.go deleted file mode 100644 index 99535b8f..00000000 --- a/compiler/check/flowbuild/mutator/container.go +++ /dev/null @@ -1,173 +0,0 @@ -package mutator - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - flowpath "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/compiler/check/flowbuild/predicate" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/types/constraint" - "github.com/wippyai/go-lua/types/contract" - "github.com/wippyai/go-lua/types/effect" - "github.com/wippyai/go-lua/types/flow" - "github.com/wippyai/go-lua/types/typ" -) - -// ExtractContainerMutatorAssignments extracts container mutator assignments (channel.send-like) -// from call sites in the graph and appends them to inputs.ContainerMutatorAssignments. -func ExtractContainerMutatorAssignments(fc *core.FlowContext, inputs *flow.Inputs) { - if fc.Graph == nil || inputs == nil { - return - } - - bindings := fc.Graph.Bindings() - - // Build a resolver that can look up types from the just-extracted assignments - assignmentTypes := resolve.BuildAssignmentTypeResolver(inputs) - - fc.Graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { - if info == nil { - return - } - - cm := ContainerMutatorFromCall(info, p, fc.Derived.Synth, fc.Derived.SymResolver, assignmentTypes, fc.Graph, bindings, fc.ModuleBindings) - if cm == nil { - return - } - - targetExpr := callsite.RuntimeArgAt(info, cm.Container.Index) - valueExpr := callsite.RuntimeArgAt(info, cm.Value.Index) - - if targetExpr == nil || valueExpr == nil { - return - } - - sc := fc.Scopes[p] - valueType := typ.Unknown - if fc.Derived != nil && fc.Derived.Synth != nil { - if t := fc.Derived.Synth(valueExpr, p); t != nil { - valueType = t - } - } - valueType = resolve.Ref(valueType, sc) - - var valuePath constraint.Path - if ident, ok := valueExpr.(*ast.IdentExpr); ok && bindings != nil { - if sym, found := bindings.SymbolOf(ident); found && sym != 0 { - valuePath = constraint.Path{ - Root: resolve.RootNameFromBindings(bindings, sym, ident.Value), - Symbol: sym, - } - } - } - - constResolver := predicate.BuildConstResolver(inputs, p) - if path := flowpath.FromExprWithBindingsAt(targetExpr, constResolver, bindings, fc.Graph, p); !path.IsEmpty() && path.Symbol != 0 { - inputs.ContainerMutatorAssignments = append(inputs.ContainerMutatorAssignments, flow.ContainerMutatorAssignment{ - Point: p, - Target: constraint.Path{ - Root: resolve.RootNameFromBindings(bindings, path.Symbol, path.Root), - Symbol: path.Symbol, - Segments: path.Segments, - }, - ValuePath: valuePath, - ValueType: valueType, - }) - } - }) -} - -// ContainerElementReturnInfo holds info about a method that returns container element types. -type ContainerElementReturnInfo struct { - ReturnIndex int // Which return value (0-based) - SourceRef effect.ParamRef // Which parameter is the container -} - -// ContainerElementReturnFromCall detects if a call returns a container's element type. -// Returns info about the Return effect if found, nil otherwise. -func ContainerElementReturnFromCall( - info *cfg.CallInfo, - p cfg.Point, - synth func(ast.Expr, cfg.Point) typ.Type, - symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), - assignmentTypes func(cfg.SymbolID) typ.Type, - graph *cfg.Graph, - bindings *bind.BindingTable, - moduleBindings *bind.BindingTable, -) *ContainerElementReturnInfo { - if info == nil { - return nil - } - - fnType := resolve.CalleeType(info, p, synth, symResolver, assignmentTypes, graph, bindings, moduleBindings) - if fnType == nil { - return nil - } - - spec := contract.ExtractSpec(fnType) - if spec == nil { - return nil - } - - // Look for Return effects with ElementOf transform - for _, label := range spec.Effects.Labels { - ret, ok := label.(effect.Return) - if !ok { - continue - } - elemOf, ok := ret.Transform.(effect.ElementOf) - if !ok { - continue - } - return &ContainerElementReturnInfo{ - ReturnIndex: ret.ReturnIndex, - SourceRef: elemOf.Source, - } - } - - return nil -} - -// ContainerMutatorFromCall extracts the container mutation spec from a call site. -func ContainerMutatorFromCall( - info *cfg.CallInfo, - p cfg.Point, - synth func(ast.Expr, cfg.Point) typ.Type, - symResolver func(cfg.Point, cfg.SymbolID) (typ.Type, bool), - assignmentTypes func(cfg.SymbolID) typ.Type, - graph *cfg.Graph, - bindings *bind.BindingTable, - moduleBindings *bind.BindingTable, -) *effect.ContainerElementUnion { - if info == nil { - return nil - } - - fnType := resolve.CalleeType(info, p, synth, symResolver, assignmentTypes, graph, bindings, moduleBindings) - if fnType == nil { - return nil - } - - spec := contract.ExtractSpec(fnType) - if spec == nil { - return nil - } - - for _, label := range spec.Effects.Labels { - mut, ok := label.(effect.Mutate) - if !ok { - continue - } - ceu, ok := mut.Transform.(effect.ContainerElementUnion) - if !ok { - continue - } - ce := ceu - return &ce - } - - return nil -} diff --git a/compiler/check/hooks/assign_check.go b/compiler/check/hooks/assign_check.go index 046b5c8b..80ab1ba4 100644 --- a/compiler/check/hooks/assign_check.go +++ b/compiler/check/hooks/assign_check.go @@ -33,27 +33,32 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/iteration" + "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/provenance" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/flow/join" + querycore "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" ) // CheckAssignments validates assignment type annotations. -func CheckAssignments(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, narrowSynth api.Synth, flowQ api.FlowQuery, sourceName string) []diag.Diagnostic { +func CheckAssignments(graph *cfg.Graph, evidence api.FlowEvidence, scopes map[cfg.Point]*scope.State, narrowSynth api.Synth, flowQ api.FlowQuery, sourceName string) []diag.Diagnostic { if graph == nil || narrowSynth == nil { return nil } annotated := make(map[cfg.SymbolID]typ.Type) assigned := make(map[cfg.SymbolID]bool) - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range evidence.Assignments { + p := assign.Point + info := assign.Info if info == nil { - return + continue } sc := scopes[p] if info.IsLocal { @@ -86,20 +91,30 @@ func CheckAssignments(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, narro assigned[target.Symbol] = true } }) - }) - graph.EachFuncDef(func(_ cfg.Point, info *cfg.FuncDefInfo) { - if info == nil || info.Symbol == 0 { - return + } + for _, def := range evidence.FunctionDefinitions { + if def.Symbol != 0 { + assigned[def.Symbol] = true } - assigned[info.Symbol] = true - }) + } var diags []diag.Diagnostic - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } sc := scopes[p] info.EachTargetSource(func(i int, target cfg.AssignTarget, source ast.Expr) { + if target.Kind != cfg.TargetIdent { + if d, ok := checkStructuredAssignmentTarget(target, source, p, narrowSynth, flowQ, graph, evidence, sourceName); ok { + diags = append(diags, d) + } + return + } if target.Kind != cfg.TargetIdent || target.Name == "" { return } @@ -157,7 +172,9 @@ func CheckAssignments(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, narro valueType := narrowSynth.SynthWithExpected(source, p, declaredType) if sourceUsesTarget { if pre := preAssignmentExprTypeForAssign(source, p, narrowSynth, graph, declaredType); pre != nil { - valueType = pre + if !typ.IsAbsentOrUnknown(pre) { + valueType = pre + } } } if valueType == nil { @@ -248,11 +265,163 @@ func CheckAssignments(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, narro }) } }) - }) + } return diags } +func checkStructuredAssignmentTarget(target cfg.AssignTarget, source ast.Expr, p cfg.Point, synth api.Synth, flowQ api.FlowQuery, graph *cfg.Graph, evidence api.FlowEvidence, sourceName string) (diag.Diagnostic, bool) { + if source == nil || synth == nil { + return diag.Diagnostic{}, false + } + expected := assignmentTargetWriteType(target, p, synth, flowQ) + if iteratorExpected := pairedIteratorWriteExpected(target, p, flowQ, graph, evidence.Assignments); iteratorExpected != nil && nilOnlyType(expected) { + expected = iteratorExpected + } + if typ.IsAbsentOrUnknown(expected) || typ.IsAny(expected) { + return diag.Diagnostic{}, false + } + + valueType := synth.SynthWithExpected(source, p, expected) + if valueType == nil { + valueType = synth.TypeOf(source, p) + } + if typ.IsAbsentOrUnknown(valueType) { + return diag.Diagnostic{}, false + } + if nilOnlyType(valueType) && assignmentTargetDeleteAllowed(target, p, synth) { + return diag.Diagnostic{}, false + } + if flowQ != nil { + sourcePath := extractSourcePath(source, graph, p) + if !sourcePath.IsEmpty() { + if narrowed := flowQ.NarrowedTypeAt(p, sourcePath); !typ.IsAbsentOrUnknown(narrowed) { + valueType = preferPreciseSourcePathType(valueType, narrowed) + } + } + } + if table, ok := source.(*ast.TableExpr); ok { + if result := tableCheck(table, expected, synth, p); result.Handled { + if result.Compatible { + return diag.Diagnostic{}, false + } + pos := diag.Position{File: sourceName, Line: source.Line(), Column: source.Column()} + span := ast.SpanOf(source) + msg := formatAssignMismatchDetailed(valueType, expected, result.Reason) + _, help := diag.ContextualHelp(diag.ErrTypeMismatch, msg, "") + return diag.Diagnostic{ + Severity: diag.SeverityError, + Code: diag.ErrTypeMismatch, + Position: pos, + Span: span, + Message: msg, + Help: help, + }, true + } + } + var bindings provenance.IdentBindingLookup + if graph != nil { + bindings = graph.Bindings() + } + if fresh, ok := provenance.CurrentFreshTableLiteral(source, p, bindings, evidence.FreshTableLiterals); ok { + if result := tableCheck(fresh.Table, expected, synth, fresh.Point); result.Handled && result.Compatible { + return diag.Diagnostic{}, false + } + } + if subtype.IsSubtype(valueType, expected) { + return diag.Diagnostic{}, false + } + + pos := diag.Position{File: sourceName, Line: source.Line(), Column: source.Column()} + span := ast.SpanOf(source) + msg := formatAssignMismatch(valueType, expected) + _, help := diag.ContextualHelp(diag.ErrTypeMismatch, msg, "") + return diag.Diagnostic{ + Severity: diag.SeverityError, + Code: diag.ErrTypeMismatch, + Position: pos, + Span: span, + Message: msg, + Help: help, + }, true +} + +func assignmentTargetWriteType(target cfg.AssignTarget, p cfg.Point, synth api.Synth, flowQ api.FlowQuery) typ.Type { + switch target.Kind { + case cfg.TargetIndex: + if target.Base == nil { + return nil + } + objType := synth.TypeOf(target.Base, p) + keyType := typ.Type(nil) + if target.Key != nil { + keyType = synth.TypeOf(target.Key, p) + } + if expected, ok := querycore.IndexWrite(objType, keyType); ok { + return expected + } + return nil + default: + return nil + } +} + +func assignmentTargetDeleteAllowed(target cfg.AssignTarget, p cfg.Point, synth api.Synth) bool { + if target.Kind != cfg.TargetIndex || target.Base == nil || synth == nil { + return false + } + objType := synth.TypeOf(target.Base, p) + keyType := typ.Type(nil) + if target.Key != nil { + keyType = synth.TypeOf(target.Key, p) + } + return querycore.IndexDelete(objType, keyType) +} + +func pairedIteratorWriteExpected(target cfg.AssignTarget, p cfg.Point, flowQ api.FlowQuery, graph *cfg.Graph, assignments []api.AssignmentEvidence) typ.Type { + if target.Kind != cfg.TargetIndex || target.Base == nil || flowQ == nil || graph == nil { + return nil + } + key, ok := target.Key.(*ast.IdentExpr) + if !ok { + return nil + } + pair, ok := iteration.FindKeyedPairValue(graph, assignments, target.Base, key) + if !ok { + return nil + } + valueType := flowQ.NarrowedTypeAt(p, pair.ValuePath) + if typ.IsAbsentOrUnknown(valueType) && pair.ValuePath.Symbol != 0 { + valueType = flowQ.EffectiveTypeAt(p, pair.ValuePath.Symbol).Type + } + if typ.IsAbsentOrUnknown(valueType) || typ.IsAny(valueType) { + return nil + } + return valueType +} + +func nilOnlyType(t typ.Type) bool { + if t == nil { + return false + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return nilOnlyType(v.Target) + case *typ.Union: + if len(v.Members) == 0 { + return false + } + for _, member := range v.Members { + if !nilOnlyType(member) { + return false + } + } + return true + default: + return v != nil && v.Kind() == typ.Nil.Kind() + } +} + func preferPreciseSourcePathType(current, narrowed typ.Type) typ.Type { if typ.IsAbsentOrUnknown(current) { return narrowed @@ -296,10 +465,6 @@ func formatAssignMismatchDetailed(value, declared typ.Type, reason string) strin return msg + ": " + reason } -type identBindingLookup interface { - SymbolOf(ident *ast.IdentExpr) (cfg.SymbolID, bool) -} - func sourceUsesTargetSymbol(expr ast.Expr, sym cfg.SymbolID, graph *cfg.Graph) bool { if expr == nil || sym == 0 || graph == nil { return false @@ -308,61 +473,7 @@ func sourceUsesTargetSymbol(expr ast.Expr, sym cfg.SymbolID, graph *cfg.Graph) b if bindings == nil { return false } - return exprReferencesSymbol(expr, sym, bindings) -} - -func exprReferencesSymbol(expr ast.Expr, sym cfg.SymbolID, bindings identBindingLookup) bool { - if expr == nil || sym == 0 || bindings == nil { - return false - } - - switch e := expr.(type) { - case *ast.IdentExpr: - if bound, ok := bindings.SymbolOf(e); ok && bound == sym { - return true - } - return false - case *ast.AttrGetExpr: - return exprReferencesSymbol(e.Object, sym, bindings) || exprReferencesSymbol(e.Key, sym, bindings) - case *ast.TableExpr: - for _, field := range e.Fields { - if field == nil { - continue - } - if exprReferencesSymbol(field.Key, sym, bindings) || exprReferencesSymbol(field.Value, sym, bindings) { - return true - } - } - return false - case *ast.FuncCallExpr: - if exprReferencesSymbol(e.Func, sym, bindings) || exprReferencesSymbol(e.Receiver, sym, bindings) { - return true - } - for _, arg := range e.Args { - if exprReferencesSymbol(arg, sym, bindings) { - return true - } - } - return false - case *ast.LogicalOpExpr: - return exprReferencesSymbol(e.Lhs, sym, bindings) || exprReferencesSymbol(e.Rhs, sym, bindings) - case *ast.RelationalOpExpr: - return exprReferencesSymbol(e.Lhs, sym, bindings) || exprReferencesSymbol(e.Rhs, sym, bindings) - case *ast.StringConcatOpExpr: - return exprReferencesSymbol(e.Lhs, sym, bindings) || exprReferencesSymbol(e.Rhs, sym, bindings) - case *ast.ArithmeticOpExpr: - return exprReferencesSymbol(e.Lhs, sym, bindings) || exprReferencesSymbol(e.Rhs, sym, bindings) - case *ast.UnaryMinusOpExpr: - return exprReferencesSymbol(e.Expr, sym, bindings) - case *ast.UnaryNotOpExpr: - return exprReferencesSymbol(e.Expr, sym, bindings) - case *ast.UnaryLenOpExpr: - return exprReferencesSymbol(e.Expr, sym, bindings) - case *ast.UnaryBNotOpExpr: - return exprReferencesSymbol(e.Expr, sym, bindings) - default: - return false - } + return provenance.ExprReferencesSymbol(expr, sym, bindings) } func preAssignmentExprTypeForAssign(expr ast.Expr, p cfg.Point, synth api.Synth, graph *cfg.Graph, expected typ.Type) typ.Type { diff --git a/compiler/check/hooks/call_check.go b/compiler/check/hooks/call_check.go index eb703add..c36a87c7 100644 --- a/compiler/check/hooks/call_check.go +++ b/compiler/check/hooks/call_check.go @@ -9,8 +9,8 @@ // - Generic calls: infers type arguments and validates against instantiated params // - Type constructors: TypeName(x) - special handling for callable type effects // -// For each call, the CallPipeline from synth/phase/extract handles the two-phase -// synthesis process, allowing contextual typing for callback arguments. +// For each call, the shared ops.CallPipeline handles the two-phase synthesis +// process, allowing contextual typing for callback arguments. // // Errors are mapped from ops.CallError to diag.Diagnostic with appropriate // source positions (pointing to the problematic argument when possible). @@ -24,9 +24,10 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/compiler/check/synth/callarg" "github.com/wippyai/go-lua/compiler/check/synth/ops" - "github.com/wippyai/go-lua/compiler/check/synth/phase/extract" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/query/core" @@ -37,9 +38,11 @@ import ( // CheckCalls validates function call arguments against parameter types. func CheckCalls( graph *cfg.Graph, + evidence api.FlowEvidence, scopes map[cfg.Point]*scope.State, narrowSynth api.Synth, narrowView api.BaseSynth, + results map[*ast.FunctionExpr]*api.FuncResult, sourceName string, ) []diag.Diagnostic { if graph == nil || narrowSynth == nil || narrowView == nil { @@ -49,14 +52,20 @@ func CheckCalls( var diags []diag.Diagnostic query := narrowSynth.CallQuery() bindings := graph.Bindings() + unobservedLocalParams := make(map[cfg.SymbolID][]bool) - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range evidence.Calls { + if call.Origin == api.CallOriginExpression { + continue + } + p := call.Point + info := call.Info if info == nil { - return + continue } - callDiags := checkSingleCall(p, info, scopes, narrowView, narrowSynth, query, sourceName, graph, bindings) + callDiags := checkSingleCall(p, info, scopes, narrowView, narrowSynth, query, sourceName, graph, evidence, bindings, results, unobservedLocalParams) diags = append(diags, callDiags...) - }) + } return diags } @@ -70,7 +79,10 @@ func checkSingleCall( query core.TypeOps, sourceName string, graph *cfg.Graph, + evidence api.FlowEvidence, bindings *bind.BindingTable, + results map[*ast.FunctionExpr]*api.FuncResult, + unobservedLocalParams map[cfg.SymbolID][]bool, ) []diag.Diagnostic { if info.Method == "" && info.Callee != nil { if t := narrowView.TypeOf(info.Callee, p); hasCallableTypeEffect(t) { @@ -105,6 +117,7 @@ func checkSingleCall( args[i] = narrowView.TypeOf(arg, p) } + ctx := narrowSynth.Context() def := ops.CallDef{ Args: args, Query: query, @@ -114,15 +127,27 @@ func checkSingleCall( def.IsMethod = true def.MethodName = info.Method def.Receiver = narrowView.TypeOf(info.Receiver, p) - def.ForceMethodReceiver = callsite.ForceMethodReceiver(bindings, graph, info) + def.ForceMethodReceiver = callsite.ForceMethodReceiver(bindings, graph, evidence, info) } else if info.Callee != nil { def.Callee = narrowView.TypeOf(info.Callee, p) + if projection, ok := functionfact.ProjectCall(functionfact.CallProjectionInput{ + Store: api.StoreFrom(ctx), + Info: info, + Graph: graph, + Evidence: evidence, + Bindings: bindings, + Results: results, + Args: args, + Current: def.Callee, + UnobservedLocalParams: unobservedLocalParams, + }); ok { + def.Callee = projection.Callee + def.AllowExtraArgs = projection.AllowExtraArgs + } } - ctx := narrowSynth.Context() - - pipeline := extract.NewCallPipeline(ctx, def, info.Args). - WithReSynth(extract.FullArgReSynth( + pipeline := ops.NewCallPipeline(ctx, def, len(info.Args)). + WithReSynth(callarg.ForArgs(info.Args, callarg.Full( func(arg ast.Expr, pt cfg.Point, expected typ.Type) typ.Type { return narrowView.TypeOfWithExpected(arg, pt, expected) }, @@ -130,7 +155,7 @@ func checkSingleCall( return tableCompatible(table, expected, narrowSynth, pt) }, p, - )) + ))) result := pipeline.Run() return callErrorsToDiags(result.Errors, info, sourceName) } diff --git a/compiler/check/hooks/exhaustiveness_check.go b/compiler/check/hooks/exhaustiveness_check.go new file mode 100644 index 00000000..c68d48fb --- /dev/null +++ b/compiler/check/hooks/exhaustiveness_check.go @@ -0,0 +1,505 @@ +package hooks + +import ( + "fmt" + "strings" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/diag" + "github.com/wippyai/go-lua/types/narrow" + "github.com/wippyai/go-lua/types/numparse" + "github.com/wippyai/go-lua/types/typ" +) + +// CheckExhaustiveness warns when a match-like if/elseif chain misses variants +// from a provably closed discriminated union. +func CheckExhaustiveness(fn *ast.FunctionExpr, graph *cfg.Graph, evidence api.FlowEvidence, synth api.BaseSynth, sourceName string) []diag.Diagnostic { + if fn == nil || graph == nil || synth == nil { + return nil + } + checker := exhaustivenessChecker{ + branchPoint: branchPointsByCondition(evidence.Branches), + graph: graph, + bindings: graph.Bindings(), + selectCases: selectCasesByResult(graph, evidence.Assignments), + synth: synth, + sourceName: sourceName, + } + checker.checkStmts(fn.Stmts) + return checker.diags +} + +type exhaustivenessChecker struct { + branchPoint map[ast.Expr]cfg.Point + graph *cfg.Graph + bindings *bind.BindingTable + selectCases map[string]selectCaseDomain + synth api.BaseSynth + sourceName string + diags []diag.Diagnostic +} + +type discriminantCheck struct { + object ast.Expr + objectPath constraint.Path + field string + path string + literal *typ.Literal + value ast.Expr + valuePath constraint.Path + valueName string + condition ast.Expr + point cfg.Point +} + +type selectCaseDomain struct { + cases []selectCase +} + +type selectCase struct { + path constraint.Path + name string +} + +func branchPointsByCondition(branches []api.BranchEvidence) map[ast.Expr]cfg.Point { + points := make(map[ast.Expr]cfg.Point) + for _, branch := range branches { + p := branch.Point + info := branch.Info + if info != nil && info.Condition != nil { + points[info.Condition] = p + } + } + return points +} + +func (c *exhaustivenessChecker) checkStmts(stmts []ast.Stmt) { + for _, stmt := range stmts { + c.checkStmt(stmt) + } +} + +func (c *exhaustivenessChecker) checkStmt(stmt ast.Stmt) { + switch s := stmt.(type) { + case *ast.IfStmt: + c.checkIf(s) + case *ast.WhileStmt: + c.checkStmts(s.Stmts) + case *ast.RepeatStmt: + c.checkStmts(s.Stmts) + case *ast.NumberForStmt: + c.checkStmts(s.Stmts) + case *ast.GenericForStmt: + c.checkStmts(s.Stmts) + case *ast.DoBlockStmt: + c.checkStmts(s.Stmts) + } +} + +func (c *exhaustivenessChecker) checkIf(stmt *ast.IfStmt) { + c.checkIfChain(stmt) + + for current := stmt; current != nil; { + c.checkStmts(current.Then) + next, ok := singleElseIf(current) + if !ok { + c.checkStmts(current.Else) + break + } + current = next + } +} + +func (c *exhaustivenessChecker) checkIfChain(stmt *ast.IfStmt) { + checks, hasElse, ok := c.collectDiscriminantChain(stmt) + if !ok || hasElse || len(checks) < 2 { + return + } + + first := checks[0] + for _, check := range checks[1:] { + if check.path != first.path || check.field != first.field { + return + } + } + + if first.literal == nil { + c.checkSelectCaseChain(stmt, checks) + return + } + + objectType := c.synth.TypeOf(first.object, first.point) + domain, ok := narrow.ClosedDiscriminantDomain(objectType, first.field) + if !ok { + return + } + + handled := make([]*typ.Literal, 0, len(checks)) + handledInDomain := false + for _, check := range checks { + handled = append(handled, check.literal) + if domain.Contains(check.literal) { + handledInDomain = true + } + } + if !handledInDomain { + return + } + + missing := domain.Missing(handled) + if len(missing) == 0 { + return + } + c.addNonExhaustiveWarning(stmt.Condition, first.path, missing) +} + +func (c *exhaustivenessChecker) checkSelectCaseChain(stmt *ast.IfStmt, checks []discriminantCheck) { + first := checks[0] + if first.field != "channel" || first.objectPath.IsEmpty() { + return + } + domain, ok := c.selectCases[pathKey(first.objectPath)] + if !ok || len(domain.cases) < 2 { + return + } + + handled := make(map[string]struct{}, len(checks)) + for _, check := range checks { + if check.valuePath.IsEmpty() { + return + } + key := pathKey(check.valuePath) + if !domain.contains(key) { + return + } + handled[key] = struct{}{} + } + + var missing []string + for _, candidate := range domain.cases { + if _, ok := handled[pathKey(candidate.path)]; !ok { + missing = append(missing, candidate.name) + } + } + if len(missing) == 0 { + return + } + c.addNonExhaustiveNamesWarning(stmt.Condition, first.path, missing) +} + +func (c *exhaustivenessChecker) collectDiscriminantChain(stmt *ast.IfStmt) ([]discriminantCheck, bool, bool) { + var checks []discriminantCheck + current := stmt + for current != nil { + check, ok := c.discriminantCheck(current.Condition) + if !ok { + return nil, false, false + } + checks = append(checks, check) + + next, ok := singleElseIf(current) + if !ok { + return checks, len(current.Else) > 0, true + } + current = next + } + return checks, false, true +} + +func (c *exhaustivenessChecker) discriminantCheck(condition ast.Expr) (discriminantCheck, bool) { + point, ok := c.branchPoint[condition] + if !ok { + return discriminantCheck{}, false + } + + check, ok := equalityDiscriminantCheck(condition) + if !ok { + return discriminantCheck{}, false + } + check.condition = condition + check.point = point + if check.object != nil && c.bindings != nil { + check.objectPath = flowpath.FromExprWithBindingsAt(check.object, nil, c.bindings, c.graph, point) + } + if check.value != nil && c.bindings != nil { + check.valuePath = flowpath.FromExprWithBindingsAt(check.value, nil, c.bindings, c.graph, point) + } + return check, true +} + +func (c *exhaustivenessChecker) addNonExhaustiveWarning(node ast.Expr, path string, missing []*typ.Literal) { + pos := diag.Position{File: c.sourceName} + span := diag.Span{} + if node != nil { + pos.Line = node.Line() + pos.Column = node.Column() + span = ast.SpanOf(node) + } + message := fmt.Sprintf("non-exhaustive match on %s; missing %s", path, formatMissingCases(missing)) + c.diags = append(c.diags, diag.Diagnostic{ + Severity: diag.SeverityWarning, + Code: diag.ErrNonExhaustive, + Position: pos, + Span: span, + Message: message, + Explanation: diag.ErrNonExhaustive.Info().Explanation, + Help: "Handle the missing case or add an else branch.", + }) +} + +func (c *exhaustivenessChecker) addNonExhaustiveNamesWarning(node ast.Expr, path string, missing []string) { + pos := diag.Position{File: c.sourceName} + span := diag.Span{} + if node != nil { + pos.Line = node.Line() + pos.Column = node.Column() + span = ast.SpanOf(node) + } + message := fmt.Sprintf("non-exhaustive match on %s; missing %s", path, formatMissingNames(missing)) + c.diags = append(c.diags, diag.Diagnostic{ + Severity: diag.SeverityWarning, + Code: diag.ErrNonExhaustive, + Position: pos, + Span: span, + Message: message, + Explanation: diag.ErrNonExhaustive.Info().Explanation, + Help: "Handle the missing case or add an else branch.", + }) +} + +func singleElseIf(stmt *ast.IfStmt) (*ast.IfStmt, bool) { + if stmt == nil || len(stmt.Else) != 1 { + return nil, false + } + next, ok := stmt.Else[0].(*ast.IfStmt) + return next, ok +} + +func equalityDiscriminantCheck(expr ast.Expr) (discriminantCheck, bool) { + rel, ok := expr.(*ast.RelationalOpExpr) + if !ok || rel.Operator != "==" { + return discriminantCheck{}, false + } + if check, ok := attrEqualsLiteral(rel.Lhs, rel.Rhs); ok { + return check, true + } + if check, ok := attrEqualsLiteral(rel.Rhs, rel.Lhs); ok { + return check, true + } + if check, ok := attrEqualsPath(rel.Lhs, rel.Rhs); ok { + return check, true + } + return attrEqualsPath(rel.Rhs, rel.Lhs) +} + +func attrEqualsLiteral(attrExpr, literalExpr ast.Expr) (discriminantCheck, bool) { + lit, ok := literalFromExpr(literalExpr) + if !ok { + return discriminantCheck{}, false + } + attr, ok := attrExpr.(*ast.AttrGetExpr) + if !ok { + return discriminantCheck{}, false + } + field := ast.KeyName(attr.Key) + if field == "" { + return discriminantCheck{}, false + } + objectPath, ok := exprPath(attr.Object) + if !ok { + return discriminantCheck{}, false + } + return discriminantCheck{ + object: attr.Object, + field: field, + path: objectPath + "." + field, + literal: lit, + }, true +} + +func attrEqualsPath(attrExpr, valueExpr ast.Expr) (discriminantCheck, bool) { + attr, ok := attrExpr.(*ast.AttrGetExpr) + if !ok { + return discriminantCheck{}, false + } + field := ast.KeyName(attr.Key) + if field == "" { + return discriminantCheck{}, false + } + objectPath, ok := exprPath(attr.Object) + if !ok { + return discriminantCheck{}, false + } + valuePath, ok := exprPath(valueExpr) + if !ok { + return discriminantCheck{}, false + } + return discriminantCheck{ + object: attr.Object, + field: field, + path: objectPath + "." + field, + value: valueExpr, + valueName: valuePath, + }, true +} + +func literalFromExpr(expr ast.Expr) (*typ.Literal, bool) { + switch e := expr.(type) { + case *ast.StringExpr: + return typ.LiteralString(e.Value), true + case *ast.TrueExpr: + return typ.True, true + case *ast.FalseExpr: + return typ.False, true + case *ast.NumberExpr: + if i, ok := numparse.ParseIntegerLiteral(e.Value); ok { + return typ.LiteralInt(i), true + } + if f, ok := numparse.ParseFloatLiteral(e.Value); ok { + return typ.LiteralNumber(f), true + } + } + return nil, false +} + +func exprPath(expr ast.Expr) (string, bool) { + switch e := expr.(type) { + case *ast.IdentExpr: + if e.Value == "" { + return "", false + } + return e.Value, true + case *ast.AttrGetExpr: + base, ok := exprPath(e.Object) + if !ok { + return "", false + } + key := ast.KeyName(e.Key) + if key == "" { + return "", false + } + return base + "." + key, true + default: + return "", false + } +} + +func formatMissingCases(missing []*typ.Literal) string { + values := make([]string, 0, len(missing)) + for _, lit := range missing { + if lit != nil { + values = append(values, lit.String()) + } + } + if len(values) == 1 { + return "case: " + values[0] + } + return "cases: " + strings.Join(values, ", ") +} + +func formatMissingNames(missing []string) string { + if len(missing) == 1 { + return "case: " + missing[0] + } + return "cases: " + strings.Join(missing, ", ") +} + +func selectCasesByResult(graph *cfg.Graph, assignments []api.AssignmentEvidence) map[string]selectCaseDomain { + if graph == nil || graph.Bindings() == nil { + return nil + } + bindings := graph.Bindings() + domains := make(map[string]selectCaseDomain) + for _, assign := range assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + target, ok := info.FirstTarget() + if !ok || target.Kind != cfg.TargetIdent || target.Symbol == 0 { + continue + } + call := info.SingleSourceCall() + if !isChannelSelectCall(call) || len(call.Args) == 0 { + continue + } + cases, ok := selectCaseChannels(call.Args[0], p, graph, bindings) + if !ok || len(cases) < 2 { + continue + } + resultPath := constraint.Path{Root: target.Name, Symbol: target.Symbol} + if len(info.TargetVersions) > 0 && !info.TargetVersions[0].IsZero() { + resultPath.Version = info.TargetVersions[0].ID + } + domains[pathKey(resultPath)] = selectCaseDomain{cases: cases} + } + return domains +} + +func isChannelSelectCall(call *cfg.CallInfo) bool { + if call == nil || call.Method != "" { + return false + } + attr, ok := call.Callee.(*ast.AttrGetExpr) + if !ok { + return false + } + key := ast.KeyName(attr.Key) + if key != "select" { + return false + } + root, ok := attr.Object.(*ast.IdentExpr) + return ok && root.Value == "channel" +} + +func selectCaseChannels(expr ast.Expr, p cfg.Point, graph *cfg.Graph, bindings *bind.BindingTable) ([]selectCase, bool) { + table, ok := expr.(*ast.TableExpr) + if !ok { + return nil, false + } + cases := make([]selectCase, 0, len(table.Fields)) + for _, field := range table.Fields { + if field == nil { + return nil, false + } + if key := ast.KeyName(field.Key); key == "default" { + return nil, false + } + call, ok := field.Value.(*ast.FuncCallExpr) + if !ok || call.Method != "case_receive" || call.Receiver == nil { + return nil, false + } + casePath := flowpath.FromExprWithBindingsAt(call.Receiver, nil, bindings, graph, p) + if casePath.IsEmpty() { + return nil, false + } + name, ok := exprPath(call.Receiver) + if !ok { + name = casePath.String() + } + cases = append(cases, selectCase{path: casePath, name: name}) + } + return cases, len(cases) > 0 +} + +func (d selectCaseDomain) contains(key string) bool { + for _, c := range d.cases { + if pathKey(c.path) == key { + return true + } + } + return false +} + +func pathKey(p constraint.Path) string { + if p.Symbol != 0 { + return fmt.Sprintf("#%d@%d%s", p.Symbol, p.Version, constraint.FormatSegments(p.Segments)) + } + return p.String() +} diff --git a/compiler/check/hooks/field_check.go b/compiler/check/hooks/field_check.go index 68be760d..76dd89e5 100644 --- a/compiler/check/hooks/field_check.go +++ b/compiler/check/hooks/field_check.go @@ -19,7 +19,8 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/guard" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/compiler/check/scope" checksynth "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/compiler/check/synth/ops" @@ -35,7 +36,7 @@ import ( ) // CheckFields validates field accesses on narrowed types. -func CheckFields(graph *cfg.Graph, narrowSynth api.Synth, narrowView api.BaseSynth, sourceName string) []diag.Diagnostic { +func CheckFields(graph *cfg.Graph, evidence api.FlowEvidence, narrowSynth api.Synth, narrowView api.BaseSynth, flowOps api.FlowOps, sourceName string) []diag.Diagnostic { if graph == nil || narrowSynth == nil || narrowView == nil { return nil } @@ -44,43 +45,110 @@ func CheckFields(graph *cfg.Graph, narrowSynth api.Synth, narrowView api.BaseSyn resolver := fieldResolverImpl{view: narrowView, synth: narrowSynth, bindings: bindings} var diags []diag.Diagnostic - seen := make(map[ast.Expr]bool) - - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range evidence.Assignments { + p := assign.Point + if fieldPointIsDead(flowOps, p) { + continue + } + info := assign.Info + if info == nil { + continue + } assignView, assignResolver := applyAssignPreStateNarrowing(graph, info, p, narrowView, resolver) info.EachSource(func(_ int, source ast.Expr) { - diags = append(diags, checkFieldExpr(source, p, assignView, assignResolver, seen, sourceName)...) + diags = append(diags, checkFieldExpr(source, p, assignView, assignResolver, make(map[ast.Expr]bool), sourceName)...) }) if info.NumericFor != nil { diags = append(diags, checkNumericFor(info.NumericFor, p, narrowView, sourceName)...) } - }) + } - graph.EachStmtCall(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range evidence.Calls { + if call.Origin != api.CallOriginStatement { + continue + } + p := call.Point + if fieldPointIsDead(flowOps, p) { + continue + } + info := call.Info if info == nil { - return + continue } if info.Callee != nil { - diags = append(diags, checkFieldExpr(info.Callee, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkFieldExpr(info.Callee, p, narrowView, resolver, make(map[ast.Expr]bool), sourceName)...) } if info.Receiver != nil { - diags = append(diags, checkFieldExpr(info.Receiver, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkFieldExpr(info.Receiver, p, narrowView, resolver, make(map[ast.Expr]bool), sourceName)...) } for _, arg := range info.Args { - diags = append(diags, checkFieldExpr(arg, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkFieldExpr(arg, p, narrowView, resolver, make(map[ast.Expr]bool), sourceName)...) } - }) + } - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range evidence.Returns { + p := ret.Point + if fieldPointIsDead(flowOps, p) { + continue + } + info := ret.Info if info == nil { - return + continue } for _, expr := range info.Exprs { - diags = append(diags, checkFieldExpr(expr, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkFieldExpr(expr, p, narrowView, resolver, make(map[ast.Expr]bool), sourceName)...) } - }) + } - return diags + for _, branch := range evidence.Branches { + p := branch.Point + if fieldPointIsDead(flowOps, p) { + continue + } + info := branch.Info + if info == nil { + continue + } + diags = append(diags, checkFieldProbe(info.Condition, p, narrowView, resolver, make(map[ast.Expr]bool), sourceName)...) + } + + return dedupeFieldDiagnostics(diags) +} + +func fieldPointIsDead(flowOps api.FlowOps, p cfg.Point) bool { + return flowOps != nil && flowOps.IsPointDead(p) +} + +func dedupeFieldDiagnostics(diags []diag.Diagnostic) []diag.Diagnostic { + if len(diags) < 2 { + return diags + } + type key struct { + file string + line int + column int + code diag.Code + severity diag.Severity + message string + } + seen := make(map[key]struct{}, len(diags)) + out := diags[:0] + for _, d := range diags { + k := key{ + file: d.Position.File, + line: d.Position.Line, + column: d.Position.Column, + code: d.Code, + severity: d.Severity, + message: d.Message, + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, d) + } + return out } type fieldResolverImpl struct { @@ -105,16 +173,20 @@ type localNarrowView struct { bindings *bind.BindingTable overridePath constraint.Path overrideType typ.Type + overrides []fieldLocalOverride +} + +type fieldLocalOverride struct { + path constraint.Path + t typ.Type } func (v *localNarrowView) TypeOf(expr ast.Expr, p cfg.Point) typ.Type { if v == nil || v.base == nil { return typ.Unknown } - if v.overrideType != nil && v.bindings != nil { - if p := path.FromExprWithBindings(expr, nil, v.bindings); !p.IsEmpty() && p.Equal(v.overridePath) { - return v.overrideType - } + if t, ok := v.overrideForExpr(expr); ok { + return t } return v.base.TypeOf(expr, p) } @@ -123,9 +195,31 @@ func (v *localNarrowView) TypeOfWithExpected(expr ast.Expr, p cfg.Point, expecte if v == nil || v.base == nil { return typ.Unknown } + if t, ok := v.overrideForExpr(expr); ok { + return t + } return v.base.TypeOfWithExpected(expr, p, expected) } +func (v *localNarrowView) overrideForExpr(expr ast.Expr) (typ.Type, bool) { + if v == nil || v.bindings == nil { + return nil, false + } + exprPath := path.FromExprWithBindings(expr, nil, v.bindings) + if exprPath.IsEmpty() { + return nil, false + } + if v.overrideType != nil && exprPath.Equal(v.overridePath) { + return v.overrideType, true + } + for i := len(v.overrides) - 1; i >= 0; i-- { + if exprPath.Equal(v.overrides[i].path) { + return v.overrides[i].t, true + } + } + return nil, false +} + func (v *localNarrowView) MultiTypeOf(expr ast.Expr, p cfg.Point) []typ.Type { if v == nil || v.base == nil { return nil @@ -168,7 +262,22 @@ func (v *localNarrowView) ResolveReturnTypes(types []ast.TypeExpr, sc *scope.Sta return v.base.ResolveReturnTypes(types, sc) } +type fieldUse uint8 + +const ( + fieldUseValue fieldUse = iota + fieldUseProbe +) + func checkFieldExpr(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string) []diag.Diagnostic { + return checkFieldExprUse(expr, p, narrowView, resolver, seen, sourceName, fieldUseValue) +} + +func checkFieldProbe(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string) []diag.Diagnostic { + return checkFieldExprUse(expr, p, narrowView, resolver, seen, sourceName, fieldUseProbe) +} + +func checkFieldExprUse(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string, use fieldUse) []diag.Diagnostic { if expr == nil || seen[expr] { return nil } @@ -178,10 +287,14 @@ func checkFieldExpr(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolv switch e := expr.(type) { case *ast.AttrGetExpr: - diags = append(diags, checkAttrGet(e, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkAttrGet(e, p, narrowView, resolver, seen, sourceName, use)...) case *ast.FuncCallExpr: diags = append(diags, checkFieldExpr(e.Func, p, narrowView, resolver, seen, sourceName)...) for _, arg := range e.Args { + if guard.IsTypeCall(e) { + diags = append(diags, checkTypeProbeArg(arg, p, narrowView, resolver, seen, sourceName)...) + continue + } diags = append(diags, checkFieldExpr(arg, p, narrowView, resolver, seen, sourceName)...) } case *ast.TableExpr: @@ -189,7 +302,25 @@ func checkFieldExpr(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolv diags = append(diags, checkFieldExpr(f.Value, p, narrowView, resolver, seen, sourceName)...) } case *ast.LogicalOpExpr: - diags = append(diags, checkFieldExpr(e.Lhs, p, narrowView, resolver, seen, sourceName)...) + if e.Operator == "and" { + if probe, ok := guard.ExtractTypeEqualityProbe(e.Lhs); ok && resolver.bindings != nil { + probeType := guard.TypeForTypeKey(probe.Key) + diags = append(diags, checkTypeProbeArg(probe.Expr, p, narrowView, resolver, seen, sourceName)...) + probePath := path.FromExprWithBindings(probe.Expr, nil, resolver.bindings) + if !probePath.IsEmpty() { + localView := &localNarrowView{ + base: narrowView, + bindings: resolver.bindings, + overridePath: probePath, + overrideType: probeType, + } + localResolver := fieldResolverImpl{view: localView, synth: resolver.synth, bindings: resolver.bindings} + diags = append(diags, checkFieldExprUse(e.Rhs, p, localView, localResolver, seen, sourceName, use)...) + return diags + } + } + } + diags = append(diags, checkFieldExprUse(e.Lhs, p, narrowView, resolver, seen, sourceName, use)...) lhsType := narrowView.TypeOf(e.Lhs, p) if e.Operator == "and" && ops.IsFalsy(lhsType) { return diags @@ -198,10 +329,14 @@ func checkFieldExpr(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolv return diags } rhsView, rhsResolver := applyLogicalOpNarrowing(e, p, narrowView, resolver) - diags = append(diags, checkFieldExpr(e.Rhs, p, rhsView, rhsResolver, seen, sourceName)...) + diags = append(diags, checkFieldExprUse(e.Rhs, p, rhsView, rhsResolver, seen, sourceName, use)...) case *ast.RelationalOpExpr: - diags = append(diags, checkFieldExpr(e.Lhs, p, narrowView, resolver, seen, sourceName)...) - diags = append(diags, checkFieldExpr(e.Rhs, p, narrowView, resolver, seen, sourceName)...) + operandUse := fieldUseValue + if use == fieldUseProbe && relationalIsEquality(e) { + operandUse = fieldUseProbe + } + diags = append(diags, checkFieldExprUse(e.Lhs, p, narrowView, resolver, seen, sourceName, operandUse)...) + diags = append(diags, checkFieldExprUse(e.Rhs, p, narrowView, resolver, seen, sourceName, operandUse)...) diags = append(diags, checkRelational(e, p, narrowView, sourceName)...) case *ast.ArithmeticOpExpr: diags = append(diags, checkFieldExpr(e.Lhs, p, narrowView, resolver, seen, sourceName)...) @@ -220,7 +355,7 @@ func checkFieldExpr(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolv diags = append(diags, checkFieldExpr(e.Expr, p, narrowView, resolver, seen, sourceName)...) diags = append(diags, checkUnaryBNot(e, p, narrowView, sourceName)...) case *ast.UnaryNotOpExpr: - diags = append(diags, checkFieldExpr(e.Expr, p, narrowView, resolver, seen, sourceName)...) + diags = append(diags, checkFieldExprUse(e.Expr, p, narrowView, resolver, seen, sourceName, fieldUseProbe)...) } return diags @@ -246,6 +381,27 @@ func applyLogicalOpNarrowing( return view, resolver } + if expr.Operator == "and" { + if overrides := collectFieldLocalOverrides(expr.Lhs, p, view, resolver, true); len(overrides) > 0 { + localView := &localNarrowView{ + base: view, + bindings: resolver.bindings, + overrides: overrides, + } + return localView, fieldResolverImpl{view: localView, synth: resolver.synth, bindings: resolver.bindings} + } + } + if expr.Operator == "or" { + if overrides := collectFieldLocalOverrides(expr.Lhs, p, view, resolver, false); len(overrides) > 0 { + localView := &localNarrowView{ + base: view, + bindings: resolver.bindings, + overrides: overrides, + } + return localView, fieldResolverImpl{view: localView, synth: resolver.synth, bindings: resolver.bindings} + } + } + lhsType := view.TypeOf(expr.Lhs, p) if lhsType == nil || !ops.CanBeFalsy(lhsType) { return view, resolver @@ -277,6 +433,80 @@ func applyLogicalOpNarrowing( return localView, fieldResolverImpl{view: localView, synth: resolver.synth, bindings: resolver.bindings} } +func collectFieldLocalOverrides( + expr ast.Expr, + p cfg.Point, + view api.BaseSynth, + resolver fieldResolverImpl, + truthy bool, +) []fieldLocalOverride { + if expr == nil || view == nil || resolver.bindings == nil { + return nil + } + if logical, ok := expr.(*ast.LogicalOpExpr); ok { + switch { + case truthy && logical.Operator == "and": + left := collectFieldLocalOverrides(logical.Lhs, p, view, resolver, true) + leftView, leftResolver := composeFieldLocalView(view, resolver, left) + right := collectFieldLocalOverrides(logical.Rhs, p, leftView, leftResolver, true) + return append(left, right...) + case !truthy && logical.Operator == "or": + left := collectFieldLocalOverrides(logical.Lhs, p, view, resolver, false) + leftView, leftResolver := composeFieldLocalView(view, resolver, left) + right := collectFieldLocalOverrides(logical.Rhs, p, leftView, leftResolver, false) + return append(left, right...) + } + } + if truthy { + if probe, ok := guard.ExtractTypeEqualityProbe(expr); ok { + probePath := path.FromExprWithBindings(probe.Expr, nil, resolver.bindings) + if !probePath.IsEmpty() { + return []fieldLocalOverride{{ + path: probePath, + t: guard.TypeForTypeKey(probe.Key), + }} + } + } + } + exprPath := path.FromExprWithBindings(expr, nil, resolver.bindings) + if exprPath.IsEmpty() { + return nil + } + exprType := view.TypeOf(expr, p) + if exprType == nil { + return nil + } + var narrowed typ.Type + if truthy { + narrowed = narrow.ToTruthy(exprType) + } else { + narrowed = narrow.ToFalsy(exprType) + } + if narrowed == nil || narrowed.Kind().IsNever() || typ.TypeEquals(narrowed, exprType) { + return nil + } + return []fieldLocalOverride{{ + path: exprPath, + t: narrowed, + }} +} + +func composeFieldLocalView( + view api.BaseSynth, + resolver fieldResolverImpl, + overrides []fieldLocalOverride, +) (api.BaseSynth, fieldResolverImpl) { + if len(overrides) == 0 { + return view, resolver + } + localView := &localNarrowView{ + base: view, + bindings: resolver.bindings, + overrides: overrides, + } + return localView, fieldResolverImpl{view: localView, synth: resolver.synth, bindings: resolver.bindings} +} + func applyAssignPreStateNarrowing( graph *cfg.Graph, info *cfg.AssignInfo, @@ -357,7 +587,7 @@ func preAssignmentExprType(graph *cfg.Graph, expr ast.Expr, p cfg.Point, view ap func checkArithmetic(e *ast.ArithmeticOpExpr, p cfg.Point, narrowView api.BaseSynth, sourceName string) []diag.Diagnostic { check := func(expr ast.Expr) *diag.Diagnostic { t := narrowView.TypeOf(expr, p) - if t == nil || ops.IsNumeric(t) { + if t == nil || ops.IsNumeric(t) || typ.IsNever(t) { return nil } msg := "cannot perform arithmetic on " + typ.FormatShort(t) + ", expected number" @@ -530,10 +760,13 @@ func checkNumericFor(info *cfg.NumericForInfo, p cfg.Point, narrowView api.BaseS return diags } -func checkAttrGet(e *ast.AttrGetExpr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string) []diag.Diagnostic { +func checkAttrGet(e *ast.AttrGetExpr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string, use fieldUse) []diag.Diagnostic { var diags []diag.Diagnostic diags = append(diags, checkFieldExpr(e.Object, p, narrowView, resolver, seen, sourceName)...) + if localViewOverridesExpr(narrowView, e) { + return diags + } objType := narrowView.TypeOf(e.Object, p) @@ -571,6 +804,9 @@ func checkAttrGet(e *ast.AttrGetExpr, p cfg.Point, narrowView api.BaseSynth, res } if !result.Found { + if use == fieldUseProbe && querycore.MissingFieldReadsNil(objType) { + return diags + } pos := diag.Position{File: sourceName, Line: e.Line(), Column: e.Column()} span := ast.SpanOf(e) if e.Key != nil && e.Key.Line() > 0 { @@ -593,6 +829,48 @@ func checkAttrGet(e *ast.AttrGetExpr, p cfg.Point, narrowView api.BaseSynth, res return diags } +func relationalIsEquality(e *ast.RelationalOpExpr) bool { + if e == nil { + return false + } + switch e.Operator { + case "==", "~=": + return true + } + return false +} + +func checkTypeProbeArg(expr ast.Expr, p cfg.Point, narrowView api.BaseSynth, resolver fieldResolverImpl, seen map[ast.Expr]bool, sourceName string) []diag.Diagnostic { + attr, ok := expr.(*ast.AttrGetExpr) + if !ok || attr == nil { + return checkFieldExpr(expr, p, narrowView, resolver, seen, sourceName) + } + return checkFieldExpr(attr.Object, p, narrowView, resolver, seen, sourceName) +} + +func localViewOverridesExpr(view api.BaseSynth, expr ast.Expr) bool { + for { + localView, ok := view.(*localNarrowView) + if !ok || localView == nil { + return false + } + if localView.bindings != nil { + exprPath := path.FromExprWithBindings(expr, nil, localView.bindings) + if !exprPath.IsEmpty() && exprPath.Equal(localView.overridePath) { + return true + } + if !exprPath.IsEmpty() { + for i := len(localView.overrides) - 1; i >= 0; i-- { + if exprPath.Equal(localView.overrides[i].path) { + return true + } + } + } + } + view = localView.base + } +} + func isStringKeyExpr(key ast.Expr) bool { if key == nil { return false diff --git a/compiler/check/hooks/hooks.go b/compiler/check/hooks/hooks.go index c70ed869..4c851d57 100644 --- a/compiler/check/hooks/hooks.go +++ b/compiler/check/hooks/hooks.go @@ -19,6 +19,7 @@ // - WithCall: Argument type mismatches in function calls // - WithField: Invalid field access on types without the field // - WithControl: Unreachable code and control flow issues +// - WithExhaustiveness: Non-exhaustive discriminated union matches // - WithIdent: References to undefined identifiers // // # USAGE @@ -50,6 +51,7 @@ func All() []check.Option { WithCall(), WithField(), WithControl(), + WithExhaustiveness(), WithIdent(), } } @@ -60,7 +62,7 @@ func WithAssign() check.Option { if result.NarrowSynth == nil || result.Graph == nil { return nil } - return CheckAssignments(result.Graph, result.Scopes, result.NarrowSynth, result, sess.SourceName) + return CheckAssignments(result.Graph, result.Evidence, result.Scopes, result.NarrowSynth, result, sess.SourceName) }) } @@ -71,7 +73,7 @@ func WithReturn() check.Option { return nil } narrowView := result.NarrowSynth.Narrow() - return CheckReturns(fn, result.Graph, result.Scopes, result.BaseScope, result.NarrowSynth, narrowView, sess.SourceName) + return CheckReturns(fn, result.Graph, result.Evidence, result.Scopes, result.BaseScope, result.NarrowSynth, narrowView, sess.SourceName) }) } @@ -82,7 +84,7 @@ func WithCall() check.Option { return nil } narrowView := result.NarrowSynth.Narrow() - return CheckCalls(result.Graph, result.Scopes, result.NarrowSynth, narrowView, sess.SourceName) + return CheckCalls(result.Graph, result.Evidence, result.Scopes, result.NarrowSynth, narrowView, sess.ResultsMap(), sess.SourceName) }) } @@ -93,7 +95,7 @@ func WithField() check.Option { return nil } narrowView := result.NarrowSynth.Narrow() - return CheckFields(result.Graph, result.NarrowSynth, narrowView, sess.SourceName) + return CheckFields(result.Graph, result.Evidence, result.NarrowSynth, narrowView, result.FlowSolution, sess.SourceName) }) } @@ -107,12 +109,22 @@ func WithControl() check.Option { }) } +// WithExhaustiveness enables warnings for non-exhaustive discriminated union matches. +func WithExhaustiveness() check.Option { + return check.WithPass(func(sess *check.Session, fn *ast.FunctionExpr, result *api.FuncResult) []diag.Diagnostic { + if fn == nil || result.Graph == nil || result.NarrowSynth == nil { + return nil + } + return CheckExhaustiveness(fn, result.Graph, result.Evidence, result.NarrowSynth.Narrow(), sess.SourceName) + }) +} + // WithIdent enables undefined identifier checking. func WithIdent() check.Option { return check.WithPass(func(sess *check.Session, _ *ast.FunctionExpr, result *api.FuncResult) []diag.Diagnostic { if result.Graph == nil { return nil } - return CheckIdents(result.Graph, result.Scopes, sess.SourceName) + return CheckIdents(result.Graph, result.Evidence, result.Scopes, sess.SourceName) }) } diff --git a/compiler/check/hooks/hooks_test.go b/compiler/check/hooks/hooks_test.go index b46c1518..2cc2a4f9 100644 --- a/compiler/check/hooks/hooks_test.go +++ b/compiler/check/hooks/hooks_test.go @@ -6,8 +6,8 @@ import ( func TestAll_ReturnsOptions(t *testing.T) { opts := All() - if len(opts) != 6 { - t.Errorf("All() returned %d options, expected 6", len(opts)) + if len(opts) != 7 { + t.Errorf("All() returned %d options, expected 7", len(opts)) } } @@ -46,6 +46,13 @@ func TestWithControl_NotNil(t *testing.T) { } } +func TestWithExhaustiveness_NotNil(t *testing.T) { + opt := WithExhaustiveness() + if opt == nil { + t.Error("WithExhaustiveness() returned nil") + } +} + func TestWithIdent_NotNil(t *testing.T) { opt := WithIdent() if opt == nil { diff --git a/compiler/check/hooks/ident_check.go b/compiler/check/hooks/ident_check.go index 8bc45134..4dec5e3e 100644 --- a/compiler/check/hooks/ident_check.go +++ b/compiler/check/hooks/ident_check.go @@ -21,12 +21,13 @@ package hooks import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/diag" ) // CheckIdents validates that all identifier expressions are defined at their use point. -func CheckIdents(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, sourceName string) []diag.Diagnostic { +func CheckIdents(graph *cfg.Graph, evidence api.FlowEvidence, scopes map[cfg.Point]*scope.State, sourceName string) []diag.Diagnostic { if graph == nil { return nil } @@ -39,14 +40,13 @@ func CheckIdents(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, sourceName diags: &diags, } - for _, p := range graph.RPO() { - info := graph.Info(p) - if info == nil { + for _, use := range evidence.IdentifierUses { + if use.Expr == nil { continue } - checker.point = p - checker.scope = scopes[p] - checker.checkNodeInfo(info) + checker.point = use.Point + checker.scope = scopes[use.Point] + checker.checkIdent(use.Expr) } return diags @@ -61,87 +61,6 @@ type identChecker struct { diags *[]diag.Diagnostic } -func (c *identChecker) checkNodeInfo(info cfg.NodeInfo) { - switch v := info.(type) { - case *cfg.AssignInfo: - for _, expr := range v.Sources { - c.checkIdentExpr(expr) - } - for _, expr := range v.IterExprs { - c.checkIdentExpr(expr) - } - if v.NumericFor != nil { - c.checkIdentExpr(v.NumericFor.Init) - c.checkIdentExpr(v.NumericFor.Limit) - c.checkIdentExpr(v.NumericFor.Step) - } - for _, target := range v.Targets { - if target.Kind == cfg.TargetField || target.Kind == cfg.TargetIndex { - c.checkIdentExpr(target.Base) - c.checkIdentExpr(target.Key) - } - } - case *cfg.CallInfo: - c.checkIdentExpr(v.Callee) - c.checkIdentExpr(v.Receiver) - for _, arg := range v.Args { - c.checkIdentExpr(arg) - } - case *cfg.ReturnInfo: - for _, expr := range v.Exprs { - c.checkIdentExpr(expr) - } - case *cfg.BranchInfo: - c.checkIdentExpr(v.Condition) - } -} - -func (c *identChecker) checkIdentExpr(expr ast.Expr) { - if expr == nil { - return - } - - switch e := expr.(type) { - case *ast.IdentExpr: - c.checkIdent(e) - case *ast.AttrGetExpr: - c.checkIdentExpr(e.Object) - case *ast.TableExpr: - for _, field := range e.Fields { - c.checkIdentExpr(field.Value) - } - case *ast.FuncCallExpr: - c.checkIdentExpr(e.Func) - c.checkIdentExpr(e.Receiver) - for _, arg := range e.Args { - c.checkIdentExpr(arg) - } - case *ast.LogicalOpExpr: - c.checkIdentExpr(e.Lhs) - c.checkIdentExpr(e.Rhs) - case *ast.RelationalOpExpr: - c.checkIdentExpr(e.Lhs) - c.checkIdentExpr(e.Rhs) - case *ast.StringConcatOpExpr: - c.checkIdentExpr(e.Lhs) - c.checkIdentExpr(e.Rhs) - case *ast.ArithmeticOpExpr: - c.checkIdentExpr(e.Lhs) - c.checkIdentExpr(e.Rhs) - case *ast.UnaryMinusOpExpr: - c.checkIdentExpr(e.Expr) - case *ast.UnaryNotOpExpr: - c.checkIdentExpr(e.Expr) - case *ast.UnaryLenOpExpr: - c.checkIdentExpr(e.Expr) - case *ast.UnaryBNotOpExpr: - c.checkIdentExpr(e.Expr) - case *ast.CastExpr: - c.checkIdentExpr(e.Expr) - case *ast.FunctionExpr: - } -} - func (c *identChecker) checkIdent(ident *ast.IdentExpr) { if ident == nil || ident.Value == "" { return diff --git a/compiler/check/hooks/lspindex.go b/compiler/check/hooks/lspindex.go index 19f89b44..21d21112 100644 --- a/compiler/check/hooks/lspindex.go +++ b/compiler/check/hooks/lspindex.go @@ -102,7 +102,7 @@ func (idx *LSPIndexer) extractParameters(file string, graph *cfg.Graph, result * continue } - // Use function span as best available fallback (parameter names have no AST nodes). + // Use function span when parameter names have no AST nodes. span := astSpan(fn) if !span.Valid() { continue diff --git a/compiler/check/hooks/return_check.go b/compiler/check/hooks/return_check.go index a7d017bf..39549e49 100644 --- a/compiler/check/hooks/return_check.go +++ b/compiler/check/hooks/return_check.go @@ -51,6 +51,7 @@ type synthForReturn interface { func CheckReturns( fn *ast.FunctionExpr, graph *cfg.Graph, + evidence api.FlowEvidence, scopes map[cfg.Point]*scope.State, baseScope *scope.State, declared api.Synth, @@ -128,15 +129,17 @@ func CheckReturns( return nil } - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range evidence.Returns { + p := ret.Point + info := ret.Info if info == nil { - return + continue } if len(info.Exprs) == 0 { if idx, decl := missingRequired(0); idx >= 0 { missingReturnDiag(returnPosNode(info), idx, decl) } - return + continue } for i, expr := range info.Exprs { @@ -175,7 +178,7 @@ func CheckReturns( missingReturnDiag(returnPosNode(info), idx, decl) } } - }) + } return diags } diff --git a/compiler/check/hooks/table_check.go b/compiler/check/hooks/table_check.go index 75a13a3e..a44c8e13 100644 --- a/compiler/check/hooks/table_check.go +++ b/compiler/check/hooks/table_check.go @@ -217,6 +217,9 @@ func checkTableWithOptionalRelax(fields []ops.FieldDef, arrayElems []typ.Type, e if err.Message == "missing required field" && unwrap.IsOptionalLike(err.Expected) { continue } + if err.Message == "field type mismatch" && unresolvedTableEvidence(err.Got) { + continue + } if err.Message == "unexpected field" { continue } @@ -240,6 +243,14 @@ func checkTableWithOptionalRelax(fields []ops.FieldDef, arrayElems []typ.Type, e return false, reason } +func unresolvedTableEvidence(t typ.Type) bool { + if typ.IsAbsentOrUnknown(t) { + return true + } + rec := unwrap.Record(t) + return rec != nil && len(rec.Fields) == 0 && !rec.HasMapComponent() +} + func unionAllRecordLike(u *typ.Union) bool { if u == nil { return false diff --git a/compiler/check/hooks/table_check_test.go b/compiler/check/hooks/table_check_test.go new file mode 100644 index 00000000..56cd2cf9 --- /dev/null +++ b/compiler/check/hooks/table_check_test.go @@ -0,0 +1,38 @@ +package hooks + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/check/synth/ops" + "github.com/wippyai/go-lua/types/typ" +) + +func TestCheckTableWithOptionalRelax_ContextualizesUnresolvedFieldEvidence(t *testing.T) { + expected := typ.NewRecord(). + Field("timeout", typ.NewOptional(typ.Number)). + Build() + + ok, reason := checkTableWithOptionalRelax( + []ops.FieldDef{{Name: "timeout", Type: typ.Unknown}}, + nil, + expected, + ) + if !ok { + t.Fatalf("expected unresolved field evidence to accept contextual type, got %q", reason) + } +} + +func TestCheckTableWithOptionalRelax_RejectsConcreteMismatch(t *testing.T) { + expected := typ.NewRecord(). + Field("timeout", typ.NewOptional(typ.Number)). + Build() + + ok, _ := checkTableWithOptionalRelax( + []ops.FieldDef{{Name: "timeout", Type: typ.String}}, + nil, + expected, + ) + if ok { + t.Fatal("expected concrete mismatched field evidence to fail") + } +} diff --git a/compiler/check/infer/interproc/doc.go b/compiler/check/infer/interproc/doc.go index 915e77fe..7f5b3217 100644 --- a/compiler/check/infer/interproc/doc.go +++ b/compiler/check/infer/interproc/doc.go @@ -7,7 +7,7 @@ // // After flow analysis completes for a function, this package: // - Extracts return type summaries -// - Computes parameter type hints from call sites +// - Computes parameter evidence from call sites // - Identifies captured variable assignments // - Propagates effect information // @@ -20,6 +20,6 @@ // // # Integration // -// This package bridges per-function flow analysis with the global +// This package connects per-function flow analysis with the global // fixpoint iteration that resolves cross-function dependencies. package interproc diff --git a/compiler/check/infer/interproc/postflow.go b/compiler/check/infer/interproc/postflow.go index b9d2bdf1..22270551 100644 --- a/compiler/check/infer/interproc/postflow.go +++ b/compiler/check/infer/interproc/postflow.go @@ -2,18 +2,23 @@ package interproc import ( "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + interprocdomain "github.com/wippyai/go-lua/compiler/check/domain/interproc" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" "github.com/wippyai/go-lua/compiler/check/erreffect" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" - "github.com/wippyai/go-lua/compiler/check/nested" - "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth/ops" + synthextract "github.com/wippyai/go-lua/compiler/check/synth/phase/extract" + "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" + typjoin "github.com/wippyai/go-lua/types/typ/join" "github.com/wippyai/go-lua/types/typ/unwrap" ) @@ -23,10 +28,9 @@ type functionTypeWithExpected interface { // Store is the minimal store interface required to record post-flow interproc facts. type Store interface { - api.StoreView + api.StoreReader - UpdateInterprocFactsNext(key api.GraphKey, update func(*api.Facts)) - StoreLiteralSigs(graphID uint64, sigs map[*ast.FunctionExpr]*typ.Function) + MergeInterprocFactsNext(key api.GraphKey, delta api.Facts) ParentGraphKeyForSymbol(sym cfg.SymbolID) (api.GraphKey, bool) } @@ -53,8 +57,8 @@ func StoreFactsFromResult( fnSym = resolvedSym } } - // Collect parameter hints regardless of whether the function has a symbol. - CollectParamHintsFromResult(store, result, parent) + // Collect parameter evidence regardless of whether the function has a symbol. + CollectParameterEvidenceFromResult(store, result, parent) if fnSym == 0 { return @@ -65,26 +69,33 @@ func StoreFactsFromResult( if fnType == nil { return } - narrowReturns := returns.NormalizeReturnVector(fnType.Returns) - if snapNarrow := narrowSummarySnapshotForSymbol(store, result, parent, fnSym); len(snapNarrow) > 0 { - narrowReturns = returns.MergeReturnSummary(narrowReturns, snapNarrow) - if aligned, changed := returns.AlignFunctionTypeWithSummary(fnType, narrowReturns); changed { + narrowSummary := returnsummary.Normalize(fnType.Returns) + if factNarrow := functionfact.NarrowSummaryForSymbol(store, fnSym, parent, nil); len(factNarrow) > 0 { + narrowSummary = returnsummary.Merge(narrowSummary, factNarrow) + if aligned, changed := returnsummary.AlignFunction(fnType, narrowSummary); changed { fnType = aligned } } - summaryFromSnapshot := returnSummarySnapshotForSymbol(store, result, parent, fnSym) + summaryFromFacts := functionfact.ReturnSummaryForSymbol(store, fnSym, parent, nil) - writer.updateParentFactsForSymbol(fnSym, func(facts *api.Facts) { - candidateFunc := fnType - if hinted := paramhints.MergeIntoSignature(fn, facts.ParamHints[fnSym], unwrap.Function(candidateFunc)); hinted != nil { + candidateFunc := fnType + if len(narrowSummary) > 0 && !returnsummary.AllNil(narrowSummary) { + if aligned := typjoin.WithReturns(candidateFunc, narrowSummary); aligned != nil { + candidateFunc = aligned + } + } + if facts := store.GetInterprocFacts(result.Graph, parent).FunctionFacts; len(facts) > 0 { + if hinted := paramevidence.MergeIntoSignature(fn, functionfact.ParameterEvidenceFromMap(facts, fnSym), unwrap.Function(candidateFunc)); hinted != nil { candidateFunc = hinted } - returns.MergeFunctionFactIntoFacts(facts, fnSym, returns.FunctionFactCandidate{ - Summary: summaryFromSnapshot, - Narrow: narrowReturns, - Func: candidateFunc, - }) - }) + } + candidateFunc = stripSyntheticVariadic(fn, unwrap.Function(candidateFunc)) + delta := interprocdomain.FunctionFactsDelta(functionfact.FromPart(fnSym, functionfact.Parts{ + Summary: summaryFromFacts, + Narrow: narrowSummary, + Type: candidateFunc, + })) + writer.mergeParentFactsForSymbol(fnSym, delta) } func storeCapturedFactsFromResult( @@ -97,75 +108,128 @@ func storeCapturedFactsFromResult( if store == nil || fn == nil || fnSym == 0 || result == nil || result.Graph == nil || result.NarrowSynth == nil { return } - bindings := bindingsForGraphOrModule(result.Graph, store) - if bindings == nil { - return - } - capturedSet := capturedSymbolSet(bindings, fn) - if len(capturedSet) == 0 { - return - } - fields := nested.CollectCapturedFieldAssignments(result.Graph, capturedSet, result.NarrowSynth.TypeOf) + fields := capturedFieldFactsFromEvidence(result.Evidence.CapturedFields, result.NarrowSynth.TypeOf) if len(fields) > 0 { - writer.updateParentFactsForSymbol(fnSym, func(facts *api.Facts) { - if facts.CapturedFields == nil { - facts.CapturedFields = make(api.CapturedFieldAssigns) - } - existing := facts.CapturedFields[fnSym] - facts.CapturedFields[fnSym] = returns.MergeCapturedFieldSymbolMaps(existing, fields, typ.JoinPreferNonSoft) - }) + writer.mergeParentFactsForSymbol(fnSym, interprocdomain.CapturedFieldAssignsDelta(fnSym, fields)) } - mutations := nested.CollectCapturedContainerMutations(result.Graph, capturedSet, result.NarrowSynth.TypeOf) + mutations := capturedContainerFactsFromEvidence(result.Evidence.CapturedContainers, result.NarrowSynth.TypeOf) if len(mutations) > 0 { - writer.updateParentFactsForSymbol(fnSym, func(facts *api.Facts) { - if facts.CapturedContainers == nil { - facts.CapturedContainers = make(api.CapturedContainerMutations) - } - existing := facts.CapturedContainers[fnSym] - facts.CapturedContainers[fnSym] = returns.MergeCapturedContainerMutationMaps(existing, mutations, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { - if prev != nil { - next.ValueType = typ.JoinPreferNonSoft(prev.ValueType, next.ValueType) - } - return next - }) - }) + writer.mergeParentFactsForSymbol(fnSym, interprocdomain.CapturedContainerMutationsDelta(fnSym, mutations)) } } -func bindingsForGraphOrModule(graph *cfg.Graph, store Store) *bind.BindingTable { - if graph == nil { +func capturedFieldFactsFromEvidence( + evidence []api.CapturedFieldEvidence, + synth func(ast.Expr, cfg.Point) typ.Type, +) map[cfg.SymbolID]map[string]typ.Type { + if len(evidence) == 0 { return nil } - bindings := graph.Bindings() - if bindings != nil { - return bindings + fields := make(map[cfg.SymbolID]map[string]typ.Type) + for _, ev := range evidence { + if ev.Target == 0 || ev.Field == "" { + continue + } + fieldType := typ.Unknown + if synth != nil && ev.Value != nil { + if t := synth(ev.Value, ev.Point); t != nil { + fieldType = t + } + } + if fields[ev.Target] == nil { + fields[ev.Target] = make(map[string]typ.Type) + } + if existing := fields[ev.Target][ev.Field]; existing != nil { + fields[ev.Target][ev.Field] = typ.JoinPreferNonSoft(existing, fieldType) + } else { + fields[ev.Target][ev.Field] = fieldType + } } - if store != nil { - return store.ModuleBindings() + if len(fields) == 0 { + return nil } - return nil + return fields } -func capturedSymbolSet(bindings *bind.BindingTable, fn *ast.FunctionExpr) map[cfg.SymbolID]bool { - if bindings == nil || fn == nil { +func capturedContainerFactsFromEvidence( + evidence []api.CapturedContainerEvidence, + synth func(ast.Expr, cfg.Point) typ.Type, +) map[cfg.SymbolID][]api.ContainerMutation { + if len(evidence) == 0 { + return nil + } + mutations := make(map[cfg.SymbolID][]api.ContainerMutation) + for _, ev := range evidence { + if ev.Target == 0 || ev.Value == nil { + continue + } + valueType := typ.Unknown + if synth != nil { + if t := synth(ev.Value, ev.Point); t != nil { + valueType = t + } + } + var keyType typ.Type + if ev.Key != nil { + keyType = typ.Unknown + if synth != nil { + if t := synth(ev.Key, ev.Point); t != nil { + keyType = subtype.WidenForInference(t) + } + } + } + mutations[ev.Target] = append(mutations[ev.Target], api.ContainerMutation{ + Kind: ev.Kind, + Segments: cloneSegments(ev.Segments), + KeyType: keyType, + ValueType: subtype.WidenForInference(valueType), + }) + } + if len(mutations) == 0 { return nil } - captured := bindings.CapturedSymbols(fn) - if len(captured) == 0 { + return mutations +} + +func cloneSegments(segments []constraint.Segment) []constraint.Segment { + if len(segments) == 0 { return nil } - set := make(map[cfg.SymbolID]bool, len(captured)) - for _, sym := range captured { - if sym != 0 { - set[sym] = true + out := make([]constraint.Segment, len(segments)) + copy(out, segments) + return out +} + +func stripSyntheticVariadic(fn *ast.FunctionExpr, sig *typ.Function) *typ.Function { + if fn == nil || fn.ParList == nil || fn.ParList.HasVargs || sig == nil || sig.Variadic == nil { + return sig + } + builder := typ.Func().ReserveParams(len(sig.Params)) + for _, tp := range sig.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for _, p := range sig.Params { + if p.Optional { + builder = builder.OptParam(p.Name, p.Type) + } else { + builder = builder.Param(p.Name, p.Type) } } - if len(set) == 0 { - return nil + if len(sig.Returns) > 0 { + builder = builder.Returns(sig.Returns...) + } + if sig.Effects != nil { + builder = builder.Effects(sig.Effects) } - return set + if sig.Spec != nil { + builder = builder.Spec(sig.Spec) + } + if sig.Refinement != nil { + builder = builder.WithRefinement(sig.Refinement) + } + return builder.Build() } func narrowFunctionTypeFromResult(result *api.FuncResult, fn *ast.FunctionExpr) *typ.Function { @@ -180,59 +244,122 @@ func narrowFunctionTypeFromResult(result *api.FuncResult, fn *ast.FunctionExpr) } } } - return erreffect.AttachInferredErrorReturnSpec(fnType, result.Graph, result.FlowSolution, result.NarrowSynth) + fnType = attachSolvedCallbackOverlaySpec(fnType, result) + return erreffect.AttachInferredErrorReturnSpec(fnType, result.Evidence, result.FlowSolution, result.NarrowSynth) } -func returnSummarySnapshotForSymbol(store Store, result *api.FuncResult, parent *scope.State, sym cfg.SymbolID) []typ.Type { - if store == nil || result == nil || result.Graph == nil || sym == 0 { - return nil +func attachSolvedCallbackOverlaySpec(fnType *typ.Function, result *api.FuncResult) *typ.Function { + if fnType == nil || result == nil || result.Graph == nil || result.NarrowSynth == nil { + return fnType + } + overlays := synthextract.InferCallbackEnvOverlays( + result.Graph, + result.Evidence, + result.Graph.ParamSlotsReadOnly(), + result.NarrowSynth.TypeOf, + result.ModuleBindings, + ) + if len(overlays) == 0 { + return fnType } - summaryGraph := result.Graph - summaryScope := api.ParentScopeForGraph(store, result.Graph.ID(), parent) - if parentKey, ok := store.ParentGraphKeyForSymbol(sym); ok { - if g := store.Graphs()[parentKey.GraphID]; g != nil { - summaryGraph = g - if scopedParent, ok := store.Parents()[parentKey.ParentHash]; ok { - summaryScope = scopedParent - } + + spec := cloneContractSpecForCallbacks(fnType) + for paramIdx, overlay := range overlays { + if len(overlay) == 0 { + continue } + cb := spec.GetCallback(paramIdx).Clone() + if cb == nil { + cb = &contract.CallbackSpec{Cardinality: contract.CardExactlyOnce} + } + cb.EnvOverlay = mergeCallbackEnvOverlay(cb.EnvOverlay, overlay) + spec.WithCallback(paramIdx, cb) } - snap := store.GetReturnSummariesSnapshot(summaryGraph, summaryScope) - if len(snap) == 0 { - return nil + return cloneFunctionWithSpec(fnType, spec) +} + +func cloneContractSpecForCallbacks(fnType *typ.Function) *contract.Spec { + if fnType == nil || fnType.Spec == nil { + return contract.NewSpec() + } + spec, ok := fnType.Spec.(*contract.Spec) + if !ok || spec == nil { + return contract.NewSpec() + } + clone := contract.NewSpec() + clone.Requires = spec.Requires + clone.Ensures = spec.Ensures + if len(spec.ExprRequires) > 0 { + clone.ExprRequires = append([]constraint.ExprCompare(nil), spec.ExprRequires...) + } + if len(spec.ExprEnsures) > 0 { + clone.ExprEnsures = append([]constraint.ExprCompare(nil), spec.ExprEnsures...) + } + clone.Effects = spec.Effects + if len(spec.Callbacks) > 0 { + clone.Callbacks = make(map[int]*contract.CallbackSpec, len(spec.Callbacks)) + for idx, cb := range spec.Callbacks { + clone.Callbacks[idx] = cb.Clone() + } } - return snap[sym] + clone.Return = spec.Return + return clone } -func narrowSummarySnapshotForSymbol(store Store, result *api.FuncResult, parent *scope.State, sym cfg.SymbolID) []typ.Type { - if store == nil || result == nil || result.Graph == nil || sym == 0 { +func mergeCallbackEnvOverlay(base, overlay map[string]typ.Type) map[string]typ.Type { + if len(base) == 0 && len(overlay) == 0 { return nil } - summaryGraph := result.Graph - summaryScope := api.ParentScopeForGraph(store, result.Graph.ID(), parent) - if parentKey, ok := store.ParentGraphKeyForSymbol(sym); ok { - if g := store.Graphs()[parentKey.GraphID]; g != nil { - summaryGraph = g - if scopedParent, ok := store.Parents()[parentKey.ParentHash]; ok { - summaryScope = scopedParent - } + out := make(map[string]typ.Type, len(base)+len(overlay)) + for name, t := range base { + if name != "" && t != nil { + out[name] = t } } - - var snap map[cfg.SymbolID][]typ.Type - if phaser, ok := any(store).(interface { - WithPhase(api.Phase, func()) - }); ok { - phaser.WithPhase(api.PhaseNarrowing, func() { - snap = store.GetNarrowReturnSummariesSnapshot(summaryGraph, summaryScope) - }) - } else { - snap = store.GetNarrowReturnSummariesSnapshot(summaryGraph, summaryScope) + for name, candidate := range overlay { + if name == "" || candidate == nil { + continue + } + if existing := out[name]; existing != nil { + out[name] = typ.JoinPreferNonSoft(existing, candidate) + } else { + out[name] = candidate + } } - if len(snap) == 0 { + return out +} + +func cloneFunctionWithSpec(fn *typ.Function, spec *contract.Spec) *typ.Function { + if fn == nil { return nil } - return snap[sym] + builder := typ.Func().ReserveParams(len(fn.Params)) + for _, tp := range fn.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + for _, p := range fn.Params { + if p.Optional { + builder = builder.OptParam(p.Name, p.Type) + } else { + builder = builder.Param(p.Name, p.Type) + } + } + if fn.Variadic != nil { + builder = builder.Variadic(fn.Variadic) + } + if len(fn.Returns) > 0 { + builder = builder.Returns(fn.Returns...) + } + if fn.Effects != nil { + builder = builder.Effects(fn.Effects) + } + if spec != nil { + builder = builder.Spec(spec) + } + if fn.Refinement != nil { + builder = builder.WithRefinement(fn.Refinement) + } + return builder.Build() } func expectedFunctionFromResult(result *api.FuncResult) *typ.Function { @@ -245,7 +372,6 @@ func expectedFunctionFromResult(result *api.FuncResult) *typ.Function { } declared := result.FlowInputs.DeclaredTypes builder := typ.Func() - hasUntypedSourceParam := false sourceFn := result.Graph.Func() for _, slot := range slots { name := slot.Name @@ -266,7 +392,6 @@ func expectedFunctionFromResult(result *api.FuncResult) *typ.Function { optional := false if slot.TypeAnnotation == nil { optional = true - hasUntypedSourceParam = true } if _, ok := slot.TypeAnnotation.(*ast.OptionalTypeExpr); ok { optional = true @@ -280,16 +405,13 @@ func expectedFunctionFromResult(result *api.FuncResult) *typ.Function { if sourceFn != nil && sourceFn.ParList != nil && sourceFn.ParList.HasVargs { builder = builder.Variadic(typ.Any) - } else if hasUntypedSourceParam { - // Unannotated Lua functions accept extra positional arguments. - builder = builder.Variadic(typ.Any) } return builder.Build() } -// CollectParamHintsFromResult records parameter hints based on call sites -// within the current function's graph using narrowed expression types. -func CollectParamHintsFromResult(store Store, result *api.FuncResult, parent *scope.State) { +// CollectParameterEvidenceFromResult reduces transfer-discovered call evidence +// into canonical parameter facts using narrowed expression types. +func CollectParameterEvidenceFromResult(store Store, result *api.FuncResult, parent *scope.State) { if store == nil || result == nil || result.Graph == nil || result.NarrowSynth == nil { return } @@ -300,12 +422,12 @@ func CollectParamHintsFromResult(store Store, result *api.FuncResult, parent *sc if bindings == nil { bindings = moduleBindings } - preAssignTargets := checkcallsite.PreAssignmentTargetsByCall(graph) + preAssignTargets := checkcallsite.PreAssignmentTargetsByCall(result.Evidence.Assignments) hasFunctionRef := func(sym cfg.SymbolID) bool { return sym != 0 && store.FunctionRefBySym(sym) != nil } - collectCallHints := func(p cfg.Point, info *cfg.CallInfo) { - if info == nil || len(info.Args) == 0 { + collectCallEvidence := func(p cfg.Point, info *cfg.CallInfo) { + if info == nil || checkcallsite.RuntimeArgCount(info) == 0 { return } callTargets := preAssignTargets[info] @@ -385,168 +507,69 @@ func CollectParamHintsFromResult(store Store, result *api.FuncResult, parent *sc if calleeSym == 0 { return } - ref := store.FunctionRefBySym(calleeSym) - if ref == nil { - return - } - parentKey, ok := parentGraphKeyForCallee(store, result, parent, calleeSym) + parentKey, ok := functionfact.GraphKeyForSymbol(store, calleeSym, parent) if !ok { return } - store.UpdateInterprocFactsNext(parentKey, func(facts *api.Facts) { - if facts.ParamHints == nil { - facts.ParamHints = make(api.ParamHints) + paramFacts := make(map[cfg.SymbolID][]typ.Type) + runtimeArgCount := checkcallsite.RuntimeArgCount(info) + evidence := paramevidence.EnsureCapacity(nil, runtimeArgCount) + for runtimeIdx := 0; runtimeIdx < runtimeArgCount; runtimeIdx++ { + arg := checkcallsite.RuntimeArgAt(info, runtimeIdx) + if arg == nil { + continue } - hints := paramhints.EnsureHintCapacity(facts.ParamHints[calleeSym], len(info.Args)) - for i, arg := range info.Args { - if arg == nil { - continue + var argType typ.Type + if checkcallsite.IsMethodCallInfo(info) && runtimeIdx == 0 { + argType = def.Receiver + } else { + argIdx := runtimeIdx + if checkcallsite.IsMethodCallInfo(info) { + argIdx-- } - if expectedFn := unwrap.Function(infer.ExpectedArgType(i)); expectedFn != nil { - argSym := checkcallsite.CanonicalSymbolFromExprWithAliases( - arg, - 0, - result.Graph, - bindings, - moduleBindings, - hasFunctionRef, - ) - if argSym != 0 && hasFunctionRef(argSym) { - hintsForFn := facts.ParamHints[argSym] - for j, param := range expectedFn.Params { - hintsForFn, _ = paramhints.MergeHintAt(hintsForFn, j, param.Type, typ.JoinPreferNonSoft) - } - if len(hintsForFn) > 0 { - facts.ParamHints[argSym] = hintsForFn - } - } + if argIdx >= 0 && argIdx < len(argTypes) { + argType = argTypes[argIdx] } - - argType := argTypes[i] - if argType == nil { - argType = result.NarrowSynth.TypeOf(arg, p) - } - hints, _ = paramhints.MergeCallArgHintAt(hints, i, argType, typ.JoinPreferNonSoft, true) - } - if len(hints) > 0 { - facts.ParamHints[calleeSym] = hints } - }) - } - - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { - collectCallHints(p, info) - - seenNested := make(map[*ast.FuncCallExpr]struct{}) - for _, arg := range info.Args { - collectNestedFuncCalls(arg, seenNested) - } - for nested := range seenNested { - nestedInfo := graph.CallSiteAt(p, nested) - if nestedInfo == nil { - nestedInfo = synthCallInfoFromExpr(nested, bindings) + if argType == nil { + argType = result.NarrowSynth.TypeOf(arg, p) } - collectCallHints(p, nestedInfo) + evidence, _ = paramevidence.MergeCallArgAt(evidence, runtimeIdx, argType, typ.JoinPreferNonSoft, true) } - }) -} - -func synthCallInfoFromExpr(ex *ast.FuncCallExpr, bindings *bind.BindingTable) *cfg.CallInfo { - if ex == nil { - return nil - } - info := &cfg.CallInfo{ - Call: ex, - Callee: ex.Func, - Args: ex.Args, - Method: ex.Method, - Receiver: ex.Receiver, - IsStmt: false, - } - if id, ok := ex.Func.(*ast.IdentExpr); ok { - info.CalleeName = id.Value - } - if bindings != nil { - info.CalleeSymbol = checkcallsite.SymbolFromExpr(ex.Func, bindings) - if ex.Receiver != nil { - info.ReceiverSymbol = checkcallsite.SymbolFromExpr(ex.Receiver, bindings) - if id, ok := ex.Receiver.(*ast.IdentExpr); ok { - info.ReceiverName = id.Value + for i, arg := range info.Args { + if arg == nil { + continue + } + if expectedFn := unwrap.Function(infer.ExpectedArgType(i)); expectedFn != nil { + argSym := checkcallsite.CanonicalSymbolFromExprWithAliases( + arg, + 0, + result.Graph, + bindings, + moduleBindings, + hasFunctionRef, + ) + if argSym != 0 && hasFunctionRef(argSym) { + fnEvidence := paramFacts[argSym] + for j, param := range expectedFn.Params { + fnEvidence, _ = paramevidence.MergeAt(fnEvidence, j, param.Type, typ.JoinPreferNonSoft) + } + if len(fnEvidence) > 0 { + paramFacts[argSym] = fnEvidence + } + } } } - info.ArgSymbols = make([]cfg.SymbolID, len(ex.Args)) - for i, arg := range ex.Args { - info.ArgSymbols[i] = checkcallsite.SymbolFromExpr(arg, bindings) + if len(evidence) > 0 { + paramFacts[calleeSym] = paramevidence.JoinVectors(paramFacts[calleeSym], evidence) + } + if facts := functionfact.FromMaps(paramFacts, nil, nil); len(facts) > 0 { + store.MergeInterprocFactsNext(parentKey, interprocdomain.FunctionFactsDelta(facts)) } - } - return info -} - -func collectNestedFuncCalls(expr ast.Expr, out map[*ast.FuncCallExpr]struct{}) { - if expr == nil || out == nil { - return - } - switch e := expr.(type) { - case *ast.FuncCallExpr: - out[e] = struct{}{} - collectNestedFuncCalls(e.Func, out) - collectNestedFuncCalls(e.Receiver, out) - for _, arg := range e.Args { - collectNestedFuncCalls(arg, out) - } - case *ast.AttrGetExpr: - collectNestedFuncCalls(e.Object, out) - collectNestedFuncCalls(e.Key, out) - case *ast.TableExpr: - for _, field := range e.Fields { - if field == nil { - continue - } - collectNestedFuncCalls(field.Key, out) - collectNestedFuncCalls(field.Value, out) - } - case *ast.LogicalOpExpr: - collectNestedFuncCalls(e.Lhs, out) - collectNestedFuncCalls(e.Rhs, out) - case *ast.RelationalOpExpr: - collectNestedFuncCalls(e.Lhs, out) - collectNestedFuncCalls(e.Rhs, out) - case *ast.StringConcatOpExpr: - collectNestedFuncCalls(e.Lhs, out) - collectNestedFuncCalls(e.Rhs, out) - case *ast.ArithmeticOpExpr: - collectNestedFuncCalls(e.Lhs, out) - collectNestedFuncCalls(e.Rhs, out) - case *ast.UnaryMinusOpExpr: - collectNestedFuncCalls(e.Expr, out) - case *ast.UnaryNotOpExpr: - collectNestedFuncCalls(e.Expr, out) - case *ast.UnaryLenOpExpr: - collectNestedFuncCalls(e.Expr, out) - case *ast.UnaryBNotOpExpr: - collectNestedFuncCalls(e.Expr, out) - } -} - -func parentGraphKeyForCallee(store Store, result *api.FuncResult, parent *scope.State, calleeSym cfg.SymbolID) (api.GraphKey, bool) { - if store == nil || result == nil || result.Graph == nil || calleeSym == 0 { - return api.GraphKey{}, false - } - if key, ok := store.ParentGraphKeyForSymbol(calleeSym); ok { - return key, true } - ref := store.FunctionRefBySym(calleeSym) - if ref == nil { - return api.GraphKey{}, false - } - parentGraphID := ref.ParentGraphID - if parentGraphID == 0 { - parentGraphID = ref.GraphID - } - if parentGraphID != result.Graph.ID() { - return api.GraphKey{}, false + for _, evidence := range result.Evidence.Calls { + collectCallEvidence(evidence.Point, evidence.Info) } - return store.GraphKeyFor(result.Graph, parent) } diff --git a/compiler/check/infer/interproc/postflow_test.go b/compiler/check/infer/interproc/postflow_test.go index 57843410..a5cbdc3f 100644 --- a/compiler/check/infer/interproc/postflow_test.go +++ b/compiler/check/infer/interproc/postflow_test.go @@ -55,8 +55,8 @@ func TestExpectedFunctionFromResult_UnannotatedParamsRemainOptional(t *testing.T if !got.Params[0].Optional || !got.Params[1].Optional { t.Fatalf("expected both params optional, got %+v", got.Params) } - if got.Variadic == nil || !typ.TypeEquals(got.Variadic, typ.Any) { - t.Fatalf("expected variadic any for unannotated expected function, got %v", got.Variadic) + if got.Variadic != nil { + t.Fatalf("unannotated expected function should not create a fake variadic slot, got %v", got.Variadic) } } diff --git a/compiler/check/infer/interproc/writer.go b/compiler/check/infer/interproc/writer.go index bae53cb3..8932c92d 100644 --- a/compiler/check/infer/interproc/writer.go +++ b/compiler/check/infer/interproc/writer.go @@ -4,13 +4,13 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + interprocdomain "github.com/wippyai/go-lua/compiler/check/domain/interproc" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/typ" ) type factsWriteStore interface { - UpdateInterprocFactsNext(key api.GraphKey, update func(*api.Facts)) - StoreLiteralSigs(graphID uint64, sigs map[*ast.FunctionExpr]*typ.Function) + MergeInterprocFactsNext(key api.GraphKey, delta api.Facts) GraphKeyFor(graph *cfg.Graph, parent *scope.State) (api.GraphKey, bool) ParentGraphKeyForSymbol(sym cfg.SymbolID) (api.GraphKey, bool) } @@ -23,15 +23,15 @@ func newInterprocFactWriter(store factsWriteStore) interprocFactWriter { return interprocFactWriter{store: store} } -func (w interprocFactWriter) updateParentFactsForSymbol(sym cfg.SymbolID, update func(*api.Facts)) bool { - if w.store == nil || sym == 0 || update == nil { +func (w interprocFactWriter) mergeParentFactsForSymbol(sym cfg.SymbolID, delta api.Facts) bool { + if w.store == nil || sym == 0 { return false } parentKey, ok := w.store.ParentGraphKeyForSymbol(sym) if !ok { return false } - w.store.UpdateInterprocFactsNext(parentKey, update) + w.store.MergeInterprocFactsNext(parentKey, delta) return true } @@ -43,17 +43,15 @@ func (w interprocFactWriter) writeLiteralSignatures( if w.store == nil || graph == nil || len(sigs) == 0 { return } - w.store.StoreLiteralSigs(graph.ID(), sigs) if key, ok := w.store.GraphKeyFor(graph, parent); ok { - w.store.UpdateInterprocFactsNext(key, func(facts *api.Facts) { - if facts.LiteralSigs == nil { - facts.LiteralSigs = make(api.LiteralSigs, len(sigs)) + delta := api.LiteralSigs{} + for fnExpr, sig := range sigs { + if fnExpr != nil && sig != nil { + delta[fnExpr] = sig } - for fnExpr, sig := range sigs { - if fnExpr != nil && sig != nil { - facts.LiteralSigs[fnExpr] = sig - } - } - }) + } + if len(delta) > 0 { + w.store.MergeInterprocFactsNext(key, interprocdomain.LiteralSigsDelta(delta)) + } } } diff --git a/compiler/check/infer/interproc/writer_test.go b/compiler/check/infer/interproc/writer_test.go index e5593860..c90582ec 100644 --- a/compiler/check/infer/interproc/writer_test.go +++ b/compiler/check/infer/interproc/writer_test.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/typ" ) @@ -14,26 +15,18 @@ type factsWriteStoreStub struct { graphKeyFor api.GraphKey graphKeyForOK bool parentKeyBySymbol map[cfg.SymbolID]api.GraphKey - literalSigsByGraph map[uint64]map[*ast.FunctionExpr]*typ.Function factsByGraphKeyNext map[api.GraphKey]api.Facts } func newFactsWriteStoreStub() *factsWriteStoreStub { return &factsWriteStoreStub{ parentKeyBySymbol: make(map[cfg.SymbolID]api.GraphKey), - literalSigsByGraph: make(map[uint64]map[*ast.FunctionExpr]*typ.Function), factsByGraphKeyNext: make(map[api.GraphKey]api.Facts), } } -func (s *factsWriteStoreStub) UpdateInterprocFactsNext(key api.GraphKey, update func(*api.Facts)) { - facts := s.factsByGraphKeyNext[key] - update(&facts) - s.factsByGraphKeyNext[key] = facts -} - -func (s *factsWriteStoreStub) StoreLiteralSigs(graphID uint64, sigs map[*ast.FunctionExpr]*typ.Function) { - s.literalSigsByGraph[graphID] = sigs +func (s *factsWriteStoreStub) MergeInterprocFactsNext(key api.GraphKey, delta api.Facts) { + s.factsByGraphKeyNext[key] = delta } func (s *factsWriteStoreStub) GraphKeyFor(_ *cfg.Graph, _ *scope.State) (api.GraphKey, bool) { @@ -45,26 +38,26 @@ func (s *factsWriteStoreStub) ParentGraphKeyForSymbol(sym cfg.SymbolID) (api.Gra return key, ok } -func TestInterprocFactWriter_UpdateParentFactsForSymbol(t *testing.T) { +func TestInterprocFactWriter_MergeParentFactsForSymbol(t *testing.T) { stub := newFactsWriteStoreStub() key := api.GraphKey{GraphID: 7, ParentHash: 11} stub.parentKeyBySymbol[3] = key writer := newInterprocFactWriter(stub) - ok := writer.updateParentFactsForSymbol(3, func(facts *api.Facts) { - facts.ParamHints = map[cfg.SymbolID][]typ.Type{ - 3: {typ.String}, - } + ok := writer.mergeParentFactsForSymbol(3, api.Facts{ + FunctionFacts: api.FunctionFacts{ + 3: {Params: []typ.Type{typ.String}}, + }, }) if !ok { t.Fatal("expected update to succeed") } got := stub.factsByGraphKeyNext[key] - if len(got.ParamHints[3]) != 1 || !typ.TypeEquals(got.ParamHints[3][0], typ.String) { - t.Fatalf("unexpected parent facts update: %#v", got.ParamHints) + if params := functionfact.ParameterEvidenceFromMap(got.FunctionFacts, 3); len(params) != 1 || !typ.TypeEquals(params[0], typ.String) { + t.Fatalf("unexpected parent facts update: %#v", got.FunctionFacts) } - if writer.updateParentFactsForSymbol(99, func(*api.Facts) {}) { + if writer.mergeParentFactsForSymbol(99, api.Facts{}) { t.Fatal("expected update to fail for unknown symbol") } } @@ -85,16 +78,13 @@ func TestInterprocFactWriter_WriteLiteralSignatures(t *testing.T) { writer.writeLiteralSignatures(graph, scope.New(), sigs) - if len(stub.literalSigsByGraph[graph.ID()]) != 1 || stub.literalSigsByGraph[graph.ID()][fn] != sig { - t.Fatalf("expected literal sigs stored for graph %d", graph.ID()) - } gotFacts := stub.factsByGraphKeyNext[key] if gotFacts.LiteralSigs == nil || gotFacts.LiteralSigs[fn] != sig { t.Fatalf("expected literal sig in facts update, got %#v", gotFacts.LiteralSigs) } } -func TestInterprocFactWriter_WriteLiteralSignatures_StoresScratchWithoutGraphKey(t *testing.T) { +func TestInterprocFactWriter_WriteLiteralSignatures_RequiresGraphKey(t *testing.T) { stub := newFactsWriteStoreStub() stub.graphKeyForOK = false writer := newInterprocFactWriter(stub) @@ -106,9 +96,6 @@ func TestInterprocFactWriter_WriteLiteralSignatures_StoresScratchWithoutGraphKey sig := typ.Func().Returns(typ.Number).Build() writer.writeLiteralSignatures(graph, scope.New(), map[*ast.FunctionExpr]*typ.Function{fn: sig}) - if len(stub.literalSigsByGraph[graph.ID()]) != 1 || stub.literalSigsByGraph[graph.ID()][fn] != sig { - t.Fatalf("expected literal sigs stored even without graph key") - } if len(stub.factsByGraphKeyNext) != 0 { t.Fatalf("expected no facts writes without graph key, got %#v", stub.factsByGraphKeyNext) } diff --git a/compiler/check/infer/nested/processor.go b/compiler/check/infer/nested/processor.go index e4c32d76..88403f8e 100644 --- a/compiler/check/infer/nested/processor.go +++ b/compiler/check/infer/nested/processor.go @@ -21,25 +21,30 @@ import ( "sort" "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + interprocdomain "github.com/wippyai/go-lua/compiler/check/domain/interproc" "github.com/wippyai/go-lua/compiler/check/infer/captured" "github.com/wippyai/go-lua/compiler/check/nested" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/siblings" + "github.com/wippyai/go-lua/compiler/check/synth/ops" phasecore "github.com/wippyai/go-lua/compiler/check/synth/phase/core" "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/contract" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" ) // CheckFunc analyzes a nested function with a given parent scope. -type CheckFunc func(fn *ast.FunctionExpr, parent *scope.State) +type CheckFunc func(fn *ast.FunctionExpr, parent *scope.State, ctx api.AnalysisContext) // ResultFunc returns the analysis result for a function literal. -type ResultFunc func(fn *ast.FunctionExpr) *api.FuncResultView +type ResultFunc func(fn *ast.FunctionExpr) *api.FuncAnalysisView // Config holds dependencies for nested processing. type Config struct { @@ -48,7 +53,7 @@ type Config struct { Graphs api.GraphProvider Check CheckFunc ResultForFunc ResultFunc - RootResult *api.FuncResultView + RootResult *api.FuncAnalysisView } // Processor analyzes nested functions for a parent graph. @@ -58,7 +63,7 @@ type Processor struct { graphs api.GraphProvider check CheckFunc resultForFunc ResultFunc - rootResult *api.FuncResultView + rootResult *api.FuncAnalysisView } // New creates a nested processor. @@ -74,7 +79,7 @@ func New(cfg Config) *Processor { } // ProcessNestedFunctions analyzes all nested function definitions within a parent graph. -func (p *Processor) ProcessNestedFunctions(graph *cfg.Graph, parentResult *api.FuncResultView) { +func (p *Processor) ProcessNestedFunctions(graph *cfg.Graph, parentResult *api.FuncAnalysisView) { if parentResult == nil { return } @@ -84,8 +89,8 @@ func (p *Processor) ProcessNestedFunctions(graph *cfg.Graph, parentResult *api.F return } - // Gather nested function definitions. - gathered := nested.GatherChildren(graph, scopes, p.stdlib) + // Gather nested function definitions from transfer-owned evidence. + gathered := p.childrenFromEvidence(parentResult.Evidence.FunctionDefinitions, scopes) if len(gathered) == 0 { return } @@ -105,6 +110,184 @@ func (p *Processor) ProcessNestedFunctions(graph *cfg.Graph, parentResult *api.F } } +func (p *Processor) childrenFromEvidence( + defs []api.FunctionDefinitionEvidence, + scopes map[cfg.Point]*scope.State, +) []nested.Child { + if len(defs) == 0 { + return nil + } + children := make([]nested.Child, 0, len(defs)) + for _, def := range defs { + if def.Nested.Func == nil { + continue + } + defScope := scopes[def.Nested.Point] + if defScope == nil { + defScope = p.stdlib + } + children = append(children, nested.Child{ + NF: def.Nested, + DefScope: defScope, + FuncDef: def.FuncDef, + FuncName: def.Name, + FuncSym: def.Symbol, + IsLocal: def.IsLocal, + }) + } + return children +} + +func (p *Processor) callbackAnalysisContext( + fn *ast.FunctionExpr, + parentResult *api.FuncAnalysisView, +) api.AnalysisContext { + if fn == nil || parentResult == nil || parentResult.Graph == nil || parentResult.NarrowSynth == nil { + return api.AnalysisContext{} + } + var targetSym cfg.SymbolID + if p.store != nil { + if sym, ok := p.store.SymbolForFunc(fn); ok { + targetSym = sym + } + } + + bindings := parentResult.Graph.Bindings() + moduleBindings := bindings + if p.store != nil { + if mb := p.store.ModuleBindings(); mb != nil { + moduleBindings = mb + } + } + if bindings == nil { + bindings = moduleBindings + } + + preferTarget := func(sym cfg.SymbolID) bool { + return targetSym != 0 && sym == targetSym + } + + var ctx api.AnalysisContext + for _, ev := range parentResult.Evidence.Calls { + if parentResult.FlowSolution != nil && parentResult.FlowSolution.IsPointDead(ev.Point) { + continue + } + info := ev.Info + if info == nil { + continue + } + for idx, arg := range info.Args { + if !callbackArgMatchesFunction(arg, fn, targetSym, parentResult.Graph, bindings, moduleBindings, preferTarget) { + continue + } + calleeType := callbackCalleeType(parentResult.NarrowSynth, info, ev.Point) + if expected := callbackExpectedFunction(parentResult.NarrowSynth, calleeType, info, ev.Point, idx, arg); expected != nil { + ctx = api.MergeAnalysisContext(ctx, api.AnalysisContext{ExpectedFunction: expected}) + } + spec := contract.ExtractSpec(calleeType) + if spec == nil { + continue + } + cb := spec.GetCallback(idx) + if cb == nil || len(cb.EnvOverlay) == 0 { + continue + } + ctx = api.MergeAnalysisContext(ctx, api.AnalysisContext{GlobalOverlay: cb.EnvOverlay}) + } + } + return ctx +} + +func callbackExpectedFunction( + synth api.Synth, + calleeType typ.Type, + info *cfg.CallInfo, + p cfg.Point, + idx int, + arg ast.Expr, +) *typ.Function { + if synth == nil || info == nil || idx < 0 || arg == nil { + return nil + } + fnArg, ok := arg.(*ast.FunctionExpr) + if !ok { + return nil + } + query := synth.CallQuery() + if query == nil { + return nil + } + def := ops.CallDef{ + Callee: calleeType, + Args: shallowCallbackArgTypes(synth, info.Args, p), + Query: query, + } + if callsite.IsMethodCallInfo(info) { + def.IsMethod = true + def.Receiver = synth.TypeOf(info.Receiver, p) + def.MethodName = info.Method + def.Callee = nil + } + inferred := ops.InferCall(synth.Context(), def) + var expected typ.Type + if idx < len(inferred.ExpectedArgs) { + expected = inferred.ExpectedArgs[idx] + } else { + expected = inferred.ExpectedVariadic + } + return phasecore.ExpectedFunctionLiteralSignature(fnArg, expected) +} + +func shallowCallbackArgTypes(synth api.Synth, args []ast.Expr, p cfg.Point) []typ.Type { + if len(args) == 0 { + return nil + } + out := make([]typ.Type, len(args)) + for i, arg := range args { + if fn, ok := arg.(*ast.FunctionExpr); ok { + out[i] = phasecore.ShallowFunctionLiteralSignature(fn) + continue + } + out[i] = synth.TypeOf(arg, p) + } + return out +} + +func callbackArgMatchesFunction( + arg ast.Expr, + fn *ast.FunctionExpr, + targetSym cfg.SymbolID, + graph *cfg.Graph, + bindings, moduleBindings *bind.BindingTable, + prefer func(cfg.SymbolID) bool, +) bool { + if arg == nil || fn == nil { + return false + } + if arg == fn { + return true + } + if targetSym == 0 { + return false + } + sym := callsite.CanonicalSymbolFromExprWithAliases(arg, 0, graph, bindings, moduleBindings, prefer) + return sym != 0 && sym == targetSym +} + +func callbackCalleeType(synth api.Synth, info *cfg.CallInfo, p cfg.Point) typ.Type { + if synth == nil || info == nil { + return nil + } + if callsite.IsMethodCallInfo(info) { + recv := synth.TypeOf(info.Receiver, p) + if method, ok := synth.Method(recv, info.Method); ok { + return method + } + return nil + } + return synth.TypeOf(info.Callee, p) +} + // nestedGroup holds a group of functions sharing the same parent scope. type nestedGroup struct { Hash uint64 @@ -155,18 +338,18 @@ func (p *Processor) processNestedGroup( graph *cfg.Graph, scopes map[cfg.Point]*scope.State, group *nestedGroup, - parentResult *api.FuncResultView, + parentResult *api.FuncAnalysisView, parentFunc *ast.FunctionExpr, ) { - // Build sibling types for this group. - siblingTypes := p.buildSiblingTypesForGroup(graph, scopes, group.Hash, group.Funcs, parentResult) - if siblingTypes == nil { - siblingTypes = make(map[cfg.SymbolID]typ.Type) + // Build sibling function types for this group. + siblingFunctionTypes := p.buildSiblingTypesForGroup(graph, scopes, group.Hash, group.Funcs, parentResult) + if siblingFunctionTypes == nil { + siblingFunctionTypes = make(map[cfg.SymbolID]typ.Type) } // Process each function in the group. for _, info := range group.Funcs { - p.processNestedFunction(graph, scopes, info, siblingTypes, parentResult, parentFunc) + p.processNestedFunction(graph, scopes, info, siblingFunctionTypes, parentResult, parentFunc) } } @@ -175,8 +358,8 @@ func (p *Processor) processNestedFunction( graph *cfg.Graph, scopes map[cfg.Point]*scope.State, info *nested.FuncInfo, - siblingTypes map[cfg.SymbolID]typ.Type, - parentResult *api.FuncResultView, + siblingFunctionTypes map[cfg.SymbolID]typ.Type, + parentResult *api.FuncAnalysisView, parentFunc *ast.FunctionExpr, ) { baseParentScope := scopes[info.NF.Point] @@ -206,7 +389,7 @@ func (p *Processor) processNestedFunction( } } if len(capturedSet) > 0 { - fields := assign.CollectFieldAssignments(parentResult.Graph, parentResult.NarrowSynth.TypeOf, capturedSet) + fields := overlaymut.CollectFieldAssignments(parentResult.Evidence.Assignments, parentResult.NarrowSynth.TypeOf, capturedSet) if len(fields) > 0 { if capturedTypes == nil { capturedTypes = make(map[cfg.SymbolID]typ.Type, len(fields)) @@ -217,7 +400,7 @@ func (p *Processor) processNestedFunction( continue } base := capturedTypes[sym] - capturedTypes[sym] = returns.MergeFieldsIntoType(base, fieldMap) + capturedTypes[sym] = overlaymut.MergeFieldsIntoType(base, fieldMap) } } } @@ -244,9 +427,9 @@ func (p *Processor) processNestedFunction( if info.FuncDef == nil || !info.FuncDef.IsMethod { fn := info.NF.Func if phasecore.HasUnannotatedSelfParam(fn, graph.Bindings()) { - selfType, tblSym := p.resolveSelfTypeForImplicitSelf(info, siblingTypes, graph, parentResult, capturedTypes) + selfType, tblSym := p.resolveSelfTypeForImplicitSelf(info, siblingFunctionTypes, graph, parentResult, capturedTypes) if selfType != nil && tblSym != 0 && p.store != nil { - selfType = nested.EnrichSelfTypeWithConstructorFields(selfType, tblSym, &nestedStoreAdapter{store: p.store}) + selfType = nested.EnrichSelfTypeWithConstructorFields(selfType, p.constructorFieldsForClass(tblSym)) } if selfType != nil { parentScope = parentScope.WithSelf(selfType).WithLocalName("self") @@ -260,11 +443,11 @@ func (p *Processor) processNestedFunction( // Check the function. if p.check != nil { - p.check(info.NF.Func, parentScope) + p.check(info.NF.Func, parentScope, p.callbackAnalysisContext(info.NF.Func, parentResult)) } // Get the result for constructor detection and sibling updates. - result := (*api.FuncResultView)(nil) + result := (*api.FuncAnalysisView)(nil) if p.resultForFunc != nil { result = p.resultForFunc(info.NF.Func) } @@ -274,24 +457,57 @@ func (p *Processor) processNestedFunction( // Detect constructor pattern and store instance fields. if result.Graph != nil && p.store != nil { - classSym, selfSym := nested.DetectConstructorPattern(result.Graph, graph, info.NF.Func, info.FuncDef) - if classSym != 0 && selfSym != 0 { - var synthFn func(ast.Expr, cfg.Point) typ.Type - if result.NarrowSynth != nil { - synthFn = result.NarrowSynth.TypeOf - } - fields := nested.CollectConstructorFields(result.Graph, selfSym, synthFn) - if len(fields) > 0 { - p.store.StoreConstructorFields(classSym, fields) - } - } + pattern := nested.DetectConstructorPatternInfo(result.Evidence, parentResult.Evidence, info.NF.Func, info.FuncDef) + p.persistConstructorFields(pattern, result) } // Update sibling types with the fully-inferred function type. if info.IsLocal && info.FuncSym != 0 && result.NarrowSynth != nil { if inferredType := result.NarrowSynth.FunctionType(info.NF.Func, parentScope); inferredType != nil { - siblingTypes[info.FuncSym] = returns.MergeFunctionFactType(siblingTypes[info.FuncSym], inferredType) + siblingFunctionTypes[info.FuncSym] = functionfact.MergeType(siblingFunctionTypes[info.FuncSym], inferredType) + } + } +} + +func (p *Processor) persistConstructorFields(pattern nested.ConstructorPattern, result *api.FuncAnalysisView) { + if p.store == nil || result == nil || pattern.ClassSymbol == 0 { + return + } + synthFn := constructorFieldSynth(result) + fields := nested.CollectConstructorFields(result.Evidence.Assignments, pattern.SelfSymbol, synthFn) + fields = nested.MergeConstructorFieldMaps(fields, + nested.CollectConstructorLiteralFields(pattern.InstanceLiteral, pattern.InstancePoint, synthFn)) + if len(fields) == 0 { + return + } + p.store.MergeInterprocFactsNext(api.ModuleFactsKey(), interprocdomain.ConstructorFieldsDelta(pattern.ClassSymbol, fields)) + if pattern.PrototypeSymbol != 0 && pattern.PrototypeSymbol != pattern.ClassSymbol { + p.store.MergeInterprocFactsNext(api.ModuleFactsKey(), interprocdomain.ConstructorFieldsDelta(pattern.PrototypeSymbol, fields)) + } +} + +func constructorFieldSynth(result *api.FuncAnalysisView) func(ast.Expr, cfg.Point) typ.Type { + if result == nil || result.NarrowSynth == nil { + return nil + } + synthFn := result.NarrowSynth.TypeOf + if result.Graph == nil { + return synthFn + } + bindings := result.Graph.Bindings() + if bindings == nil { + return synthFn + } + return func(expr ast.Expr, p cfg.Point) typ.Type { + if ident, ok := expr.(*ast.IdentExpr); ok { + if sym, found := bindings.SymbolOf(ident); found && sym != 0 && result.Facts != nil { + tv := result.Facts.EffectiveTypeAt(p, sym) + if tv.State == flow.StateResolved && !typ.IsAbsentOrUnknown(tv.Type) { + return tv.Type + } + } } + return synthFn(expr, p) } } @@ -300,8 +516,8 @@ func (p *Processor) resolveSelfTypeForMethod( info *nested.FuncInfo, sym cfg.SymbolID, graph *cfg.Graph, - parentResult *api.FuncResultView, - rootResult *api.FuncResultView, + parentResult *api.FuncAnalysisView, + rootResult *api.FuncAnalysisView, ) typ.Type { var selfType typ.Type @@ -314,7 +530,18 @@ func (p *Processor) resolveSelfTypeForMethod( } } - // First try root result facts. + // Then ask the parent narrowed synthesizer for the receiver expression at + // the method definition point. This keeps prototype methods anchored to + // their receiver table even when later field evidence stores back-references + // to other objects inside that table. + if selfType == nil && info != nil && info.FuncDef != nil && info.FuncDef.Receiver != nil && + parentResult != nil && parentResult.NarrowSynth != nil { + if t := parentResult.NarrowSynth.TypeOf(info.FuncDef.Receiver, info.NF.Point); t != nil { + selfType = t + } + } + + // Then try root result facts. if selfType == nil && rootResult != nil && rootResult.Facts != nil { tv := rootResult.Facts.EffectiveTypeAt(info.NF.Point, sym) if tv.Type != nil && tv.State == flow.StateResolved { @@ -322,7 +549,7 @@ func (p *Processor) resolveSelfTypeForMethod( } } - // Fall back to parent result facts. + // Then consult parent result facts. if selfType == nil && parentResult != nil && parentResult.Facts != nil { tv := parentResult.Facts.EffectiveTypeAt(info.NF.Point, sym) if tv.Type != nil && tv.State == flow.StateResolved { @@ -332,7 +559,7 @@ func (p *Processor) resolveSelfTypeForMethod( // Enrich self-type with constructor instance fields. if selfType != nil && p.store != nil { - selfType = nested.EnrichSelfTypeWithConstructorFields(selfType, sym, &nestedStoreAdapter{store: p.store}) + selfType = nested.EnrichSelfTypeWithConstructorFields(selfType, p.constructorFieldsForClass(sym)) } return selfType @@ -368,27 +595,29 @@ func (p *Processor) persistCapturedTypesForNestedGraph( if len(nextCaptured) == 0 { return } - p.store.UpdateInterprocFactsNext(key, func(facts *api.Facts) { - facts.CapturedTypes = returns.WidenCapturedTypes(facts.CapturedTypes, nextCaptured) - }) + p.store.MergeInterprocFactsNext(key, interprocdomain.CapturedTypesDelta(nextCaptured)) } // resolveSelfTypeForImplicitSelf resolves the self-type for methods with implicit self parameter. func (p *Processor) resolveSelfTypeForImplicitSelf( info *nested.FuncInfo, - siblingTypes map[cfg.SymbolID]typ.Type, + siblingFunctionTypes map[cfg.SymbolID]typ.Type, graph *cfg.Graph, - parentResult *api.FuncResultView, + parentResult *api.FuncAnalysisView, capturedTypes map[cfg.SymbolID]typ.Type, ) (typ.Type, cfg.SymbolID) { fn := info.NF.Func var selfType typ.Type var tblSym cfg.SymbolID var tbl *ast.TableExpr + var assignments []api.AssignmentEvidence + if parentResult != nil { + assignments = parentResult.Evidence.Assignments + } // Pattern 1: Table literal methods {m = function(self)...} - if tbl, tblSym = nested.FindTableLiteralOwner(graph, fn); tbl != nil && tblSym != 0 { - selfType = siblingTypes[tblSym] + if tbl, tblSym = nested.FindTableLiteralOwner(assignments, fn); tbl != nil && tblSym != 0 { + selfType = siblingFunctionTypes[tblSym] // Use table literal type when available. if selfType == nil && parentResult != nil && parentResult.NarrowSynth != nil { selfType = parentResult.NarrowSynth.TypeOf(tbl, info.NF.Point) @@ -398,7 +627,7 @@ func (p *Processor) resolveSelfTypeForImplicitSelf( path := constraint.Path{Symbol: tblSym} selfType = parentResult.FlowSolution.TypeAt(info.NF.Point, path) } - // Fall back to Facts.EffectiveTypeAt. + // Then consult Facts.EffectiveTypeAt. if selfType == nil && parentResult != nil && parentResult.Facts != nil { tv := parentResult.Facts.EffectiveTypeAt(info.NF.Point, tblSym) if tv.Type != nil && tv.State == flow.StateResolved { @@ -406,16 +635,18 @@ func (p *Processor) resolveSelfTypeForImplicitSelf( } } if rec, ok := selfType.(*typ.Record); ok { - selfType = nested.EnrichTableTypeWithFuncTypes(rec, tbl, graph, siblingTypes) + selfType = nested.EnrichTableTypeWithFunctionLookup(rec, tbl, graph, func(sym cfg.SymbolID) typ.Type { + return siblingFunctionTypes[sym] + }) } } // Pattern 2: Field assignment methods obj.m = function(self)... if selfType == nil { - baseSym, baseTbl, baseTblPoint := nested.FindFieldAssignmentBase(graph, fn, info.NF.Point) + baseSym, baseTbl, baseTblPoint := nested.FindFieldAssignmentBase(assignments, fn, info.NF.Point) if baseSym != 0 { tblSym = baseSym - selfType = siblingTypes[baseSym] + selfType = siblingFunctionTypes[baseSym] // Use captured types from the parent scope (flow-derived). if selfType == nil && len(capturedTypes) > 0 { if t := capturedTypes[baseSym]; t != nil { @@ -431,7 +662,7 @@ func (p *Processor) resolveSelfTypeForImplicitSelf( path := constraint.Path{Symbol: baseSym} selfType = parentResult.FlowSolution.TypeAt(info.NF.Point, path) } - // Fall back to Facts.EffectiveTypeAt. + // Then consult Facts.EffectiveTypeAt. if selfType == nil && parentResult != nil && parentResult.Facts != nil { tv := parentResult.Facts.EffectiveTypeAt(info.NF.Point, baseSym) if tv.Type != nil && tv.State == flow.StateResolved { @@ -439,7 +670,9 @@ func (p *Processor) resolveSelfTypeForImplicitSelf( } } if rec, ok := selfType.(*typ.Record); ok && baseTbl != nil { - selfType = nested.EnrichTableTypeWithFuncTypes(rec, baseTbl, graph, siblingTypes) + selfType = nested.EnrichTableTypeWithFunctionLookup(rec, baseTbl, graph, func(sym cfg.SymbolID) typ.Type { + return siblingFunctionTypes[sym] + }) } } } @@ -447,16 +680,11 @@ func (p *Processor) resolveSelfTypeForImplicitSelf( return selfType, tblSym } -// nestedStoreAdapter implements nested.Store for the enrich functions. -type nestedStoreAdapter struct { - store api.NestedStore -} - -func (s *nestedStoreAdapter) LookupConstructorFields(classSym cfg.SymbolID) map[string]typ.Type { - if s.store == nil { +func (p *Processor) constructorFieldsForClass(classSym cfg.SymbolID) map[string]typ.Type { + if p == nil || p.store == nil || classSym == 0 { return nil } - return s.store.LookupConstructorFields(classSym) + return p.store.GetModuleFacts().ConstructorFields[classSym] } // buildSiblingTypesForGroup computes sibling function types for a scope group. @@ -465,7 +693,7 @@ func (p *Processor) buildSiblingTypesForGroup( scopes map[cfg.Point]*scope.State, groupHash uint64, funcs []*nested.FuncInfo, - parentResult *api.FuncResultView, + parentResult *api.FuncAnalysisView, ) map[cfg.SymbolID]typ.Type { if p.store == nil || graph == nil || len(funcs) == 0 { return nil @@ -488,12 +716,11 @@ func (p *Processor) buildSiblingTypesForGroup( GroupHash: groupHash, } - // Use canonical local function types (signatures + param hints + return summaries). var parentScope *scope.State if len(funcs) > 0 { parentScope = funcs[0].DefScope } - buildCfg.FuncTypes = p.store.GetLocalFuncTypesSnapshot(graph, parentScope) + buildCfg.FunctionFacts = p.store.GetInterprocFacts(graph, parentScope).FunctionFacts buildCfg.Services = siblings.BuildServicesFuncs{ CapturedSymbolsFn: func(fn *ast.FunctionExpr) []cfg.SymbolID { @@ -525,8 +752,13 @@ func (p *Processor) buildSiblingTypesForGroup( return chosen }, EnrichRecordFn: func(rec *typ.Record, sym cfg.SymbolID) typ.Type { - if tbl, _ := nested.FindTableLiteralForSymbol(graph, sym); tbl != nil { - return nested.EnrichTableTypeWithFuncTypes(rec, tbl, graph, buildCfg.FuncTypes) + if parentResult == nil { + return nil + } + if tbl, _ := nested.FindTableLiteralForSymbol(parentResult.Evidence.Assignments, sym); tbl != nil { + return nested.EnrichTableTypeWithFunctionLookup(rec, tbl, graph, func(fnSym cfg.SymbolID) typ.Type { + return functionfact.TypeFromMap(buildCfg.FunctionFacts, fnSym) + }) } return nil }, diff --git a/compiler/check/infer/nested/processor_test.go b/compiler/check/infer/nested/processor_test.go index c343fbba..b28cef73 100644 --- a/compiler/check/infer/nested/processor_test.go +++ b/compiler/check/infer/nested/processor_test.go @@ -13,5 +13,5 @@ func TestProcessNestedFunctions_NilResult(t *testing.T) { func TestProcessNestedFunctions_NilScopes(t *testing.T) { p := New(Config{}) - p.ProcessNestedFunctions(nil, &api.FuncResultView{}) + p.ProcessNestedFunctions(nil, &api.FuncAnalysisView{}) } diff --git a/compiler/check/infer/paramhints/doc.go b/compiler/check/infer/paramhints/doc.go deleted file mode 100644 index b8a27c21..00000000 --- a/compiler/check/infer/paramhints/doc.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package paramhints infers parameter types from call site arguments. -// -// This package analyzes function call sites to infer parameter types for -// functions without explicit type annotations. When a function is called -// with known-type arguments, those types hint at the parameter types. -// -// # Hint Collection -// -// For each call site: -// -// foo(123, "bar") -- hints: param1=number, param2=string -// -// The package collects argument types and associates them with parameter -// positions. Multiple call sites contribute hints that are joined. -// -// # Hint Merging -// -// When multiple calls provide conflicting hints: -// -// foo(1) -- hints: param1=number -// foo("a") -- hints: param1=string -// -// The hints are joined to produce: param1 = number | string -// -// # Integration -// -// Parameter hints feed into function signature inference, providing -// types for parameters that lack explicit annotations. -package paramhints diff --git a/compiler/check/infer/paramhints/param_hints.go b/compiler/check/infer/paramhints/param_hints.go deleted file mode 100644 index cdbf1879..00000000 --- a/compiler/check/infer/paramhints/param_hints.go +++ /dev/null @@ -1,379 +0,0 @@ -package paramhints - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/internal" - "github.com/wippyai/go-lua/types/kind" - "github.com/wippyai/go-lua/types/typ" -) - -type HintJoinFn func(prev, next typ.Type) typ.Type - -// MergeIntoSignature replaces unannotated parameter slots (and refinable -// top-like annotations) with call-site hints. -func MergeIntoSignature(fn *ast.FunctionExpr, hints []typ.Type, sig *typ.Function) *typ.Function { - if sig == nil || fn == nil || fn.ParList == nil { - return sig - } - modified := false - for i, p := range sig.Params { - if i >= len(hints) || hints[i] == nil { - continue - } - if i < len(fn.ParList.Types) && fn.ParList.Types[i] != nil { - if !typ.IsRefinableAnnotation(p.Type) { - continue - } - } - if !typ.TypeEquals(p.Type, hints[i]) { - modified = true - } - } - if !modified { - return sig - } - - builder := typ.Func() - for i, p := range sig.Params { - paramType := p.Type - if i < len(hints) && hints[i] != nil { - annotated := i < len(fn.ParList.Types) && fn.ParList.Types[i] != nil - if !annotated || typ.IsRefinableAnnotation(paramType) { - paramType = hints[i] - } - } - if p.Optional { - builder = builder.OptParam(p.Name, paramType) - } else { - builder = builder.Param(p.Name, paramType) - } - } - if sig.Variadic != nil { - builder = builder.Variadic(sig.Variadic) - } - if len(sig.Returns) > 0 { - builder = builder.Returns(sig.Returns...) - } - if sig.Effects != nil { - builder = builder.Effects(sig.Effects) - } - if sig.Spec != nil { - builder = builder.Spec(sig.Spec) - } - if sig.Refinement != nil { - builder = builder.WithRefinement(sig.Refinement) - } - return builder.Build() -} - -func WidenParamHintType(t typ.Type) typ.Type { - if t == nil { - return nil - } - switch v := t.(type) { - case *typ.Literal: - switch v.Base { - case kind.Boolean: - return typ.Boolean - case kind.Integer: - return typ.Integer - case kind.Number: - return typ.Number - case kind.String: - return typ.String - } - case *typ.Optional: - inner := WidenParamHintType(v.Inner) - if inner != v.Inner && inner != nil { - return typ.NewOptional(inner) - } - case *typ.Alias: - if v.Target != nil { - return WidenParamHintType(v.Target) - } - case *typ.Union: - changed := false - members := make([]typ.Type, 0, len(v.Members)) - for _, m := range v.Members { - wm := WidenParamHintType(m) - if wm != m { - changed = true - } - members = append(members, wm) - } - if changed { - return typ.NewUnion(members...) - } - case *typ.Record: - builder := typ.NewRecord() - changed := false - if !v.Open { - // Call-site table literals should not over-constrain unannotated params. - // Widen record hints to open records so optional field probes remain valid. - builder.SetOpen(true) - changed = true - } else { - builder.SetOpen(true) - } - for _, f := range v.Fields { - ft := WidenParamHintType(f.Type) - if ft != f.Type { - changed = true - } - if f.Optional { - builder.OptField(f.Name, ft) - } else { - builder.Field(f.Name, ft) - } - } - if v.MapKey != nil && v.MapValue != nil { - k := WidenParamHintType(v.MapKey) - val := WidenParamHintType(v.MapValue) - if k != v.MapKey || val != v.MapValue { - changed = true - } - builder.MapComponent(k, val) - } - if v.Metatable != nil { - builder.Metatable(v.Metatable) - } - if changed { - return builder.Build() - } - } - return t -} - -// NormalizeHintType applies canonical widening and soft-member pruning. -func NormalizeHintType(t typ.Type) typ.Type { - return typ.PruneSoftUnionMembers(WidenParamHintType(t)) -} - -// EnsureHintCapacity grows hint vector to at least size. -func EnsureHintCapacity(hints []typ.Type, size int) []typ.Type { - if size <= len(hints) { - return hints - } - expanded := make([]typ.Type, size) - copy(expanded, hints) - return expanded -} - -// MergeHintAt normalizes and joins one hint into vector slot idx. -func MergeHintAt(hints []typ.Type, idx int, hint typ.Type, join HintJoinFn) ([]typ.Type, bool) { - if idx < 0 { - return hints, false - } - hint = NormalizeHintType(hint) - if !IsInformativeHintType(hint) { - return hints, false - } - hints = EnsureHintCapacity(hints, idx+1) - - joinFn := join - if joinFn == nil { - joinFn = typ.JoinPreferNonSoft - } - prev := hints[idx] - merged := joinFn(prev, hint) - if typ.TypeEquals(prev, merged) { - return hints, false - } - hints[idx] = merged - return hints, true -} - -// MergeCallArgHintAt merges a call-argument observation into a parameter hint -// slot. Unlike MergeHintAt, unresolved/top-like argument observations are -// preserved as uncertainty evidence so later literal calls cannot over-specialize -// unannotated parameters. -func MergeCallArgHintAt(hints []typ.Type, idx int, argType typ.Type, join HintJoinFn, unknownOnNil bool) ([]typ.Type, bool) { - if idx < 0 { - return hints, false - } - argType = NormalizeHintType(argType) - if argType == nil { - if !unknownOnNil { - return hints, false - } - argType = typ.Unknown - } - hints = EnsureHintCapacity(hints, idx+1) - - joinFn := join - if joinFn == nil { - joinFn = typ.JoinPreferNonSoft - } - - prev := NormalizeHintType(hints[idx]) - if prev == nil { - prev = hints[idx] - } - - mergeTopAware := func(a, b typ.Type) typ.Type { - if a == nil { - return b - } - if b == nil { - return a - } - if typ.IsAny(a) || typ.IsAny(b) { - return typ.Any - } - if typ.IsUnknown(a) { - return b - } - if typ.IsUnknown(b) { - return a - } - return joinFn(a, b) - } - - topLikeArg := typ.IsAny(argType) || typ.IsUnknown(argType) - if !topLikeArg && !IsInformativeHintType(argType) { - return hints, false - } - - merged := mergeTopAware(prev, argType) - if typ.TypeEquals(hints[idx], merged) { - return hints, false - } - hints[idx] = merged - return hints, true -} - -// IsInformativeHintType reports whether a type carries useful call-site -// information for parameter hint propagation. -// -// It intentionally rejects top-like and empty placeholder shapes that tend to -// poison hints, while preserving structured hints such as maps/arrays with -// partial information (for example `{[string]: any[]}`). -func IsInformativeHintType(t typ.Type) bool { - return isInformativeHintType(t, typ.NewGuard()) -} - -func isInformativeHintType(t typ.Type, guard internal.RecursionGuard) bool { - if t == nil { - return false - } - next, ok := guard.Enter(t) - if !ok { - return false - } - - if t.Kind().IsDeferred() { - return false - } - - k := t.Kind() - if k.IsPlaceholder() || k == kind.Nil || k == kind.Never { - return false - } - - switch v := t.(type) { - case *typ.Optional: - return isInformativeHintType(v.Inner, next) - case *typ.Union: - for _, m := range v.Members { - if isInformativeHintType(m, next) { - return true - } - } - return false - case *typ.Alias: - if v.Target == nil { - return false - } - return isInformativeHintType(v.Target, next) - } - - if r, ok := t.(*typ.Record); ok { - if len(r.Fields) == 0 && !r.HasMapComponent() && !r.Open { - return false - } - } - - return true -} - -// BuildParamHintSigView builds a function-expression keyed hint map for this graph. -// It merges per-iteration scratch hints with symbol-based hints from the store. -// Scratch hints take precedence over symbol-derived hints. -func BuildParamHintSigView( - store api.StoreView, - graph *cfg.Graph, - parent *scope.State, - stdlib *scope.State, -) map[*ast.FunctionExpr][]typ.Type { - if store == nil || graph == nil || parent == nil { - return nil - } - - // Use stable snapshot param hints during analysis. - symHints := store.GetParamHintsSnapshot(graph, parent) - - out := make(map[*ast.FunctionExpr][]typ.Type) - - if len(symHints) > 0 { - for _, sym := range cfg.SortedSymbolIDs(symHints) { - hints := symHints[sym] - if len(hints) == 0 { - continue - } - hasHint := false - for _, hint := range hints { - if hint != nil { - hasHint = true - break - } - } - if !hasHint { - continue - } - fn := store.FuncForSymbol(sym) - if fn == nil { - continue - } - if _, exists := out[fn]; !exists { - out[fn] = hints - } - } - } - - // If this graph is a nested function, pull param hints from the parent graph - // and apply them to the current function signature. - if meta, ok := store.NestedMetaFor(graph.ID()); ok { - parentGraph := store.Graphs()[meta.ParentGraphID] - if parentGraph != nil { - fallback := (*scope.State)(nil) - if _, isNestedParent := store.NestedMetaFor(parentGraph.ID()); !isNestedParent { - fallback = stdlib - } - parentScope := api.ParentScopeForGraph(store, parentGraph.ID(), fallback) - if parentScope != nil { - parentHints := store.GetParamHintsSnapshot(parentGraph, parentScope) - if len(parentHints) > 0 { - fn := store.FuncForGraph(graph) - if fn == nil { - fn = graph.Func() - } - if fn != nil { - if sym, ok := store.SymbolForFunc(fn); ok { - if hints := parentHints[sym]; len(hints) > 0 { - out[fn] = hints - } - } - } - } - } - } - } - - if len(out) == 0 { - return nil - } - return out -} diff --git a/compiler/check/infer/paramhints/param_hints_test.go b/compiler/check/infer/paramhints/param_hints_test.go deleted file mode 100644 index 62fd6e82..00000000 --- a/compiler/check/infer/paramhints/param_hints_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package paramhints - -import ( - "testing" - - "github.com/wippyai/go-lua/types/typ" -) - -func TestWidenParamHintType_Nil(t *testing.T) { - result := WidenParamHintType(nil) - if result != nil { - t.Errorf("expected nil, got %v", result) - } -} - -func TestWidenParamHintType_BooleanLiteral(t *testing.T) { - lit := typ.LiteralBool(true) - result := WidenParamHintType(lit) - if result != typ.Boolean { - t.Errorf("expected Boolean, got %v", result) - } -} - -func TestWidenParamHintType_IntegerLiteral(t *testing.T) { - lit := typ.LiteralInt(42) - result := WidenParamHintType(lit) - if result != typ.Integer { - t.Errorf("expected Integer, got %v", result) - } -} - -func TestWidenParamHintType_NumberLiteral(t *testing.T) { - lit := typ.LiteralNumber(3.14) - result := WidenParamHintType(lit) - if result != typ.Number { - t.Errorf("expected Number, got %v", result) - } -} - -func TestWidenParamHintType_StringLiteral(t *testing.T) { - lit := typ.LiteralString("hello") - result := WidenParamHintType(lit) - if result != typ.String { - t.Errorf("expected String, got %v", result) - } -} - -func TestWidenParamHintType_NonLiteral(t *testing.T) { - result := WidenParamHintType(typ.String) - if result != typ.String { - t.Errorf("expected String unchanged, got %v", result) - } -} - -func TestWidenParamHintType_Alias(t *testing.T) { - alias := typ.NewAlias("NumAlias", typ.Number) - result := WidenParamHintType(alias) - if result != typ.Number { - t.Errorf("expected alias to widen to Number, got %v", result) - } -} - -func TestWidenParamHintType_Optional(t *testing.T) { - lit := typ.LiteralString("hello") - opt := typ.NewOptional(lit) - result := WidenParamHintType(opt) - if result == nil { - t.Fatal("expected non-nil result") - } - optResult, ok := result.(*typ.Optional) - if !ok { - t.Fatalf("expected Optional, got %T", result) - } - if optResult.Inner != typ.String { - t.Errorf("expected inner to be String, got %v", optResult.Inner) - } -} - -func TestWidenParamHintType_Union(t *testing.T) { - lit1 := typ.LiteralString("a") - lit2 := typ.LiteralNumber(1.0) - union := typ.NewUnion(lit1, lit2) - result := WidenParamHintType(union) - if result == nil { - t.Fatal("expected non-nil result") - } -} - -func TestWidenParamHintType_RecordBecomesOpen(t *testing.T) { - rec := typ.NewRecord(). - Field("pid", typ.LiteralString("abc")). - Field("topic", typ.LiteralString("test:update")). - Build() - - result := WidenParamHintType(rec) - widened, ok := result.(*typ.Record) - if !ok { - t.Fatalf("expected record result, got %T", result) - } - if !widened.Open { - t.Fatalf("expected widened param hint record to be open, got closed: %v", widened) - } - - pid := widened.GetField("pid") - if pid == nil || !typ.TypeEquals(pid.Type, typ.String) { - t.Fatalf("expected pid field widened to string, got %v", pid) - } - topic := widened.GetField("topic") - if topic == nil || !typ.TypeEquals(topic.Type, typ.String) { - t.Fatalf("expected topic field widened to string, got %v", topic) - } -} - -func TestBuildParamHintSigView_NilInputs(t *testing.T) { - result := BuildParamHintSigView(nil, nil, nil, nil) - if result != nil { - t.Errorf("expected nil for nil inputs, got %v", result) - } -} - -func TestIsInformativeHintType(t *testing.T) { - tests := []struct { - name string - in typ.Type - want bool - }{ - {name: "nil", in: nil, want: false}, - {name: "any", in: typ.Any, want: false}, - {name: "unknown", in: typ.Unknown, want: false}, - {name: "never", in: typ.Never, want: false}, - {name: "nil type", in: typ.Nil, want: false}, - {name: "empty record", in: typ.NewRecord().Build(), want: false}, - {name: "map with string key", in: typ.NewMap(typ.String, typ.NewArray(typ.Any)), want: true}, - {name: "record map component", in: typ.NewRecord().MapComponent(typ.String, typ.Any).Build(), want: true}, - {name: "string", in: typ.String, want: true}, - {name: "literal", in: typ.LiteralString("x"), want: true}, - {name: "type param", in: typ.NewTypeParam("T", nil), want: false}, - {name: "ref", in: typ.NewRef("", "Foo"), want: false}, - {name: "optional unknown", in: typ.NewOptional(typ.Unknown), want: false}, - {name: "optional string", in: typ.NewOptional(typ.String), want: true}, - {name: "union placeholders", in: typ.NewUnion(typ.Unknown, typ.Nil), want: false}, - {name: "union with informative member", in: typ.NewUnion(typ.Unknown, typ.String), want: true}, - } - - for _, tt := range tests { - if got := IsInformativeHintType(tt.in); got != tt.want { - t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) - } - } -} - -func TestEnsureHintCapacity(t *testing.T) { - base := []typ.Type{typ.String} - got := EnsureHintCapacity(base, 3) - if len(got) != 3 { - t.Fatalf("EnsureHintCapacity len = %d, want 3", len(got)) - } - if got[0] != typ.String { - t.Fatalf("EnsureHintCapacity preserved value = %v, want string", got[0]) - } -} - -func TestMergeHintAt(t *testing.T) { - join := func(prev, next typ.Type) typ.Type { return typ.JoinPreferNonSoft(prev, next) } - - t.Run("filters non-informative", func(t *testing.T) { - hints := []typ.Type{typ.String} - got, changed := MergeHintAt(hints, 1, typ.Unknown, join) - if changed { - t.Fatal("expected no change for unknown hint") - } - if len(got) != 1 { - t.Fatalf("expected unchanged slice len 1, got %d", len(got)) - } - }) - - t.Run("normalizes literal and merges", func(t *testing.T) { - got, changed := MergeHintAt(nil, 0, typ.LiteralString("x"), join) - if !changed { - t.Fatal("expected merge change for informative literal") - } - if len(got) != 1 { - t.Fatalf("expected one hint, got %d", len(got)) - } - if !typ.TypeEquals(got[0], typ.String) { - t.Fatalf("expected normalized string hint, got %v", got[0]) - } - }) -} diff --git a/compiler/check/infer/return/infer.go b/compiler/check/infer/return/infer.go index 06340f42..e514a303 100644 --- a/compiler/check/infer/return/infer.go +++ b/compiler/check/infer/return/infer.go @@ -1,6 +1,6 @@ // infer.go implements return type inference for local functions. // This runs as a pre-phase before the main analysis pipeline to ensure -// return summaries are available when the parent function is analyzed. +// return vectors are available when the parent function is analyzed. // // # RETURN TYPE INFERENCE // @@ -20,18 +20,19 @@ // Return type inference uses monotone union for convergence: // - New return types are joined with previous return types // - Types can only grow (become more general), never shrink -// - Bounded iteration with widening to unknown on non-convergence +// - Recursive SCCs use convergence widening, so iteration is governed by +// domain stabilization rather than an artificial budget // -// # PARAM HINTS +// # PARAMETER EVIDENCE // -// Parameter type hints are collected from call sites: -// - When a() calls b(10), b's first param gets hint "number" -// - Hints from multiple call sites are joined -// - Hints propagate through the call graph (if a() calls b(), b() calls c()) +// Parameter evidence is collected from call sites: +// - When a() calls b(10), b's first param records number evidence. +// - Multiple call sites are joined. +// - Evidence propagates through the call graph (if a() calls b(), b() calls c()). // // # SEED PROPAGATION // -// Return summaries are seeded from the previous fixpoint iteration: +// Return vectors are seeded from the previous fixpoint iteration: // - Seeds provide initial return type estimates // - Iteration refines seeds using actual function body analysis // - Convergence occurs when seeds stabilize across iterations @@ -42,61 +43,60 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + flowpath "github.com/wippyai/go-lua/compiler/check/domain/path" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" "github.com/wippyai/go-lua/compiler/check/modules" "github.com/wippyai/go-lua/compiler/check/phase" "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" + "github.com/wippyai/go-lua/compiler/check/synth/ops" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/query/core" + "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" ) // Config holds dependencies for return inference. type Config struct { - Types core.TypeOps - GlobalTypes map[string]typ.Type - Manifests io.ManifestQuerier - Stdlib *scope.State - Store api.StoreView - Graphs api.GraphProvider - SourceName string - MaxIterations int + Types core.TypeOps + GlobalTypes map[string]typ.Type + Manifests io.ManifestQuerier + Stdlib *scope.State + Store api.StoreReader + Graphs api.GraphProvider + SourceName string } -// Inferencer computes pre-flow return summaries for local functions. +// Inferencer computes pre-flow return vectors for local functions. type Inferencer struct { - types core.TypeOps - globalTypes map[string]typ.Type - manifests io.ManifestQuerier - stdlib *scope.State - store api.StoreView - graphs api.GraphProvider - sourceName string - maxIterations int + types core.TypeOps + globalTypes map[string]typ.Type + manifests io.ManifestQuerier + stdlib *scope.State + store api.StoreReader + graphs api.GraphProvider + sourceName string } // New creates a configured return inferencer. func New(cfg Config) *Inferencer { - maxIter := cfg.MaxIterations - if maxIter <= 0 { - maxIter = 10 - } return &Inferencer{ - types: cfg.Types, - globalTypes: cfg.GlobalTypes, - manifests: cfg.Manifests, - stdlib: cfg.Stdlib, - store: cfg.Store, - graphs: cfg.Graphs, - sourceName: cfg.SourceName, - maxIterations: maxIter, + types: cfg.Types, + globalTypes: cfg.GlobalTypes, + manifests: cfg.Manifests, + stdlib: cfg.Stdlib, + store: cfg.Store, + graphs: cfg.Graphs, + sourceName: cfg.SourceName, } } @@ -107,76 +107,57 @@ type RunContext struct { EffectLookup constraint.RefinementLookupBySym } -// collectLocalFunctions gathers local function definitions from assignments and FuncDef nodes. +// collectLocalFunctions gathers function definitions from transfer evidence. func (i *Inferencer) collectLocalFunctions( graph *cfg.Graph, pointScopes map[cfg.Point]*scope.State, parentFn *ast.FunctionExpr, ) map[cfg.SymbolID]*returns.LocalFuncInfo { localFuncs := make(map[cfg.SymbolID]*returns.LocalFuncInfo) + parentEvidence := i.transferEvidenceForGraph(graph) - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { - if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return - } - info.EachTargetSource(func(idx int, target cfg.AssignTarget, source ast.Expr) { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { - return - } - fnExpr, ok := source.(*ast.FunctionExpr) - if !ok { - return - } - - fnGraph := (*cfg.Graph)(nil) - if i.graphs != nil { - fnGraph = i.graphs.GetOrBuildCFG(fnExpr) - } - localFuncs[target.Symbol] = &returns.LocalFuncInfo{ - Sym: target.Symbol, - Fn: fnExpr, - DefScope: pointScopes[p], - Graph: fnGraph, - ParentGraph: graph, - ParentFn: parentFn, - DefPoint: p, - } - }) - }) - - graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { - if info == nil || info.Symbol == 0 || info.FuncExpr == nil { - return + for _, def := range parentEvidence.FunctionDefinitions { + if def.Symbol == 0 || def.Nested.Func == nil { + continue } - if _, exists := localFuncs[info.Symbol]; exists { - return + if _, exists := localFuncs[def.Symbol]; exists { + continue } fnGraph := (*cfg.Graph)(nil) if i.graphs != nil { - fnGraph = i.graphs.GetOrBuildCFG(info.FuncExpr) + fnGraph = i.graphs.GetOrBuildCFG(def.Nested.Func) } - localFuncs[info.Symbol] = &returns.LocalFuncInfo{ - Sym: info.Symbol, - Fn: info.FuncExpr, - DefScope: pointScopes[p], - Graph: fnGraph, - ParentGraph: graph, - ParentFn: parentFn, - DefPoint: p, + localFuncs[def.Symbol] = &returns.LocalFuncInfo{ + Sym: def.Symbol, + Fn: def.Nested.Func, + DefScope: pointScopes[def.Nested.Point], + Graph: fnGraph, + ParentGraph: graph, + ParentFn: parentFn, + DefPoint: def.Nested.Point, + Evidence: i.transferEvidenceForGraph(fnGraph), + ParentEvidence: parentEvidence, } - }) + } return localFuncs } +func (i *Inferencer) transferEvidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if i != nil && i.store != nil { + return i.store.EvidenceForGraph(graph) + } + return api.FlowEvidence{} +} + // newReturnInferenceEngine creates a synthesis engine configured for return type -// inference within the pre-flow return summary computation phase. +// inference within the pre-flow return-vector computation phase. // // The engine operates in PhaseScopeCompute mode with: // - Declared types from the overlay (params, siblings, captured variables) // - Global types for built-in function resolution // - Module aliases for require() resolution -// - Return summaries from previous iteration for recursive call resolution +// - Return vectors from previous iteration for recursive call resolution // // Unlike the main analysis engine, this engine does not have access to flow // solution or narrowed types, producing "declared-phase" type estimates. @@ -184,6 +165,8 @@ func (i *Inferencer) newReturnInferenceEngine( run RunContext, scopes map[cfg.Point]*scope.State, ctx api.DeclaredEnv, + evidence api.FlowEvidence, + functionFacts api.FunctionFacts, ) *synth.Engine { return synth.New(synth.Config{ Ctx: run.Ctx, @@ -191,61 +174,68 @@ func (i *Inferencer) newReturnInferenceEngine( Scopes: scopes, Manifests: i.manifests, Env: ctx, + FunctionFacts: functionFacts, Phase: api.PhaseScopeCompute, + Evidence: evidence, ModuleBindings: i.store.ModuleBindings(), ModuleAliases: i.store.ModuleAliases(), }) } -// computeReturnSummariesForGraph computes return summaries for local functions in a graph -// and stores them into the interproc facts for the current iteration. +// ComputeForGraph computes canonical function facts for local functions in a graph. func (i *Inferencer) ComputeForGraph( run RunContext, graph *cfg.Graph, parent *scope.State, -) (api.ReturnSummaries, api.FuncTypes, []diag.Diagnostic) { +) (api.FunctionFacts, []diag.Diagnostic) { if i == nil || i.store == nil || graph == nil || parent == nil { - return nil, nil, nil + return nil, nil } parentScope := api.ParentScopeForGraph(i.store, graph.ID(), parent) - engine := phase.CreateTypeResolutionEngine(run.Ctx, graph, i.globalTypes, nil, parentScope, i.types, i.manifests) + engine := phase.CreateTypeResolutionEngine(run.Ctx, graph, i.globalTypes, nil, parentScope, i.types, i.manifests, i.store.ModuleAliases()) pointScopes := scope.BuildTypeDefScopes(graph, parentScope, engine.ResolveTypeDef) localFuncs := i.collectLocalFunctions(graph, pointScopes, graph.Func()) if len(localFuncs) == 0 { - return nil, nil, nil + return nil, nil } - // Apply param hints from the stable snapshot (deterministic order). - if hints := i.store.GetParamHintsSnapshot(graph, parentScope); len(hints) > 0 { + // Apply parameter evidence from the stable canonical function facts. + if facts := i.store.GetInterprocFacts(graph, parentScope).FunctionFacts; len(facts) > 0 { for _, sym := range cfg.SortedSymbolIDs(localFuncs) { info := localFuncs[sym] if info == nil { continue } - if hintVec, ok := hints[sym]; ok && len(hintVec) > 0 { - info.ParamHints = hintVec + if hintVec := functionfact.ParameterEvidenceFromMap(facts, sym); len(hintVec) > 0 { + info.ParameterEvidence = paramevidence.ProjectToParameterUse(info.Graph.ParamSlotsReadOnly(), info.Evidence.ParameterUses, hintVec) } } } - seed := i.store.GetReturnSummariesSnapshot(graph, parentScope) - summaries, diags := i.computeReturnSummariesForGroup(run, parentScope.GroupHash(), localFuncs, seed) - funcTypes := i.buildLocalFuncTypes(localFuncs, summaries, engine, parentScope) - return summaries, funcTypes, diags + seedFacts := i.store.GetInterprocFacts(graph, parentScope).FunctionFacts + seed := make(map[cfg.SymbolID][]typ.Type, len(seedFacts)) + for sym, fact := range seedFacts { + if len(fact.Summary) > 0 { + seed[sym] = fact.Summary + } + } + returnVectors, diags := i.computeReturnVectorsForGroup(run, parentScope.GroupHash(), localFuncs, seed) + functionTypes := i.buildLocalFunctionTypes(localFuncs, returnVectors, engine, parentScope) + return assembleFunctionFacts(localFuncs, returnVectors, functionTypes), diags } -func (i *Inferencer) buildLocalFuncTypes( +func (i *Inferencer) buildLocalFunctionTypes( localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, engine *synth.Engine, parentScope *scope.State, -) api.FuncTypes { +) map[cfg.SymbolID]typ.Type { if len(localFuncs) == 0 { return nil } - out := make(api.FuncTypes, len(localFuncs)) + out := make(map[cfg.SymbolID]typ.Type, len(localFuncs)) for _, sym := range cfg.SortedSymbolIDs(localFuncs) { info := localFuncs[sym] if info == nil || info.Fn == nil { @@ -270,13 +260,13 @@ func (i *Inferencer) buildLocalFuncTypes( if fnType == nil { continue } - if len(info.ParamHints) > 0 { - if merged := paramhints.MergeIntoSignature(info.Fn, info.ParamHints, fnType); merged != nil { + if len(info.ParameterEvidence) > 0 { + if merged := paramevidence.MergeIntoSignature(info.Fn, info.ParameterEvidence, fnType); merged != nil { fnType = merged } } - if summary := summaries[sym]; len(summary) > 0 { - if withSummary := returns.WithSummaryOrUnknown(fnType, summary); withSummary != nil { + if returnVector := returnVectors[sym]; len(returnVector) > 0 { + if withSummary := returnsummary.ApplyToFunctionType(fnType, returnVector); withSummary != nil { fnType = withSummary } } @@ -288,7 +278,21 @@ func (i *Inferencer) buildLocalFuncTypes( return out } -// computeReturnSummariesForGroup computes return type summaries for a scope group +func assembleFunctionFacts( + localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, + returnVectors map[cfg.SymbolID][]typ.Type, + funcs map[cfg.SymbolID]typ.Type, +) api.FunctionFacts { + params := make(map[cfg.SymbolID][]typ.Type, len(localFuncs)) + for sym, info := range localFuncs { + if sym != 0 && info != nil && len(info.ParameterEvidence) > 0 { + params[sym] = info.ParameterEvidence + } + } + return functionfact.FromMaps(params, returnVectors, funcs) +} + +// computeReturnVectorsForGroup computes return type vectors for a scope group // using strongly connected component (SCC) based fixpoint iteration. // // SCC ORDERING: Functions are partitioned into SCCs by their call graph. SCCs are @@ -301,12 +305,12 @@ func (i *Inferencer) buildLocalFuncTypes( // - New types are joined with previous types via monotone union // - Iteration stops when no type changes // -// WIDENING: If SCC iteration exceeds MaxReturnSummaryIterations, types are widened -// to unknown to guarantee termination. A diagnostic is emitted for the non-convergence. +// WIDENING: Recursive SCCs merge through the convergence widening operator each +// round. The domain owns termination; callers do not cap iteration count. // // SEEDING: Initial return type estimates come from the seed map (previous fixpoint // iteration). This accelerates convergence for iteratively-refined modules. -func (i *Inferencer) computeReturnSummariesForGroup( +func (i *Inferencer) computeReturnVectorsForGroup( run RunContext, groupHash uint64, localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, @@ -322,15 +326,15 @@ func (i *Inferencer) computeReturnSummariesForGroup( return nil, nil } - summaries := seedSummariesFromSeed(localFuncs, seed) - return summaries, i.processSCCSummaries(run, sccs, localFuncs, summaries) + returnVectors := seedReturnVectorsFromSeed(localFuncs, seed) + return returnVectors, i.processSCCReturnVectors(run, sccs, localFuncs, returnVectors) } // returnInferenceContext holds shared state for return type inference phases. type returnInferenceContext struct { run RunContext info *returns.LocalFuncInfo - summaries map[cfg.SymbolID][]typ.Type + returnVectors map[cfg.SymbolID][]typ.Type localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo engine *synth.Engine resolveScope *scope.State @@ -340,35 +344,41 @@ type returnInferenceContext struct { } // buildParameterOverlay creates the initial type overlay with parameter types. -// Parameters are typed from annotations, hints, or default to unknown. +// Parameters are typed from annotations, parameter evidence, or default to unknown. func collectReturnTypes( - fnGraph *cfg.Graph, + returns []api.ReturnEvidence, synthEngine api.Synth, deadPoints map[cfg.Point]bool, + skipReturnExpr func(ast.Expr) bool, ) []typ.Type { - if fnGraph == nil || synthEngine == nil { + if len(returns) == 0 || synthEngine == nil { return nil } var returnTypes []typ.Type seenReturn := false - fnGraph.EachReturn(func(p cfg.Point, retInfo *cfg.ReturnInfo) { + for _, ret := range returns { + p := ret.Point + retInfo := ret.Info if retInfo == nil { - return + continue } _ = deadPoints - types := synthesizeReturnExprs(synthEngine, retInfo, p) + if len(retInfo.Exprs) == 1 && skipReturnExpr != nil && skipReturnExpr(retInfo.Exprs[0]) { + continue + } + types := synthesizeReturnExprs(synthEngine, retInfo, p, skipReturnExpr) if !seenReturn { seenReturn = true returnTypes = types - return + continue } returnTypes = joinReturnTypes(returnTypes, types) - }) + } - return returns.NormalizeReturnVector(returnTypes) + return returnsummary.NormalizeOwned(returnTypes) } // synthesizeReturnExprs computes types for a single return statement's expressions. @@ -376,14 +386,14 @@ func synthesizeReturnExprs( synthEngine api.Synth, retInfo *cfg.ReturnInfo, p cfg.Point, + skipReturnExpr func(ast.Expr) bool, ) []typ.Type { if len(retInfo.Exprs) == 0 { return nil } - - var types []typ.Type + types := make([]typ.Type, 0, len(retInfo.Exprs)) for i, expr := range retInfo.Exprs { - if i == len(retInfo.Exprs)-1 { + if i == len(retInfo.Exprs)-1 && ast.CanProduceMultipleValues(expr) { multi := synthEngine.MultiTypeOf(expr, p) if len(multi) == 0 { multi = []typ.Type{typ.Unknown} @@ -429,41 +439,69 @@ func (i *Inferencer) inferReturnTypesFromBody( finalOverlay map[cfg.SymbolID]typ.Type, ) []typ.Type { state := i.runPhase2FlowNarrowing(ctx, finalOverlay) - narrowed := collectReturnTypes(ctx.info.Graph, state.synth, state.deadPoints) + skipUnresolvedLocalCall := i.skipUnresolvedLocalReturnCall(ctx) + narrowed := collectReturnTypes(ctx.info.Evidence.Returns, state.synth, state.deadPoints, skipUnresolvedLocalCall) fnGraph := ctx.info.Graph if fnGraph == nil { return narrowed } - phaseReturnSummaries := summarizeWithoutCurrent(ctx.summaries, ctx.info) + phaseFunctionFacts := functionfact.FromSummariesExcept(ctx.returnVectors, ctx.info.Sym) declCheckCtx := api.NewReturnInferenceEnv(api.ReturnInferenceEnvConfig{ - Graph: fnGraph, - Bindings: ctx.bindings, - BaseScope: ctx.resolveScope, - DeclaredTypes: finalOverlay, - GlobalTypes: i.globalTypes, - ModuleAliases: ctx.moduleAliases, - ReturnSummaries: phaseReturnSummaries, + Graph: fnGraph, + Bindings: ctx.bindings, + BaseScope: ctx.resolveScope, + DeclaredTypes: finalOverlay, + GlobalTypes: i.globalTypes, + ModuleAliases: ctx.moduleAliases, + FunctionType: functionfact.TypeLookup(phaseFunctionFacts), }) declSynth := i.newReturnInferenceEngine( ctx.run, uniformFunctionScopes(fnGraph, ctx.resolveScope), declCheckCtx, + ctx.info.Evidence, + phaseFunctionFacts, ) - declared := collectReturnTypes(fnGraph, declSynth, nil) + declared := collectReturnTypes(ctx.info.Evidence.Returns, declSynth, nil, skipUnresolvedLocalCall) - return returns.MergeReturnSummary(declared, narrowed) + return returnsummary.Merge(declared, narrowed) +} + +func (i *Inferencer) skipUnresolvedLocalReturnCall(ctx *returnInferenceContext) func(ast.Expr) bool { + if ctx == nil || ctx.info == nil || ctx.info.Graph == nil || len(ctx.localFuncs) == 0 { + return nil + } + bindings := ctx.bindings + if bindings == nil { + bindings = ctx.info.Graph.Bindings() + } + if bindings == nil { + return nil + } + return func(expr ast.Expr) bool { + call, ok := expr.(*ast.FuncCallExpr) + if !ok || call == nil || call.Method != "" { + return false + } + sym := callsite.SymbolFromExpr(call.Func, bindings) + if sym == 0 || ctx.localFuncs[sym] == nil { + return false + } + return typ.IsUnknownOnlyOrEmpty(returnsummary.Normalize(ctx.returnVectors[sym])) + } } -// inferReturnWithSummary infers return types for a single function using available summaries. -// This is the core inference logic called by computeReturnSummariesForGroup for each function. +// inferReturnForFunction infers return types for one local function from the +// current SCC return-vector state. +// This is the core inference logic called by computeReturnVectorsForGroup for each function. // // TWO-PHASE INFERENCE: // // Phase 1 (Preliminary): Collect inferred types for local variables within the function. // This uses a preliminary synthesis engine with: -// - Parameter types (from annotations or param hints) -// - Sibling function types (from summaries) +// - Parameter types (from annotations or parameter evidence) +// - Sibling function types (from return vectors) // - Captured variable types (from parent function result) // // Phase 2 (Final): Compute return types using enriched overlay containing: @@ -477,10 +515,10 @@ func (i *Inferencer) inferReturnTypesFromBody( // // MULTI-RETURN: Functions may return multiple values. The inference handles multi-return // by expanding the last expression (which may be a call or vararg) and joining position-wise. -func (i *Inferencer) inferReturnWithSummary( +func (i *Inferencer) inferReturnForFunction( run RunContext, info *returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, ) []typ.Type { if info == nil || info.Fn == nil || info.Graph == nil { @@ -490,9 +528,9 @@ func (i *Inferencer) inferReturnWithSummary( fn := info.Fn fnGraph := info.Graph parentScope := info.DefScope - moduleAliases := modules.MergeAliases(i.store.ModuleAliases(), modules.CollectAliases(fnGraph)) + moduleAliases := modules.MergeAliases(i.store.ModuleAliases(), modules.AliasesFromAssignments(info.Evidence.Assignments, fnGraph)) - engine := phase.CreateTypeResolutionEngine(run.Ctx, fnGraph, i.globalTypes, nil, parentScope, i.types, i.manifests) + engine := phase.CreateTypeResolutionEngine(run.Ctx, fnGraph, i.globalTypes, nil, parentScope, i.types, i.manifests, moduleAliases) resolveScope := parentScope if len(fn.TypeParams) > 0 { @@ -525,7 +563,7 @@ func (i *Inferencer) inferReturnWithSummary( ctx := &returnInferenceContext{ run: run, info: info, - summaries: summaries, + returnVectors: returnVectors, localFuncs: localFuncs, engine: engine, resolveScope: resolveScope, @@ -537,25 +575,367 @@ func (i *Inferencer) inferReturnWithSummary( // Build type overlay with parameter types. overlay := i.buildParameterOverlay(ctx) - // Add sibling function types from summaries. + // Add sibling function types from return vectors. i.enrichOverlayWithSiblings(ctx, overlay) - // Collect all return summaries and add local function types. - allSummaries := i.collectAllReturnSummaries(ctx) - i.enrichOverlayWithLocalFunctions(ctx, overlay, allSummaries) + // Collect normalized return vectors and add local function types. + allReturnVectors := i.collectAllReturnVectors(ctx) + i.enrichOverlayWithLocalFunctions(ctx, overlay, allReturnVectors) // Add captured variable types from parent. i.enrichOverlayWithCaptured(ctx, overlay) // Add local declared types (annotations, loop variables) as overlay hints. - i.enrichOverlayWithLocalDeclarations(ctx, overlay) + localValueSeeds := i.enrichOverlayWithLocalDeclarations(ctx, overlay) + + // Body-derived parameter contracts are needed by local assignment inference + // in the same function. For example, a helper call may prove that a parameter + // field is string?, which then makes `param.field or "default"` synthesize as + // string without a value-level shortcut. + i.mergeParameterEvidenceFromBodyUses(ctx, overlay) + i.applyParameterEvidenceToOverlay(ctx, overlay) // Phase 1: Infer local variable types. - inferred, _, synthAdapter := i.inferLocalVariableTypes(ctx, overlay) + inferred, _, prelimSynth := i.inferLocalVariableTypes(ctx, overlay, localValueSeeds) // Collect field/indexer assignments and apply mutations. - finalOverlay := i.collectAndApplyMutations(ctx, overlay, inferred, synthAdapter) + finalOverlay := i.collectAndApplyMutations(ctx, overlay, inferred, prelimSynth, localValueSeeds) + + // Re-harvest call obligations after local assignment inference. The early pass + // catches direct calls; this pass catches builder/receiver chains whose method + // contracts are only visible once local variables have precise types. + i.mergeParameterEvidenceFromBodyUses(ctx, finalOverlay) + i.applyParameterEvidenceToOverlay(ctx, finalOverlay) // Phase 2: Infer return types from body. return i.inferReturnTypesFromBody(ctx, finalOverlay) } + +func (i *Inferencer) applyParameterEvidenceToOverlay(ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type) { + if ctx == nil || ctx.info == nil || ctx.info.Graph == nil || len(ctx.info.ParameterEvidence) == 0 || overlay == nil { + return + } + for idx, slot := range ctx.info.Graph.ParamSlotsReadOnly() { + if slot.Symbol == 0 || idx >= len(ctx.info.ParameterEvidence) { + continue + } + evidence := ctx.info.ParameterEvidence[idx] + if !paramevidence.IsInformative(evidence) { + continue + } + if slot.TypeAnnotation != nil { + resolved := ctx.engine.ResolveType(slot.TypeAnnotation, ctx.resolveScope) + if resolved == nil || !typ.IsRefinableAnnotation(resolved) { + continue + } + overlay[slot.Symbol] = paramevidence.RefineAnnotationWithEvidence(resolved, evidence) + continue + } + if current := overlay[slot.Symbol]; current != nil { + merged, _ := paramevidence.MergeUnannotatedParam(typ.Param{Name: slot.Name, Type: current}, evidence) + overlay[slot.Symbol] = merged + } else { + overlay[slot.Symbol] = evidence + } + } +} + +func (i *Inferencer) mergeParameterEvidenceFromBodyUses(ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type) { + if i == nil || ctx == nil || ctx.info == nil || ctx.info.Graph == nil || ctx.info.Fn == nil { + return + } + bindings := ctx.info.Graph.Bindings() + if bindings == nil || i.types == nil { + return + } + paramIndexBySym := make(map[cfg.SymbolID]int) + for idx, slot := range ctx.info.Graph.ParamSlotsReadOnly() { + if slot.Symbol == 0 { + continue + } + _, hasSource := slot.SourceParamIndex() + if hasSource && hardParameterAnnotation(ctx, slot) { + continue + } + paramIndexBySym[slot.Symbol] = idx + } + if len(paramIndexBySym) == 0 { + return + } + + mergeReceiver := func(receiver ast.Expr, method string) { + if receiver == nil || method == "" { + return + } + ident, ok := receiver.(*ast.IdentExpr) + if !ok || ident == nil { + return + } + sym, ok := bindings.SymbolOf(ident) + if !ok || sym == 0 { + return + } + idx, ok := paramIndexBySym[sym] + if !ok { + return + } + evidence := i.receiverEvidenceForMethod(ctx, method) + if !paramevidence.IsInformative(evidence) { + return + } + next, merged := paramevidence.MergeAt(ctx.info.ParameterEvidence, idx, evidence, typ.JoinPreferNonSoft) + if merged { + ctx.info.ParameterEvidence = next + } + } + mergeParamFieldEvidence := func(sym cfg.SymbolID, field string, evidence typ.Type, required bool) { + if sym == 0 || field == "" || !paramevidence.IsInformative(evidence) { + return + } + idx, ok := paramIndexBySym[sym] + if !ok { + return + } + builder := typ.NewRecord() + if required { + builder.Field(field, evidence) + } else { + builder.OptField(field, evidence) + } + rec := builder.Build() + next, merged := paramevidence.MergeAt(ctx.info.ParameterEvidence, idx, rec, typ.JoinPreferNonSoft) + if merged { + ctx.info.ParameterEvidence = next + } + } + bodyContractJoin := func(prev, next typ.Type) typ.Type { + if next != nil { + return next + } + return prev + } + mergeParameterEvidence := func(sym cfg.SymbolID, evidence typ.Type) { + if sym == 0 || !paramevidence.IsInformative(evidence) { + return + } + idx, ok := paramIndexBySym[sym] + if !ok { + return + } + next, merged := paramevidence.MergeAt(ctx.info.ParameterEvidence, idx, evidence, bodyContractJoin) + if merged { + ctx.info.ParameterEvidence = next + } + } + paramSymbol := func(expr ast.Expr) (cfg.SymbolID, bool) { + ident, ok := expr.(*ast.IdentExpr) + if !ok || ident == nil { + return 0, false + } + sym, ok := bindings.SymbolOf(ident) + if !ok || sym == 0 { + return 0, false + } + if _, ok := paramIndexBySym[sym]; !ok { + return 0, false + } + return sym, true + } + paramFieldPath := func(expr ast.Expr) (cfg.SymbolID, string, bool) { + attr, ok := expr.(*ast.AttrGetExpr) + if !ok || attr == nil { + return 0, "", false + } + obj, ok := attr.Object.(*ast.IdentExpr) + if !ok || obj == nil { + return 0, "", false + } + key, ok := attr.Key.(*ast.StringExpr) + if !ok || key == nil || key.Value == "" { + return 0, "", false + } + sym, ok := bindings.SymbolOf(obj) + if !ok { + return 0, "", false + } + if _, ok := paramIndexBySym[sym]; !ok { + return 0, "", false + } + return sym, key.Value, true + } + typeAt := func(expr ast.Expr, p cfg.Point) typ.Type { + if expr == nil { + return typ.Unknown + } + if t, ok := overlayPathType(expr, overlay, bindings, i.types, ctx.run.Ctx); ok { + return t + } + if ctx.engine != nil { + if t := ctx.engine.TypeOf(expr, p); t != nil { + return t + } + } + return typ.Unknown + } + isDirectSelfRecursiveCall := func(info *cfg.CallInfo) bool { + if info == nil || ctx.info.Sym == 0 { + return false + } + for _, sym := range callsite.CallableCalleeSymbolCandidates(info, ctx.info.Graph, bindings, bindings) { + if sym == ctx.info.Sym { + return true + } + } + return false + } + var bodyParamContracts map[cfg.SymbolID]typ.Type + mergeParamContract := func(sym cfg.SymbolID, evidence typ.Type) { + if sym == 0 || !paramevidence.IsInformative(evidence) { + return + } + if bodyParamContracts == nil { + bodyParamContracts = make(map[cfg.SymbolID]typ.Type) + } + if prev := bodyParamContracts[sym]; prev != nil { + bodyParamContracts[sym] = subtype.NormalizeIntersection(prev, evidence) + return + } + bodyParamContracts[sym] = evidence + } + mergeExpectedFieldEvidence := func(p cfg.Point, info *cfg.CallInfo) { + if info == nil || i.types == nil { + return + } + if isDirectSelfRecursiveCall(info) { + return + } + args := make([]typ.Type, len(info.Args)) + for idx, arg := range info.Args { + args[idx] = typeAt(arg, p) + } + def := ops.CallDef{ + Args: args, + Query: i.types, + } + if info.Method != "" { + def.IsMethod = true + def.MethodName = info.Method + def.Receiver = typeAt(info.Receiver, p) + def.ForceMethodReceiver = callsite.ForceMethodReceiver(bindings, ctx.info.Graph, ctx.info.Evidence, info) + } else { + def.Callee = typeAt(info.Callee, p) + } + inferredCall := ops.InferCall(ctx.run.Ctx, def) + for idx, arg := range info.Args { + expected := inferredCall.ExpectedArgType(idx) + if !paramevidence.IsInformative(expected) { + continue + } + if sym, ok := paramSymbol(arg); ok { + mergeParamContract(sym, expected) + continue + } + if sym, field, ok := paramFieldPath(arg); ok { + mergeParamFieldEvidence(sym, field, expected, true) + continue + } + } + } + for _, ev := range ctx.info.Evidence.Calls { + mergeExpectedFieldEvidence(ev.Point, ev.Info) + } + for _, sym := range cfg.SortedSymbolIDs(bodyParamContracts) { + mergeParameterEvidence(sym, bodyParamContracts[sym]) + } + defaultLiteralType := func(expr ast.Expr) typ.Type { + switch expr.(type) { + case *ast.StringExpr: + return typ.String + case *ast.NumberExpr: + return typ.Number + case *ast.TrueExpr, *ast.FalseExpr: + return typ.Boolean + default: + return nil + } + } + for _, ev := range ctx.info.Evidence.Calls { + if ev.Info != nil { + mergeReceiver(ev.Info.Receiver, ev.Info.Method) + } + } + for _, ev := range ctx.info.Evidence.FieldDefaults { + mergeParamFieldEvidence(ev.Target, ev.Field, defaultLiteralType(ev.Value), false) + } +} + +func hardParameterAnnotation(ctx *returnInferenceContext, slot cfg.ParamSlot) bool { + if ctx == nil || ctx.engine == nil || slot.TypeAnnotation == nil { + return false + } + resolved := ctx.engine.ResolveType(slot.TypeAnnotation, ctx.resolveScope) + return resolved != nil && !typ.IsRefinableAnnotation(resolved) +} + +func (i *Inferencer) receiverEvidenceForMethod(ctx *returnInferenceContext, method string) typ.Type { + if i == nil || i.types == nil || method == "" { + return nil + } + methodType, ok := i.types.Method(ctx.run.Ctx, typ.String, method) + if !ok || methodType == nil { + return nil + } + fn, ok := methodType.(*typ.Function) + if !ok || len(fn.Params) == 0 || !typ.TypeEquals(fn.Params[0].Type, typ.String) { + return nil + } + return typ.String +} + +func overlayPathType( + expr ast.Expr, + overlay map[cfg.SymbolID]typ.Type, + bindings *bind.BindingTable, + typeOps core.TypeOps, + ctx *db.QueryContext, +) (typ.Type, bool) { + if expr == nil || len(overlay) == 0 || bindings == nil { + return nil, false + } + p := flowpath.FromExprWithBindings(expr, nil, bindings) + if p.IsEmpty() || p.Symbol == 0 { + return nil, false + } + t, ok := overlay[p.Symbol] + if !ok || t == nil { + return nil, false + } + for _, seg := range p.Segments { + if typeOps == nil { + return nil, false + } + switch seg.Kind { + case constraint.SegmentField: + ft, ok := typeOps.Field(ctx, t, seg.Name) + if !ok { + return nil, false + } + t = ft + case constraint.SegmentIndexString: + ft, ok := typeOps.Index(ctx, t, typ.LiteralString(seg.Name)) + if !ok { + return nil, false + } + t = ft + case constraint.SegmentIndexInt: + ft, ok := typeOps.Index(ctx, t, typ.LiteralInt(int64(seg.Index))) + if !ok { + return nil, false + } + t = ft + default: + return nil, false + } + } + return t, true +} diff --git a/compiler/check/infer/return/infer_test.go b/compiler/check/infer/return/infer_test.go index e06faf85..da5f0436 100644 --- a/compiler/check/infer/return/infer_test.go +++ b/compiler/check/infer/return/infer_test.go @@ -10,21 +10,18 @@ import ( "github.com/wippyai/go-lua/types/typ" ) -func TestComputeReturnSummariesForGraph_Empty(t *testing.T) { +func TestComputeFunctionFactsForGraph_Empty(t *testing.T) { inferencer := New(Config{}) - summaries, funcTypes, diags := inferencer.ComputeForGraph(RunContext{}, nil, nil) - if summaries != nil { - t.Error("nil graph should return nil summaries") - } - if funcTypes != nil { - t.Error("nil graph should return nil function types") + functionFacts, diags := inferencer.ComputeForGraph(RunContext{}, nil, nil) + if functionFacts != nil { + t.Error("nil graph should return nil function facts") } if len(diags) != 0 { t.Error("nil graph should return no diagnostics") } } -func TestSeedSummariesFromSeed_UsesKnownFunctionSymbolsOnly(t *testing.T) { +func TestSeedReturnVectorsFromSeed_UsesKnownFunctionSymbolsOnly(t *testing.T) { localFuncs := map[cfg.SymbolID]*returns.LocalFuncInfo{ 1: nil, 2: nil, @@ -34,28 +31,28 @@ func TestSeedSummariesFromSeed_UsesKnownFunctionSymbolsOnly(t *testing.T) { 3: {typ.Number}, // not in local funcs; should be ignored } - got := seedSummariesFromSeed(localFuncs, seed) + got := seedReturnVectorsFromSeed(localFuncs, seed) if len(got) != 1 { - t.Fatalf("expected one seeded summary, got %d", len(got)) + t.Fatalf("expected one seeded return vector, got %d", len(got)) } if seeded := got[1]; len(seeded) != 1 || !typ.TypeEquals(seeded[0], typ.String) { - t.Fatalf("unexpected seeded summary for symbol 1: %v", seeded) + t.Fatalf("unexpected seeded return vector for symbol 1: %v", seeded) } if _, ok := got[3]; ok { t.Fatalf("unexpected seed for unknown symbol 3: %v", got[3]) } } -func TestSeedSummariesFromSeed_HandlesNilSeed(t *testing.T) { +func TestSeedReturnVectorsFromSeed_HandlesNilSeed(t *testing.T) { localFuncs := map[cfg.SymbolID]*returns.LocalFuncInfo{ 1: nil, } - got := seedSummariesFromSeed(localFuncs, nil) + got := seedReturnVectorsFromSeed(localFuncs, nil) if got == nil { - t.Fatal("expected non-nil summary map") + t.Fatal("expected non-nil return-vector map") } if len(got) != 0 { - t.Fatalf("expected empty summary map, got %v", got) + t.Fatalf("expected empty return-vector map, got %v", got) } } @@ -141,50 +138,50 @@ func TestReconcileSoftAnnotatedInference_RecordTemplateKeepsFields(t *testing.T) } } -func TestCollectAllReturnSummaries_NormalizesAndFilters(t *testing.T) { +func TestCollectAllReturnVectors_NormalizesAndFilters(t *testing.T) { inferencer := New(Config{}) ctx := &returnInferenceContext{ - summaries: map[cfg.SymbolID][]typ.Type{ + returnVectors: map[cfg.SymbolID][]typ.Type{ 0: {typ.String}, // invalid symbol id, ignored - 1: nil, // empty summary, ignored + 1: nil, // empty return vector, ignored 2: {nil, typ.String}, }, } - got := inferencer.collectAllReturnSummaries(ctx) + got := inferencer.collectAllReturnVectors(ctx) if len(got) != 1 { - t.Fatalf("expected one normalized summary, got %d (%v)", len(got), got) + t.Fatalf("expected one normalized return vector, got %d (%v)", len(got), got) } - summary := got[2] - if len(summary) != 2 { - t.Fatalf("expected 2-slot summary, got %v", summary) + returnVector := got[2] + if len(returnVector) != 2 { + t.Fatalf("expected 2-slot return vector, got %v", returnVector) } - if !typ.TypeEquals(summary[0], typ.Nil) { - t.Fatalf("expected first slot normalized to nil, got %v", summary[0]) + if !typ.TypeEquals(returnVector[0], typ.Nil) { + t.Fatalf("expected first slot normalized to nil, got %v", returnVector[0]) } - if !typ.TypeEquals(summary[1], typ.String) { - t.Fatalf("expected second slot string, got %v", summary[1]) + if !typ.TypeEquals(returnVector[1], typ.String) { + t.Fatalf("expected second slot string, got %v", returnVector[1]) } } -func TestResolveLocalFunctionSummary_UsesCurrentSummaryWithoutStore(t *testing.T) { +func TestResolveLocalFunctionReturns_UsesCurrentVectorWithoutStore(t *testing.T) { inferencer := New(Config{}) - got := inferencer.resolveLocalFunctionSummary(nil, map[cfg.SymbolID][]typ.Type{ + got := inferencer.resolveLocalFunctionReturns(nil, map[cfg.SymbolID][]typ.Type{ 1: {typ.String}, }, 1) if len(got) != 1 || !typ.TypeEquals(got[0], typ.String) { - t.Fatalf("expected string summary, got %v", got) + t.Fatalf("expected string return vector, got %v", got) } - unknownOnly := inferencer.resolveLocalFunctionSummary(nil, map[cfg.SymbolID][]typ.Type{ + unknownOnly := inferencer.resolveLocalFunctionReturns(nil, map[cfg.SymbolID][]typ.Type{ 1: {typ.Unknown}, }, 1) if len(unknownOnly) != 1 || !typ.TypeEquals(unknownOnly[0], typ.Unknown) { - t.Fatalf("expected unknown summary without store fallback, got %v", unknownOnly) + t.Fatalf("expected unknown return vector without store recovery, got %v", unknownOnly) } - if got := inferencer.resolveLocalFunctionSummary(nil, nil, 0); got != nil { - t.Fatalf("expected nil summary for symbol 0, got %v", got) + if got := inferencer.resolveLocalFunctionReturns(nil, nil, 0); got != nil { + t.Fatalf("expected nil return vector for symbol 0, got %v", got) } } diff --git a/compiler/check/infer/return/overlay_pipeline.go b/compiler/check/infer/return/overlay_pipeline.go index 2f0cfdbe..03615d55 100644 --- a/compiler/check/infer/return/overlay_pipeline.go +++ b/compiler/check/infer/return/overlay_pipeline.go @@ -2,42 +2,49 @@ package infer import ( "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/assign" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" - fbcore "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" + "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/compiler/check/phase" "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/siblings" "github.com/wippyai/go-lua/compiler/check/synth" + "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/typ" ) func (i *Inferencer) buildParameterOverlay(ctx *returnInferenceContext) map[cfg.SymbolID]typ.Type { - overlay := make(map[cfg.SymbolID]typ.Type) fnGraph := ctx.info.Graph - for _, slot := range fnGraph.ParamSlotsReadOnly() { + paramSlots := fnGraph.ParamSlotsReadOnly() + overlay := make(map[cfg.SymbolID]typ.Type, overlaySymbolCapacity(fnGraph, len(paramSlots))) + for paramIdx, slot := range paramSlots { if slot.Symbol == 0 { continue } // Binder/CFG-injected implicit self parameter. - srcIdx, hasSource := slot.SourceParamIndex() + _, hasSource := slot.SourceParamIndex() if !hasSource { if selfType := ctx.resolveScope.SelfType(); selfType != nil { overlay[slot.Symbol] = selfType + } else if ctx.info.ParameterEvidence != nil && paramIdx < len(ctx.info.ParameterEvidence) && ctx.info.ParameterEvidence[paramIdx] != nil { + overlay[slot.Symbol] = ctx.info.ParameterEvidence[paramIdx] } else { overlay[slot.Symbol] = typ.Unknown } continue } - i := srcIdx paramType := typ.Unknown if slot.Name == "self" { if selfType := ctx.resolveScope.SelfType(); selfType != nil { @@ -45,19 +52,22 @@ func (i *Inferencer) buildParameterOverlay(ctx *returnInferenceContext) map[cfg. } } if typ.IsAbsentOrUnknown(paramType) { - if ctx.info.ParamHints != nil && i < len(ctx.info.ParamHints) && ctx.info.ParamHints[i] != nil { - paramType = ctx.info.ParamHints[i] + if ctx.info.ParameterEvidence != nil && paramIdx < len(ctx.info.ParameterEvidence) && ctx.info.ParameterEvidence[paramIdx] != nil { + paramType = ctx.info.ParameterEvidence[paramIdx] } } + if typ.IsAbsentOrUnknown(paramType) && slot.TypeAnnotation == nil { + paramType = typ.Any + } + if slot.TypeAnnotation == nil && ctx.info.ParameterEvidence != nil && paramIdx < len(ctx.info.ParameterEvidence) && ctx.info.ParameterEvidence[paramIdx] != nil { + paramType, _ = paramevidence.MergeUnannotatedParam(typ.Param{Name: slot.Name, Type: paramType}, ctx.info.ParameterEvidence[paramIdx]) + } if slot.TypeAnnotation != nil { resolved := ctx.engine.ResolveType(slot.TypeAnnotation, ctx.resolveScope) if resolved != nil { - if typ.IsRefinableAnnotation(resolved) { - if typ.IsAbsentOrUnknown(paramType) { - paramType = resolved - } - } else { - paramType = resolved + paramType = resolved + if ctx.info.ParameterEvidence != nil && paramIdx < len(ctx.info.ParameterEvidence) { + paramType = paramevidence.RefineAnnotationWithEvidence(resolved, ctx.info.ParameterEvidence[paramIdx]) } } } @@ -66,7 +76,18 @@ func (i *Inferencer) buildParameterOverlay(ctx *returnInferenceContext) map[cfg. return overlay } -// enrichOverlayWithSiblings adds sibling function types to the overlay using summaries. +func overlaySymbolCapacity(fnGraph *cfg.Graph, floor int) int { + if fnGraph == nil { + return floor + } + if count := fnGraph.SymbolCount(); count > floor { + return count + } + return floor +} + +// enrichOverlayWithSiblings adds sibling function types from the current +// return-vector state. func (i *Inferencer) enrichOverlayWithSiblings( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, @@ -82,21 +103,39 @@ func (i *Inferencer) enrichOverlayWithSiblings( } } siblingOverlay := siblings.BuildOverlay(siblings.OverlayConfig{ - Summaries: ctx.summaries, - Siblings: siblingEntries, - CurrentSym: ctx.info.Sym, + ReturnVectors: ctx.returnVectors, + Siblings: siblingEntries, + CurrentSym: ctx.info.Sym, Services: siblings.OverlayServicesFuncs{ SeedTypeFn: func(fn *ast.FunctionExpr) typ.Type { + var localInfo *returns.LocalFuncInfo + for _, sym := range cfg.SortedSymbolIDs(ctx.localFuncs) { + candidate := ctx.localFuncs[sym] + if candidate != nil && candidate.Fn == fn { + localInfo = candidate + break + } + } var bindings interface { ParamSymbols(*ast.FunctionExpr) []cfg.SymbolID Name(cfg.SymbolID) string } - if ctx.info != nil && ctx.info.Graph != nil { + if localInfo != nil && localInfo.Graph != nil { + if b := localInfo.Graph.Bindings(); b != nil { + bindings = b + } + } + if bindings == nil && ctx.info != nil && ctx.info.Graph != nil { if b := ctx.info.Graph.Bindings(); b != nil { bindings = b } } - return returns.BuildSeedFunctionTypeWithBindings(fn, ctx.engine, ctx.resolveScope, bindings) + seed := returns.BuildSeedFunctionTypeWithBindings(fn, ctx.engine, ctx.resolveScope, bindings) + fnType, _ := seed.(*typ.Function) + if localInfo != nil && len(localInfo.ParameterEvidence) > 0 && fnType != nil { + return paramevidence.MergeIntoSignature(fn, localInfo.ParameterEvidence, fnType) + } + return seed }, }, }) @@ -105,26 +144,26 @@ func (i *Inferencer) enrichOverlayWithSiblings( } } -// collectAllReturnSummaries normalizes the current local summary map. -func (i *Inferencer) collectAllReturnSummaries(ctx *returnInferenceContext) map[cfg.SymbolID][]typ.Type { - if ctx == nil || len(ctx.summaries) == 0 { +// collectAllReturnVectors normalizes the current local return-vector map. +func (i *Inferencer) collectAllReturnVectors(ctx *returnInferenceContext) map[cfg.SymbolID][]typ.Type { + if ctx == nil || len(ctx.returnVectors) == 0 { return nil } - allSummaries := make(map[cfg.SymbolID][]typ.Type, len(ctx.summaries)) - for _, sym := range cfg.SortedSymbolIDs(ctx.summaries) { + allReturnVectors := make(map[cfg.SymbolID][]typ.Type, len(ctx.returnVectors)) + for _, sym := range cfg.SortedSymbolIDs(ctx.returnVectors) { if sym == 0 { continue } - normalized := returns.NormalizeReturnVector(ctx.summaries[sym]) + normalized := returnsummary.NormalizeOwned(ctx.returnVectors[sym]) if len(normalized) == 0 { continue } - allSummaries[sym] = normalized + allReturnVectors[sym] = normalized } - return allSummaries + return allReturnVectors } -func (i *Inferencer) summaryFromSnapshot( +func (i *Inferencer) returnVectorFromFacts( graph *cfg.Graph, parentScope *scope.State, sym cfg.SymbolID, @@ -132,71 +171,73 @@ func (i *Inferencer) summaryFromSnapshot( if i == nil || i.store == nil || graph == nil || parentScope == nil || sym == 0 { return nil } - snap := i.store.GetReturnSummariesSnapshot(graph, parentScope) - if len(snap) == 0 { + facts := i.store.GetInterprocFacts(graph, parentScope).FunctionFacts + if len(facts) == 0 { return nil } - normalized := returns.NormalizeReturnVector(snap[sym]) + normalized := returnsummary.Normalize(functionfact.ReturnSummaryFromMap(facts, sym)) if len(normalized) == 0 { return nil } return normalized } -func (i *Inferencer) resolveLocalFunctionSummary( +func (i *Inferencer) resolveLocalFunctionReturns( ctx *returnInferenceContext, - allSummaries map[cfg.SymbolID][]typ.Type, + allReturnVectors map[cfg.SymbolID][]typ.Type, sym cfg.SymbolID, ) []typ.Type { if sym == 0 { return nil } - // Keep the current SCC-derived summary unless it is still unknown-only. - summary := returns.NormalizeReturnVector(allSummaries[sym]) - if !typ.IsUnknownOnlyOrEmpty(summary) { - return summary + // Keep the current SCC-derived return vector unless it is still unknown-only. + returnVector := returnsummary.NormalizeOwned(allReturnVectors[sym]) + if !typ.IsUnknownOnlyOrEmpty(returnVector) { + return returnVector } if ctx == nil || i == nil || i.store == nil { - return summary + return returnVector } - // First fallback: current graph snapshot under the current resolve scope. + // Product fact recovery path: current graph under the current resolve scope. if ctx.info != nil && ctx.info.Graph != nil && ctx.resolveScope != nil { - if snapSummary := i.summaryFromSnapshot(ctx.info.Graph, ctx.resolveScope, sym); len(snapSummary) > 0 { - return snapSummary + if factVector := i.returnVectorFromFacts(ctx.info.Graph, ctx.resolveScope, sym); len(factVector) > 0 { + return factVector } } - // Second fallback: parent graph snapshot for the function symbol, if known. + // Product fact recovery path: parent graph for the function symbol, if known. ref := i.store.FunctionRefBySym(sym) if ref == nil || ref.ParentGraphID == 0 { - return summary + return returnVector } parentGraph := i.store.Graphs()[ref.ParentGraphID] if parentGraph == nil { - return summary + return returnVector } parentScope := api.ParentScopeForGraph(i.store, parentGraph.ID(), nil) if parentScope == nil { - return summary + return returnVector } - if snapSummary := i.summaryFromSnapshot(parentGraph, parentScope, sym); len(snapSummary) > 0 { - return snapSummary + if factVector := i.returnVectorFromFacts(parentGraph, parentScope, sym); len(factVector) > 0 { + return factVector } - return summary + return returnVector } // enrichOverlayWithLocalFunctions adds local function types from the function body. func (i *Inferencer) enrichOverlayWithLocalFunctions( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, - allSummaries map[cfg.SymbolID][]typ.Type, + allReturnVectors map[cfg.SymbolID][]typ.Type, ) { - ctx.info.Graph.EachAssign(func(p cfg.Point, assignInfo *cfg.AssignInfo) { + for _, assign := range ctx.info.Evidence.Assignments { + p := assign.Point + assignInfo := assign.Info if assignInfo == nil || !assignInfo.IsLocal || len(assignInfo.Targets) == 0 || len(assignInfo.Sources) == 0 { - return + continue } for idx, target := range assignInfo.Targets { if target.Kind != cfg.TargetIdent || target.Symbol == 0 { @@ -217,13 +258,16 @@ func (i *Inferencer) enrichOverlayWithLocalFunctions( i.store.RegisterFunctionRef(target.Symbol, fnExpr, fnGraph, ctx.info.Graph.ID(), p) } } - summary := i.resolveLocalFunctionSummary(ctx, allSummaries, target.Symbol) + returnVector := i.resolveLocalFunctionReturns(ctx, allReturnVectors, target.Symbol) sig := ctx.engine.ResolveFunctionSignature(fnExpr, ctx.resolveScope) - if fnType := returns.WithSummaryOrUnknown(sig, summary); fnType != nil { + if localInfo := ctx.localFuncs[target.Symbol]; localInfo != nil && len(localInfo.ParameterEvidence) > 0 && sig != nil { + sig = paramevidence.MergeIntoSignature(fnExpr, localInfo.ParameterEvidence, sig) + } + if fnType := returnsummary.ApplyToFunctionType(sig, returnVector); fnType != nil { overlay[target.Symbol] = fnType } } - }) + } } // enrichOverlayWithCaptured adds captured variable types from parent function result. @@ -244,7 +288,7 @@ func (i *Inferencer) enrichOverlayWithCaptured( } if i.store != nil && ctx.info.DefScope != nil { parentScope := api.ParentScopeForGraph(i.store, ctx.info.Graph.ID(), ctx.info.DefScope) - if capturedTypes := i.store.GetCapturedTypesSnapshot(ctx.info.Graph, parentScope); len(capturedTypes) > 0 { + if capturedTypes := i.store.GetInterprocFacts(ctx.info.Graph, parentScope).CapturedTypes; len(capturedTypes) > 0 { for _, sym := range cfg.SortedSymbolIDs(capturedTypes) { t := capturedTypes[sym] if sym == 0 || t == nil { @@ -261,14 +305,14 @@ func (i *Inferencer) enrichOverlayWithCaptured( return } resolveCapturedAnnotation := func(sym cfg.SymbolID) typ.Type { - parentGraph := ctx.info.ParentGraph - if parentGraph == nil || sym == 0 { + if sym == 0 { return nil } var annType typ.Type - parentGraph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { + for _, assign := range ctx.info.ParentEvidence.Assignments { + info := assign.Info if annType != nil || info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + continue } info.EachTarget(func(i int, target cfg.AssignTarget) { if annType != nil || target.Kind != cfg.TargetIdent || target.Symbol != sym { @@ -284,13 +328,17 @@ func (i *Inferencer) enrichOverlayWithCaptured( } annType = ctx.engine.ResolveType(ann, resolveScope) }) - }) + } return annType } for _, sym := range localBindings.CapturedSymbols(ctx.info.Fn) { if sym == 0 { continue } + if fnType := i.capturedFunctionFactType(ctx, sym); fnType != nil { + overlay[sym] = fnType + continue + } if existing, ok := overlay[sym]; ok && existing != nil && !typ.IsSoft(existing, typ.SoftAnnotationPolicy) { continue } @@ -304,22 +352,60 @@ func (i *Inferencer) enrichOverlayWithCaptured( } } +func (i *Inferencer) capturedFunctionFactType(ctx *returnInferenceContext, sym cfg.SymbolID) typ.Type { + if i == nil || i.store == nil || ctx == nil || sym == 0 { + return nil + } + ref := i.store.FunctionRefBySym(sym) + if ref == nil { + return nil + } + parentGraphID := ref.ParentGraphID + if parentGraphID == 0 { + parentGraphID = ref.GraphID + } + parentGraph := i.store.Graphs()[parentGraphID] + if parentGraph == nil { + return nil + } + parentScope := ctx.info.DefScope + if parentHash := i.store.GraphParentHashOf(parentGraphID); parentHash != 0 { + if scoped := i.store.Parents()[parentHash]; scoped != nil { + parentScope = scoped + } + } + return functionfact.TypeForGraph(i.store, parentGraph, sym, parentScope, nil) +} + // inferLocalVariableTypes runs phase 1 synthesis to infer local variable types. func (i *Inferencer) inferLocalVariableTypes( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, + localValueSeeds map[cfg.SymbolID]bool, ) (map[cfg.SymbolID]typ.Type, *synth.Engine, func(ast.Expr, cfg.Point) typ.Type) { fnGraph := ctx.info.Graph annotated := make(map[cfg.SymbolID]bool, len(overlay)) - paramSet := make(map[cfg.SymbolID]bool) - for _, sym := range fnGraph.ParamSymbols() { - if sym != 0 { - paramSet[sym] = true + paramSet := paramSymbolSet(fnGraph) + explicitParamAnnotations := make(map[cfg.SymbolID]bool) + for _, slot := range fnGraph.ParamSlotsReadOnly() { + if slot.Symbol == 0 || slot.TypeAnnotation == nil || ctx.engine == nil { + continue + } + resolved := ctx.engine.ResolveType(slot.TypeAnnotation, ctx.resolveScope) + if resolved != nil && !typ.IsRefinableAnnotation(resolved) { + explicitParamAnnotations[slot.Symbol] = true } } for sym, tp := range overlay { if paramSet[sym] { - annotated[sym] = true + if explicitParamAnnotations[sym] { + annotated[sym] = true + } else if tp != nil && !typ.IsAny(tp) && !typ.IsAbsentOrUnknown(tp) && !typ.IsSoft(tp, typ.SoftAnnotationPolicy) { + annotated[sym] = true + } + continue + } + if localValueSeeds[sym] { continue } if tp != nil && !typ.IsSoft(tp, typ.SoftAnnotationPolicy) { @@ -327,9 +413,10 @@ func (i *Inferencer) inferLocalVariableTypes( } } - fnGraph.EachAssign(func(_ cfg.Point, assignInfo *cfg.AssignInfo) { + for _, assign := range ctx.info.Evidence.Assignments { + assignInfo := assign.Info if assignInfo == nil || len(assignInfo.TypeAnnotations) == 0 { - return + continue } for idx, target := range assignInfo.Targets { if target.Kind != cfg.TargetIdent || target.Symbol == 0 { @@ -347,23 +434,32 @@ func (i *Inferencer) inferLocalVariableTypes( } } } - }) + } + + inferenceOverlay := overlay + if len(localValueSeeds) > 0 { + inferenceOverlay = cloneOverlay(overlay, 0) + for sym := range localValueSeeds { + delete(inferenceOverlay, sym) + } + } fnScopes := uniformFunctionScopes(fnGraph, ctx.resolveScope) + phaseFunctionFacts := functionfact.FromSummaries(ctx.returnVectors) prelimCtx := api.NewReturnInferenceEnv(api.ReturnInferenceEnvConfig{ - Graph: fnGraph, - Bindings: ctx.bindings, - BaseScope: ctx.resolveScope, - DeclaredTypes: overlay, - GlobalTypes: i.globalTypes, - ModuleAliases: ctx.moduleAliases, - ReturnSummaries: ctx.summaries, + Graph: fnGraph, + Bindings: ctx.bindings, + BaseScope: ctx.resolveScope, + DeclaredTypes: inferenceOverlay, + GlobalTypes: i.globalTypes, + ModuleAliases: ctx.moduleAliases, + FunctionType: functionfact.TypeLookup(phaseFunctionFacts), }) - prelimEngine := i.newReturnInferenceEngine(ctx.run, fnScopes, prelimCtx) + prelimEngine := i.newReturnInferenceEngine(ctx.run, fnScopes, prelimCtx, ctx.info.Evidence, phaseFunctionFacts) - synthAdapter := func(expr ast.Expr, p cfg.Point) typ.Type { + prelimSynth := func(expr ast.Expr, p cfg.Point) typ.Type { return prelimEngine.TypeOf(expr, p) } symResolver := func(p cfg.Point, sym cfg.SymbolID) (typ.Type, bool) { @@ -380,38 +476,44 @@ func (i *Inferencer) inferLocalVariableTypes( return nil, false } - inferred := assign.CollectInferredTypes(&fbcore.FlowContext{ - Graph: fnGraph, - Scopes: fnScopes, - API: prelimEngine, - CallCtx: ctx.run.Ctx, - TypeOps: i.types, - Derived: &fbcore.Derived{ - SymResolver: symResolver, - }, - }, overlay, annotated, nil) + inferred := assign.InferLocalTypes(assign.LocalInferenceConfig{ + Graph: fnGraph, + Evidence: ctx.info.Evidence, + Scopes: fnScopes, + Synth: prelimSynth, + SynthAPI: prelimEngine, + SymResolver: symResolver, + SeedTypes: inferenceOverlay, + Annotated: annotated, + ModuleBindings: ctx.bindings, + CallCtx: ctx.run.Ctx, + TypeOps: i.types, + }) - return inferred, prelimEngine, synthAdapter + return inferred, prelimEngine, prelimSynth } func (i *Inferencer) enrichOverlayWithLocalDeclarations( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, -) { +) map[cfg.SymbolID]bool { fnGraph := ctx.info.Graph if fnGraph == nil { - return + return nil } - fnGraph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + localValueSeeds := make(map[cfg.SymbolID]bool) + for _, assign := range ctx.info.Evidence.Assignments { + p := assign.Point + info := assign.Info if info == nil || !info.IsLocal { - return + continue } if info.NumericFor != nil { target, ok := info.FirstTarget() if !ok { - return + continue } if target.Kind == cfg.TargetIdent && target.Symbol != 0 { if _, exists := overlay[target.Symbol]; !exists { @@ -454,25 +556,27 @@ func (i *Inferencer) enrichOverlayWithLocalDeclarations( if _, ok := info.Sources[idx].(*ast.TableExpr); ok { if seeded := ctx.engine.TypeOf(info.Sources[idx], p); seeded != nil { overlay[target.Symbol] = seeded + localValueSeeds[target.Symbol] = true } } } }) - }) - -} + } -type localSymbolLookup interface { - SymbolOf(expr *ast.IdentExpr) (cfg.SymbolID, bool) + if len(localValueSeeds) == 0 { + return nil + } + return localValueSeeds } type overlayMutationStage struct { - fnGraph *cfg.Graph - paramSyms map[cfg.SymbolID]bool - finalOverlay map[cfg.SymbolID]typ.Type - inferred map[cfg.SymbolID]typ.Type - synthAdapter func(ast.Expr, cfg.Point) typ.Type - enrichedSynthAdapter func(ast.Expr, cfg.Point) typ.Type + fnGraph *cfg.Graph + paramSyms map[cfg.SymbolID]bool + localValueSeeds map[cfg.SymbolID]bool + finalOverlay map[cfg.SymbolID]typ.Type + inferred map[cfg.SymbolID]typ.Type + baseSynth func(ast.Expr, cfg.Point) typ.Type + enrichedSynth func(ast.Expr, cfg.Point) typ.Type } // collectAndApplyMutations collects field/indexer assignments and applies mutations to overlay. @@ -480,15 +584,16 @@ func (i *Inferencer) collectAndApplyMutations( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, inferred map[cfg.SymbolID]typ.Type, - synthAdapter func(ast.Expr, cfg.Point) typ.Type, + baseSynth func(ast.Expr, cfg.Point) typ.Type, + localValueSeeds map[cfg.SymbolID]bool, ) map[cfg.SymbolID]typ.Type { - stage := newOverlayMutationStage(ctx, overlay, inferred, synthAdapter) - mergeInferredIntoOverlay(stage.finalOverlay, stage.inferred, stage.paramSyms) - stage.enrichedSynthAdapter = buildEnrichedSynthAdapter(stage.fnGraph.Bindings(), stage.inferred, stage.finalOverlay, stage.synthAdapter) + stage := newOverlayMutationStage(ctx, overlay, inferred, baseSynth, localValueSeeds) + mergeInferredIntoOverlay(stage.finalOverlay, stage.inferred, stage.paramSyms, stage.localValueSeeds) + stage.enrichedSynth = buildEnrichedSynth(stage.fnGraph.Bindings(), stage.inferred, stage.finalOverlay, stage.localValueSeeds, i.types, ctx.run.Ctx, stage.baseSynth) i.applyFieldMutations(ctx, &stage) - i.applyIndexerMutations(&stage) - i.applyDirectMutations(&stage) + i.applyIndexerMutations(ctx, &stage) + i.applyDirectMutations(ctx, &stage) return stage.finalOverlay } @@ -497,29 +602,35 @@ func newOverlayMutationStage( ctx *returnInferenceContext, overlay map[cfg.SymbolID]typ.Type, inferred map[cfg.SymbolID]typ.Type, - synthAdapter func(ast.Expr, cfg.Point) typ.Type, + baseSynth func(ast.Expr, cfg.Point) typ.Type, + localValueSeeds map[cfg.SymbolID]bool, ) overlayMutationStage { fnGraph := (*cfg.Graph)(nil) if ctx != nil && ctx.info != nil { fnGraph = ctx.info.Graph } return overlayMutationStage{ - fnGraph: fnGraph, - paramSyms: paramSymbolSet(fnGraph), - finalOverlay: cloneOverlay(overlay, len(inferred)), - inferred: inferred, - synthAdapter: synthAdapter, + fnGraph: fnGraph, + paramSyms: paramSymbolSet(fnGraph), + localValueSeeds: localValueSeeds, + finalOverlay: cloneOverlay(overlay, len(inferred)), + inferred: inferred, + baseSynth: baseSynth, } } func paramSymbolSet(graph *cfg.Graph) map[cfg.SymbolID]bool { - out := make(map[cfg.SymbolID]bool) if graph == nil { - return out + return nil } - for _, sym := range graph.ParamSymbols() { - if sym != 0 { - out[sym] = true + paramSlots := graph.ParamSlotsReadOnly() + if len(paramSlots) == 0 { + return nil + } + out := make(map[cfg.SymbolID]bool, len(paramSlots)) + for _, slot := range paramSlots { + if slot.Symbol != 0 { + out[slot.Symbol] = true } } return out @@ -537,10 +648,11 @@ func mergeInferredIntoOverlay( finalOverlay map[cfg.SymbolID]typ.Type, inferred map[cfg.SymbolID]typ.Type, paramSyms map[cfg.SymbolID]bool, + localValueSeeds map[cfg.SymbolID]bool, ) { for sym, inferredType := range inferred { baseType := finalOverlay[sym] - // Parameter domains are seeded from annotations/hints and must not be + // Parameter domains are seeded from annotations/evidence and must not be // rewritten by local variable inference artifacts. if paramSyms[sym] { if typ.IsAbsentOrUnknown(baseType) { @@ -548,6 +660,10 @@ func mergeInferredIntoOverlay( } continue } + if localValueSeeds[sym] { + finalOverlay[sym] = inferredType + continue + } if typ.IsAbsentOrUnknown(baseType) { finalOverlay[sym] = inferredType continue @@ -587,7 +703,7 @@ func extractMapComponentType(t typ.Type) (typ.Type, typ.Type, bool) { continue } key = typ.JoinPreferNonSoft(key, k) - val = returns.JoinValueTypes(val, vv) + val = overlaymut.JoinValueTypes(val, vv) } if ok { return key, val, true @@ -671,16 +787,22 @@ func reconcileSoftAnnotatedInference(baseType, inferredType typ.Type) typ.Type { return inferredType } -func buildEnrichedSynthAdapter( - bindings localSymbolLookup, +func buildEnrichedSynth( + bindings *bind.BindingTable, inferred map[cfg.SymbolID]typ.Type, finalOverlay map[cfg.SymbolID]typ.Type, - baseAdapter func(ast.Expr, cfg.Point) typ.Type, + localValueSeeds map[cfg.SymbolID]bool, + typeOps core.TypeOps, + queryCtx *db.QueryContext, + baseSynth func(ast.Expr, cfg.Point) typ.Type, ) func(ast.Expr, cfg.Point) typ.Type { return func(expr ast.Expr, p cfg.Point) typ.Type { if ident, ok := expr.(*ast.IdentExpr); ok && bindings != nil { if sym, found := bindings.SymbolOf(ident); found && sym != 0 { if t, exists := inferred[sym]; exists && !typ.IsAbsentOrUnknown(t) { + if localValueSeeds[sym] { + return t + } if baseType := finalOverlay[sym]; baseType != nil && !typ.IsSoft(baseType, typ.SoftAnnotationPolicy) { return baseType } @@ -688,15 +810,18 @@ func buildEnrichedSynthAdapter( } } } - return baseAdapter(expr, p) + if t, ok := overlayPathType(expr, finalOverlay, bindings, typeOps, queryCtx); ok && !typ.IsAbsentOrUnknown(t) { + return t + } + return baseSynth(expr, p) } } func (i *Inferencer) applyFieldMutations(ctx *returnInferenceContext, stage *overlayMutationStage) { - if i == nil || ctx == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynthAdapter == nil { + if i == nil || ctx == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynth == nil { return } - fieldAssignments := assign.CollectFieldAssignments(stage.fnGraph, stage.enrichedSynthAdapter, nil) + fieldAssignments := overlaymut.CollectFieldAssignments(ctx.info.Evidence.Assignments, stage.enrichedSynth, nil) nestedBindings := stage.fnGraph.Bindings() if nestedBindings == nil { @@ -705,41 +830,41 @@ func (i *Inferencer) applyFieldMutations(ctx *returnInferenceContext, stage *ove var capturedByCallee map[cfg.SymbolID]map[cfg.SymbolID]map[string]typ.Type if i.store != nil { capturedParent := api.ParentScopeForGraph(i.store, stage.fnGraph.ID(), ctx.info.DefScope) - capturedByCallee = i.store.GetCapturedFieldAssignsSnapshot(stage.fnGraph, capturedParent) + capturedByCallee = i.store.GetInterprocFacts(stage.fnGraph, capturedParent).CapturedFields } calleeTypeResolver := func(info *cfg.CallInfo, p cfg.Point) typ.Type { - return resolve.CalleeType(info, p, stage.enrichedSynthAdapter, nil, nil, stage.fnGraph, nestedBindings, i.store.ModuleBindings()) + return resolve.CalleeType(info, p, stage.enrichedSynth, nil, nil, stage.fnGraph, nestedBindings, i.store.ModuleBindings()) } - nestedFieldAssignments := returns.CollectCalledNestedFieldAssignments(stage.fnGraph, nestedBindings, capturedByCallee, calleeTypeResolver) - returns.MergeFieldAssignments(fieldAssignments, nestedFieldAssignments) + nestedFieldAssignments := calleffect.CollectCalledNestedFieldAssignments(stage.fnGraph, nestedBindings, ctx.info.Evidence.Calls, capturedByCallee, calleeTypeResolver) + overlaymut.MergeFieldAssignments(fieldAssignments, nestedFieldAssignments) - returns.ApplyFieldMergeToOverlay(stage.finalOverlay, fieldAssignments) + overlaymut.ApplyFieldMergeToOverlay(stage.finalOverlay, fieldAssignments) } -func (i *Inferencer) applyIndexerMutations(stage *overlayMutationStage) { - if i == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynthAdapter == nil { +func (i *Inferencer) applyIndexerMutations(ctx *returnInferenceContext, stage *overlayMutationStage) { + if i == nil || ctx == nil || ctx.info == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynth == nil { return } indexerBindings := stage.fnGraph.Bindings() if indexerBindings == nil { indexerBindings = i.store.ModuleBindings() } - indexerAssignments := assign.CollectIndexerAssignments(stage.fnGraph, stage.enrichedSynthAdapter, indexerBindings, nil) - tableMutations := mutator.CollectTableInsertMutations(stage.fnGraph, stage.enrichedSynthAdapter, indexerBindings) - mutator.MergeIndexerMutations(indexerAssignments, tableMutations) - returns.ApplyIndexerMergeToOverlay(stage.finalOverlay, indexerAssignments) + indexerAssignments := overlaymut.CollectIndexerAssignments(ctx.info.Evidence.Assignments, stage.enrichedSynth, indexerBindings, nil) + tableMutations := calleffect.CollectTableInsertMutations(ctx.info.Evidence.Calls, stage.fnGraph, stage.enrichedSynth, indexerBindings) + overlaymut.MergeIndexerMutations(indexerAssignments, tableMutations) + overlaymut.ApplyIndexerMergeToOverlay(stage.finalOverlay, indexerAssignments) } -func (i *Inferencer) applyDirectMutations(stage *overlayMutationStage) { - if i == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynthAdapter == nil { +func (i *Inferencer) applyDirectMutations(ctx *returnInferenceContext, stage *overlayMutationStage) { + if i == nil || ctx == nil || ctx.info == nil || stage == nil || stage.fnGraph == nil || stage.enrichedSynth == nil { return } indexerBindings := stage.fnGraph.Bindings() if indexerBindings == nil { indexerBindings = i.store.ModuleBindings() } - directMutations := mutator.CollectTableInsertOnDirect(stage.fnGraph, stage.enrichedSynthAdapter, indexerBindings) - returns.ApplyDirectMutationsToOverlay(stage.finalOverlay, directMutations) + directMutations := calleffect.CollectTableInsertOnDirect(ctx.info.Evidence.Calls, stage.fnGraph, stage.enrichedSynth, indexerBindings) + overlaymut.ApplyDirectMutationsToOverlay(stage.finalOverlay, directMutations) } // phase2InferenceState holds narrowed synthesis and dead return points. @@ -749,7 +874,7 @@ type phase2InferenceState struct { } // runPhase2FlowNarrowing executes extract->solve->narrow over the final overlay. -// This makes return summary collection path-sensitive instead of declared-only. +// This makes return collection path-sensitive instead of declared-only. func (i *Inferencer) runPhase2FlowNarrowing( ctx *returnInferenceContext, finalOverlay map[cfg.SymbolID]typ.Type, @@ -770,6 +895,7 @@ func (i *Inferencer) runPhase2FlowNarrowing( GlobalTypes: i.globalTypes, ModuleAliases: ctx.moduleAliases, ModuleBindings: i.store.ModuleBindings(), + Evidence: ctx.info.Evidence, Scopes: fnScopes, } @@ -781,13 +907,13 @@ func (i *Inferencer) runPhase2FlowNarrowing( return ctx.engine.ResolveFunctionSignature(fn, sc) }), } - phaseReturnSummaries := summarizeWithoutCurrent(ctx.summaries, ctx.info) + phaseFunctionFacts := functionfact.FromSummariesExcept(ctx.returnVectors, ctx.info.Sym) extractOut := phase.RunExtract(phase.FlowExtractInput{ - PhaseEnv: phaseEnv, - Resolve: phase.ResolveOutput{TypeResolver: ctx.engine}, - Scope: scopeOut, - ReturnSummaries: phaseReturnSummaries, + PhaseEnv: phaseEnv, + Resolve: phase.ResolveOutput{TypeResolver: ctx.engine}, + Scope: scopeOut, + FunctionFacts: phaseFunctionFacts, }) if extractOut.Inputs == nil { return phase2InferenceState{} @@ -800,20 +926,20 @@ func (i *Inferencer) runPhase2FlowNarrowing( }) narrowOut := phase.RunNarrow(phase.NarrowInput{ - PhaseEnv: phaseEnv, - Scope: scopeOut, - Extract: extractOut, - Solve: solveOut, - NarrowReturnSummaries: phaseReturnSummaries, + PhaseEnv: phaseEnv, + Scope: scopeOut, + Extract: extractOut, + Solve: solveOut, + FunctionFacts: phaseFunctionFacts, }) deadPoints := map[cfg.Point]bool{} if solveOut.Solution != nil { - fnGraph.EachReturn(func(p cfg.Point, _ *cfg.ReturnInfo) { - if solveOut.Solution.IsPointDead(p) { - deadPoints[p] = true + for _, ret := range extractOut.Evidence.Returns { + if solveOut.Solution.IsPointDead(ret.Point) { + deadPoints[ret.Point] = true } - }) + } } if narrowOut.Synth != nil { @@ -823,42 +949,22 @@ func (i *Inferencer) runPhase2FlowNarrowing( } } - // Fallback: declared-phase synth (should be uncommon, e.g. nil solution path). + // Declared-phase recomputation path for uncommon nil-solution states. fnCheckCtx := api.NewReturnInferenceEnv(api.ReturnInferenceEnvConfig{ - Graph: fnGraph, - Bindings: ctx.bindings, - BaseScope: ctx.resolveScope, - DeclaredTypes: finalOverlay, - GlobalTypes: i.globalTypes, - ModuleAliases: ctx.moduleAliases, - ReturnSummaries: phaseReturnSummaries, + Graph: fnGraph, + Bindings: ctx.bindings, + BaseScope: ctx.resolveScope, + DeclaredTypes: finalOverlay, + GlobalTypes: i.globalTypes, + ModuleAliases: ctx.moduleAliases, + FunctionType: functionfact.TypeLookup(phaseFunctionFacts), }) return phase2InferenceState{ - synth: i.newReturnInferenceEngine(ctx.run, fnScopes, fnCheckCtx), + synth: i.newReturnInferenceEngine(ctx.run, fnScopes, fnCheckCtx, ctx.info.Evidence, phaseFunctionFacts), deadPoints: deadPoints, } } -func summarizeWithoutCurrent( - summaries map[cfg.SymbolID][]typ.Type, - info *returns.LocalFuncInfo, -) map[cfg.SymbolID][]typ.Type { - if len(summaries) == 0 || info == nil || info.Sym == 0 { - return summaries - } - if _, ok := summaries[info.Sym]; !ok { - return summaries - } - out := make(map[cfg.SymbolID][]typ.Type, len(summaries)-1) - for _, sym := range cfg.SortedSymbolIDs(summaries) { - if sym == info.Sym { - continue - } - out[sym] = summaries[sym] - } - return out -} - func uniformFunctionScopes(graph *cfg.Graph, base *scope.State) map[cfg.Point]*scope.State { if graph == nil { return nil diff --git a/compiler/check/infer/return/scc.go b/compiler/check/infer/return/scc.go index 7f4aa8b3..f82f335d 100644 --- a/compiler/check/infer/return/scc.go +++ b/compiler/check/infer/return/scc.go @@ -1,36 +1,37 @@ package infer import ( - "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/typ" ) // iterateSCCFixpoint runs fixpoint iteration for a single SCC until convergence. -// Returns true if types stabilized within the iteration limit. +// Returns once the widened return-vector product stabilizes. func (i *Inferencer) iterateSCCFixpoint( run RunContext, scc []cfg.SymbolID, localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, ) bool { - for iter := 0; iter < i.maxIterations; iter++ { - next, changed := i.runSCCIteration(run, scc, localFuncs, summaries) - applySCCIterationUpdates(summaries, scc, next) + for { + next, changed := i.runSCCIteration(run, scc, localFuncs, returnVectors) + applySCCIterationUpdates(returnVectors, scc, next) if !changed { return true } } - return false } func (i *Inferencer) planLocalFunctionSCCs(localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo) [][]cfg.SymbolID { - // Propagate inter-procedural parameter hints across local call edges before + // Propagate inter-procedural parameter evidence across local call edges before // SCC return inference so unannotated params get stable callsite-driven seeds. - returns.PropagateParamHintsFromCallGraph(localFuncs) + returns.PropagateParameterEvidence(localFuncs) + projectLocalParameterEvidence(localFuncs) var moduleBindings *bind.BindingTable if i != nil && i.store != nil { @@ -40,48 +41,52 @@ func (i *Inferencer) planLocalFunctionSCCs(localFuncs map[cfg.SymbolID]*returns. return returns.ComputeSymbolSCCs(adj) } -func seedSummariesFromSeed( +func projectLocalParameterEvidence(localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo) { + for _, sym := range cfg.SortedSymbolIDs(localFuncs) { + info := localFuncs[sym] + if info == nil || info.Graph == nil || len(info.ParameterEvidence) == 0 { + continue + } + info.ParameterEvidence = paramevidence.ProjectToParameterUse(info.Graph.ParamSlotsReadOnly(), info.Evidence.ParameterUses, info.ParameterEvidence) + } +} + +func seedReturnVectorsFromSeed( localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, seed map[cfg.SymbolID][]typ.Type, ) map[cfg.SymbolID][]typ.Type { - summaries := make(map[cfg.SymbolID][]typ.Type, len(localFuncs)) + returnVectors := make(map[cfg.SymbolID][]typ.Type, len(localFuncs)) if seed == nil { - return summaries + return returnVectors } for _, sym := range cfg.SortedSymbolIDs(localFuncs) { if seeded := seed[sym]; len(seeded) > 0 { - summaries[sym] = seeded + returnVectors[sym] = seeded } } - return summaries + return returnVectors } -func (i *Inferencer) processSCCSummaries( +func (i *Inferencer) processSCCReturnVectors( run RunContext, sccs [][]cfg.SymbolID, localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, ) []diag.Diagnostic { - var diags []diag.Diagnostic for _, scc := range sccs { if len(scc) == 0 { continue } - if i.iterateSCCFixpoint(run, scc, localFuncs, summaries) { - continue - } - if warn := i.widenSCCToUnknown(scc, localFuncs, summaries); warn != nil { - diags = append(diags, *warn) - } + i.iterateSCCFixpoint(run, scc, localFuncs, returnVectors) } - return diags + return nil } func (i *Inferencer) runSCCIteration( run RunContext, scc []cfg.SymbolID, localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, ) (map[cfg.SymbolID][]typ.Type, bool) { changed := false next := make(map[cfg.SymbolID][]typ.Type, len(scc)) @@ -90,11 +95,11 @@ func (i *Inferencer) runSCCIteration( if info == nil || info.Fn == nil { continue } - newReturn := i.inferReturnWithSummary(run, info, summaries, localFuncs) - oldReturn := summaries[sym] - merged := returns.MergeReturnSummary(oldReturn, newReturn) + newReturn := i.inferReturnForFunction(run, info, returnVectors, localFuncs) + oldReturn := returnVectors[sym] + merged := returnsummary.WidenForConvergence(oldReturn, newReturn) next[sym] = merged - if !returns.ReturnTypesEqual(merged, oldReturn) { + if !returnsummary.Equal(merged, oldReturn) { changed = true } } @@ -102,43 +107,13 @@ func (i *Inferencer) runSCCIteration( } func applySCCIterationUpdates( - summaries map[cfg.SymbolID][]typ.Type, + returnVectors map[cfg.SymbolID][]typ.Type, scc []cfg.SymbolID, next map[cfg.SymbolID][]typ.Type, ) { for _, sym := range scc { if v, ok := next[sym]; ok { - summaries[sym] = v + returnVectors[sym] = v } } } - -// widenSCCToUnknown widens all SCC members to unknown when fixpoint did not converge. -// Preserves return arity while replacing type slots with unknown. -func (i *Inferencer) widenSCCToUnknown( - scc []cfg.SymbolID, - localFuncs map[cfg.SymbolID]*returns.LocalFuncInfo, - summaries map[cfg.SymbolID][]typ.Type, -) *diag.Diagnostic { - for _, sym := range scc { - existing := summaries[sym] - if len(existing) == 0 { - summaries[sym] = []typ.Type{typ.Unknown} - } else { - widened := make([]typ.Type, len(existing)) - for i := range widened { - widened[i] = typ.Unknown - } - summaries[sym] = widened - } - } - if info := localFuncs[scc[0]]; info != nil && info.Fn != nil { - return &diag.Diagnostic{ - Position: diag.Position{File: i.sourceName, Line: info.Fn.Line(), Column: info.Fn.Column()}, - Span: ast.SpanOf(info.Fn), - Severity: diag.SeverityWarning, - Message: "return type fixpoint did not converge; using unknown", - } - } - return nil -} diff --git a/compiler/check/modules/alias.go b/compiler/check/modules/alias.go index 2694de3c..4b00eaf7 100644 --- a/compiler/check/modules/alias.go +++ b/compiler/check/modules/alias.go @@ -3,12 +3,14 @@ package modules import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" ) -// CollectAliases extracts module alias mappings from require() assignments in a graph. +// AliasesFromAssignments extracts module alias mappings from transfer-owned +// assignment evidence. // -// This scans the graph for patterns like: +// It recognizes patterns like: // // local json = require("json") // @@ -20,18 +22,19 @@ import ( // It does not require the assignment to be local, since module aliases can be // introduced via re-assignments or outer scope bindings. // -// This is called once per graph; nested functions merge their local aliases -// with the session-level alias map. -func CollectAliases(graph *cfg.Graph) map[cfg.SymbolID]string { - if graph == nil { +// Nested functions merge their local aliases with the session-level alias map. +func AliasesFromAssignments(assignments []api.AssignmentEvidence, graph *cfg.Graph) map[cfg.SymbolID]string { + if len(assignments) == 0 { return nil } aliases := make(map[cfg.SymbolID]string) - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info == nil || len(info.Targets) == 0 { - return + continue } info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { if target.Kind != cfg.TargetIdent { @@ -57,7 +60,7 @@ func CollectAliases(graph *cfg.Graph) map[cfg.SymbolID]string { } sym := target.Symbol - if sym == 0 && target.Name != "" { + if sym == 0 && target.Name != "" && graph != nil { var symOk bool sym, symOk = graph.SymbolAt(p, target.Name) if !symOk { @@ -69,7 +72,7 @@ func CollectAliases(graph *cfg.Graph) map[cfg.SymbolID]string { } aliases[sym] = strLit.Value }) - }) + } if len(aliases) == 0 { return nil diff --git a/compiler/check/modules/alias_test.go b/compiler/check/modules/alias_test.go index f0f55b01..c1a2b514 100644 --- a/compiler/check/modules/alias_test.go +++ b/compiler/check/modules/alias_test.go @@ -7,19 +7,19 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" ) -func TestCollectAliases_NilGraph(t *testing.T) { - aliases := CollectAliases(nil) +func TestAliasesFromAssignments_NilEvidence(t *testing.T) { + aliases := AliasesFromAssignments(nil, nil) if aliases != nil { - t.Errorf("expected nil for nil graph, got %v", aliases) + t.Errorf("expected nil for nil evidence, got %v", aliases) } } -func TestCollectAliases_EmptyGraph(t *testing.T) { +func TestAliasesFromAssignments_EmptyGraph(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{}, } graph := cfg.Build(fn) - result := CollectAliases(graph) + result := AliasesFromAssignments(nil, graph) if result != nil { t.Error("expected nil for empty graph") } diff --git a/compiler/check/modules/export.go b/compiler/check/modules/export.go index 4494a7ec..d1c0da4c 100644 --- a/compiler/check/modules/export.go +++ b/compiler/check/modules/export.go @@ -22,9 +22,14 @@ func ExportType(result *api.FuncResult, refinementsBySym map[cfg.SymbolID]*const var exportRootName string var exportRootSet bool - result.Graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range result.Evidence.Returns { + p := ret.Point + info := ret.Info + if info == nil { + continue + } if result.FlowInputs != nil && result.FlowInputs.DeadPoints[p] { - return + continue } if len(info.Exprs) == 0 { if export == nil { @@ -32,7 +37,7 @@ func ExportType(result *api.FuncResult, refinementsBySym map[cfg.SymbolID]*const } else { export = typ.NewUnion(export, typ.Nil) } - return + continue } valueType := synth.TypeOf(info.Exprs[0], p) @@ -57,7 +62,7 @@ func ExportType(result *api.FuncResult, refinementsBySym map[cfg.SymbolID]*const } else if !typ.TypeEquals(export, valueType) { export = typ.NewUnion(export, valueType) } - }) + } if export != nil && len(refinementsBySym) > 0 && result.Graph != nil { export = effects.EnrichExportWithEffects(export, exportRootName, refinementsBySym, result.Graph) diff --git a/compiler/check/nested/constructor.go b/compiler/check/nested/constructor.go index 0cfae6dc..efc5796e 100644 --- a/compiler/check/nested/constructor.go +++ b/compiler/check/nested/constructor.go @@ -3,7 +3,8 @@ package nested import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/typ" ) @@ -35,11 +36,41 @@ import ( // 2. Creates self via setmetatable({}, T) or setmetatable({}, {__index = T}) // 3. Returns the self variable // -// The nestedGraph is the CFG of the function being analyzed. -// The parentGraph is the CFG where the function is defined (needed for T.new = function() pattern). -func DetectConstructorPattern(nestedGraph, parentGraph *cfg.Graph, fn *ast.FunctionExpr, funcDef *cfg.FuncDefInfo) (classSymbol, selfSymbol cfg.SymbolID) { - if nestedGraph == nil || fn == nil { - return 0, 0 +// The nested evidence belongs to the function being analyzed. The parent +// evidence belongs to the graph where it is defined, for `T.new = function()`. +func DetectConstructorPattern( + nestedEvidence api.FlowEvidence, + parentEvidence api.FlowEvidence, + fn *ast.FunctionExpr, + funcDef *cfg.FuncDefInfo, +) (classSymbol, selfSymbol cfg.SymbolID) { + pattern := DetectConstructorPatternInfo(nestedEvidence, parentEvidence, fn, funcDef) + return pattern.ClassSymbol, pattern.SelfSymbol +} + +// ConstructorPattern describes the instance/prototype relation discovered for a +// Lua setmetatable-backed constructor. +type ConstructorPattern struct { + ClassSymbol cfg.SymbolID + PrototypeSymbol cfg.SymbolID + SelfSymbol cfg.SymbolID + InstanceLiteral *ast.TableExpr + InstancePoint cfg.Point + ReturnedViaSetmetatable bool +} + +// DetectConstructorPatternInfo detects constructors and identifies the prototype +// table that owns instance methods. In the common T.__index = T case the class +// and prototype are the same symbol; in split-prototype code such as +// `local mt = { __index = methods }`, the prototype is the method table. +func DetectConstructorPatternInfo( + nestedEvidence api.FlowEvidence, + parentEvidence api.FlowEvidence, + fn *ast.FunctionExpr, + funcDef *cfg.FuncDefInfo, +) ConstructorPattern { + if fn == nil { + return ConstructorPattern{} } // Check if function is T.new pattern @@ -56,12 +87,16 @@ func DetectConstructorPattern(nestedGraph, parentGraph *cfg.Graph, fn *ast.Funct } // Also check for T.new = function(...) pattern in the parent graph - if receiverSymbol == 0 && parentGraph != nil { + if receiverSymbol == 0 { var found cfg.SymbolID var foundName string - parentGraph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range parentEvidence.Assignments { if found != 0 { - return + break + } + info := assign.Info + if info == nil { + continue } info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { fnExpr, ok := src.(*ast.FunctionExpr) @@ -73,63 +108,71 @@ func DetectConstructorPattern(nestedGraph, parentGraph *cfg.Graph, fn *ast.Funct foundName = target.BaseName } }) - }) + } receiverSymbol = found receiverName = foundName } if receiverSymbol == 0 { - return 0, 0 + return ConstructorPattern{} } - // Find setmetatable call that creates self - selfSym := findSetmetatablePatternByName(nestedGraph, receiverName) - if selfSym == 0 { - return 0, 0 + pattern := findSetmetatableConstructorPattern(nestedEvidence, parentEvidence, receiverName, receiverSymbol) + if pattern.SelfSymbol == 0 && pattern.InstanceLiteral == nil { + return ConstructorPattern{} + } + pattern.ClassSymbol = receiverSymbol + if pattern.PrototypeSymbol == 0 { + pattern.PrototypeSymbol = receiverSymbol } - // Check that self is returned - if !isSymbolReturned(nestedGraph, selfSym) { - return 0, 0 + if pattern.SelfSymbol != 0 && + !pattern.ReturnedViaSetmetatable && + !isConstructorSymbolReturned(nestedEvidence.Returns, pattern.SelfSymbol) { + return ConstructorPattern{} } - return receiverSymbol, selfSym + return pattern } -func findSetmetatablePatternByName(graph *cfg.Graph, expectedClassName string) cfg.SymbolID { - if graph == nil { +func findSetmetatablePatternByName(assignments []api.AssignmentEvidence, expectedClassName string) cfg.SymbolID { + if len(assignments) == 0 { return 0 } var selfSym cfg.SymbolID - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { if selfSym != 0 { - return + break + } + info := assign.Info + if info == nil { + continue } if !info.IsLocal || len(info.Targets) == 0 { - return + continue } // Look for setmetatable call call, ok := info.SourceAt(0).(*ast.FuncCallExpr) if !ok { - return + continue } // Check if it's a setmetatable call ident, ok := call.Func.(*ast.IdentExpr) if !ok || ident.Value != "setmetatable" { - return + continue } if len(call.Args) < 2 { - return + continue } // First arg should be an empty table literal or table with initial values if _, ok := call.Args[0].(*ast.TableExpr); !ok { - return + continue } // Second arg is the metatable - check for T or {__index = T} @@ -155,7 +198,7 @@ func findSetmetatablePatternByName(graph *cfg.Graph, expectedClassName string) c // Validate class name if expected if expectedClassName != "" && foundClassName != expectedClassName { - return + continue } if target, ok := info.FirstTarget(); ok { @@ -163,68 +206,307 @@ func findSetmetatablePatternByName(graph *cfg.Graph, expectedClassName string) c selfSym = target.Symbol } } - }) + } return selfSym } -// isSymbolReturnedOnAllPaths checks if a symbol is returned on ALL return paths. -// Returns false if any return path returns something other than the symbol. -func isSymbolReturned(graph *cfg.Graph, sym cfg.SymbolID) bool { - if graph == nil || sym == 0 { - return false +func findSetmetatableConstructorPattern( + nestedEvidence api.FlowEvidence, + parentEvidence api.FlowEvidence, + receiverName string, + receiverSym cfg.SymbolID, +) ConstructorPattern { + for _, assign := range nestedEvidence.Assignments { + info := assign.Info + if info == nil || !info.IsLocal || len(info.Targets) == 0 { + continue + } + call, ok := setmetatableCall(info.SourceAt(0)) + if !ok { + continue + } + tableArg, ok := call.Args[0].(*ast.TableExpr) + if !ok || tableArg == nil { + continue + } + target, ok := info.FirstTarget() + if !ok || target.Kind != cfg.TargetIdent || target.Symbol == 0 { + continue + } + return ConstructorPattern{ + PrototypeSymbol: prototypeSymbolFromMetatableArg(call.Args[1], parentEvidence, receiverName, receiverSym), + SelfSymbol: target.Symbol, + InstanceLiteral: tableArg, + InstancePoint: assign.Point, + } + } + + for _, ret := range nestedEvidence.Returns { + if ret.Info == nil || len(ret.Info.Exprs) == 0 { + continue + } + call, ok := setmetatableCall(ret.Info.Exprs[0]) + if !ok { + continue + } + selfSym, tableArg := constructorInstanceFromSetmetatableArg(call.Args[0], nestedEvidence.Assignments) + if selfSym == 0 && tableArg == nil { + continue + } + return ConstructorPattern{ + PrototypeSymbol: prototypeSymbolFromMetatableArg(call.Args[1], parentEvidence, receiverName, receiverSym), + SelfSymbol: selfSym, + InstanceLiteral: tableArg, + InstancePoint: ret.Point, + ReturnedViaSetmetatable: true, + } } - bindings := graph.Bindings() - if bindings == nil { + return ConstructorPattern{} +} + +func setmetatableCall(expr ast.Expr) (*ast.FuncCallExpr, bool) { + call, ok := expr.(*ast.FuncCallExpr) + if !ok || call == nil || len(call.Args) < 2 { + return nil, false + } + ident, ok := call.Func.(*ast.IdentExpr) + if !ok || ident.Value != "setmetatable" { + return nil, false + } + return call, true +} + +func constructorInstanceFromSetmetatableArg( + arg ast.Expr, + assignments []api.AssignmentEvidence, +) (cfg.SymbolID, *ast.TableExpr) { + switch inst := arg.(type) { + case *ast.TableExpr: + return 0, inst + case *ast.IdentExpr: + if inst.Value == "" { + return 0, nil + } + for _, assign := range assignments { + info := assign.Info + if info == nil { + continue + } + target, ok := info.FirstTarget() + if !ok || target.Kind != cfg.TargetIdent || target.Name != inst.Value { + continue + } + if tbl, ok := info.SourceAt(0).(*ast.TableExpr); ok { + return target.Symbol, tbl + } + } + } + return 0, nil +} + +func prototypeSymbolFromMetatableArg(arg ast.Expr, parentEvidence api.FlowEvidence, receiverName string, receiverSym cfg.SymbolID) cfg.SymbolID { + switch mt := arg.(type) { + case *ast.TableExpr: + if name := indexPrototypeName(mt); name != "" { + if sym := symbolForAssignedName(parentEvidence.Assignments, name); sym != 0 { + return sym + } + if name == receiverName { + return receiverSym + } + } + case *ast.IdentExpr: + if mt.Value == receiverName { + return receiverSym + } + for _, assign := range parentEvidence.Assignments { + info := assign.Info + if info == nil || len(info.Targets) == 0 { + continue + } + target, ok := info.FirstTarget() + if !ok || target.Kind != cfg.TargetIdent || target.Name != mt.Value { + continue + } + if tbl, ok := info.SourceAt(0).(*ast.TableExpr); ok { + if name := indexPrototypeName(tbl); name != "" { + if sym := symbolForAssignedName(parentEvidence.Assignments, name); sym != 0 { + return sym + } + if name == receiverName { + return receiverSym + } + } + } + if target.Symbol != 0 { + return target.Symbol + } + } + } + return 0 +} + +func indexPrototypeName(tbl *ast.TableExpr) string { + if tbl == nil { + return "" + } + for _, field := range tbl.Fields { + key, ok := field.Key.(*ast.StringExpr) + if !ok || key.Value != "__index" { + continue + } + if ident, ok := field.Value.(*ast.IdentExpr); ok { + return ident.Value + } + } + return "" +} + +func symbolForAssignedName(assignments []api.AssignmentEvidence, name string) cfg.SymbolID { + if name == "" { + return 0 + } + for _, assign := range assignments { + info := assign.Info + if info == nil { + continue + } + for _, target := range info.Targets { + if target.Kind == cfg.TargetIdent && target.Name == name && target.Symbol != 0 { + return target.Symbol + } + } + } + return 0 +} + +// isSymbolReturnedOnAllPaths checks if a symbol is returned on ALL return paths. +// Returns false if any return path returns something other than the symbol. +func isSymbolReturned(returns []api.ReturnEvidence, sym cfg.SymbolID) bool { + if len(returns) == 0 || sym == 0 { return false } hasReturn := false allReturnSym := true - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range returns { if !allReturnSym { - return + break + } + info := ret.Info + if info == nil { + continue } hasReturn = true // Empty return or return with no expressions doesn't return self. if len(info.Exprs) == 0 { allReturnSym = false - return + break } // Check if first return expression is the symbol. - ident, ok := info.Exprs[0].(*ast.IdentExpr) - if !ok { - allReturnSym = false - return - } - retSym, ok := bindings.SymbolOf(ident) - if !ok || retSym != sym { + if len(info.Symbols) == 0 || info.Symbols[0] != sym { allReturnSym = false } - }) + } return hasReturn && allReturnSym } +func isConstructorSymbolReturned(returns []api.ReturnEvidence, sym cfg.SymbolID) bool { + if len(returns) == 0 || sym == 0 { + return false + } + for _, ret := range returns { + info := ret.Info + if info == nil || len(info.Symbols) == 0 { + continue + } + if info.Symbols[0] == sym { + return true + } + } + return false +} + // CollectConstructorFields collects field assignments to a self symbol in a constructor. // -// This scans the constructor's CFG for statements like `self.field = value` and -// builds a map of field names to their types. These fields become part of the -// class's instance type, enabling the type checker to validate field access -// on instances created by this constructor. -func CollectConstructorFields(graph *cfg.Graph, selfSym cfg.SymbolID, synth func(ast.Expr, cfg.Point) typ.Type) map[string]typ.Type { - if graph == nil || selfSym == 0 { +// This reduces transfer assignment evidence for statements like +// `self.field = value` and builds a map of field names to their types. +// These fields become part of the class's instance type, enabling the type +// checker to validate field access on instances created by this constructor. +func CollectConstructorFields(assignments []api.AssignmentEvidence, selfSym cfg.SymbolID, synth func(ast.Expr, cfg.Point) typ.Type) map[string]typ.Type { + if len(assignments) == 0 || selfSym == 0 { return nil } filterSyms := map[cfg.SymbolID]bool{selfSym: true} - fields := assign.CollectFieldAssignments(graph, synth, filterSyms) + fields := overlaymut.CollectFieldAssignments(assignments, synth, filterSyms) if selfFields, ok := fields[selfSym]; ok && len(selfFields) > 0 { - return selfFields + filtered := make(map[string]typ.Type, len(selfFields)) + for name, t := range selfFields { + if typ.IsAbsentOrUnknown(t) { + continue + } + filtered[name] = t + } + if len(filtered) > 0 { + return filtered + } } return nil } + +// CollectConstructorLiteralFields collects fields declared directly in the +// instance table passed to setmetatable. +func CollectConstructorLiteralFields(table *ast.TableExpr, point cfg.Point, synth func(ast.Expr, cfg.Point) typ.Type) map[string]typ.Type { + if table == nil { + return nil + } + fields := make(map[string]typ.Type) + for _, field := range table.Fields { + key, ok := field.Key.(*ast.StringExpr) + if !ok || key.Value == "" || field.Value == nil { + continue + } + var fieldType typ.Type + if synth != nil { + fieldType = synth(field.Value, point) + } + if typ.IsAbsentOrUnknown(fieldType) { + fieldType = typ.Any + } + fields[key.Value] = fieldType + } + if len(fields) == 0 { + return nil + } + return fields +} + +// MergeConstructorFieldMaps joins constructor field maps from assignment and +// literal sources. +func MergeConstructorFieldMaps(a, b map[string]typ.Type) map[string]typ.Type { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + out := make(map[string]typ.Type, len(a)+len(b)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if prev := out[k]; prev != nil { + out[k] = typ.JoinPreferNonSoft(prev, v) + } else { + out[k] = v + } + } + return out +} diff --git a/compiler/check/nested/constructor_test.go b/compiler/check/nested/constructor_test.go index e46cc92f..faac4870 100644 --- a/compiler/check/nested/constructor_test.go +++ b/compiler/check/nested/constructor_test.go @@ -4,17 +4,18 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" ) func TestDetectConstructorPattern_NilInputs(t *testing.T) { - classSym, selfSym := DetectConstructorPattern(nil, nil, nil, nil) + classSym, selfSym := DetectConstructorPattern(api.FlowEvidence{}, api.FlowEvidence{}, nil, nil) if classSym != 0 || selfSym != 0 { t.Errorf("expected (0, 0) for nil inputs, got (%d, %d)", classSym, selfSym) } } func TestDetectConstructorPattern_NilGraph(t *testing.T) { - classSym, selfSym := DetectConstructorPattern(nil, &cfg.Graph{}, nil, nil) + classSym, selfSym := DetectConstructorPattern(api.FlowEvidence{}, api.FlowEvidence{}, nil, nil) if classSym != 0 || selfSym != 0 { t.Errorf("expected (0, 0) for nil nestedGraph, got (%d, %d)", classSym, selfSym) } @@ -35,7 +36,7 @@ func TestIsSymbolReturned_NilGraph(t *testing.T) { } func TestIsSymbolReturned_ZeroSymbol(t *testing.T) { - result := isSymbolReturned(&cfg.Graph{}, 0) + result := isSymbolReturned(nil, 0) if result { t.Error("expected false for zero symbol") } @@ -49,15 +50,15 @@ func TestCollectConstructorFields_NilInputs(t *testing.T) { } func TestCollectConstructorFields_ZeroSymbol(t *testing.T) { - result := CollectConstructorFields(&cfg.Graph{}, 0, nil) + result := CollectConstructorFields(nil, 0, nil) if result != nil { t.Errorf("expected nil for zero symbol, got %v", result) } } -func TestCollectConstructorFields_NilGraph(t *testing.T) { +func TestCollectConstructorFields_NilEvidence(t *testing.T) { result := CollectConstructorFields(nil, cfg.SymbolID(1), nil) if result != nil { - t.Errorf("expected nil for nil graph, got %v", result) + t.Errorf("expected nil for nil evidence, got %v", result) } } diff --git a/compiler/check/nested/enrich.go b/compiler/check/nested/enrich.go index f6736612..2d5dccb8 100644 --- a/compiler/check/nested/enrich.go +++ b/compiler/check/nested/enrich.go @@ -3,12 +3,6 @@ package nested import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/flowbuild/assign" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - flowpath "github.com/wippyai/go-lua/compiler/check/flowbuild/path" - "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" ) @@ -19,20 +13,20 @@ import ( // (with inferred refinements and return types) may be more precise than the initially // synthesized type. These utilities replace placeholder types with literal sigs. -// EnrichTableTypeWithFuncTypes replaces method function types in a record -// with canonical function types derived from the interproc queries. +// EnrichTableTypeWithFunctionLookup replaces method function types in a record +// with function types resolved by symbol. // // For table literals with method fields, the initially synthesized record may // have function types without inferred return types. After analyzing the methods, // canonical function types are available per symbol. This function updates the // record with those more precise signatures. -func EnrichTableTypeWithFuncTypes( +func EnrichTableTypeWithFunctionLookup( rec *typ.Record, tableExpr *ast.TableExpr, graph *cfg.Graph, - funcTypes map[cfg.SymbolID]typ.Type, + lookup func(cfg.SymbolID) typ.Type, ) typ.Type { - if rec == nil || tableExpr == nil || graph == nil || len(funcTypes) == 0 { + if rec == nil || tableExpr == nil || graph == nil || lookup == nil { return rec } @@ -60,7 +54,7 @@ func EnrichTableTypeWithFuncTypes( } if bindings != nil { if sym, ok := bindings.FuncLitSymbol(fnExpr); ok { - if t := funcTypes[sym]; t != nil { + if t := lookup(sym); t != nil { fieldType = t modified = true } @@ -87,80 +81,6 @@ func EnrichTableTypeWithFuncTypes( return builder.SetOpen(rec.Open).Build() } -// CollectCapturedFieldAssignments scans a nested function's graph for field assignments -// to captured variables. -// -// When a nested function assigns fields to a captured variable (e.g., `parent.field = v`), -// those assignments affect the type visible in the parent scope. This function collects -// such assignments for propagation back to the parent. -func CollectCapturedFieldAssignments( - graph *cfg.Graph, - capturedSyms map[cfg.SymbolID]bool, - synth func(ast.Expr, cfg.Point) typ.Type, -) map[cfg.SymbolID]map[string]typ.Type { - if graph == nil || len(capturedSyms) == 0 { - return make(map[cfg.SymbolID]map[string]typ.Type) - } - return assign.CollectFieldAssignments(graph, synth, capturedSyms) -} - -// CollectCapturedContainerMutations scans a nested function's graph for container mutations -// (e.g., channel.send) that target captured variables. -func CollectCapturedContainerMutations( - graph *cfg.Graph, - capturedSyms map[cfg.SymbolID]bool, - synth func(ast.Expr, cfg.Point) typ.Type, -) map[cfg.SymbolID][]api.ContainerMutation { - result := make(map[cfg.SymbolID][]api.ContainerMutation) - if graph == nil || len(capturedSyms) == 0 { - return result - } - - bindings := graph.Bindings() - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { - if info == nil { - return - } - - ceu := mutator.ContainerMutatorFromCall(info, p, synth, nil, nil, graph, bindings, nil) - if ceu == nil { - return - } - - targetExpr := callsite.RuntimeArgAt(info, ceu.Container.Index) - valueExpr := callsite.RuntimeArgAt(info, ceu.Value.Index) - if targetExpr == nil || valueExpr == nil { - return - } - - targetPath := flowpath.FromExprWithBindings(targetExpr, nil, bindings) - if targetPath.IsEmpty() || targetPath.Symbol == 0 { - return - } - if !capturedSyms[targetPath.Symbol] { - return - } - - var valueType typ.Type - if synth != nil { - valueType = synth(valueExpr, p) - } - if valueType == nil { - valueType = typ.Unknown - } - valueType = subtype.WidenForInference(valueType) - - segs := make([]constraint.Segment, len(targetPath.Segments)) - copy(segs, targetPath.Segments) - result[targetPath.Symbol] = append(result[targetPath.Symbol], api.ContainerMutation{ - Segments: segs, - ValueType: valueType, - }) - }) - - return result -} - // EnrichSelfTypeWithConstructorFields merges constructor instance fields into a self-type. // // When a method is defined on a class that has a constructor, the self-type should @@ -177,16 +97,10 @@ func CollectCapturedContainerMutations( // function T:greet() // print(self.name) -- self.name is recognized because of constructor fields // end -func EnrichSelfTypeWithConstructorFields(selfType typ.Type, classSymbol cfg.SymbolID, store Store) typ.Type { - if selfType == nil || store == nil || classSymbol == 0 { +func EnrichSelfTypeWithConstructorFields(selfType typ.Type, fields map[string]typ.Type) typ.Type { + if selfType == nil || len(fields) == 0 { return selfType } - - fields := store.LookupConstructorFields(classSymbol) - if len(fields) == 0 { - return selfType - } - return mergeFieldsIntoSelfType(selfType, fields) } @@ -213,10 +127,14 @@ func mergeFieldsIntoSelfType(selfType typ.Type, fields map[string]typ.Type) typ. existingFields := make(map[string]bool) for _, f := range v.Fields { + fieldType := f.Type + if constructorType := fields[f.Name]; constructorType != nil && (typ.IsAbsentOrUnknown(fieldType) || typ.IsAny(fieldType)) { + fieldType = constructorType + } if f.Optional { - builder.OptField(f.Name, f.Type) + builder.OptField(f.Name, fieldType) } else { - builder.Field(f.Name, f.Type) + builder.Field(f.Name, fieldType) } existingFields[f.Name] = true } diff --git a/compiler/check/nested/enrich_test.go b/compiler/check/nested/enrich_test.go index fedd0499..58b4ad71 100644 --- a/compiler/check/nested/enrich_test.go +++ b/compiler/check/nested/enrich_test.go @@ -3,57 +3,33 @@ package nested import ( "testing" - "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/parse" - "github.com/wippyai/go-lua/types/contract" - "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/typ" ) -func TestEnrichTableTypeWithFuncTypes_NilInputs(t *testing.T) { - result := EnrichTableTypeWithFuncTypes(nil, nil, nil, nil) +func TestEnrichTableTypeWithFunctionLookup_NilInputs(t *testing.T) { + result := EnrichTableTypeWithFunctionLookup(nil, nil, nil, nil) if rec, ok := result.(*typ.Record); !ok || rec != nil { t.Error("expected nil record for nil inputs") } } -func TestEnrichTableTypeWithFuncTypes_NilRecord(t *testing.T) { - result := EnrichTableTypeWithFuncTypes(nil, nil, &cfg.Graph{}, nil) +func TestEnrichTableTypeWithFunctionLookup_NilRecord(t *testing.T) { + result := EnrichTableTypeWithFunctionLookup(nil, nil, &cfg.Graph{}, nil) if rec, ok := result.(*typ.Record); !ok || rec != nil { t.Error("expected nil record for nil record input") } } -func TestCollectCapturedFieldAssignments_NilGraph(t *testing.T) { - result := CollectCapturedFieldAssignments(nil, nil, nil) - if result == nil { - t.Error("expected empty map, got nil") - } - if len(result) != 0 { - t.Errorf("expected empty map, got %v", result) - } -} - -func TestCollectCapturedFieldAssignments_EmptyCapturedSyms(t *testing.T) { - result := CollectCapturedFieldAssignments(&cfg.Graph{}, map[cfg.SymbolID]bool{}, nil) - if result == nil { - t.Error("expected empty map, got nil") - } - if len(result) != 0 { - t.Errorf("expected empty map, got %v", result) - } -} - func TestEnrichSelfTypeWithConstructorFields_NilInputs(t *testing.T) { - result := EnrichSelfTypeWithConstructorFields(nil, 0, nil) + result := EnrichSelfTypeWithConstructorFields(nil, nil) if result != nil { t.Error("expected nil for nil inputs") } } func TestEnrichSelfTypeWithConstructorFields_NilSelfType(t *testing.T) { - result := EnrichSelfTypeWithConstructorFields(nil, 1, nil) + result := EnrichSelfTypeWithConstructorFields(nil, nil) if result != nil { t.Error("expected nil for nil selfType") } @@ -75,64 +51,3 @@ func TestMergeFieldsIntoSelfType_NonRecordNonInterface(t *testing.T) { t.Errorf("expected original selfType for non-record/interface, got %v", result) } } - -func TestCollectCapturedContainerMutations_AssignmentCallSite(t *testing.T) { - code := ` - local c = {} - local _ = send(c, 1) - ` - stmts, err := parse.ParseString(code, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - fn := &ast.FunctionExpr{ - ParList: &ast.ParList{HasVargs: true}, - Stmts: stmts, - } - graph := cfg.Build(fn, "send") - if graph == nil { - t.Fatal("expected graph") - } - symC, ok := graph.SymbolAt(graph.Exit(), "c") - if !ok || symC == 0 { - t.Fatal("expected symbol for c") - } - - captured := map[cfg.SymbolID]bool{symC: true} - result := CollectCapturedContainerMutations(graph, captured, nestedContainerSendSynth()) - muts := result[symC] - if len(muts) != 1 { - t.Fatalf("expected 1 container mutation for c, got %d", len(muts)) - } - if !typ.TypeEquals(muts[0].ValueType, typ.Integer) { - t.Fatalf("expected integer mutation value, got %v", muts[0].ValueType) - } -} - -func nestedContainerSendSynth() func(ast.Expr, cfg.Point) typ.Type { - spec := contract.NewSpec().WithEffects(effect.Mutate{ - Target: effect.ParamRef{Index: 0}, - Transform: effect.ContainerElementUnion{ - Container: effect.ParamRef{Index: 0}, - Value: effect.ParamRef{Index: 1}, - }, - }) - send := typ.Func(). - Param("container", typ.Any). - Param("value", typ.Any). - Returns(typ.Nil). - Spec(spec). - Build() - - return func(expr ast.Expr, _ cfg.Point) typ.Type { - switch v := expr.(type) { - case *ast.IdentExpr: - if v.Value == "send" { - return send - } - case *ast.NumberExpr: - return typ.Integer - } - return typ.Unknown - } -} diff --git a/compiler/check/nested/nested.go b/compiler/check/nested/nested.go index 73bd355c..8cc1f584 100644 --- a/compiler/check/nested/nested.go +++ b/compiler/check/nested/nested.go @@ -8,7 +8,7 @@ // # Data Types // // This package exports data types that describe nested functions: -// - Child: A discovered nested function with its identity resolved +// - Child: A transfer-discovered nested function with its identity resolved // - FuncInfo: A Child extended with its synthesized function type // - ScopeGroup: A group of functions sharing the same parent scope // @@ -45,7 +45,6 @@ package nested import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/types/typ" ) // Child holds the resolved identity of a nested function definition. @@ -77,77 +76,3 @@ type ScopeGroup struct { Funcs []*FuncInfo MinPoint cfg.Point } - -// Store provides access to session storage for enrichment functions. -// -// This interface abstracts the SessionStore, providing access to literal -// signatures and constructor fields without depending on the session package. -type Store interface { - LookupConstructorFields(classSym cfg.SymbolID) map[string]typ.Type -} - -// GatherChildren iterates the graph's nested functions and resolves each one's -// definition scope and identity. -// -// For each nested function in the CFG, this function: -// - Looks up the definition scope at the function's point -// - Retrieves any FuncDefInfo (for named function definitions) -// - Resolves the function's name, symbol, and locality -// -// The result is a slice of Child structs ready for grouping and processing. -func GatherChildren(graph *cfg.Graph, scopes map[cfg.Point]*scope.State, fallback *scope.State) []Child { - if graph == nil { - return nil - } - nestedFuncs := graph.NestedFunctions() - if len(nestedFuncs) == 0 { - return nil - } - children := make([]Child, 0, len(nestedFuncs)) - for _, nf := range nestedFuncs { - if nf.Func == nil { - continue - } - defScope := scopes[nf.Point] - if defScope == nil { - defScope = fallback - } - funcDef := graph.FuncDef(nf.Point) - funcName, funcSym, isLocal := ResolveNestedFuncIdentity(graph, nf, funcDef) - children = append(children, Child{ - NF: nf, - DefScope: defScope, - FuncDef: funcDef, - FuncName: funcName, - FuncSym: funcSym, - IsLocal: isLocal, - }) - } - return children -} - -// ResolveNestedFuncIdentity determines the name, symbol, and locality of a nested function. -// -// The identity is resolved by checking (in order): -// 1. FuncDefInfo: Named function definitions provide name, symbol, and locality -// 2. Local assignment: `local f = function()` provides target name and symbol -// 3. NestedFunc symbol: Anonymous functions may still have an assigned symbol -// -// Returns the function name (may be empty), symbol ID (may be 0), and whether -// the function is locally scoped. -func ResolveNestedFuncIdentity(graph *cfg.Graph, nf cfg.NestedFunc, funcDef *cfg.FuncDefInfo) (string, cfg.SymbolID, bool) { - if funcDef != nil { - return funcDef.Name, funcDef.Symbol, funcDef.TargetKind == cfg.FuncDefGlobal - } - if assignInfo := graph.Assign(nf.Point); assignInfo != nil && assignInfo.IsLocal { - if len(assignInfo.Targets) == 1 && assignInfo.Targets[0].Kind == cfg.TargetIdent { - if len(assignInfo.Sources) == 1 && assignInfo.Sources[0] == nf.Func { - return assignInfo.Targets[0].Name, assignInfo.Targets[0].Symbol, true - } - } - } - if nf.Symbol != 0 { - return "", nf.Symbol, true - } - return "", 0, false -} diff --git a/compiler/check/nested/nested_test.go b/compiler/check/nested/nested_test.go index 07994973..17b43fec 100644 --- a/compiler/check/nested/nested_test.go +++ b/compiler/check/nested/nested_test.go @@ -6,45 +6,6 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" ) -func TestGatherChildren_NilGraph(t *testing.T) { - children := GatherChildren(nil, nil, nil) - if len(children) != 0 { - t.Errorf("expected empty children for nil graph, got %d", len(children)) - } -} - -func TestGatherChildren_NilNestedFuncs(t *testing.T) { - graph := &cfg.Graph{} - children := GatherChildren(graph, nil, nil) - if len(children) != 0 { - t.Errorf("expected empty children for graph with no nested functions, got %d", len(children)) - } -} - -func TestResolveNestedFuncIdentity_NilFuncDef(t *testing.T) { - graph := &cfg.Graph{} - nf := cfg.NestedFunc{Point: 0, Func: nil, Symbol: 0} - name, sym, isLocal := ResolveNestedFuncIdentity(graph, nf, nil) - if name != "" || sym != 0 || isLocal != false { - t.Errorf("expected empty identity for nil funcDef and zero symbol, got (%s, %d, %v)", name, sym, isLocal) - } -} - -func TestResolveNestedFuncIdentity_WithSymbol(t *testing.T) { - graph := &cfg.Graph{} - nf := cfg.NestedFunc{Point: 0, Func: nil, Symbol: cfg.SymbolID(42)} - name, sym, isLocal := ResolveNestedFuncIdentity(graph, nf, nil) - if name != "" { - t.Errorf("expected empty name, got %s", name) - } - if sym != 42 { - t.Errorf("expected symbol 42, got %d", sym) - } - if !isLocal { - t.Error("expected isLocal to be true") - } -} - func TestChild_Struct(t *testing.T) { c := Child{ FuncName: "test", diff --git a/compiler/check/nested/table.go b/compiler/check/nested/table.go index a2067c11..d2bf89c1 100644 --- a/compiler/check/nested/table.go +++ b/compiler/check/nested/table.go @@ -3,6 +3,7 @@ package nested import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" ) // This file provides utilities for finding table literals and their relationships @@ -13,15 +14,20 @@ import ( // // This is used to find the table literal that defines a class or object, // enabling self-type resolution for methods defined on that table. -func FindTableLiteralForSymbol(graph *cfg.Graph, sym cfg.SymbolID) (*ast.TableExpr, cfg.Point) { - if graph == nil || sym == 0 { +func FindTableLiteralForSymbol(assignments []api.AssignmentEvidence, sym cfg.SymbolID) (*ast.TableExpr, cfg.Point) { + if len(assignments) == 0 || sym == 0 { return nil, 0 } var result *ast.TableExpr var resultPoint cfg.Point - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { if result != nil { - return + break + } + p := assign.Point + info := assign.Info + if info == nil { + continue } info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { if target.Symbol != sym { @@ -32,7 +38,7 @@ func FindTableLiteralForSymbol(graph *cfg.Graph, sym cfg.SymbolID) (*ast.TableEx resultPoint = p } }) - }) + } return result, resultPoint } @@ -41,8 +47,8 @@ func FindTableLiteralForSymbol(graph *cfg.Graph, sym cfg.SymbolID) (*ast.TableEx // For patterns like `obj.method = function(self) ... end`, this function finds // the base object (obj) so that self can be typed as the object's type. Also // returns the table literal assigned to that symbol, if any. -func FindFieldAssignmentBase(graph *cfg.Graph, fn *ast.FunctionExpr, point cfg.Point) (cfg.SymbolID, *ast.TableExpr, cfg.Point) { - if graph == nil || fn == nil { +func FindFieldAssignmentBase(assignments []api.AssignmentEvidence, fn *ast.FunctionExpr, point cfg.Point) (cfg.SymbolID, *ast.TableExpr, cfg.Point) { + if len(assignments) == 0 || fn == nil { return 0, nil, 0 } var baseSym cfg.SymbolID @@ -66,7 +72,7 @@ func FindFieldAssignmentBase(graph *cfg.Graph, fn *ast.FunctionExpr, point cfg.P // Prefer the assignment at the function's definition point. if point != 0 { - if info := graph.Assign(point); info != nil { + if info := assignmentInfoAt(assignments, point); info != nil { info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { if !matchFunc(src) { return @@ -83,9 +89,13 @@ func FindFieldAssignmentBase(graph *cfg.Graph, fn *ast.FunctionExpr, point cfg.P } } - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { if baseSym != 0 { - return + break + } + info := assign.Info + if info == nil { + continue } info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { if !matchFunc(src) { @@ -100,12 +110,12 @@ func FindFieldAssignmentBase(graph *cfg.Graph, fn *ast.FunctionExpr, point cfg.P return } }) - }) + } if baseSym == 0 { return 0, nil, 0 } // Find the table literal assigned to the base symbol. - tbl, p := FindTableLiteralForSymbol(graph, baseSym) + tbl, p := FindTableLiteralForSymbol(assignments, baseSym) if p != 0 { tblPoint = p } @@ -117,15 +127,19 @@ func FindFieldAssignmentBase(graph *cfg.Graph, fn *ast.FunctionExpr, point cfg.P // For patterns like `local obj = { method = function(self) ... }`, this function // finds the containing table so that self can be typed as the table's type. // Returns both the TableExpr and its assigned symbol. -func FindTableLiteralOwner(graph *cfg.Graph, fn *ast.FunctionExpr) (*ast.TableExpr, cfg.SymbolID) { - if graph == nil || fn == nil { +func FindTableLiteralOwner(assignments []api.AssignmentEvidence, fn *ast.FunctionExpr) (*ast.TableExpr, cfg.SymbolID) { + if len(assignments) == 0 || fn == nil { return nil, 0 } var resultTbl *ast.TableExpr var resultSym cfg.SymbolID - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { if resultTbl != nil { - return + break + } + info := assign.Info + if info == nil { + continue } info.EachSource(func(i int, src ast.Expr) { if resultTbl != nil { @@ -145,6 +159,15 @@ func FindTableLiteralOwner(graph *cfg.Graph, fn *ast.FunctionExpr) (*ast.TableEx } } }) - }) + } return resultTbl, resultSym } + +func assignmentInfoAt(assignments []api.AssignmentEvidence, point cfg.Point) *cfg.AssignInfo { + for _, assign := range assignments { + if assign.Point == point { + return assign.Info + } + } + return nil +} diff --git a/compiler/check/nested/table_test.go b/compiler/check/nested/table_test.go index a8d6522d..2bed0da3 100644 --- a/compiler/check/nested/table_test.go +++ b/compiler/check/nested/table_test.go @@ -2,8 +2,6 @@ package nested import ( "testing" - - "github.com/wippyai/go-lua/compiler/cfg" ) func TestFindTableLiteralForSymbol_NilGraph(t *testing.T) { @@ -14,7 +12,7 @@ func TestFindTableLiteralForSymbol_NilGraph(t *testing.T) { } func TestFindTableLiteralForSymbol_ZeroSymbol(t *testing.T) { - tbl, point := FindTableLiteralForSymbol(&cfg.Graph{}, 0) + tbl, point := FindTableLiteralForSymbol(nil, 0) if tbl != nil || point != 0 { t.Error("expected nil result for zero symbol") } diff --git a/compiler/check/overlaymut/collect.go b/compiler/check/overlaymut/collect.go index 0e2d2b03..47ea4d58 100644 --- a/compiler/check/overlaymut/collect.go +++ b/compiler/check/overlaymut/collect.go @@ -4,27 +4,35 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/types/typ" ) -// CollectFieldAssignments scans the graph for field assignments and groups them by base symbol. +// IndexerInfo holds key and value types for dynamic index assignments. +type IndexerInfo struct { + KeyType typ.Type + ValType typ.Type +} + +// CollectFieldAssignments reduces transfer assignment evidence into field assignments grouped by base symbol. // Returns a map: symbolID -> map[fieldName]typ.Type representing fields assigned to each symbol. // The synth function is used to synthesize field value types. // If filterSyms is non-nil, only symbols in the filter are collected. func CollectFieldAssignments( - graph *cfg.Graph, + assignments []api.AssignmentEvidence, synth func(ast.Expr, cfg.Point) typ.Type, filterSyms map[cfg.SymbolID]bool, ) map[cfg.SymbolID]map[string]typ.Type { result := make(map[cfg.SymbolID]map[string]typ.Type) - if graph == nil { + if len(assignments) == 0 { return result } - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info == nil { - return + continue } sources := info.Sources for i, target := range info.Targets { @@ -74,27 +82,29 @@ func CollectFieldAssignments( result[sym][fieldName] = fieldType } } - }) + } return result } -// CollectIndexerAssignments scans the graph for dynamic index assignments (t[k] = v where k is non-const). +// CollectIndexerAssignments reduces transfer assignment evidence for dynamic index writes. // Returns a map: symbolID -> []IndexerInfo representing index assignments to each symbol. func CollectIndexerAssignments( - graph *cfg.Graph, + assignments []api.AssignmentEvidence, synth func(ast.Expr, cfg.Point) typ.Type, bindings *bind.BindingTable, filterSyms map[cfg.SymbolID]bool, -) map[cfg.SymbolID][]mutator.IndexerInfo { - result := make(map[cfg.SymbolID][]mutator.IndexerInfo) - if graph == nil { +) map[cfg.SymbolID][]IndexerInfo { + result := make(map[cfg.SymbolID][]IndexerInfo) + if len(assignments) == 0 { return result } - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range assignments { + p := assign.Point + info := assign.Info if info == nil { - return + continue } sources := info.Sources for i, target := range info.Targets { @@ -141,12 +151,12 @@ func CollectIndexerAssignments( valType = typ.Unknown } - result[sym] = append(result[sym], mutator.IndexerInfo{ + result[sym] = append(result[sym], IndexerInfo{ KeyType: keyType, ValType: valType, }) } - }) + } return result } @@ -157,3 +167,13 @@ func canonicalDynamicKeyType(keyType typ.Type) typ.Type { } return keyType } + +// MergeIndexerMutations merges table mutator mutations into indexer assignments. +func MergeIndexerMutations( + indexers map[cfg.SymbolID][]IndexerInfo, + mutations map[cfg.SymbolID][]IndexerInfo, +) { + for sym, infos := range mutations { + indexers[sym] = append(indexers[sym], infos...) + } +} diff --git a/compiler/check/flowbuild/assign/collect_test.go b/compiler/check/overlaymut/collect_test.go similarity index 55% rename from compiler/check/flowbuild/assign/collect_test.go rename to compiler/check/overlaymut/collect_test.go index 70eddbf3..ce035839 100644 --- a/compiler/check/flowbuild/assign/collect_test.go +++ b/compiler/check/overlaymut/collect_test.go @@ -1,4 +1,4 @@ -package assign +package overlaymut import ( "testing" @@ -6,12 +6,17 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/types/typ" ) +func buildEmptyGraph() *cfg.Graph { + fn := &ast.FunctionExpr{Stmts: []ast.Stmt{}} + return cfg.Build(fn) +} + func TestIndexerInfo_ZeroValue(t *testing.T) { - var info mutator.IndexerInfo + var info IndexerInfo if info.KeyType != nil { t.Error("expected nil KeyType for zero value IndexerInfo") } @@ -20,6 +25,35 @@ func TestIndexerInfo_ZeroValue(t *testing.T) { } } +func TestMergeIndexerMutations_EmptyInputs(t *testing.T) { + indexers := make(map[cfg.SymbolID][]IndexerInfo) + mutations := make(map[cfg.SymbolID][]IndexerInfo) + + MergeIndexerMutations(indexers, mutations) + + if len(indexers) != 0 { + t.Errorf("expected empty indexers after merging empty mutations, got %d", len(indexers)) + } +} + +func TestMergeIndexerMutations_MergesCorrectly(t *testing.T) { + indexers := make(map[cfg.SymbolID][]IndexerInfo) + indexers[1] = []IndexerInfo{{KeyType: typ.String, ValType: typ.Integer}} + + mutations := make(map[cfg.SymbolID][]IndexerInfo) + mutations[1] = []IndexerInfo{{KeyType: typ.Integer, ValType: typ.String}} + mutations[2] = []IndexerInfo{{KeyType: typ.Number, ValType: typ.Boolean}} + + MergeIndexerMutations(indexers, mutations) + + if len(indexers[1]) != 2 { + t.Errorf("expected 2 infos for symbol 1, got %d", len(indexers[1])) + } + if len(indexers[2]) != 1 { + t.Errorf("expected 1 info for symbol 2, got %d", len(indexers[2])) + } +} + func TestCollectFieldAssignments_NilGraph(t *testing.T) { result := CollectFieldAssignments(nil, nil, nil) if result == nil { @@ -32,7 +66,7 @@ func TestCollectFieldAssignments_NilGraph(t *testing.T) { func TestCollectFieldAssignments_EmptyGraph(t *testing.T) { graph := buildEmptyGraph() - result := CollectFieldAssignments(graph, nil, nil) + result := CollectFieldAssignments(assignmentsFromGraph(graph), nil, nil) if result == nil { t.Error("expected non-nil result") } @@ -43,7 +77,7 @@ func TestCollectFieldAssignments_WithSynth(t *testing.T) { synth := func(expr ast.Expr, p cfg.Point) typ.Type { return typ.String } - result := CollectFieldAssignments(graph, synth, nil) + result := CollectFieldAssignments(assignmentsFromGraph(graph), synth, nil) if result == nil { t.Error("expected non-nil result") } @@ -53,7 +87,7 @@ func TestCollectFieldAssignments_WithFilter(t *testing.T) { graph := buildEmptyGraph() filter := make(map[cfg.SymbolID]bool) filter[1] = true - result := CollectFieldAssignments(graph, nil, filter) + result := CollectFieldAssignments(assignmentsFromGraph(graph), nil, filter) if result == nil { t.Error("expected non-nil result") } @@ -71,7 +105,7 @@ func TestCollectIndexerAssignments_NilGraph(t *testing.T) { func TestCollectIndexerAssignments_EmptyGraph(t *testing.T) { graph := buildEmptyGraph() - result := CollectIndexerAssignments(graph, nil, nil, nil) + result := CollectIndexerAssignments(assignmentsFromGraph(graph), nil, nil, nil) if result == nil { t.Error("expected non-nil result") } @@ -82,7 +116,7 @@ func TestCollectIndexerAssignments_WithSynth(t *testing.T) { synth := func(expr ast.Expr, p cfg.Point) typ.Type { return typ.Integer } - result := CollectIndexerAssignments(graph, synth, nil, nil) + result := CollectIndexerAssignments(assignmentsFromGraph(graph), synth, nil, nil) if result == nil { t.Error("expected non-nil result") } @@ -91,7 +125,7 @@ func TestCollectIndexerAssignments_WithSynth(t *testing.T) { func TestCollectIndexerAssignments_WithBindings(t *testing.T) { graph := buildEmptyGraph() bindings := &bind.BindingTable{} - result := CollectIndexerAssignments(graph, nil, bindings, nil) + result := CollectIndexerAssignments(assignmentsFromGraph(graph), nil, bindings, nil) if result == nil { t.Error("expected non-nil result") } @@ -101,7 +135,7 @@ func TestCollectIndexerAssignments_WithFilter(t *testing.T) { graph := buildEmptyGraph() filter := make(map[cfg.SymbolID]bool) filter[1] = true - result := CollectIndexerAssignments(graph, nil, nil, filter) + result := CollectIndexerAssignments(assignmentsFromGraph(graph), nil, nil, filter) if result == nil { t.Error("expected non-nil result") } @@ -115,8 +149,21 @@ func TestCollectIndexerAssignments_AllParams(t *testing.T) { bindings := &bind.BindingTable{} filter := make(map[cfg.SymbolID]bool) filter[1] = true - result := CollectIndexerAssignments(graph, synth, bindings, filter) + result := CollectIndexerAssignments(assignmentsFromGraph(graph), synth, bindings, filter) if result == nil { t.Error("expected non-nil result") } } + +func assignmentsFromGraph(graph *cfg.Graph) []api.AssignmentEvidence { + if graph == nil { + return nil + } + var assignments []api.AssignmentEvidence + graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + if info != nil { + assignments = append(assignments, api.AssignmentEvidence{Point: p, Info: info}) + } + }) + return assignments +} diff --git a/compiler/check/overlaymut/merge.go b/compiler/check/overlaymut/merge.go index 13d9c5ec..1332fc74 100644 --- a/compiler/check/overlaymut/merge.go +++ b/compiler/check/overlaymut/merge.go @@ -2,7 +2,6 @@ package overlaymut import ( "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" "github.com/wippyai/go-lua/types/flow" querycore "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/typ" @@ -106,7 +105,7 @@ func MergeFieldsIntoType(baseType typ.Type, fields map[string]typ.Type) typ.Type // ApplyIndexerMergeToOverlay adds map components to symbol types based on dynamic index assignments. func ApplyIndexerMergeToOverlay( overlay map[cfg.SymbolID]typ.Type, - indexerAssignments map[cfg.SymbolID][]mutator.IndexerInfo, + indexerAssignments map[cfg.SymbolID][]IndexerInfo, ) { for _, sym := range cfg.SortedSymbolIDs(indexerAssignments) { infos := indexerAssignments[sym] @@ -116,15 +115,18 @@ func ApplyIndexerMergeToOverlay( var keyType, valType typ.Type for _, info := range infos { + if indexerAssignmentDeletesSlot(info.ValType) { + continue + } keyType = typ.JoinPreferNonSoft(keyType, info.KeyType) valType = JoinValueTypes(valType, info.ValType) } + if valType == nil { + continue + } if keyType == nil { keyType = typ.String } - if valType == nil { - valType = typ.Unknown - } baseType := overlay[sym] merged := MergeMapComponentIntoType(baseType, keyType, valType) @@ -166,6 +168,30 @@ func JoinValueTypes(a, b typ.Type) typ.Type { return typ.JoinPreferNonSoft(a, b) } +func indexerAssignmentDeletesSlot(t typ.Type) bool { + if t == nil { + return false + } + switch v := typ.UnwrapAnnotated(t).(type) { + case *typ.Alias: + return indexerAssignmentDeletesSlot(v.Target) + case *typ.Optional: + return indexerAssignmentDeletesSlot(v.Inner) + case *typ.Union: + if len(v.Members) == 0 { + return false + } + for _, member := range v.Members { + if !indexerAssignmentDeletesSlot(member) { + return false + } + } + return true + default: + return unwrap.IsNilType(v) + } +} + // MergeMapComponentIntoType adds a map component to a base type. func MergeMapComponentIntoType(baseType, keyType, valType typ.Type) typ.Type { if baseType == nil { diff --git a/compiler/check/overlaymut/merge_test.go b/compiler/check/overlaymut/merge_test.go new file mode 100644 index 00000000..897a5468 --- /dev/null +++ b/compiler/check/overlaymut/merge_test.go @@ -0,0 +1,39 @@ +package overlaymut + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/types/typ" +) + +func TestApplyIndexerMergeToOverlay_TreatsNilWriteAsDeletion(t *testing.T) { + overlay := make(map[cfg.SymbolID]typ.Type) + ApplyIndexerMergeToOverlay(overlay, map[cfg.SymbolID][]IndexerInfo{ + 1: { + {KeyType: typ.String, ValType: typ.Nil}, + }, + }) + if got := overlay[1]; got != nil { + t.Fatalf("nil index write should not create map value evidence, got %v", got) + } +} + +func TestApplyIndexerMergeToOverlay_DeletionDoesNotPoisonWriteValue(t *testing.T) { + entry := typ.NewRecord().OptField("proc", typ.Any).Build() + overlay := make(map[cfg.SymbolID]typ.Type) + ApplyIndexerMergeToOverlay(overlay, map[cfg.SymbolID][]IndexerInfo{ + 1: { + {KeyType: typ.String, ValType: typ.Nil}, + {KeyType: typ.String, ValType: entry}, + }, + }) + + got, ok := overlay[1].(*typ.Map) + if !ok { + t.Fatalf("mixed deletion/write should create map evidence, got %T", overlay[1]) + } + if !typ.TypeEquals(got.Value, entry) { + t.Fatalf("map value evidence = %v, want %v", got.Value, entry) + } +} diff --git a/compiler/check/returns/overlay_test.go b/compiler/check/overlaymut/overlay_test.go similarity index 97% rename from compiler/check/returns/overlay_test.go rename to compiler/check/overlaymut/overlay_test.go index c37154f8..854c8938 100644 --- a/compiler/check/returns/overlay_test.go +++ b/compiler/check/overlaymut/overlay_test.go @@ -1,10 +1,9 @@ -package returns +package overlaymut import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" @@ -247,7 +246,7 @@ func TestMergeMapComponentIntoType(t *testing.T) { func TestApplyIndexerMergeToOverlay(t *testing.T) { t.Run("empty infos are skipped", func(t *testing.T) { overlay := make(map[cfg.SymbolID]typ.Type) - indexerAssignments := map[cfg.SymbolID][]mutator.IndexerInfo{ + indexerAssignments := map[cfg.SymbolID][]IndexerInfo{ 1: {}, } ApplyIndexerMergeToOverlay(overlay, indexerAssignments) @@ -258,7 +257,7 @@ func TestApplyIndexerMergeToOverlay(t *testing.T) { t.Run("indexer info is merged", func(t *testing.T) { overlay := make(map[cfg.SymbolID]typ.Type) - indexerAssignments := map[cfg.SymbolID][]mutator.IndexerInfo{ + indexerAssignments := map[cfg.SymbolID][]IndexerInfo{ 1: {{KeyType: typ.String, ValType: typ.Number}}, } ApplyIndexerMergeToOverlay(overlay, indexerAssignments) diff --git a/compiler/check/phase/flow.go b/compiler/check/phase/flow.go index 2df1db33..4f9d920f 100644 --- a/compiler/check/phase/flow.go +++ b/compiler/check/phase/flow.go @@ -3,10 +3,10 @@ package phase import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract" + "github.com/wippyai/go-lua/compiler/check/abstract/core" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild" - "github.com/wippyai/go-lua/compiler/check/flowbuild/core" - "github.com/wippyai/go-lua/compiler/check/flowbuild/keyscoll" + "github.com/wippyai/go-lua/compiler/check/domain/keyscoll" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/types/constraint" @@ -29,9 +29,8 @@ func RunExtract(input FlowExtractInput) FlowExtractOutput { extractionCtx := NewContextBuilder(input.PhaseEnv). WithScope(input.Scope). - WithSiblingTypes(input.SiblingTypes). + WithFunctionFacts(input.FunctionFacts). WithLiteralTypes(input.LiteralTypes). - WithReturnSummaries(input.ReturnSummaries). BuildDeclared() engine := synth.New(synth.Config{ @@ -40,16 +39,20 @@ func RunExtract(input FlowExtractInput) FlowExtractOutput { Scopes: input.Scope.Scopes, Manifests: input.Manifests, Env: extractionCtx, + FunctionFacts: input.FunctionFacts, Phase: api.PhaseScopeCompute, + Evidence: input.Evidence, ModuleBindings: input.ModuleBindings, ModuleAliases: moduleAliases, }) - inputs := flowbuild.Run(&core.FlowContext{ + abstractOut := abstract.Run(&core.FlowContext{ + Fn: input.Fn, Graph: input.Graph, Scopes: input.Scope.Scopes, CheckCtx: extractionCtx, CallCtx: input.Ctx, + Graphs: api.GraphsFrom(input.Ctx), TypeOps: input.Types, Base: input.Scope.BaseScope, Globals: input.GlobalTypes, @@ -59,22 +62,23 @@ func RunExtract(input FlowExtractInput) FlowExtractOutput { TypeExprResolver: typeResolverFn, }, InitialDeclaredTypes: input.Scope.DeclaredTypes, - SiblingTypes: input.SiblingTypes, LiteralTypes: input.LiteralTypes, ModuleAliases: moduleAliases, ModuleBindings: input.ModuleBindings, + Evidence: input.Evidence, }) + inputs := abstractOut.Inputs applyModuleAliasTypes(inputs, input.Manifests) params := ExtractParams(input.Fn, input.Scope.DeclaredTypes, input.Graph) // Return inference is performed in the return inference pass; flow uses Unknown here. returnType := typ.Unknown - return FlowExtractOutput{ Inputs: inputs, Params: params, ReturnType: returnType, + Evidence: abstractOut.Evidence, } } @@ -89,7 +93,7 @@ func applyModuleAliasTypes(inputs *flow.Inputs, manifests io.ManifestQuerier) { func RunLiteral(input LiteralInput) LiteralOutput { initialCtx := NewContextBuilder(input.PhaseEnv). WithScope(input.Scope). - WithReturnSummaries(input.ReturnSummaries). + WithFunctionFacts(input.FunctionFacts). BuildDeclared() engine := synth.New(synth.Config{ @@ -98,12 +102,14 @@ func RunLiteral(input LiteralInput) LiteralOutput { Scopes: input.Scope.Scopes, Manifests: input.Manifests, Env: initialCtx, + FunctionFacts: input.FunctionFacts, Phase: api.PhaseScopeCompute, + Evidence: input.Evidence, ModuleBindings: input.ModuleBindings, ModuleAliases: input.ModuleAliases, }) - fnLiteralTypes := synth.FunctionLiteralTypes(input.Graph, func(expr ast.Expr, p cfg.Point) typ.Type { + fnLiteralTypes := synth.FunctionLiteralTypes(input.Graph, input.Evidence, func(expr ast.Expr, p cfg.Point) typ.Type { return engine.TypeOf(expr, p) }) @@ -117,7 +123,7 @@ func RunLiteral(input LiteralInput) LiteralOutput { } } - signatures := synth.FunctionLiteralSignatures(input.Graph, engine, declaredReturns) + signatures := synth.FunctionLiteralSignatures(input.Graph, input.Evidence, engine, declaredReturns) return LiteralOutput{ LiteralTypes: fnLiteralTypes, @@ -179,12 +185,12 @@ func ExtractParams(fn *ast.FunctionExpr, paramTypes map[cfg.SymbolID]typ.Type, g // EnrichWithKeysCollector detects if a function is a "keys collector" // (returns keys of a parameter) and adds KeyOf constraint to OnReturn. // This enables cross-module key-provenance tracking. -func EnrichWithKeysCollector(eff *constraint.FunctionRefinement, fn *ast.FunctionExpr) *constraint.FunctionRefinement { - if fn == nil { +func EnrichWithKeysCollector(eff *constraint.FunctionRefinement, graph *cfg.Graph, evidence api.FlowEvidence) *constraint.FunctionRefinement { + if graph == nil { return eff } - info := keyscoll.DetectKeysCollector(fn) + info := keyscoll.DetectKeysCollector(graph, evidence) if info == nil { return eff } diff --git a/compiler/check/phase/flow_test.go b/compiler/check/phase/flow_test.go index 1d1e191f..00a5003a 100644 --- a/compiler/check/phase/flow_test.go +++ b/compiler/check/phase/flow_test.go @@ -5,6 +5,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/flow" @@ -19,6 +21,13 @@ func TestExtractParams_NilParList(t *testing.T) { } } +func testEvidence(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + func TestExtractParams_NilFunction(t *testing.T) { result := ExtractParams(nil, nil, nil) if result != nil { @@ -83,7 +92,7 @@ func TestInferRefinement_NilSolution(t *testing.T) { } func TestEnrichWithKeysCollector_NilFn(t *testing.T) { - result := EnrichWithKeysCollector(nil, nil) + result := EnrichWithKeysCollector(nil, nil, api.FlowEvidence{}) if result != nil { t.Errorf("expected nil for nil fn, got %v", result) } @@ -91,7 +100,8 @@ func TestEnrichWithKeysCollector_NilFn(t *testing.T) { func TestEnrichWithKeysCollector_NonKeysCollector(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{}} - result := EnrichWithKeysCollector(nil, fn) + graph := cfg.Build(fn) + result := EnrichWithKeysCollector(nil, graph, testEvidence(graph)) if result != nil { t.Errorf("expected nil for non-keys-collector fn, got %v", result) } @@ -113,7 +123,8 @@ func TestEnrichWithKeysCollector_UsesDetectedReturnIndex(t *testing.T) { Stmts: body, } - result := EnrichWithKeysCollector(nil, fn) + graph := cfg.Build(fn) + result := EnrichWithKeysCollector(nil, graph, testEvidence(graph)) if result == nil { t.Fatal("expected non-nil enriched effect") } @@ -150,7 +161,8 @@ func TestEnrichWithKeysCollector_AppendsToExistingOnReturn(t *testing.T) { existing := &constraint.FunctionRefinement{ OnReturn: constraint.FromConstraints(constraint.NotNil{Path: constraint.RetPath(0)}), } - result := EnrichWithKeysCollector(existing, fn) + graph := cfg.Build(fn) + result := EnrichWithKeysCollector(existing, graph, testEvidence(graph)) if result == nil { t.Fatal("expected non-nil enriched effect") } diff --git a/compiler/check/phase/narrow.go b/compiler/check/phase/narrow.go index a53b020b..cba1da00 100644 --- a/compiler/check/phase/narrow.go +++ b/compiler/check/phase/narrow.go @@ -5,7 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/path" + "github.com/wippyai/go-lua/compiler/check/domain/path" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/types/constraint" @@ -41,7 +41,7 @@ func RunNarrow(input NarrowInput) NarrowOutput { } } - // Prefer graph bindings for local symbol resolution; fall back to module bindings. + // Prefer graph bindings for local symbol resolution, then module bindings. bindings := input.Graph.Bindings() if bindings == nil { bindings = input.ModuleBindings @@ -52,10 +52,9 @@ func RunNarrow(input NarrowInput) NarrowOutput { WithBindings(bindings). WithDeclaredTypes(declaredTypes). WithAnnotatedVars(annotatedVars). - WithSiblingTypes(input.SiblingTypes). + WithFunctionFacts(input.FunctionFacts). WithLiteralTypes(input.LiteralTypes). WithSolution(input.Solve.Solution). - WithNarrowReturnSummaries(input.NarrowReturnSummaries). BuildNarrow() engine := createNarrowedEngine( @@ -65,12 +64,14 @@ func RunNarrow(input NarrowInput) NarrowOutput { input.Scope.Scopes, input.Solve.Solution, narrowingCtx, + input.FunctionFacts, input.ModuleBindings, input.ModuleAliases, + input.Extract.Evidence, ) fnEffect := InferRefinement(input.Graph, input.Solve.Solution, input.Extract.Params, input.Extract.ReturnType) - fnEffect = EnrichWithKeysCollector(fnEffect, input.Fn) + fnEffect = EnrichWithKeysCollector(fnEffect, input.Graph, input.Extract.Evidence) return NarrowOutput{ Facts: narrowingCtx.Types(), @@ -86,8 +87,10 @@ func createNarrowedEngine( scopes map[cfg.Point]*scope.State, solution *flow.Solution, checkCtx api.NarrowEnv, + functionFacts api.FunctionFacts, moduleBindings *bind.BindingTable, moduleAliases map[cfg.SymbolID]string, + evidence api.FlowEvidence, ) *synth.Engine { var bindings *bind.BindingTable if checkCtx != nil { @@ -104,7 +107,9 @@ func createNarrowedEngine( Paths: newPathFromExprFunc(solution, bindings), Manifests: manifests, Env: checkCtx, + FunctionFacts: functionFacts, Phase: api.PhaseNarrowing, + Evidence: evidence, ModuleBindings: moduleBindings, ModuleAliases: moduleAliases, }) diff --git a/compiler/check/phase/resolve.go b/compiler/check/phase/resolve.go index 4ecaad3e..5faf9cd2 100644 --- a/compiler/check/phase/resolve.go +++ b/compiler/check/phase/resolve.go @@ -51,7 +51,7 @@ func RunResolve(input ResolveInput) ResolveOutput { Env: globalCtx, Phase: api.PhaseTypeResolution, ModuleBindings: firstNonNilBindings(input.ModuleBindings, input.Bindings), - ModuleAliases: firstNonNilAliases(input.ModuleAliases, modules.CollectAliases(input.Graph)), + ModuleAliases: firstNonNilAliases(input.ModuleAliases, graphModuleAliases(input.Graph, input.Evidence)), }) return ResolveOutput{ @@ -68,10 +68,15 @@ func CreateTypeResolutionEngine( base *scope.State, types core.TypeOps, manifests io.ManifestQuerier, + moduleAliases map[cfg.SymbolID]string, ) *synth.Engine { + var bindings *bind.BindingTable + if graph != nil { + bindings = graph.Bindings() + } checkCtx := api.NewDeclaredEnv(api.DeclaredEnvConfig{ Graph: graph, - Bindings: graph.Bindings(), + Bindings: bindings, DeclaredTypes: BuildDeclaredTypesForResolve(graph, globalTypes, paramTypes), BaseScope: base, GlobalTypes: globalTypes, @@ -82,23 +87,27 @@ func CreateTypeResolutionEngine( Manifests: manifests, Env: checkCtx, Phase: api.PhaseTypeResolution, - ModuleBindings: graph.Bindings(), - ModuleAliases: modules.CollectAliases(graph), + ModuleBindings: bindings, + ModuleAliases: moduleAliases, }) } -func firstNonNilBindings(primary, fallback *bind.BindingTable) *bind.BindingTable { +func firstNonNilBindings(primary, secondary *bind.BindingTable) *bind.BindingTable { if primary != nil { return primary } - return fallback + return secondary } -func firstNonNilAliases(primary, fallback map[cfg.SymbolID]string) map[cfg.SymbolID]string { +func firstNonNilAliases(primary, secondary map[cfg.SymbolID]string) map[cfg.SymbolID]string { if len(primary) > 0 { return primary } - return fallback + return secondary +} + +func graphModuleAliases(graph *cfg.Graph, evidence api.FlowEvidence) map[cfg.SymbolID]string { + return modules.AliasesFromAssignments(evidence.Assignments, graph) } // BuildInitialSymbolTypes creates SymbolTypes for globals and parameters at all CFG points. diff --git a/compiler/check/phase/resolve_test.go b/compiler/check/phase/resolve_test.go index ed7cf60d..f3096617 100644 --- a/compiler/check/phase/resolve_test.go +++ b/compiler/check/phase/resolve_test.go @@ -287,7 +287,7 @@ func TestBuildDeclaredTypesFromSymbolTypes_EntryOverridesAndElseUsesLowestPoint( } func TestCreateTypeResolutionEngine_NilGraph(t *testing.T) { - result := CreateTypeResolutionEngine(nil, nil, nil, nil, nil, nil, nil) + result := CreateTypeResolutionEngine(nil, nil, nil, nil, nil, nil, nil, nil) if result == nil { t.Error("expected non-nil engine even with nil graph") } @@ -296,7 +296,7 @@ func TestCreateTypeResolutionEngine_NilGraph(t *testing.T) { func TestCreateTypeResolutionEngine_EmptyGraph(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{}} graph := cfg.Build(fn) - result := CreateTypeResolutionEngine(nil, graph, nil, nil, nil, nil, nil) + result := CreateTypeResolutionEngine(nil, graph, nil, nil, nil, nil, nil, nil) if result == nil { t.Error("expected non-nil engine") } diff --git a/compiler/check/phase/scope.go b/compiler/check/phase/scope.go index 62fc5afc..7f0f734d 100644 --- a/compiler/check/phase/scope.go +++ b/compiler/check/phase/scope.go @@ -27,13 +27,15 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" basecfg "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/kind" "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" @@ -66,7 +68,7 @@ type ScopeServices interface { MutateCall(info *cfg.CallInfo, p cfg.Point, sc *scope.State) *scope.State } -// ScopeServicesFuncs is a simple adapter for providing ScopeServices via functions. +// ScopeServicesFuncs provides ScopeServices via functions. // Use this in tests or when wiring phase inputs without creating a dedicated type. type ScopeServicesFuncs struct { TypeResolver scopeTypeResolver @@ -108,17 +110,19 @@ func RunScope(input ScopeInput) ScopeOutput { depthExceeded := false base := BuildFunctionScope(input.Fn, input.Parent, typeExprResolver, input.MaxScopeDepth, &depthExceeded) + base = normalizeBaseImplicitSelf(input.Graph, base) var synthSig *typ.Function if input.SynthesizedFunctionSig != nil { synthSig = input.SynthesizedFunctionSig } - var hints []typ.Type - if input.ParamHintSignatures != nil && input.Fn != nil { - hints = input.ParamHintSignatures[input.Fn] + var parameterEvidence []typ.Type + if input.ParameterEvidenceSignatures != nil && input.Fn != nil && input.Graph != nil { + parameterEvidence = input.ParameterEvidenceSignatures[input.Fn] + parameterEvidence = paramevidence.ProjectToParameterUse(input.Graph.ParamSlotsReadOnly(), input.Evidence.ParameterUses, parameterEvidence) } - paramTypes, paramAnnotated := ExtractParamTypes(input.Graph, input.Fn, typeExprResolver, synthSig, base, hints) + paramTypes, paramAnnotated := ExtractParamTypes(input.Graph, input.Fn, typeExprResolver, synthSig, base, parameterEvidence) // Inject synthesized self type into base scope only if base doesn't already // have a more specific self type (set by processNestedFunctions from field assignment context). @@ -132,7 +136,7 @@ func RunScope(input ScopeInput) ScopeOutput { } if i < len(synthSig.Params) && synthSig.Params[i].Type != nil { if name == "self" && base.SelfType() == nil { - base = base.WithSelf(synthSig.Params[i].Type) + base = base.WithSelf(widenImplicitSelfState(synthSig.Params[i].Type)) } } } @@ -146,6 +150,7 @@ func RunScope(input ScopeInput) ScopeOutput { base, input.Types, input.Manifests, + input.ModuleAliases, ) localTypeAnnotations := make(map[cfg.SymbolID]ast.TypeExpr) @@ -189,7 +194,15 @@ func RunScope(input ScopeInput) ScopeOutput { exprSynth := func(expr ast.Expr, p cfg.Point, sc *scope.State) typ.Type { return typeResolutionEngine.SynthExprAt(expr, p, sc) } - fnSignatureResolver := buildFnSignatureResolver(input.FunctionLiteralSignatures, input.ParamHintSignatures, typeResolutionEngine) + parameterEvidenceSignatures := input.ParameterEvidenceSignatures + if input.Fn != nil && parameterEvidence != nil && input.ParameterEvidenceSignatures != nil { + parameterEvidenceSignatures = make(map[*ast.FunctionExpr][]typ.Type, len(input.ParameterEvidenceSignatures)) + for fn, evidence := range input.ParameterEvidenceSignatures { + parameterEvidenceSignatures[fn] = evidence + } + parameterEvidenceSignatures[input.Fn] = parameterEvidence + } + fnSignatureResolver := buildFnSignatureResolver(input.FunctionLiteralSignatures, parameterEvidenceSignatures, typeResolutionEngine) callMutator := buildCallMutator(input.Types, input.Ctx, exprSynth) services := ScopeServicesFuncs{ @@ -210,8 +223,7 @@ func RunScope(input ScopeInput) ScopeOutput { typeExprResolver, fnSignatureResolver, typeResolutionEngine, - input.SiblingTypes, - input.ReturnSummaries, + input.FunctionFacts, ) declaredTypes = applyModuleAliasExports(declaredTypes, input.ModuleAliases, input.Manifests) @@ -222,16 +234,29 @@ func RunScope(input ScopeInput) ScopeOutput { AnnotatedVars: annotatedVars, ParamTypes: paramTypes, FunctionSignatureResolver: fnSignatureResolver, - SiblingTypes: input.SiblingTypes, + FunctionFacts: input.FunctionFacts, DepthLimitExceeded: depthExceeded, } } +func normalizeBaseImplicitSelf(graph *cfg.Graph, base *scope.State) *scope.State { + if graph == nil || base == nil || base.SelfType() == nil { + return base + } + for _, slot := range graph.ParamSlotsReadOnly() { + if slot.Name != "self" || slot.TypeAnnotation != nil { + continue + } + return base.WithSelf(widenImplicitSelfState(base.SelfType())) + } + return base +} + // buildFnSignatureResolver creates a function signature resolver that combines -// pre-computed literal signatures, parameter hints, and annotation-based resolution. +// pre-computed literal signatures, parameter evidence, and annotation-based resolution. func buildFnSignatureResolver( literalSigs LiteralSigsProvider, - paramHints map[*ast.FunctionExpr][]typ.Type, + parameterEvidence map[*ast.FunctionExpr][]typ.Type, engine *synth.Engine, ) FunctionSignatureResolver { return FunctionSignatureResolverFunc(func(fn *ast.FunctionExpr, sc *scope.State) *typ.Function { @@ -247,14 +272,14 @@ func buildFnSignatureResolver( if sig == nil { return nil } - if paramHints == nil { + if parameterEvidence == nil { return sig } - hints := paramHints[fn] - if len(hints) == 0 { + evidence := parameterEvidence[fn] + if len(evidence) == 0 { return sig } - return paramhints.MergeIntoSignature(fn, hints, sig) + return paramevidence.MergeIntoSignature(fn, evidence, sig) }) } @@ -266,7 +291,7 @@ func ExtractParamTypes( typeExprResolver TypeResolver, synthSig *typ.Function, base *scope.State, - paramHints []typ.Type, + parameterEvidence []typ.Type, ) (types map[cfg.SymbolID]typ.Type, annotated map[cfg.SymbolID]bool) { if fn == nil || fn.ParList == nil || graph == nil { return nil, nil @@ -276,51 +301,52 @@ func ExtractParamTypes( annotated = make(map[cfg.SymbolID]bool) slots := graph.ParamSlotsReadOnly() - for _, slot := range slots { + for paramIdx, slot := range slots { if slot.Symbol == 0 || slot.Name == "" { continue } // Binder/CFG-injected implicit self parameter has no source annotation. - srcIdx, hasSource := slot.SourceParamIndex() + _, hasSource := slot.SourceParamIndex() + var evidence typ.Type + if parameterEvidence != nil && paramIdx < len(parameterEvidence) { + evidence = parameterEvidence[paramIdx] + } if !hasSource { if base != nil && base.SelfType() != nil { types[slot.Symbol] = base.SelfType() + } else if synthSig != nil && paramIdx < len(synthSig.Params) && synthSig.Params[paramIdx].Type != nil { + types[slot.Symbol] = synthSig.Params[paramIdx].Type + } else if evidence != nil { + types[slot.Symbol] = evidence } else { types[slot.Symbol] = typ.Unknown } + if slot.Name == "self" { + types[slot.Symbol] = widenImplicitSelfState(types[slot.Symbol]) + } continue } - i := srcIdx - var paramType typ.Type - var hint typ.Type - if paramHints != nil && i < len(paramHints) { - hint = paramHints[i] - } var isAnnotated bool var hasExplicitAnnotation bool if slot.TypeAnnotation != nil { + var resolved typ.Type if typeExprResolver != nil { - paramType = typeExprResolver.ResolveType(slot.TypeAnnotation, base) + resolved = typeExprResolver.ResolveType(slot.TypeAnnotation, base) } else { - paramType = typ.Unknown + resolved = typ.Unknown } - if typ.IsRefinableAnnotation(paramType) { - if hint != nil { - paramType = hint - } else if synthSig != nil && i < len(synthSig.Params) && synthSig.Params[i].Type != nil { - paramType = synthSig.Params[i].Type - } - } else { - isAnnotated = true - hasExplicitAnnotation = true + paramType = resolved + if evidence != nil { + paramType = paramevidence.RefineAnnotationWithEvidence(resolved, evidence) } - } else if hint != nil { - paramType = hint - } else if synthSig != nil && i < len(synthSig.Params) && synthSig.Params[i].Type != nil { - paramType = synthSig.Params[i].Type - isAnnotated = true + isAnnotated = resolved != nil && !typ.IsRefinableAnnotation(resolved) + hasExplicitAnnotation = true + } else if evidence != nil { + paramType = mergeParamEvidenceWithSynthSignature(evidence, synthSig, paramIdx) + } else if synthSig != nil && paramIdx < len(synthSig.Params) && synthSig.Params[paramIdx].Type != nil { + paramType = synthSig.Params[paramIdx].Type } else if slot.Name == "self" && base != nil && base.SelfType() != nil { paramType = base.SelfType() } else { @@ -334,6 +360,9 @@ func ExtractParamTypes( paramType = base.SelfType() } } + if slot.Name == "self" && !hasExplicitAnnotation { + paramType = widenImplicitSelfState(paramType) + } types[slot.Symbol] = paramType if isAnnotated { @@ -347,6 +376,78 @@ func ExtractParamTypes( return types, annotated } +func mergeParamEvidenceWithSynthSignature(evidence typ.Type, synthSig *typ.Function, paramIdx int) typ.Type { + if synthSig == nil || paramIdx < 0 || paramIdx >= len(synthSig.Params) { + return evidence + } + param := synthSig.Params[paramIdx] + if param.Type == nil { + return evidence + } + if evidence == nil { + return param.Type + } + merged, _ := paramevidence.MergeUnannotatedParam(param, evidence) + return merged +} + +func widenImplicitSelfState(t typ.Type) typ.Type { + rec, ok := t.(*typ.Record) + if !ok { + return t + } + builder := typ.NewRecord() + if rec.Open { + builder.SetOpen(true) + } + for _, f := range rec.Fields { + fieldType := widenImplicitSelfField(f.Type) + switch { + case f.Optional && f.Readonly: + builder.OptReadonlyField(f.Name, fieldType) + case f.Optional: + builder.OptField(f.Name, fieldType) + case f.Readonly: + builder.ReadonlyField(f.Name, fieldType) + default: + builder.Field(f.Name, fieldType) + } + } + if rec.Metatable != nil { + builder.Metatable(rec.Metatable) + } + if rec.HasMapComponent() { + builder.MapComponent(rec.MapKey, rec.MapValue) + } + return builder.Build() +} + +func widenImplicitSelfField(t typ.Type) typ.Type { + if t == nil { + return typ.Unknown + } + unaliased := unwrap.Alias(t) + if unaliased == nil { + return typ.Unknown + } + if unaliased.Kind() == kind.Nil { + return typ.Unknown + } + if lit, ok := unaliased.(*typ.Literal); ok { + switch lit.Base { + case kind.Boolean: + return typ.Boolean + case kind.String: + return typ.String + case kind.Integer: + return typ.Integer + case kind.Number: + return typ.Number + } + } + return t +} + // buildDeclaredTypes builds declared types from annotations. func buildDeclaredTypes( graph *cfg.Graph, @@ -357,8 +458,7 @@ func buildDeclaredTypes( typeExprResolver TypeResolver, fnSigResolver FunctionSignatureResolver, synthAPI api.SynthAPI, - siblingTypes map[cfg.SymbolID]typ.Type, - returnSummaries map[cfg.SymbolID][]typ.Type, + functionFacts api.FunctionFacts, ) (flow.DeclaredTypes, map[cfg.SymbolID]bool) { if graph == nil { return nil, nil @@ -368,11 +468,11 @@ func buildDeclaredTypes( annotated := make(map[cfg.SymbolID]bool) bindings := graph.Bindings() alignWithSummary := func(sym cfg.SymbolID, fn *typ.Function) *typ.Function { - if fn == nil || len(returnSummaries) == 0 || sym == 0 { + if fn == nil || len(functionFacts) == 0 || sym == 0 { return fn } - if summary := returnSummaries[sym]; len(summary) > 0 { - return returns.WithSummaryOrUnknown(fn, summary) + if summary := functionfact.ReturnSummaryFromMap(functionFacts, sym); len(summary) > 0 { + return returnsummary.ApplyToFunctionType(fn, summary) } return fn } @@ -452,11 +552,9 @@ func buildDeclaredTypes( } if fnExpr, ok := source.(*ast.FunctionExpr); ok && fnExpr != nil { - if siblingTypes != nil { - if siblingFn := siblingTypes[sym]; siblingFn != nil { - out[sym] = siblingFn - return - } + if siblingFn := functionfact.TypeFromMap(functionFacts, sym); siblingFn != nil { + out[sym] = siblingFn + return } if fnSigResolver != nil { if fnSig := fnSigResolver.ResolveFunctionSignature(fnExpr, sc); fnSig != nil { @@ -482,7 +580,8 @@ func buildDeclaredTypes( if info := graph.FuncDef(p); info != nil && info.Name != "" && info.FuncExpr != nil { sym := info.Symbol if sym == 0 { - // Fallback for unresolved symbols in legacy/broken binding scenarios. + // Some CFG FuncDef nodes only carry the source name; recover the + // symbol from the graph's binding table for this point. var ok bool sym, ok = graph.SymbolAt(p, info.Name) if !ok { @@ -492,11 +591,9 @@ func buildDeclaredTypes( if _, exists := out[sym]; exists { continue } - if siblingTypes != nil { - if siblingFn := siblingTypes[sym]; siblingFn != nil { - out[sym] = siblingFn - continue - } + if siblingFn := functionfact.TypeFromMap(functionFacts, sym); siblingFn != nil { + out[sym] = siblingFn + continue } sc := scopes[p] if fnSigResolver != nil { diff --git a/compiler/check/phase/solve.go b/compiler/check/phase/solve.go index 28f76a65..022ff8c2 100644 --- a/compiler/check/phase/solve.go +++ b/compiler/check/phase/solve.go @@ -1,16 +1,12 @@ package phase import ( - "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/compiler/check/abstract" ) // RunSolve executes the flow solve phase. func RunSolve(input FlowSolveInput) FlowSolveOutput { - var solution *flow.Solution - if input.Extract.Inputs != nil { - solution = flow.Solve(input.Extract.Inputs, input.Resolver) - } return FlowSolveOutput{ - Solution: solution, + Solution: abstract.Solve(input.Extract.Inputs, input.Resolver), } } diff --git a/compiler/check/phase/types.go b/compiler/check/phase/types.go index 80d01d05..29adfa47 100644 --- a/compiler/check/phase/types.go +++ b/compiler/check/phase/types.go @@ -44,6 +44,7 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/types/constraint" @@ -100,8 +101,11 @@ type PhaseEnv struct { // ModuleBindings is the binding table for the entire module. ModuleBindings *bind.BindingTable - // RefinementStore provides function refinement lookups for callee analysis. - RefinementStore api.RefinementStore + // Refinements provides canonical function refinement lookups for callee analysis. + Refinements api.RefinementFacts + + // Evidence is the transfer-owned graph event trace for this function. + Evidence api.FlowEvidence // Scopes maps CFG points to scope states (populated after scope phase). Scopes map[cfg.Point]*scope.State @@ -173,16 +177,12 @@ type ScopeInput struct { // Read-only - populated from LiteralSigs channel during iteration. // Can be a map or LiteralSigsProvider interface for lazy lookup. FunctionLiteralSignatures LiteralSigsProvider - // ParamHintSignatures contains inferred param types from call sites. - // Read-only - populated from ParamHints channel during iteration. - ParamHintSignatures map[*ast.FunctionExpr][]typ.Type - // SiblingTypes contains types of functions in the same scope group. - // Explicit input - not looked up from store during phase execution. - SiblingTypes map[cfg.SymbolID]typ.Type - - // ReturnSummaries contains pre-flow return summaries for sibling functions. - // This is declared-phase only and intentionally not part of PhaseEnv. - ReturnSummaries map[cfg.SymbolID][]typ.Type + // ParameterEvidenceSignatures contains function-expression keyed parameter evidence. + // Read-only - projected from canonical FunctionFacts during iteration. + ParameterEvidenceSignatures map[*ast.FunctionExpr][]typ.Type + // FunctionFacts contains canonical facts for functions in this graph + // context. Explicit input - not looked up from store during phase execution. + FunctionFacts api.FunctionFacts } // ScopeOutput contains outputs from Phase B (scope computation). @@ -200,8 +200,8 @@ type ScopeOutput struct { ParamTypes map[cfg.SymbolID]typ.Type // FunctionSignatureResolver resolves function signatures from AST. FunctionSignatureResolver FunctionSignatureResolver - // SiblingTypes contains sibling function types (passed through from input). - SiblingTypes map[cfg.SymbolID]typ.Type + // FunctionFacts contains canonical function facts (passed through from input). + FunctionFacts api.FunctionFacts // DepthLimitExceeded indicates scope depth exceeded MaxScopeDepth. DepthLimitExceeded bool } @@ -210,10 +210,8 @@ type ScopeOutput struct { // Phase B (continued): synthesizes function literal types. type LiteralInput struct { PhaseEnv - Scope ScopeOutput - SiblingTypes map[cfg.SymbolID]typ.Type - // ReturnSummaries contains pre-flow return summaries for sibling functions. - ReturnSummaries map[cfg.SymbolID][]typ.Type + Scope ScopeOutput + FunctionFacts api.FunctionFacts } // LiteralOutput contains outputs from the function literal synthesis phase. @@ -227,12 +225,10 @@ type LiteralOutput struct { // Phase B: extracts flow constraints and assignments. type FlowExtractInput struct { PhaseEnv - Resolve ResolveOutput - Scope ScopeOutput - SiblingTypes map[cfg.SymbolID]typ.Type - LiteralTypes flow.DeclaredTypes - // ReturnSummaries contains pre-flow return summaries for sibling functions. - ReturnSummaries map[cfg.SymbolID][]typ.Type + Resolve ResolveOutput + Scope ScopeOutput + FunctionFacts api.FunctionFacts + LiteralTypes flow.DeclaredTypes } // FlowExtractOutput contains outputs from the flow extraction phase. @@ -241,6 +237,7 @@ type FlowExtractOutput struct { Inputs *flow.Inputs Params []flow.ParamInfo ReturnType typ.Type + Evidence api.FlowEvidence } // FlowSolveInput contains inputs for the flow solve phase. @@ -261,13 +258,11 @@ type FlowSolveOutput struct { // Phase D: builds TypeFacts and infers effects. type NarrowInput struct { PhaseEnv - Scope ScopeOutput - Extract FlowExtractOutput - Solve FlowSolveOutput - SiblingTypes map[cfg.SymbolID]typ.Type - LiteralTypes flow.DeclaredTypes - // NarrowReturnSummaries contains post-flow return summaries for narrowing. - NarrowReturnSummaries map[cfg.SymbolID][]typ.Type + Scope ScopeOutput + Extract FlowExtractOutput + Solve FlowSolveOutput + FunctionFacts api.FunctionFacts + LiteralTypes flow.DeclaredTypes } // NarrowOutput contains outputs from the narrowing phase. @@ -281,16 +276,14 @@ type NarrowOutput struct { // ContextBuilder constructs Env instances from phase outputs. // Centralizes the wiring that was previously duplicated across phase run files. type ContextBuilder struct { - env PhaseEnv - bindings *bind.BindingTable - baseScope *scope.State - declaredTypes flow.DeclaredTypes - annotatedVars map[cfg.SymbolID]bool - siblingTypes map[cfg.SymbolID]typ.Type - literalTypes flow.DeclaredTypes - solution *flow.Solution - returnSummaries map[cfg.SymbolID][]typ.Type - narrowReturnSummaries map[cfg.SymbolID][]typ.Type + env PhaseEnv + bindings *bind.BindingTable + baseScope *scope.State + declaredTypes flow.DeclaredTypes + annotatedVars map[cfg.SymbolID]bool + functionFacts api.FunctionFacts + literalTypes flow.DeclaredTypes + solution *flow.Solution } // NewContextBuilder creates a builder pre-populated from the shared phase environment. @@ -311,7 +304,7 @@ func (b *ContextBuilder) WithScope(out ScopeOutput) *ContextBuilder { b.baseScope = out.BaseScope b.declaredTypes = out.DeclaredTypes b.annotatedVars = out.AnnotatedVars - b.siblingTypes = out.SiblingTypes + b.functionFacts = out.FunctionFacts return b } @@ -351,9 +344,9 @@ func (b *ContextBuilder) WithAnnotatedVars(av map[cfg.SymbolID]bool) *ContextBui return b } -// WithSiblingTypes overrides sibling function types. -func (b *ContextBuilder) WithSiblingTypes(st map[cfg.SymbolID]typ.Type) *ContextBuilder { - b.siblingTypes = st +// WithFunctionFacts overrides canonical function facts. +func (b *ContextBuilder) WithFunctionFacts(ff api.FunctionFacts) *ContextBuilder { + b.functionFacts = ff return b } @@ -363,49 +356,35 @@ func (b *ContextBuilder) WithLiteralTypes(lt flow.DeclaredTypes) *ContextBuilder return b } -// WithReturnSummaries sets declared-phase return summaries. -func (b *ContextBuilder) WithReturnSummaries(rs map[cfg.SymbolID][]typ.Type) *ContextBuilder { - b.returnSummaries = rs - return b -} - -// WithNarrowReturnSummaries sets post-flow return summaries for narrowing. -func (b *ContextBuilder) WithNarrowReturnSummaries(rs map[cfg.SymbolID][]typ.Type) *ContextBuilder { - b.narrowReturnSummaries = rs - return b -} - // BuildDeclared constructs a declared-phase Env from accumulated fields. func (b *ContextBuilder) BuildDeclared() *api.DeclaredEnvImpl { return api.NewDeclaredEnv(api.DeclaredEnvConfig{ - Graph: b.env.Graph, - Bindings: b.bindings, - DeclaredTypes: b.declaredTypes, - SiblingTypes: b.siblingTypes, - LiteralTypes: b.literalTypes, - AnnotatedVars: b.annotatedVars, - BaseScope: b.baseScope, - RefinementStore: b.env.RefinementStore, - ModuleAliases: b.env.ModuleAliases, - GlobalTypes: b.env.GlobalTypes, - ReturnSummaries: b.returnSummaries, + Graph: b.env.Graph, + Bindings: b.bindings, + DeclaredTypes: b.declaredTypes, + LiteralTypes: b.literalTypes, + AnnotatedVars: b.annotatedVars, + BaseScope: b.baseScope, + Refinements: b.env.Refinements, + ModuleAliases: b.env.ModuleAliases, + GlobalTypes: b.env.GlobalTypes, + FunctionType: functionfact.TypeLookup(b.functionFacts), }) } // BuildNarrow constructs a narrowing-phase Env from accumulated fields. func (b *ContextBuilder) BuildNarrow() *api.NarrowEnvImpl { return api.NewNarrowEnv(api.NarrowEnvConfig{ - Graph: b.env.Graph, - Bindings: b.bindings, - DeclaredTypes: b.declaredTypes, - SiblingTypes: b.siblingTypes, - LiteralTypes: b.literalTypes, - AnnotatedVars: b.annotatedVars, - Solution: b.solution, - BaseScope: b.baseScope, - RefinementStore: b.env.RefinementStore, - ModuleAliases: b.env.ModuleAliases, - GlobalTypes: b.env.GlobalTypes, - NarrowReturnSummaries: b.narrowReturnSummaries, + Graph: b.env.Graph, + Bindings: b.bindings, + DeclaredTypes: b.declaredTypes, + LiteralTypes: b.literalTypes, + AnnotatedVars: b.annotatedVars, + Solution: b.solution, + BaseScope: b.baseScope, + Refinements: b.env.Refinements, + ModuleAliases: b.env.ModuleAliases, + GlobalTypes: b.env.GlobalTypes, + FunctionType: functionfact.TypeLookup(b.functionFacts), }) } diff --git a/compiler/check/phase/types_test.go b/compiler/check/phase/types_test.go index 4adde914..a69ad361 100644 --- a/compiler/check/phase/types_test.go +++ b/compiler/check/phase/types_test.go @@ -7,7 +7,7 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/flow" "github.com/wippyai/go-lua/types/typ" @@ -40,8 +40,8 @@ func TestPhaseEnv_Fields(t *testing.T) { if env.ModuleBindings != nil { t.Error("ModuleBindings should be nil by default") } - if env.RefinementStore != nil { - t.Error("RefinementStore should be nil by default") + if env.Refinements != nil { + t.Error("Refinements should be nil by default") } if env.Scopes != nil { t.Error("Scopes should be nil by default") @@ -103,25 +103,25 @@ func TestScopeInput_Fields(t *testing.T) { if input.FunctionLiteralSignatures != nil { t.Error("FunctionLiteralSignatures should be nil by default") } - if input.ParamHintSignatures != nil { - t.Error("ParamHintSignatures should be nil by default") + if input.ParameterEvidenceSignatures != nil { + t.Error("ParameterEvidenceSignatures should be nil by default") } - if input.SiblingTypes != nil { - t.Error("SiblingTypes should be nil by default") + if input.FunctionFacts != nil { + t.Error("FunctionFacts should be nil by default") } } func TestLiteralInput_Fields(t *testing.T) { input := LiteralInput{} - if input.SiblingTypes != nil { - t.Error("SiblingTypes should be nil by default") + if input.FunctionFacts != nil { + t.Error("FunctionFacts should be nil by default") } } func TestFlowExtractInput_Fields(t *testing.T) { input := FlowExtractInput{} - if input.SiblingTypes != nil { - t.Error("SiblingTypes should be nil by default") + if input.FunctionFacts != nil { + t.Error("FunctionFacts should be nil by default") } if input.LiteralTypes != nil { t.Error("LiteralTypes should be nil by default") @@ -137,8 +137,8 @@ func TestFlowSolveInput_Fields(t *testing.T) { func TestNarrowInput_Fields(t *testing.T) { input := NarrowInput{} - if input.SiblingTypes != nil { - t.Error("SiblingTypes should be nil by default") + if input.FunctionFacts != nil { + t.Error("FunctionFacts should be nil by default") } if input.LiteralTypes != nil { t.Error("LiteralTypes should be nil by default") @@ -184,8 +184,8 @@ func TestScopeOutput_Fields(t *testing.T) { if out.FunctionSignatureResolver != nil { t.Error("FunctionSignatureResolver should be nil by default") } - if out.SiblingTypes != nil { - t.Error("SiblingTypes should be nil by default") + if out.FunctionFacts != nil { + t.Error("FunctionFacts should be nil by default") } } @@ -272,7 +272,7 @@ func TestContextBuilder_WithScope(t *testing.T) { BaseScope: &scope.State{}, DeclaredTypes: make(flow.DeclaredTypes), AnnotatedVars: make(map[cfg.SymbolID]bool), - SiblingTypes: make(map[cfg.SymbolID]typ.Type), + FunctionFacts: make(api.FunctionFacts), } result := builder.WithScope(out) @@ -345,13 +345,13 @@ func TestContextBuilder_WithAnnotatedVars(t *testing.T) { } } -func TestContextBuilder_WithSiblingTypes(t *testing.T) { +func TestContextBuilder_WithFunctionFacts(t *testing.T) { env := PhaseEnv{} builder := NewContextBuilder(env) - result := builder.WithSiblingTypes(make(map[cfg.SymbolID]typ.Type)) + result := builder.WithFunctionFacts(make(api.FunctionFacts)) if result != builder { - t.Error("WithSiblingTypes should return the same builder for chaining") + t.Error("WithFunctionFacts should return the same builder for chaining") } } @@ -381,7 +381,7 @@ func TestContextBuilder_Chaining(t *testing.T) { WithBaseScope(&scope.State{}). WithDeclaredTypes(make(flow.DeclaredTypes)). WithAnnotatedVars(make(map[cfg.SymbolID]bool)). - WithSiblingTypes(make(map[cfg.SymbolID]typ.Type)). + WithFunctionFacts(make(api.FunctionFacts)). WithLiteralTypes(make(flow.DeclaredTypes)). WithSolution(nil). BuildDeclared() @@ -419,60 +419,60 @@ func TestContextBuilder_Phases(t *testing.T) { }) } -func TestMergeParamHintsIntoSig_NilSig(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_NilSig(t *testing.T) { fn := &ast.FunctionExpr{} - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, nil) + result := paramevidence.MergeIntoSignature(fn, evidence, nil) if result != nil { t.Error("expected nil when sig is nil") } } -func TestMergeParamHintsIntoSig_NilFn(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_NilFn(t *testing.T) { sig := typ.Func().Param("x", typ.Any).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(nil, hints, sig) + result := paramevidence.MergeIntoSignature(nil, evidence, sig) if result != sig { t.Error("expected original sig when fn is nil") } } -func TestMergeParamHintsIntoSig_NilParList(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_NilParList(t *testing.T) { fn := &ast.FunctionExpr{ParList: nil} sig := typ.Func().Param("x", typ.Any).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result != sig { t.Error("expected original sig when ParList is nil") } } -func TestMergeParamHintsIntoSig_EmptyHints(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_EmptyHints(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{}} sig := typ.Func().Param("x", typ.Any).Build() - var hints []typ.Type + var evidence []typ.Type - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result != sig { - t.Error("expected original sig when hints are empty") + t.Error("expected original sig when evidence is empty") } } -func TestMergeParamHintsIntoSig_NilHintElement(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_NilHintElement(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{}} sig := typ.Func().Param("x", typ.Any).Build() - hints := []typ.Type{nil} + evidence := []typ.Type{nil} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result != sig { - t.Error("expected original sig when hint element is nil") + t.Error("expected original sig when evidence element is nil") } } -func TestMergeParamHintsIntoSig_AppliesHint(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_AppliesHint(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{ Names: []string{"x"}, @@ -480,9 +480,9 @@ func TestMergeParamHintsIntoSig_AppliesHint(t *testing.T) { }, } sig := typ.Func().Param("x", typ.Any).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result == nil { t.Fatal("expected non-nil result") } @@ -494,7 +494,7 @@ func TestMergeParamHintsIntoSig_AppliesHint(t *testing.T) { } } -func TestMergeParamHintsIntoSig_PreservesAnnotatedParam(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_PreservesAnnotatedParam(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{ Names: []string{"x"}, @@ -502,15 +502,15 @@ func TestMergeParamHintsIntoSig_PreservesAnnotatedParam(t *testing.T) { }, } sig := typ.Func().Param("x", typ.String).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result != sig { t.Error("expected original sig when param is annotated") } } -func TestMergeParamHintsIntoSig_PreservesVariadic(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_PreservesVariadic(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{ Names: []string{"x"}, @@ -518,9 +518,9 @@ func TestMergeParamHintsIntoSig_PreservesVariadic(t *testing.T) { }, } sig := typ.Func().Param("x", typ.Any).Variadic(typ.String).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result == nil { t.Fatal("expected non-nil result") } @@ -529,7 +529,7 @@ func TestMergeParamHintsIntoSig_PreservesVariadic(t *testing.T) { } } -func TestMergeParamHintsIntoSig_PreservesReturns(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_PreservesReturns(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{ Names: []string{"x"}, @@ -537,9 +537,9 @@ func TestMergeParamHintsIntoSig_PreservesReturns(t *testing.T) { }, } sig := typ.Func().Param("x", typ.Any).Returns(typ.Boolean).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result == nil { t.Fatal("expected non-nil result") } @@ -548,7 +548,7 @@ func TestMergeParamHintsIntoSig_PreservesReturns(t *testing.T) { } } -func TestMergeParamHintsIntoSig_PreservesOptionalParam(t *testing.T) { +func TestMergeParameterEvidenceIntoSig_ClearsSyntheticOptionalParam(t *testing.T) { fn := &ast.FunctionExpr{ ParList: &ast.ParList{ Names: []string{"x"}, @@ -556,16 +556,16 @@ func TestMergeParamHintsIntoSig_PreservesOptionalParam(t *testing.T) { }, } sig := typ.Func().OptParam("x", typ.Any).Build() - hints := []typ.Type{typ.Number} + evidence := []typ.Type{typ.Number} - result := paramhints.MergeIntoSignature(fn, hints, sig) + result := paramevidence.MergeIntoSignature(fn, evidence, sig) if result == nil { t.Fatal("expected non-nil result") } if len(result.Params) != 1 { t.Fatalf("expected 1 param, got %d", len(result.Params)) } - if !result.Params[0].Optional { - t.Error("expected param to remain optional") + if result.Params[0].Optional { + t.Error("expected non-nil evidence to clear synthetic optionality") } } diff --git a/compiler/check/pipeline/diagnostics.go b/compiler/check/pipeline/diagnostics.go index 45084abd..2d651c53 100644 --- a/compiler/check/pipeline/diagnostics.go +++ b/compiler/check/pipeline/diagnostics.go @@ -1,7 +1,6 @@ // This package handles post-analysis diagnostic operations: // - Sorting functions by source position for deterministic pass execution // - Sorting diagnostics for stable output ordering -// - Generating widening diagnostics when type inference doesn't converge // // Deterministic ordering is essential for reproducible builds and test stability. // All sorting uses stable tie-breakers (graph ID, message content) to ensure @@ -9,7 +8,6 @@ package pipeline import ( - "fmt" "sort" "github.com/wippyai/go-lua/compiler/ast" @@ -116,50 +114,6 @@ func SortDiagnostics(diags []diag.Diagnostic) { }) } -// WideningDiagnostics reports symbols that were widened to unknown during preflow inference. -func WideningDiagnostics(sourceName string, fn *ast.FunctionExpr, result *api.FuncResult) []diag.Diagnostic { - if result == nil || result.FlowInputs == nil || len(result.FlowInputs.WideningEvents) == 0 { - return nil - } - - seenSCC := make(map[int]bool) - var diags []diag.Diagnostic - for _, event := range result.FlowInputs.WideningEvents { - if seenSCC[event.SCCIndex] { - continue - } - seenSCC[event.SCCIndex] = true - - symName := "" - if result.Graph != nil { - symName = result.Graph.NameOf(event.Symbol) - } - if symName == "" { - symName = "" - } - - sccSize := len(event.SCC) - msg := fmt.Sprintf("type inference did not converge for '%s' (SCC size %d); widened to unknown", symName, sccSize) - - pos := diag.Position{File: sourceName} - span := diag.Span{} - if fn != nil { - pos.Line = fn.Line() - pos.Column = fn.Column() - span = ast.SpanOf(fn) - } - - diags = append(diags, diag.Diagnostic{ - Position: pos, - Span: span, - Severity: diag.SeverityWarning, - Message: msg, - }) - } - - return diags -} - // ResolveSymbolName provides a stable name for diagnostics when CFG data is available. func ResolveSymbolName(graph *cfg.Graph, sym cfg.SymbolID) string { if graph == nil { diff --git a/compiler/check/pipeline/diagnostics_test.go b/compiler/check/pipeline/diagnostics_test.go index 0ff0e6a3..e0b6c1c1 100644 --- a/compiler/check/pipeline/diagnostics_test.go +++ b/compiler/check/pipeline/diagnostics_test.go @@ -7,7 +7,6 @@ import ( "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/types/diag" - "github.com/wippyai/go-lua/types/flow" ) func TestSortedResultFunctions_Empty(t *testing.T) { @@ -155,71 +154,6 @@ func TestSortDiagnostics_ByMessage(t *testing.T) { } } -func TestWideningDiagnostics_NilResult(t *testing.T) { - result := WideningDiagnostics("test.lua", nil, nil) - if result != nil { - t.Error("expected nil for nil result") - } -} - -func TestWideningDiagnostics_NilFlowInputs(t *testing.T) { - result := WideningDiagnostics("test.lua", nil, &api.FuncResult{}) - if result != nil { - t.Error("expected nil for nil flow inputs") - } -} - -func TestWideningDiagnostics_NoEvents(t *testing.T) { - result := WideningDiagnostics("test.lua", nil, &api.FuncResult{ - FlowInputs: &flow.Inputs{}, - }) - if result != nil { - t.Error("expected nil for no widening events") - } -} - -func TestWideningDiagnostics_WithEvents(t *testing.T) { - fn := &ast.FunctionExpr{} - fn.SetLine(10) - fn.SetColumn(5) - - result := WideningDiagnostics("test.lua", fn, &api.FuncResult{ - FlowInputs: &flow.Inputs{ - WideningEvents: []flow.WideningEvent{ - {Symbol: 1, SCCIndex: 0, SCC: []cfg.SymbolID{1, 2}}, - }, - }, - }) - - if len(result) != 1 { - t.Fatalf("expected 1 diagnostic, got %d", len(result)) - } - if result[0].Position.File != "test.lua" { - t.Error("wrong file") - } - if result[0].Position.Line != 10 { - t.Error("wrong line") - } - if result[0].Severity != diag.SeverityWarning { - t.Error("expected warning severity") - } -} - -func TestWideningDiagnostics_DeduplicatesSCC(t *testing.T) { - result := WideningDiagnostics("test.lua", nil, &api.FuncResult{ - FlowInputs: &flow.Inputs{ - WideningEvents: []flow.WideningEvent{ - {Symbol: 1, SCCIndex: 0, SCC: []cfg.SymbolID{1, 2}}, - {Symbol: 2, SCCIndex: 0, SCC: []cfg.SymbolID{1, 2}}, - }, - }, - }) - - if len(result) != 1 { - t.Errorf("expected 1 diagnostic (deduplicated), got %d", len(result)) - } -} - func TestResolveSymbolName_NilGraph(t *testing.T) { name := ResolveSymbolName(nil, 1) if name != "" { diff --git a/compiler/check/pipeline/driver.go b/compiler/check/pipeline/driver.go index ff653539..dd4d766f 100644 --- a/compiler/check/pipeline/driver.go +++ b/compiler/check/pipeline/driver.go @@ -7,7 +7,7 @@ // 3. Execute the memoized function analysis pipeline // 4. Propagate effects and interprocedural facts // 5. Process nested functions recursively -// 6. Repeat until fixpoint (no channel changes) or max iterations +// 6. Repeat until the interproc product reaches fixpoint // // The driver coordinates several inference subsystems: // - Return inference: Computes return types for local functions @@ -23,12 +23,13 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + interprocdomain "github.com/wippyai/go-lua/compiler/check/domain/interproc" "github.com/wippyai/go-lua/compiler/check/effects" interprocinfer "github.com/wippyai/go-lua/compiler/check/infer/interproc" nestedinfer "github.com/wippyai/go-lua/compiler/check/infer/nested" returninfer "github.com/wippyai/go-lua/compiler/check/infer/return" "github.com/wippyai/go-lua/compiler/check/modules" - "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/db" @@ -44,7 +45,6 @@ type Config struct { GlobalTypes map[string]typ.Type Stdlib *scope.State Manifests *db.DB - MaxIterations int MaxScopeDepth int EmitScopeDiag bool FuncResultQ *db.Query[api.FuncKey, *api.FuncResult] @@ -82,54 +82,32 @@ func (d *Driver) Run(sess api.AnalysisSession, chunk []ast.Stmt) { if chunkGraph != nil { sess.RegisterGraphHierarchy(chunkGraph) if store != nil { - store.SetModuleAliases(modules.CollectAliases(chunkGraph)) + evidence := store.EvidenceForGraph(chunkGraph) + store.SetModuleAliases(modules.AliasesFromAssignments(evidence.Assignments, chunkGraph)) if d.cfg.Stdlib != nil { store.SetGraphParentHash(chunkGraph.ID(), d.cfg.Stdlib.Hash()) } } } - d.runFixpoint(sess, fn, d.cfg.Stdlib) -} - -func (d *Driver) runFixpoint(sess api.AnalysisSession, fn *ast.FunctionExpr, parent *scope.State) { - maxIterations := d.cfg.MaxIterations - if maxIterations < 1 { - maxIterations = 1 + parent := d.cfg.Stdlib + if parent == nil { + parent = scope.New() } + d.runFixpoint(sess, fn, parent, api.AnalysisContext{}) +} - converged := false - for iter := 0; iter < maxIterations; iter++ { +func (d *Driver) runFixpoint(sess api.AnalysisSession, fn *ast.FunctionExpr, parent *scope.State, ctx api.AnalysisContext) { + for { d.prepareIterationState(sess) - d.checkFunctionFixpoint(sess, fn, parent) + d.checkFunctionFixpoint(sess, fn, parent, ctx) if d.advanceFixpoint(sess.StoreHandle()) { - converged = true - break + return } } - - if !converged { - store := sess.StoreHandle() - diffs := []string(nil) - if store != nil { - diffs = store.FixpointChannelDiffs() - } - msg := "inter-function fixpoint did not converge" - if len(diffs) > 0 { - msg += "; unstable channels: " + fmt.Sprintf("%v", diffs) - } - sess.AppendDiagnostics(diag.Diagnostic{ - Position: diag.Position{File: sess.Source()}, - Severity: diag.SeverityWarning, - Message: msg, - }) - } } func (d *Driver) prepareIterationState(sess api.AnalysisSession) { - if d.cfg.FuncResultQ != nil { - d.cfg.FuncResultQ.Clear() - } sess.ResetDiagnostics() scopeState := sess.ScopeDepthDiagState() @@ -142,23 +120,19 @@ func (d *Driver) advanceFixpoint(store api.IterationStore) bool { if store == nil { return true } - if !store.FixpointSwap() { - return true - } - store.BumpRevision() - return false + return !store.FixpointSwap() } -func (d *Driver) checkFunctionFixpoint(sess api.AnalysisSession, fn *ast.FunctionExpr, parent *scope.State) { +func (d *Driver) checkFunctionFixpoint(sess api.AnalysisSession, fn *ast.FunctionExpr, parent *scope.State, ctx api.AnalysisContext) { graph := sess.GetOrBuildCFG(fn) if graph == nil { return } store := sess.StoreHandle() - parentHash := d.registerParentScope(store, graph.ID(), parent) + parentHash := d.registerParentScope(store, graph.ID(), parent, ctx) - d.runReturnInference(sess, graph, parent, store) + d.runReturnInference(sess, graph, parent, store, ctx) result := d.loadFunctionResult(sess, graph.ID(), parentHash, store) if result == nil { @@ -191,10 +165,10 @@ func (d *Driver) processNestedFunctions( Stdlib: d.cfg.Stdlib, Store: store, Graphs: sess, - Check: func(fn *ast.FunctionExpr, parent *scope.State) { - d.checkFunctionFixpoint(sess, fn, parent) + Check: func(fn *ast.FunctionExpr, parent *scope.State, ctx api.AnalysisContext) { + d.checkFunctionFixpoint(sess, fn, parent, ctx) }, - ResultForFunc: func(fn *ast.FunctionExpr) *api.FuncResultView { + ResultForFunc: func(fn *ast.FunctionExpr) *api.FuncAnalysisView { if results == nil { return nil } @@ -205,10 +179,24 @@ func (d *Driver) processNestedFunctions( nestedProc.ProcessNestedFunctions(graph, api.ViewFromResult(result)) } -func (d *Driver) registerParentScope(store api.IterationStore, graphID uint64, parent *scope.State) uint64 { - parentHash := api.ParentHashForGraph(store, graphID, parent) +func (d *Driver) registerParentScope(store api.IterationStore, graphID uint64, parent *scope.State, ctx api.AnalysisContext) uint64 { + parentHash := uint64(0) + if parent != nil { + parentHash = parent.Hash() + } else { + parentHash = api.ParentHashForGraph(store, graphID, parent) + } + parentHash = ctx.ParentHash(parentHash) if store != nil && parentHash != 0 { store.SetParentScope(parentHash, parent) + store.SetGraphParentHash(graphID, parentHash) + if !ctx.Empty() { + if contextual, ok := store.(interface { + SetGraphAnalysisContext(api.GraphKey, api.AnalysisContext) + }); ok { + contextual.SetGraphAnalysisContext(api.GraphKey{GraphID: graphID, ParentHash: parentHash}, ctx) + } + } } return parentHash } @@ -218,28 +206,29 @@ func (d *Driver) runReturnInference( graph *cfg.Graph, parent *scope.State, store api.IterationStore, + ctx api.AnalysisContext, ) { if store == nil || graph == nil { return } inferencer := returninfer.New(returninfer.Config{ - Types: d.cfg.Types, - GlobalTypes: d.cfg.GlobalTypes, - Manifests: d.cfg.Manifests, - Stdlib: d.cfg.Stdlib, - Store: store, - Graphs: sess, - SourceName: sess.Source(), - MaxIterations: returns.MaxReturnSummaryIterations, + Types: d.cfg.Types, + GlobalTypes: mergeGlobalOverlay(d.cfg.GlobalTypes, ctx.GlobalOverlay), + Manifests: d.cfg.Manifests, + Stdlib: d.cfg.Stdlib, + Store: store, + Graphs: sess, + SourceName: sess.Source(), }) + refinementFacts := refinementFactsFrom(store) var refinementLookup constraint.RefinementLookupBySym - if es := store.RefinementStore(); es != nil { - refinementLookup = es.LookupRefinementBySym + if refinementFacts != nil { + refinementLookup = refinementFacts.LookupBySym } - summaries, funcTypes, diags := inferencer.ComputeForGraph(returninfer.RunContext{ + functionFacts, diags := inferencer.ComputeForGraph(returninfer.RunContext{ Ctx: sess.Context(), ParentFacts: d.parentFactsForGraph(sess, store, graph.ID()), EffectLookup: refinementLookup, @@ -247,13 +236,11 @@ func (d *Driver) runReturnInference( if len(diags) > 0 { sess.AppendDiagnostics(diags...) } - if len(summaries) == 0 { + if len(functionFacts) == 0 { return } if key, ok := store.GraphKeyFor(graph, parent); ok { - store.UpdateInterprocFactsNext(key, func(facts *api.Facts) { - returns.MergeFunctionFactsIntoFacts(facts, summaries, nil, funcTypes) - }) + store.MergeInterprocFactsNext(key, interprocdomain.FunctionFactsDelta(functionFacts)) } } @@ -296,14 +283,9 @@ func (d *Driver) loadFunctionResult( if d.cfg.FuncResultQ == nil { return nil } - revision := uint64(0) - if store != nil { - revision = store.Revision() - } return d.cfg.FuncResultQ.Get(sess.Context(), api.FuncKey{ - GraphID: graphID, - ParentHash: parentHash, - StoreRevision: revision, + GraphID: graphID, + ParentHash: parentHash, }) } @@ -355,14 +337,21 @@ func (d *Driver) storeFunctionRefinement(store api.IterationStore, result *api.F if result == nil || store == nil || funcSym == 0 { return } + refinementFacts := refinementFactsFrom(store) lookup := func(sym cfg.SymbolID) *constraint.FunctionRefinement { - return effects.LookupRefinementBySym(store.RefinementStore(), store.ModuleBindings(), d.cfg.GlobalTypes, sym) + return effects.ResolveRefinementBySym(refinementFacts, store.ModuleBindings(), d.cfg.GlobalTypes, sym) } fnEffect := effects.Propagate(result, lookup) if fnEffect == nil { return } - store.StoreFunctionRefinement(funcSym, fnEffect) + key, ok := store.ParentGraphKeyForSymbol(funcSym) + if !ok { + return + } + store.MergeInterprocFactsNext(key, interprocdomain.FunctionFactsDelta(functionfact.FromPart(funcSym, functionfact.Parts{ + Refinement: fnEffect, + }))) } func collectGlobalNames(globalTypes map[string]typ.Type) []string { diff --git a/compiler/check/pipeline/driver_test.go b/compiler/check/pipeline/driver_test.go index 4047f923..04d05f17 100644 --- a/compiler/check/pipeline/driver_test.go +++ b/compiler/check/pipeline/driver_test.go @@ -8,15 +8,11 @@ import ( func TestNew(t *testing.T) { d := New(Config{ - MaxIterations: 5, MaxScopeDepth: 10, }) if d == nil { t.Fatal("expected non-nil driver") } - if d.cfg.MaxIterations != 5 { - t.Error("MaxIterations not set") - } if d.cfg.MaxScopeDepth != 10 { t.Error("MaxScopeDepth not set") } @@ -29,14 +25,10 @@ func TestDriver_Run_NilSession(t *testing.T) { func TestConfig_Fields(t *testing.T) { cfg := Config{ - MaxIterations: 3, MaxScopeDepth: 8, EmitScopeDiag: true, GlobalTypes: map[string]typ.Type{"foo": typ.String}, } - if cfg.MaxIterations != 3 { - t.Error("MaxIterations not set") - } if cfg.MaxScopeDepth != 8 { t.Error("MaxScopeDepth not set") } diff --git a/compiler/check/pipeline/runner.go b/compiler/check/pipeline/runner.go index dd19e746..75506b0f 100644 --- a/compiler/check/pipeline/runner.go +++ b/compiler/check/pipeline/runner.go @@ -15,14 +15,13 @@ // - Synthesis engine for expression type computation // - Flow solver for control flow analysis // - Effect propagation for side effect tracking -// - Parameter hint inference from call sites +// - Parameter evidence inference from call sites package pipeline import ( - "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/infer/captured" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" "github.com/wippyai/go-lua/compiler/check/modules" "github.com/wippyai/go-lua/compiler/check/phase" "github.com/wippyai/go-lua/compiler/check/scope" @@ -77,6 +76,12 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { if store == nil { return nil } + if tracker, ok := store.(interface { + PushFactReadContext(*db.QueryContext) func() + }); ok { + pop := tracker.PushFactReadContext(ctx) + defer pop() + } withPhase := func(_ api.Phase, fn func()) { fn() } if phaser, ok := store.(interface{ WithPhase(api.Phase, func()) }); ok { withPhase = phaser.WithPhase @@ -101,31 +106,38 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { setter.SetGraphParentHash(graph.ID(), key.ParentHash) } - paramHintSigs := paramhints.BuildParamHintSigView(store, graph, parent, r.stdlib) - synthSig := r.resolveSynthesizedSignature(ctx, store, graph, fn, parent, paramHintSigs) + // Build shared transfer evidence and phase environment once. + graphEvidence := store.EvidenceForGraph(graph) + parameterEvidenceSigs := functionfact.ParameterEvidenceSignatures(store, graph, parent, r.stdlib) + analysisCtx := api.AnalysisContext{} + globalTypes := r.globalTypes + if contextual, ok := store.(interface { + GraphAnalysisContext(api.GraphKey) api.AnalysisContext + }); ok { + analysisCtx = contextual.GraphAnalysisContext(api.GraphKey(key)) + globalTypes = mergeGlobalOverlay(globalTypes, analysisCtx.GlobalOverlay) + } + synthSig := r.resolveSynthesizedSignature(ctx, store, graph, fn, parent, graphEvidence, parameterEvidenceSigs) + if analysisCtx.ExpectedFunction != nil { + synthSig = mergeSynthesizedSignatureContext(synthSig, analysisCtx.ExpectedFunction) + } - // Canonical local function types for this graph (stable snapshot). - siblingTypes := store.GetLocalFuncTypesSnapshot(graph, parent) - // Return summaries include captured field assignments (stable snapshot). - returnSummaries := store.GetReturnSummariesSnapshot(graph, parent) - var narrowReturnSummaries map[cfg.SymbolID][]typ.Type - withPhase(api.PhaseNarrowing, func() { - narrowReturnSummaries = store.GetNarrowReturnSummariesSnapshot(graph, parent) - }) + graphFacts := store.GetInterprocFacts(graph, parent) + functionFacts := graphFacts.FunctionFacts - // Build shared phase environment once. - localAliases := modules.CollectAliases(graph) + localAliases := modules.AliasesFromAssignments(graphEvidence.Assignments, graph) mergedAliases := modules.MergeAliases(store.ModuleAliases(), localAliases) env := phase.PhaseEnv{ - Ctx: ctx, - Graph: graph, - Fn: fn, - Types: r.types, - Manifests: r.manifests, - GlobalTypes: r.globalTypes, - ModuleAliases: mergedAliases, - ModuleBindings: store.ModuleBindings(), - RefinementStore: effectStoreFrom(store), + Ctx: ctx, + Graph: graph, + Fn: fn, + Types: r.types, + Manifests: r.manifests, + GlobalTypes: globalTypes, + ModuleAliases: mergedAliases, + ModuleBindings: store.ModuleBindings(), + Refinements: refinementFactsFrom(store), + Evidence: graphEvidence, } // Phase A: Resolve type annotations. @@ -135,61 +147,58 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { BaseScope: parent, }) - // Literal signatures are provided by the stable snapshot. + // Literal signatures are provided by the visible interproc product. literalSigs := r.literalSigProvider(store, graph, parent) // Phase B: Build scopes and extract declared types. scopeOut := phase.RunScope(phase.ScopeInput{ - PhaseEnv: env, - Parent: parent, - MaxScopeDepth: r.maxScopeDepth, - Resolve: resolveOut, - SynthesizedFunctionSig: synthSig, - FunctionLiteralSignatures: literalSigs, - ParamHintSignatures: paramHintSigs, - SiblingTypes: siblingTypes, - ReturnSummaries: returnSummaries, + PhaseEnv: env, + Parent: parent, + MaxScopeDepth: r.maxScopeDepth, + Resolve: resolveOut, + SynthesizedFunctionSig: synthSig, + FunctionLiteralSignatures: literalSigs, + ParameterEvidenceSignatures: parameterEvidenceSigs, + FunctionFacts: functionFacts, }) // Declared is the default phase for scope/extract and interproc reads. - if capturedTypes := store.GetCapturedTypesSnapshot(graph, parent); len(capturedTypes) > 0 { + if capturedTypes := graphFacts.CapturedTypes; len(capturedTypes) > 0 { scopeOut.DeclaredTypes = captured.MergeCapturedTypes(scopeOut.DeclaredTypes, capturedTypes) } - r.mergeCapturedParentFuncTypes(store, graph, fn, &scopeOut) + r.mergeCapturedParentFunctionTypes(store, graph, fn, &scopeOut) // Populate scopes in env for later phases. env.Scopes = scopeOut.Scopes // Phase B (continued): Synthesize function literal types. literalOut := phase.RunLiteral(phase.LiteralInput{ - PhaseEnv: env, - Scope: scopeOut, - SiblingTypes: scopeOut.SiblingTypes, - ReturnSummaries: returnSummaries, + PhaseEnv: env, + Scope: scopeOut, + FunctionFacts: functionFacts, }) // Ensure literal function types use canonical local function types. - if len(siblingTypes) > 0 { + if len(functionFacts) > 0 { if literalOut.LiteralTypes == nil { - literalOut.LiteralTypes = make(flow.DeclaredTypes, len(siblingTypes)) + literalOut.LiteralTypes = make(flow.DeclaredTypes, len(functionFacts)) } - for sym, fnType := range siblingTypes { - if fnType == nil { + for sym, fact := range functionFacts { + if fact.Type == nil { continue } - literalOut.LiteralTypes[sym] = fnType + literalOut.LiteralTypes[sym] = fact.Type } } // Phase B (continued): Extract flow constraints. extractOut := phase.RunExtract(phase.FlowExtractInput{ - PhaseEnv: env, - Resolve: resolveOut, - Scope: scopeOut, - SiblingTypes: scopeOut.SiblingTypes, - LiteralTypes: literalOut.LiteralTypes, - ReturnSummaries: returnSummaries, + PhaseEnv: env, + Resolve: resolveOut, + Scope: scopeOut, + FunctionFacts: functionFacts, + LiteralTypes: literalOut.LiteralTypes, }) - r.appendCapturedMutatorAssignments(store, graph, parent, env, scopeOut, literalOut, returnSummaries, &extractOut) + r.appendCapturedMutatorAssignments(store, graph, parent, env, scopeOut, literalOut, functionFacts, &extractOut) // Phase C: Solve flow system. solveOut := phase.RunSolve(phase.FlowSolveInput{ @@ -201,13 +210,12 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { var narrowOut phase.NarrowOutput withPhase(api.PhaseNarrowing, func() { narrowOut = phase.RunNarrow(phase.NarrowInput{ - PhaseEnv: env, - Scope: scopeOut, - Extract: extractOut, - Solve: solveOut, - SiblingTypes: scopeOut.SiblingTypes, - LiteralTypes: literalOut.LiteralTypes, - NarrowReturnSummaries: narrowReturnSummaries, + PhaseEnv: env, + Scope: scopeOut, + Extract: extractOut, + Solve: solveOut, + FunctionFacts: functionFacts, + LiteralTypes: literalOut.LiteralTypes, }) }) @@ -221,6 +229,7 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { Facts: narrowOut.Facts, FlowInputs: extractOut.Inputs, FlowSolution: solveOut.Solution, + Evidence: extractOut.Evidence, FnRefinement: narrowOut.Refinement, NarrowSynth: narrowOut.Synth, LiteralSignatures: literalOut.Signatures, @@ -228,3 +237,19 @@ func (r *Runner) Run(ctx *db.QueryContext, key api.FuncKey) *api.FuncResult { DepthLimitExceeded: scopeOut.DepthLimitExceeded, } } + +func mergeGlobalOverlay(base map[string]typ.Type, overlay map[string]typ.Type) map[string]typ.Type { + if len(overlay) == 0 { + return base + } + out := make(map[string]typ.Type, len(base)+len(overlay)) + for name, t := range base { + out[name] = t + } + for name, t := range overlay { + if name != "" && t != nil { + out[name] = t + } + } + return out +} diff --git a/compiler/check/pipeline/runner_stages.go b/compiler/check/pipeline/runner_stages.go index 7ad22200..5ccbb1ca 100644 --- a/compiler/check/pipeline/runner_stages.go +++ b/compiler/check/pipeline/runner_stages.go @@ -4,39 +4,45 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/flowbuild/resolve" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" + "github.com/wippyai/go-lua/compiler/check/domain/resolve" "github.com/wippyai/go-lua/compiler/check/phase" "github.com/wippyai/go-lua/compiler/check/returns" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" ) func (r *Runner) resolveSynthesizedSignature( ctx *db.QueryContext, - store api.StoreView, + store api.StoreReader, graph *cfg.Graph, fn *ast.FunctionExpr, parent *scope.State, - paramHintSigs map[*ast.FunctionExpr][]typ.Type, + flowEvidence api.FlowEvidence, + parameterEvidenceSigs map[*ast.FunctionExpr][]typ.Type, ) *typ.Function { if graph == nil || fn == nil { return nil } - // Prefer literal signature from parent graph for nested functions. + factSig := paramevidence.ProjectSignatureToParamUse(graph.ParamSlotsReadOnly(), flowEvidence.ParameterUses, r.functionFactSignatureForFunction(store, graph, fn)) synthSig := r.literalSignatureForFunction(store, graph, fn) - if paramHintSigs == nil { - return synthSig + baseSig := mergeSynthesizedSignatureFact(synthSig, factSig) + if parameterEvidenceSigs == nil { + return baseSig } - hints := paramHintSigs[fn] - if len(hints) == 0 { - return synthSig + paramEvidence := parameterEvidenceSigs[fn] + if len(paramEvidence) == 0 { + return baseSig } - if synthSig == nil { + if baseSig == nil { engine := synth.New(synth.Config{ Ctx: ctx, Types: r.types, @@ -44,32 +50,214 @@ func (r *Runner) resolveSynthesizedSignature( Phase: api.PhaseTypeResolution, }) if sig := engine.ResolveFunctionSignature(fn, parent); sig != nil { - synthSig = sig + baseSig = sig } else if seedFn, ok := returns.BuildSeedFunctionTypeWithBindings(fn, engine, parent, graph.Bindings()).(*typ.Function); ok { - synthSig = seedFn + baseSig = seedFn } } - if synthSig == nil { + if baseSig == nil { return nil } - return paramhints.MergeIntoSignature(fn, hints, synthSig) + return paramevidence.MergeIntoSignature(fn, paramEvidence, baseSig) +} + +func mergeSynthesizedSignatureFact(seed, fact *typ.Function) *typ.Function { + if seed == nil { + return fact + } + if fact == nil { + return seed + } + if merged := unwrap.Function(functionfact.MergeType(seed, fact)); merged != nil { + return merged + } + return seed +} + +func mergeSynthesizedSignatureContext(seed, expected *typ.Function) *typ.Function { + if seed == nil { + return expected + } + if expected == nil { + return seed + } + if typ.TypeEquals(seed, expected) { + return seed + } + + builder := typ.Func().ReserveParams(maxInt(len(seed.Params), len(expected.Params))) + if sameFunctionTypeParams(seed, expected) { + for _, tp := range seed.TypeParams { + builder = builder.TypeParam(tp.Name, tp.Constraint) + } + } + + paramCount := maxInt(len(seed.Params), len(expected.Params)) + for i := 0; i < paramCount; i++ { + name := "" + var paramType typ.Type + optional := true + if i < len(seed.Params) { + p := seed.Params[i] + name = p.Name + paramType = p.Type + optional = p.Optional + } + if i < len(expected.Params) { + p := expected.Params[i] + if name == "" { + name = p.Name + } + paramType = mergeContextParamType(paramType, p.Type, p.Optional) + optional = p.Optional + } else if expected.Variadic != nil { + paramType = functionfact.MergeParamType(paramType, expected.Variadic) + optional = true + } else if i >= len(expected.Params) { + paramType = functionfact.MergeParamType(paramType, typ.Nil) + optional = true + } + if optional { + builder = builder.OptParam(name, paramType) + } else { + builder = builder.Param(name, paramType) + } + } + + if seed.Variadic != nil || expected.Variadic != nil { + builder = builder.Variadic(functionfact.MergeParamType(seed.Variadic, expected.Variadic)) + } + + if returns := mergeSignatureReturns(seed.Returns, expected.Returns); len(returns) > 0 { + builder = builder.Returns(returns...) + } + if seed.Effects != nil { + builder = builder.Effects(seed.Effects) + } else if expected.Effects != nil { + builder = builder.Effects(expected.Effects) + } + if seed.Spec != nil { + builder = builder.Spec(seed.Spec) + } else if expected.Spec != nil { + builder = builder.Spec(expected.Spec) + } + if seed.Refinement != nil { + builder = builder.WithRefinement(seed.Refinement) + } else if expected.Refinement != nil { + builder = builder.WithRefinement(expected.Refinement) + } + return builder.Build() +} + +func mergeContextParamType(seed, expected typ.Type, expectedOptional bool) typ.Type { + if expected == nil { + return seed + } + if seed == nil || typ.IsAny(seed) || typ.IsUnknown(seed) { + return expected + } + if !expectedOptional { + if inner, nilable := typ.SplitNilableFieldType(seed); nilable { + if typ.TypeEquals(inner, expected) || subtype.IsSubtype(expected, inner) { + return expected + } + } + if subtype.IsSubtype(expected, seed) && !subtype.IsSubtype(seed, expected) { + return expected + } + } + return functionfact.MergeParamType(seed, expected) +} + +func mergeSignatureReturns(a, b []typ.Type) []typ.Type { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + out := make([]typ.Type, maxInt(len(a), len(b))) + for i := range out { + var left, right typ.Type + if i < len(a) { + left = a[i] + } + if i < len(b) { + right = b[i] + } + out[i] = typ.JoinPreferNonSoft(left, right) + } + return out +} + +func sameFunctionTypeParams(a, b *typ.Function) bool { + if a == nil || b == nil || len(a.TypeParams) != len(b.TypeParams) { + return false + } + for i := range a.TypeParams { + if a.TypeParams[i] == nil || b.TypeParams[i] == nil { + if a.TypeParams[i] != b.TypeParams[i] { + return false + } + continue + } + if a.TypeParams[i].Name != b.TypeParams[i].Name || !typ.TypeEquals(a.TypeParams[i].Constraint, b.TypeParams[i].Constraint) { + return false + } + } + return true +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func (r *Runner) functionFactSignatureForFunction( + store api.StoreReader, + graph *cfg.Graph, + fn *ast.FunctionExpr, +) *typ.Function { + if store == nil || graph == nil || fn == nil { + return nil + } + sym, ok := store.SymbolForFunc(fn) + if !ok || sym == 0 { + return nil + } + meta, ok := store.NestedMetaFor(graph.ID()) + if !ok || meta.ParentGraphID == 0 { + return nil + } + parentGraph := store.Graphs()[meta.ParentGraphID] + if parentGraph == nil { + return nil + } + parentScope := r.parentScopeForGraph(store, parentGraph) + if parentScope == nil { + return nil + } + facts := store.GetInterprocFacts(parentGraph, parentScope).FunctionFacts + return unwrap.Function(functionfact.TypeFromMap(facts, sym)) } func (r *Runner) appendCapturedMutatorAssignments( - store api.StoreView, + store api.StoreReader, graph *cfg.Graph, parent *scope.State, env phase.PhaseEnv, scopeOut phase.ScopeOutput, literalOut phase.LiteralOutput, - returnSummaries map[cfg.SymbolID][]typ.Type, + functionFacts api.FunctionFacts, extractOut *phase.FlowExtractOutput, ) { if store == nil || graph == nil || extractOut == nil || extractOut.Inputs == nil { return } - capturedContainers := store.GetCapturedContainerMutationsSnapshot(graph, parent) + capturedContainers := store.GetInterprocFacts(graph, parent).CapturedContainers if len(capturedContainers) == 0 { return } @@ -81,9 +269,8 @@ func (r *Runner) appendCapturedMutatorAssignments( declaredEnv := phase.NewContextBuilder(env). WithScope(scopeOut). - WithSiblingTypes(scopeOut.SiblingTypes). WithLiteralTypes(literalOut.LiteralTypes). - WithReturnSummaries(returnSummaries). + WithFunctionFacts(functionFacts). BuildDeclared() synthEngine := synth.New(synth.Config{ @@ -92,7 +279,9 @@ func (r *Runner) appendCapturedMutatorAssignments( Scopes: scopeOut.Scopes, Manifests: env.Manifests, Env: declaredEnv, + FunctionFacts: functionFacts, Phase: api.PhaseScopeCompute, + Evidence: env.Evidence, ModuleBindings: env.ModuleBindings, ModuleAliases: env.ModuleAliases, }) @@ -103,11 +292,23 @@ func (r *Runner) appendCapturedMutatorAssignments( return resolve.CalleeType(info, p, synthEngine.TypeOf, symResolver, assignmentTypes, graph, bindings, env.ModuleBindings) } - extra := returns.CollectCalledNestedContainerMutatorAssignments(graph, bindings, capturedContainers, calleeTypeResolver) - if len(extra) == 0 { - return + extra := calleffect.CollectNestedMutatorAssignments( + graph, + bindings, + extractOut.Evidence.Calls, + extractOut.Evidence.EscapedFunctions, + capturedContainers, + calleeTypeResolver, + ) + if len(extra.Indexer) > 0 { + extractOut.Inputs.IndexerAssignments = append(extractOut.Inputs.IndexerAssignments, extra.Indexer...) + } + if len(extra.Table) > 0 { + extractOut.Inputs.TableMutatorAssignments = append(extractOut.Inputs.TableMutatorAssignments, extra.Table...) + } + if len(extra.Container) > 0 { + extractOut.Inputs.ContainerMutatorAssignments = append(extractOut.Inputs.ContainerMutatorAssignments, extra.Container...) } - extractOut.Inputs.ContainerMutatorAssignments = append(extractOut.Inputs.ContainerMutatorAssignments, extra...) } func (r *Runner) runComputePasses(graph *cfg.Graph, scopes map[cfg.Point]*scope.State) map[string]any { @@ -121,7 +322,7 @@ func (r *Runner) runComputePasses(graph *cfg.Graph, scopes map[cfg.Point]*scope. return extras } -func (r *Runner) literalSignatureForFunction(store api.StoreView, graph *cfg.Graph, fn *ast.FunctionExpr) *typ.Function { +func (r *Runner) literalSignatureForFunction(store api.StoreReader, graph *cfg.Graph, fn *ast.FunctionExpr) *typ.Function { if store == nil || graph == nil || fn == nil { return nil } @@ -134,17 +335,11 @@ func (r *Runner) literalSignatureForFunction(store api.StoreView, graph *cfg.Gra return nil } - if sigs := scratchLiteralSigs(store, parentGraph.ID()); len(sigs) > 0 { - if sig := sigs[fn]; sig != nil { - return sig - } - } - parentScope := r.parentScopeForGraph(store, parentGraph) if parentScope == nil { return nil } - if sigs := store.GetLiteralSigsSnapshot(parentGraph, parentScope); len(sigs) > 0 { + if sigs := store.GetInterprocFacts(parentGraph, parentScope).LiteralSigs; len(sigs) > 0 { if sig := sigs[fn]; sig != nil { return sig } @@ -152,12 +347,12 @@ func (r *Runner) literalSignatureForFunction(store api.StoreView, graph *cfg.Gra return nil } -func (r *Runner) literalSigProvider(store api.StoreView, graph *cfg.Graph, parent *scope.State) phase.LiteralSigsProvider { +func (r *Runner) literalSigProvider(store api.StoreReader, graph *cfg.Graph, parent *scope.State) phase.LiteralSigsProvider { if store == nil || graph == nil || parent == nil { return nil } var literalSigMap map[*ast.FunctionExpr]*typ.Function - if sigs := store.GetLiteralSigsSnapshot(graph, parent); len(sigs) > 0 { + if sigs := store.GetInterprocFacts(graph, parent).LiteralSigs; len(sigs) > 0 { literalSigMap = mergeLiteralSignatures(nil, sigs, true) } if meta, ok := store.NestedMetaFor(graph.ID()); ok { @@ -165,13 +360,10 @@ func (r *Runner) literalSigProvider(store api.StoreView, graph *cfg.Graph, paren if parentGraph != nil { parentScope := r.parentScopeForGraph(store, parentGraph) if parentScope != nil { - if sigs := store.GetLiteralSigsSnapshot(parentGraph, parentScope); len(sigs) > 0 { + if sigs := store.GetInterprocFacts(parentGraph, parentScope).LiteralSigs; len(sigs) > 0 { literalSigMap = mergeLiteralSignatures(literalSigMap, sigs, false) } } - if sigs := scratchLiteralSigs(store, parentGraph.ID()); len(sigs) > 0 { - literalSigMap = mergeLiteralSignatures(literalSigMap, sigs, false) - } } } if len(literalSigMap) > 0 { @@ -180,35 +372,11 @@ func (r *Runner) literalSigProvider(store api.StoreView, graph *cfg.Graph, paren return nil } -type effectStoreProvider interface { - RefinementStore() api.RefinementStore -} - -func effectStoreFrom(store api.StoreView) api.RefinementStore { - if store == nil { - return nil - } - if provider, ok := store.(effectStoreProvider); ok { - return provider.RefinementStore() - } - return nil -} - -type scratchLiteralStore interface { - ScratchLiteralSigs(graphID uint64) map[*ast.FunctionExpr]*typ.Function -} - -func scratchLiteralSigs(store api.StoreView, graphID uint64) map[*ast.FunctionExpr]*typ.Function { - if store == nil { - return nil - } - if provider, ok := store.(scratchLiteralStore); ok { - return provider.ScratchLiteralSigs(graphID) - } - return nil +func refinementFactsFrom(store api.StoreReader) api.RefinementFacts { + return functionfact.RefinementsFromStore(store, nil) } -func (r *Runner) parentScopeForGraph(store api.StoreView, graph *cfg.Graph) *scope.State { +func (r *Runner) parentScopeForGraph(store api.StoreReader, graph *cfg.Graph) *scope.State { if store == nil || graph == nil { return nil } @@ -221,8 +389,8 @@ func (r *Runner) parentScopeForGraph(store api.StoreView, graph *cfg.Graph) *sco return nil } -func (r *Runner) mergeCapturedParentFuncTypes( - store api.StoreView, +func (r *Runner) mergeCapturedParentFunctionTypes( + store api.StoreReader, graph *cfg.Graph, fn *ast.FunctionExpr, scopeOut *phase.ScopeOutput, @@ -242,12 +410,12 @@ func (r *Runner) mergeCapturedParentFuncTypes( if parentScope == nil { return } - parentFuncTypes := store.GetLocalFuncTypesSnapshot(parentGraph, parentScope) - if len(parentFuncTypes) == 0 { + parentFacts := store.GetInterprocFacts(parentGraph, parentScope).FunctionFacts + if len(parentFacts) == 0 { return } for _, sym := range graph.Bindings().CapturedSymbols(fn) { - ft := parentFuncTypes[sym] + ft := functionfact.TypeFromMap(parentFacts, sym) if sym == 0 || ft == nil { continue } diff --git a/compiler/check/returns/callgraph.go b/compiler/check/returns/callgraph.go index d3a48e30..469f40d3 100644 --- a/compiler/check/returns/callgraph.go +++ b/compiler/check/returns/callgraph.go @@ -6,8 +6,9 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/check/infer/paramhints" + "github.com/wippyai/go-lua/compiler/check/domain/paramevidence" synthresolve "github.com/wippyai/go-lua/compiler/check/synth/phase/resolve" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" @@ -81,23 +82,23 @@ func buildLocalSignatureResolver(localFuncs map[cfg.SymbolID]*LocalFuncInfo) fun } } -// PropagateParamHintsFromCallGraph propagates parameter type hints through +// PropagateParameterEvidence propagates parameter evidence through // inner function call graphs. // // This function implements inter-procedural parameter type inference. For each // local function, it scans call sites to identify argument types: // -// - Literal arguments (numbers, strings, booleans, nil) provide direct type hints -// - Identifier arguments that reference caller parameters with known hints -// propagate those hints transitively +// - Literal arguments (numbers, strings, booleans, nil) provide direct evidence. +// - Identifier arguments that reference caller parameters with known evidence +// propagate that evidence transitively. // -// The algorithm iterates to fixpoint, bounded by the number of local functions. -// This ensures that chains like f(x) -> g(x) -> h(x) are fully resolved even -// if functions are processed in arbitrary order. +// The algorithm iterates to the parameter-evidence fixed point. This ensures +// that chains like f(x) -> g(x) -> h(x) are fully resolved even if functions are +// processed in arbitrary order. // -// Hints are accumulated using typ.JoinPreferNonSoft, producing union types when a parameter -// is called with multiple different types across call sites. -func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo) { +// Evidence is accumulated with typ.JoinPreferNonSoft, producing union types when +// a parameter is called with multiple different types across call sites. +func PropagateParameterEvidence(localFuncs map[cfg.SymbolID]*LocalFuncInfo) { if len(localFuncs) == 0 { return } @@ -113,18 +114,18 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo if info.Graph == nil { continue } - for _, slot := range info.Graph.ParamSlotsReadOnly() { - srcIdx, hasSource := slot.SourceParamIndex() - if !hasSource || slot.Symbol == 0 { + for idx, slot := range info.Graph.ParamSlotsReadOnly() { + if slot.Symbol == 0 { continue } - paramOwner[slot.Symbol] = paramRef{owner: info, index: srcIdx} + paramOwner[slot.Symbol] = paramRef{owner: info, index: idx} } } resolveLocalSignature := buildLocalSignatureResolver(localFuncs) parentGraphs := make(map[uint64]*cfg.Graph) + parentCalls := make(map[uint64][]api.CallEvidence) moduleBindings := (*bind.BindingTable)(nil) for _, sym := range cfg.SortedSymbolIDs(localFuncs) { info := localFuncs[sym] @@ -132,6 +133,7 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo continue } parentGraphs[info.ParentGraph.ID()] = info.ParentGraph + parentCalls[info.ParentGraph.ID()] = info.ParentEvidence.Calls if moduleBindings == nil { moduleBindings = info.ParentGraph.Bindings() } @@ -142,7 +144,7 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo } sort.Slice(parentGraphIDs, func(i, j int) bool { return parentGraphIDs[i] < parentGraphIDs[j] }) - for round := 0; round < len(localFuncs); round++ { + for { changed := false processCall := func(ci *cfg.CallInfo, graph *cfg.Graph, bindings *bind.BindingTable) { if ci == nil { @@ -183,13 +185,13 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo } // For identifiers, check if the ident refers to a caller - // parameter with a known hint. + // parameter with known evidence. if argType == nil { if ident, ok := arg.(*ast.IdentExpr); ok && bindings != nil { if sym, found := bindings.SymbolOf(ident); found { if ref, isParam := paramOwner[sym]; isParam { - if ref.index < len(ref.owner.ParamHints) { - argType = ref.owner.ParamHints[ref.index] + if ref.index < len(ref.owner.ParameterEvidence) { + argType = ref.owner.ParameterEvidence[ref.index] } } } @@ -198,13 +200,13 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo // If a local function is passed as an argument and the callee has // a function-typed parameter annotation at this position, propagate - // those parameter types as hints to the passed local function. + // those parameter types as evidence to the passed local function. if calleeSig != nil && i < len(calleeSig.Params) { if expectedFn := unwrap.Function(calleeSig.Params[i].Type); expectedFn != nil { argSym := canonicalLocalSymbol(localFuncs, graph, moduleBindings, bindings, arg, 0) if argSym != 0 { if argLocal := localFuncs[argSym]; argLocal != nil { - if mergeFunctionParamHints(argLocal, expectedFn) { + if mergeExpectedFunctionEvidence(argLocal, expectedFn) { changed = true } } @@ -212,8 +214,8 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo } } - nextHints, merged := paramhints.MergeHintAt(callee.ParamHints, i, argType, typ.JoinPreferNonSoft) - callee.ParamHints = nextHints + nextEvidence, merged := paramevidence.MergeAt(callee.ParameterEvidence, i, argType, typ.JoinPreferNonSoft) + callee.ParameterEvidence = nextEvidence if merged { changed = true } @@ -221,16 +223,16 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo } // Parent-graph calls (e.g. chunk-level calls to local/nested functions) - // provide the first wave of hints into local function params. + // provide the first wave of evidence into local function params. for _, graphID := range parentGraphIDs { g := parentGraphs[graphID] if g == nil { continue } bindings := g.Bindings() - g.EachCallSite(func(_ cfg.Point, ci *cfg.CallInfo) { - processCall(ci, g, bindings) - }) + for _, call := range parentCalls[graphID] { + processCall(call.Info, g, bindings) + } } for _, sym := range cfg.SortedSymbolIDs(localFuncs) { @@ -239,9 +241,9 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo continue } bindings := info.Graph.Bindings() - info.Graph.EachCallSite(func(_ cfg.Point, ci *cfg.CallInfo) { - processCall(ci, info.Graph, bindings) - }) + for _, call := range info.Evidence.Calls { + processCall(call.Info, info.Graph, bindings) + } } if !changed { @@ -250,22 +252,22 @@ func PropagateParamHintsFromCallGraph(localFuncs map[cfg.SymbolID]*LocalFuncInfo } } -func mergeFunctionParamHints(target *LocalFuncInfo, expectedFn *typ.Function) bool { +func mergeExpectedFunctionEvidence(target *LocalFuncInfo, expectedFn *typ.Function) bool { if target == nil || expectedFn == nil || len(expectedFn.Params) == 0 { return false } changed := false - if target.ParamHints == nil { - target.ParamHints = make([]typ.Type, len(expectedFn.Params)) - } else if len(expectedFn.Params) > len(target.ParamHints) { + if target.ParameterEvidence == nil { + target.ParameterEvidence = make([]typ.Type, len(expectedFn.Params)) + } else if len(expectedFn.Params) > len(target.ParameterEvidence) { expanded := make([]typ.Type, len(expectedFn.Params)) - copy(expanded, target.ParamHints) - target.ParamHints = expanded + copy(expanded, target.ParameterEvidence) + target.ParameterEvidence = expanded } for i, param := range expectedFn.Params { - nextHints, merged := paramhints.MergeHintAt(target.ParamHints, i, param.Type, typ.JoinPreferNonSoft) - target.ParamHints = nextHints + nextEvidence, merged := paramevidence.MergeAt(target.ParameterEvidence, i, param.Type, typ.JoinPreferNonSoft) + target.ParameterEvidence = nextEvidence if merged { changed = true } @@ -348,9 +350,9 @@ func BuildLocalCallGraph( seen := make(map[cfg.SymbolID]bool) bindings := info.Graph.Bindings() - info.Graph.EachCallSite(func(_ cfg.Point, callInfo *cfg.CallInfo) { - addEdgesFromCall(seen, &callees, callInfo, info.Graph, bindings) - }) + for _, call := range info.Evidence.Calls { + addEdgesFromCall(seen, &callees, call.Info, info.Graph, bindings) + } if len(callees) > 1 { sort.Slice(callees, func(i, j int) bool { diff --git a/compiler/check/returns/callgraph_symbol_test.go b/compiler/check/returns/callgraph_symbol_test.go index e783f6d3..c41142a4 100644 --- a/compiler/check/returns/callgraph_symbol_test.go +++ b/compiler/check/returns/callgraph_symbol_test.go @@ -55,7 +55,7 @@ func TestCanonicalLocalSymbol_PrefersKnownLocalOverRaw(t *testing.T) { } } -func TestCanonicalLocalCalleeSymbol_UsesCalleeNameFallback(t *testing.T) { +func TestCanonicalLocalCalleeSymbol_UsesCalleeNameResolution(t *testing.T) { bindings := bind.NewBindingTable() const localSym cfg.SymbolID = 3001 bindings.SetName(localSym, "runner") @@ -69,7 +69,7 @@ func TestCanonicalLocalCalleeSymbol_UsesCalleeNameFallback(t *testing.T) { } got := canonicalLocalCalleeSymbol(localFuncs, nil, nil, bindings, callInfo) if got != localSym { - t.Fatalf("canonicalLocalCalleeSymbol via name fallback = %d, want %d", got, localSym) + t.Fatalf("canonicalLocalCalleeSymbol via name resolution = %d, want %d", got, localSym) } } diff --git a/compiler/check/returns/callgraph_test.go b/compiler/check/returns/callgraph_test.go index 7eb2325c..1bb02ff9 100644 --- a/compiler/check/returns/callgraph_test.go +++ b/compiler/check/returns/callgraph_test.go @@ -5,34 +5,35 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/typ" ) -func TestPropagateParamHintsFromCallGraph_Empty(t *testing.T) { - PropagateParamHintsFromCallGraph(nil) - PropagateParamHintsFromCallGraph(map[cfg.SymbolID]*LocalFuncInfo{}) +func TestPropagateParameterEvidence_Empty(t *testing.T) { + PropagateParameterEvidence(nil) + PropagateParameterEvidence(map[cfg.SymbolID]*LocalFuncInfo{}) } -func TestPropagateParamHintsFromCallGraph_NilGraph(t *testing.T) { +func TestPropagateParameterEvidence_NilGraph(t *testing.T) { localFuncs := map[cfg.SymbolID]*LocalFuncInfo{ 1: {Sym: 1, Graph: nil}, } - PropagateParamHintsFromCallGraph(localFuncs) + PropagateParameterEvidence(localFuncs) } -func TestPropagateParamHintsFromCallGraph_SingleFuncNoArgs(t *testing.T) { +func TestPropagateParameterEvidence_SingleFuncNoArgs(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{Names: []string{"x"}}} graph := cfg.Build(fn) localFuncs := map[cfg.SymbolID]*LocalFuncInfo{ 1: {Sym: 1, Fn: fn, Graph: graph}, } - PropagateParamHintsFromCallGraph(localFuncs) + PropagateParameterEvidence(localFuncs) - if localFuncs[1].ParamHints != nil { - t.Error("expected nil ParamHints for function with no callers") + if localFuncs[1].ParameterEvidence != nil { + t.Error("expected nil ParameterEvidence for function with no callers") } } @@ -76,7 +77,7 @@ func TestBuildLocalCallGraph_SingleFunc(t *testing.T) { } } -func TestPropagateParamHintsFromCallGraph_LiteralArgTypes(t *testing.T) { +func TestPropagateParameterEvidence_LiteralArgTypes(t *testing.T) { // Test that literal arguments (number, string, bool, nil) are typed correctly tests := []struct { name string @@ -126,7 +127,7 @@ func TestPropagateParamHintsFromCallGraph_LiteralArgTypes(t *testing.T) { } } -func TestPropagateParamHintsFromCallGraph_UnknownArgSkipped(t *testing.T) { +func TestPropagateParameterEvidence_UnknownArgSkipped(t *testing.T) { fn := &ast.FunctionExpr{ParList: &ast.ParList{Names: []string{"x"}}} graph := cfg.Build(fn) @@ -136,9 +137,9 @@ func TestPropagateParamHintsFromCallGraph_UnknownArgSkipped(t *testing.T) { Graph: graph, } - // Unknown type args should be skipped (not create hints) - if info.ParamHints != nil { - t.Error("ParamHints should be nil initially") + // Unknown type args should be skipped (not create evidence) + if info.ParameterEvidence != nil { + t.Error("ParameterEvidence should be nil initially") } } @@ -153,57 +154,48 @@ func TestLocalFuncInfo_ZeroValue(t *testing.T) { if info.Graph != nil { t.Error("Graph should be nil") } - if info.ParamHints != nil { - t.Error("ParamHints should be nil") + if info.ParameterEvidence != nil { + t.Error("ParameterEvidence should be nil") } } -func TestLocalFuncInfo_ParamHintsExpansion(t *testing.T) { - // Test that ParamHints array expands correctly +func TestLocalFuncInfo_ParameterEvidenceExpansion(t *testing.T) { + // Test that ParameterEvidence array expands correctly info := &LocalFuncInfo{ - Sym: 1, - ParamHints: []typ.Type{typ.Number}, + Sym: 1, + ParameterEvidence: []typ.Type{typ.Number}, } // Verify initial state - if len(info.ParamHints) != 1 { - t.Fatalf("expected 1 hint, got %d", len(info.ParamHints)) + if len(info.ParameterEvidence) != 1 { + t.Fatalf("expected 1 evidence, got %d", len(info.ParameterEvidence)) } - if info.ParamHints[0] != typ.Number { - t.Errorf("expected Number, got %v", info.ParamHints[0]) + if info.ParameterEvidence[0] != typ.Number { + t.Errorf("expected Number, got %v", info.ParameterEvidence[0]) } - // Simulate expansion like PropagateParamHintsFromCallGraph does + // Simulate expansion like PropagateParameterEvidence does i := 2 - if i >= len(info.ParamHints) { + if i >= len(info.ParameterEvidence) { expanded := make([]typ.Type, i+1) - copy(expanded, info.ParamHints) - info.ParamHints = expanded + copy(expanded, info.ParameterEvidence) + info.ParameterEvidence = expanded } - if len(info.ParamHints) != 3 { - t.Fatalf("expected 3 hints after expansion, got %d", len(info.ParamHints)) + if len(info.ParameterEvidence) != 3 { + t.Fatalf("expected 3 evidence after expansion, got %d", len(info.ParameterEvidence)) } - if info.ParamHints[0] != typ.Number { - t.Error("original hint should be preserved") + if info.ParameterEvidence[0] != typ.Number { + t.Error("original evidence should be preserved") } - if info.ParamHints[1] != nil { + if info.ParameterEvidence[1] != nil { t.Error("gap should be nil") } - if info.ParamHints[2] != nil { + if info.ParameterEvidence[2] != nil { t.Error("new slot should be nil") } } -func TestMaxReturnSummaryIterations_Value(t *testing.T) { - if MaxReturnSummaryIterations < 1 { - t.Error("MaxReturnSummaryIterations should be positive") - } - if MaxReturnSummaryIterations > 100 { - t.Error("MaxReturnSummaryIterations seems too high") - } -} - func TestBuildLocalCallGraph_AddsCallbackFunctionEdges(t *testing.T) { stmts, err := parse.ParseString(` local function wrapper(cb: fun(): number): number @@ -240,13 +232,15 @@ func TestBuildLocalCallGraph_AddsCallbackFunctionEdges(t *testing.T) { return } symbolsByName[target.Name] = target.Symbol + fnGraph := cfg.Build(fn) localFuncs[target.Symbol] = &LocalFuncInfo{ Sym: target.Symbol, Fn: fn, DefScope: baseScope, - Graph: cfg.Build(fn), + Graph: fnGraph, ParentGraph: chunkGraph, DefPoint: p, + Evidence: api.FlowEvidence{Calls: callEvidenceForGraph(fnGraph)}, } }) }) @@ -264,7 +258,7 @@ func TestBuildLocalCallGraph_AddsCallbackFunctionEdges(t *testing.T) { } } -func TestPropagateParamHintsFromCallGraph_MethodRuntimeIndexing(t *testing.T) { +func TestPropagateParameterEvidence_MethodRuntimeIndexing(t *testing.T) { stmts, err := parse.ParseString(` local function callee(self, x) return x @@ -297,13 +291,15 @@ func TestPropagateParamHintsFromCallGraph_MethodRuntimeIndexing(t *testing.T) { return } symbolsByName[target.Name] = target.Symbol + fnGraph := cfg.Build(fn) localFuncs[target.Symbol] = &LocalFuncInfo{ Sym: target.Symbol, Fn: fn, DefScope: baseScope, - Graph: cfg.Build(fn), + Graph: fnGraph, ParentGraph: chunkGraph, DefPoint: p, + Evidence: api.FlowEvidence{Calls: callEvidenceForGraph(fnGraph)}, } }) }) @@ -314,17 +310,17 @@ func TestPropagateParamHintsFromCallGraph_MethodRuntimeIndexing(t *testing.T) { t.Fatalf("expected symbols for callee/caller, got callee=%d caller=%d", calleeSym, callerSym) } - PropagateParamHintsFromCallGraph(localFuncs) + PropagateParameterEvidence(localFuncs) - hints := localFuncs[calleeSym].ParamHints - if len(hints) < 2 { - t.Fatalf("expected at least 2 param hints for callee(self,x), got %d", len(hints)) + evidence := localFuncs[calleeSym].ParameterEvidence + if len(evidence) < 2 { + t.Fatalf("expected at least 2 parameter evidence for callee(self,x), got %d", len(evidence)) } - if !typ.TypeEquals(hints[1], typ.Number) { - t.Fatalf("expected hint for x at index 1 to be number, got %v", hints[1]) + if !typ.TypeEquals(evidence[1], typ.Number) { + t.Fatalf("expected evidence for x at index 1 to be number, got %v", evidence[1]) } - if hints[0] != nil { - t.Fatalf("expected no informative hint for receiver at index 0, got %v", hints[0]) + if evidence[0] != nil { + t.Fatalf("expected no informative evidence for receiver at index 0, got %v", evidence[0]) } } diff --git a/compiler/check/returns/callsite_test.go b/compiler/check/returns/callsite_test.go deleted file mode 100644 index ef233ec1..00000000 --- a/compiler/check/returns/callsite_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package returns - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/bind" - "github.com/wippyai/go-lua/compiler/cfg" - checkcallsite "github.com/wippyai/go-lua/compiler/check/callsite" - "github.com/wippyai/go-lua/compiler/parse" -) - -func TestCollectCalledNestedFieldAssignments(t *testing.T) { - t.Run("nil graph returns empty map", func(t *testing.T) { - result := CollectCalledNestedFieldAssignments(nil, nil, nil, nil) - if len(result) != 0 { - t.Error("expected empty result") - } - }) -} - -func TestCollectCalledNestedContainerMutatorAssignments(t *testing.T) { - t.Run("nil graph returns empty slice", func(t *testing.T) { - result := CollectCalledNestedContainerMutatorAssignments(nil, nil, nil, nil) - if len(result) != 0 { - t.Error("expected empty result") - } - }) -} - -func TestRuntimeArgAt(t *testing.T) { - t.Run("direct call positional mapping", func(t *testing.T) { - a := &ast.NumberExpr{Value: "1"} - b := &ast.NumberExpr{Value: "2"} - info := &cfg.CallInfo{Args: []ast.Expr{a, b}} - if got := checkcallsite.RuntimeArgAt(info, 0); got != a { - t.Fatal("expected first arg at index 0") - } - if got := checkcallsite.RuntimeArgAt(info, -1); got != b { - t.Fatal("expected last arg at index -1") - } - }) - - t.Run("method call runtime mapping", func(t *testing.T) { - recv := &ast.IdentExpr{Value: "self"} - a := &ast.NumberExpr{Value: "1"} - b := &ast.NumberExpr{Value: "2"} - info := &cfg.CallInfo{ - Method: "m", - Receiver: recv, - Args: []ast.Expr{a, b}, - } - if got := checkcallsite.RuntimeArgAt(info, 0); got != recv { - t.Fatal("expected receiver at index 0 for method call") - } - if got := checkcallsite.RuntimeArgAt(info, 1); got != a { - t.Fatal("expected first positional arg at runtime index 1") - } - if got := checkcallsite.RuntimeArgAt(info, -3); got != recv { - t.Fatal("expected receiver from negative runtime index") - } - }) -} - -func TestCalledSymbolsFromCall_PrefersTrackedCanonicalSymbol(t *testing.T) { - bindings := bind.NewBindingTable() - ident := &ast.IdentExpr{Value: "f"} - const ( - rawSym cfg.SymbolID = 101 - trackedSym cfg.SymbolID = 202 - ) - bindings.Bind(ident, trackedSym) - - info := &cfg.CallInfo{ - Callee: ident, - CalleeSymbol: rawSym, - } - - got := calledSymbolsFromCall(info, 0, nil, bindings, nil, func(sym cfg.SymbolID) bool { - return sym == trackedSym - }) - - if !got[trackedSym] { - t.Fatalf("expected tracked canonical symbol %d to be selected, got %v", trackedSym, got) - } - if got[rawSym] { - t.Fatalf("expected raw symbol %d to be excluded when tracked symbol is preferred, got %v", rawSym, got) - } -} - -func TestCalledSymbolsFromCall_UsesCalleeNameCandidatesWhenRawAndExprMissing(t *testing.T) { - bindings := bind.NewBindingTable() - ident := &ast.IdentExpr{Value: "f"} - const trackedSym cfg.SymbolID = 303 - bindings.Bind(ident, trackedSym) - bindings.SetName(trackedSym, "f") - - info := &cfg.CallInfo{ - Callee: nil, - CalleeSymbol: 0, - CalleeName: "f", - } - - got := calledSymbolsFromCall(info, 0, nil, bindings, nil, func(sym cfg.SymbolID) bool { - return sym == trackedSym - }) - if !got[trackedSym] { - t.Fatalf("expected tracked symbol %d via callee-name candidates, got %v", trackedSym, got) - } -} - -func TestCalledSymbolsFromCall_UsesAliasExpandedCandidates(t *testing.T) { - stmts, err := parse.ParseString(` - local function runner() - return 1 - end - local f = runner - local _ = f() - `, "test.lua") - if err != nil { - t.Fatalf("parse failed: %v", err) - } - graph := cfg.Build(&ast.FunctionExpr{Stmts: stmts}) - if graph == nil { - t.Fatal("expected graph") - } - bindings := graph.Bindings() - if bindings == nil { - t.Fatal("expected bindings") - } - runnerSym, ok := graph.SymbolAt(graph.Exit(), "runner") - if !ok || runnerSym == 0 { - t.Fatal("expected symbol for runner") - } - - var info *cfg.CallInfo - graph.EachCallSite(func(_ cfg.Point, ci *cfg.CallInfo) { - if ci == nil || ci.CalleeName != "f" { - return - } - info = ci - }) - if info == nil { - t.Fatal("expected f() call site") - } - - got := calledSymbolsFromCall(info, 0, graph, bindings, nil, func(sym cfg.SymbolID) bool { - return sym == runnerSym - }) - if !got[runnerSym] { - t.Fatalf("expected tracked runner symbol %d via alias-expanded candidates, got %v", runnerSym, got) - } -} diff --git a/compiler/check/returns/container_mutation_merge_test.go b/compiler/check/returns/container_mutation_merge_test.go deleted file mode 100644 index e2ff9af4..00000000 --- a/compiler/check/returns/container_mutation_merge_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package returns - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/constraint" - "github.com/wippyai/go-lua/types/typ" -) - -func TestMergeContainerMutationSlices_DedupAndSorted(t *testing.T) { - existing := []api.ContainerMutation{ - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "b"}}, - ValueType: typ.Number, - }, - } - next := []api.ContainerMutation{ - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "a"}}, - ValueType: typ.String, - }, - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "b"}}, - ValueType: typ.Integer, - }, - } - - got := MergeContainerMutationSlices(existing, next, func(prev *api.ContainerMutation, n api.ContainerMutation) api.ContainerMutation { - if prev != nil { - n.ValueType = typ.JoinPreferNonSoft(prev.ValueType, n.ValueType) - } - return n - }) - - if len(got) != 2 { - t.Fatalf("len(got) = %d, want 2", len(got)) - } - if k := api.ContainerMutationKey(got[0]); k != ".a" { - t.Fatalf("first key = %q, want .a", k) - } - if k := api.ContainerMutationKey(got[1]); k != ".b" { - t.Fatalf("second key = %q, want .b", k) - } - if !typ.TypeEquals(got[1].ValueType, typ.Number) { - t.Fatalf(".b merged type = %v, want number", got[1].ValueType) - } -} - -func TestMergeCapturedContainerMutationMaps_MergeBySymbol(t *testing.T) { - existing := map[cfg.SymbolID][]api.ContainerMutation{ - 1: { - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "x"}}, - ValueType: typ.String, - }, - }, - } - next := map[cfg.SymbolID][]api.ContainerMutation{ - 1: { - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "x"}}, - ValueType: typ.String, - }, - }, - 2: { - { - Segments: []constraint.Segment{{Kind: constraint.SegmentField, Name: "y"}}, - ValueType: typ.Boolean, - }, - }, - } - - got := MergeCapturedContainerMutationMaps(existing, next, nil) - if len(got) != 2 { - t.Fatalf("len(got) = %d, want 2 symbols", len(got)) - } - if len(got[1]) != 1 || len(got[2]) != 1 { - t.Fatalf("unexpected per-symbol merge sizes: sym1=%d sym2=%d", len(got[1]), len(got[2])) - } - if key := api.ContainerMutationKey(got[2][0]); key != ".y" { - t.Fatalf("sym2 key = %q, want .y", key) - } -} diff --git a/compiler/check/returns/doc.go b/compiler/check/returns/doc.go index c9312772..075195ea 100644 --- a/compiler/check/returns/doc.go +++ b/compiler/check/returns/doc.go @@ -1,8 +1,17 @@ -// Package returns provides interprocedural return type analysis. +// Package returns orchestrates local return inference. // -// This package implements the fixpoint iteration for return type inference -// across mutually recursive function groups. It computes strongly connected -// components in the call graph and processes them in dependency order. +// It does not own the lattice laws for individual fact slots. Those live in +// domain packages: +// - domain/paramevidence owns parameter evidence; +// - domain/returnsummary owns return vectors and function-return alignment; +// - domain/functionfact owns one api.FunctionFact at a time; +// - domain/interproc owns whole api.Facts products; +// - domain/value owns reusable structural value relations. +// +// This package owns local call graph traversal, SCC iteration, return overlays, +// and signature seeding. Call/effect replay lives in domain/calleffect, and the +// interprocedural store applies product joins and widening through +// domain/interproc. // // # SCC-Based Analysis // @@ -18,18 +27,17 @@ // For each function: // - Collect return expressions from all return statements // - Synthesize types for return expressions -// - Join multiple return types into a union -// - Apply widening for recursive convergence +// - Merge candidate return vectors through domain/returnsummary +// - Publish per-function facts through domain/functionfact // -// # Type Widening +// # Convergence // -// To ensure termination in recursive cases, types are widened: -// - After N iterations, recursive types are approximated -// - Widening preserves soundness while ensuring convergence +// Local SCC iteration runs to domain convergence. Cross-graph interprocedural +// convergence is handled by the store through domain/interproc. // // # Overlay System // -// [Overlay] provides a mutable view over stable return summaries: +// [Overlay] provides the mutable return-summary layer: // - Stable summaries from previous iterations // - Pending updates from current iteration // - Atomic commit when iteration converges @@ -41,6 +49,6 @@ // // # Signature Inference // -// [InferSignature] combines parameter hints and return types to produce +// Signature inference combines parameter evidence and return types to produce // complete function signatures for functions without annotations. package returns diff --git a/compiler/check/returns/equal_test.go b/compiler/check/returns/equal_test.go deleted file mode 100644 index 3ee8e8cf..00000000 --- a/compiler/check/returns/equal_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package returns - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/typ" -) - -func TestFactsEqual_Empty(t *testing.T) { - a := api.Facts{} - b := api.Facts{} - if !FactsEqual(a, b) { - t.Error("empty facts should be equal") - } -} - -func TestFactsEqual_ReturnSummaries(t *testing.T) { - a := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, - }, - } - b := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, - }, - } - if !FactsEqual(a, b) { - t.Error("facts with same return summaries should be equal") - } -} - -func TestFactsEqual_DifferentReturnSummaries(t *testing.T) { - a := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, - }, - } - b := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.Number}}, - }, - } - if FactsEqual(a, b) { - t.Error("facts with different return summaries should not be equal") - } -} - -func TestFactsEqual_IgnoresLegacyMirrorDrift(t *testing.T) { - sym := cfg.SymbolID(77) - fn := typ.Func().Returns(typ.String).Build() - - a := api.Facts{ - FunctionFacts: api.FunctionFacts{ - sym: { - Summary: []typ.Type{typ.String}, - Narrow: []typ.Type{typ.String}, - Func: fn, - }, - }, - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - FuncTypes: api.FuncTypes{ - sym: typ.Func().Returns(typ.Number).Build(), - }, - } - b := api.Facts{ - FunctionFacts: api.FunctionFacts{ - sym: { - Summary: []typ.Type{typ.String}, - Narrow: []typ.Type{typ.String}, - Func: fn, - }, - }, - } - - if !FactsEqual(a, b) { - t.Fatal("expected facts to be equal by canonical function facts") - } -} - -func TestFactsEqual_LegacyOnlyChannelsAreComparedCanonically(t *testing.T) { - sym := cfg.SymbolID(91) - - a := api.Facts{ - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.String}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.String}, - }, - FuncTypes: api.FuncTypes{ - sym: typ.Func().Returns(typ.String).Build(), - }, - } - b := api.Facts{ - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - FuncTypes: api.FuncTypes{ - sym: typ.Func().Returns(typ.Number).Build(), - }, - } - - if FactsEqual(a, b) { - t.Fatal("legacy-only function channels should participate in canonical equality") - } -} - -func TestReturnSummariesEqual_Empty(t *testing.T) { - if !symbolTypeVectorMapEqual(nil, nil) { - t.Error("nil summaries should be equal") - } -} - -func TestReturnSummariesEqual_DifferentLength(t *testing.T) { - a := api.ReturnSummaries{1: []typ.Type{typ.String}} - b := api.ReturnSummaries{} - if symbolTypeVectorMapEqual(a, b) { - t.Error("summaries with different lengths should not be equal") - } -} - -func TestParamHintsEqual_Empty(t *testing.T) { - if !symbolTypeVectorMapEqual(nil, nil) { - t.Error("nil param hints should be equal") - } -} - -func TestParamHintsEqual_Same(t *testing.T) { - a := api.ParamHints{1: []typ.Type{typ.String}} - b := api.ParamHints{1: []typ.Type{typ.String}} - if !symbolTypeVectorMapEqual(a, b) { - t.Error("same param hints should be equal") - } -} - -func TestFuncTypesEqual_Empty(t *testing.T) { - if !symbolTypeMapEqual(nil, nil) { - t.Error("nil func types should be equal") - } -} - -func TestFuncTypesEqual_Same(t *testing.T) { - fn := typ.Func().Returns(typ.String).Build() - a := api.FuncTypes{1: fn} - b := api.FuncTypes{1: fn} - if !symbolTypeMapEqual(a, b) { - t.Error("same func types should be equal") - } -} - -func TestLiteralSigsEqual_Empty(t *testing.T) { - if !LiteralSigsEqual(nil, nil) { - t.Error("nil literal sigs should be equal") - } -} - -func TestCapturedTypesEqual_Empty(t *testing.T) { - if !symbolTypeMapEqual(nil, nil) { - t.Error("nil captured types should be equal") - } -} - -func TestCapturedTypesEqual_Same(t *testing.T) { - a := api.CapturedTypes{cfg.SymbolID(1): typ.String} - b := api.CapturedTypes{cfg.SymbolID(1): typ.String} - if !symbolTypeMapEqual(a, b) { - t.Error("same captured types should be equal") - } -} - -func TestCapturedFieldAssignsEqual_Empty(t *testing.T) { - if !CapturedFieldAssignsEqual(nil, nil) { - t.Error("nil captured field assigns should be equal") - } -} - -func TestCapturedFieldAssignsEqual_DifferentCallee(t *testing.T) { - a := api.CapturedFieldAssigns{ - cfg.SymbolID(1): {cfg.SymbolID(2): {"foo": typ.String}}, - } - b := api.CapturedFieldAssigns{ - cfg.SymbolID(3): {cfg.SymbolID(2): {"foo": typ.String}}, - } - if CapturedFieldAssignsEqual(a, b) { - t.Error("different callee symbols should not be equal") - } -} - -func TestCapturedContainerMutationsEqual_Basic(t *testing.T) { - a := api.CapturedContainerMutations{ - cfg.SymbolID(1): { - cfg.SymbolID(2): { - {Segments: nil, ValueType: typ.Number}, - }, - }, - } - b := api.CapturedContainerMutations{ - cfg.SymbolID(1): { - cfg.SymbolID(2): { - {Segments: nil, ValueType: typ.Number}, - }, - }, - } - if !CapturedContainerMutationsEqual(a, b) { - t.Error("same container mutations should be equal") - } -} diff --git a/compiler/check/returns/function_facts.go b/compiler/check/returns/function_facts.go deleted file mode 100644 index a545c35f..00000000 --- a/compiler/check/returns/function_facts.go +++ /dev/null @@ -1,240 +0,0 @@ -package returns - -import ( - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/typ" -) - -func collectFunctionFactChannelSymbols( - summaries api.ReturnSummaries, - narrows api.NarrowReturnSummaries, - funcs api.FuncTypes, - facts api.FunctionFacts, -) []cfg.SymbolID { - symbols := make(map[cfg.SymbolID]bool, len(summaries)+len(narrows)+len(funcs)+len(facts)) - markFunctionFactSymbols(symbols, summaries) - markFunctionFactSymbols(symbols, narrows) - markFunctionFactSymbols(symbols, funcs) - markFunctionFactSymbols(symbols, facts) - return cfg.SortedSymbolIDs(symbols) -} - -func collectCanonicalFunctionFactSymbols(factSets ...api.FunctionFacts) []cfg.SymbolID { - total := 0 - for _, facts := range factSets { - total += len(facts) - } - symbols := make(map[cfg.SymbolID]bool, total) - for _, facts := range factSets { - markFunctionFactSymbols(symbols, facts) - } - return cfg.SortedSymbolIDs(symbols) -} - -func markFunctionFactSymbols[T any](dst map[cfg.SymbolID]bool, src map[cfg.SymbolID]T) { - for sym := range src { - dst[sym] = true - } -} - -func setOrDeleteReturnSummary(m *api.ReturnSummaries, sym cfg.SymbolID, summary []typ.Type) { - if len(summary) > 0 { - if *m == nil { - *m = make(api.ReturnSummaries) - } - (*m)[sym] = summary - return - } - if *m == nil { - return - } - delete(*m, sym) - if len(*m) == 0 { - *m = nil - } -} - -func setOrDeleteNarrowSummary(m *api.NarrowReturnSummaries, sym cfg.SymbolID, narrow []typ.Type) { - if len(narrow) > 0 { - if *m == nil { - *m = make(api.NarrowReturnSummaries) - } - (*m)[sym] = narrow - return - } - if *m == nil { - return - } - delete(*m, sym) - if len(*m) == 0 { - *m = nil - } -} - -func setOrDeleteFuncType(m *api.FuncTypes, sym cfg.SymbolID, fn typ.Type) { - if fn != nil { - if *m == nil { - *m = make(api.FuncTypes) - } - (*m)[sym] = fn - return - } - if *m == nil { - return - } - delete(*m, sym) - if len(*m) == 0 { - *m = nil - } -} - -func functionFactFromChannels(summary, narrow []typ.Type, fn typ.Type) api.FunctionFact { - return api.FunctionFact{ - Summary: NormalizeReturnVector(summary), - Narrow: NormalizeReturnVector(narrow), - Func: fn, - } -} - -func readFunctionFactFromFacts(facts *api.Facts, sym cfg.SymbolID) api.FunctionFact { - if facts == nil || sym == 0 { - return api.FunctionFact{} - } - if facts.FunctionFacts != nil { - ff, ok := facts.FunctionFacts[sym] - if ok { - canonical := functionFactFromChannels(ff.Summary, ff.Narrow, ff.Func) - if len(canonical.Summary) > 0 || len(canonical.Narrow) > 0 || canonical.Func != nil { - return canonical - } - } - } - return functionFactFromChannels(facts.ReturnSummaries[sym], facts.NarrowReturns[sym], facts.FuncTypes[sym]) -} - -func writeFunctionFactToFacts(facts *api.Facts, sym cfg.SymbolID, ff api.FunctionFact) { - if facts == nil || sym == 0 { - return - } - - ff = functionFactFromChannels(ff.Summary, ff.Narrow, ff.Func) - if len(ff.Summary) == 0 && len(ff.Narrow) == 0 && ff.Func == nil { - if facts.FunctionFacts != nil { - delete(facts.FunctionFacts, sym) - if len(facts.FunctionFacts) == 0 { - facts.FunctionFacts = nil - } - } - } else { - if facts.FunctionFacts == nil { - facts.FunctionFacts = make(api.FunctionFacts) - } - facts.FunctionFacts[sym] = ff - } - setOrDeleteReturnSummary(&facts.ReturnSummaries, sym, ff.Summary) - setOrDeleteNarrowSummary(&facts.NarrowReturns, sym, ff.Narrow) - setOrDeleteFuncType(&facts.FuncTypes, sym, ff.Func) -} - -func projectCanonicalFunctionFactChannel[T any]( - facts api.Facts, - project func(api.FunctionFact) (T, bool), -) map[cfg.SymbolID]T { - canonical := canonicalFunctionFacts(facts) - if len(canonical) == 0 { - return nil - } - out := make(map[cfg.SymbolID]T, len(canonical)) - for _, sym := range cfg.SortedSymbolIDs(canonical) { - ff := canonical[sym] - value, ok := project(ff) - if ok { - out[sym] = value - } - } - if len(out) == 0 { - return nil - } - return out -} - -// SummaryViewFromFacts returns the canonical summary channel view derived from -// FunctionFacts. -func SummaryViewFromFacts(facts api.Facts) api.ReturnSummaries { - return projectCanonicalFunctionFactChannel(facts, func(ff api.FunctionFact) ([]typ.Type, bool) { - if len(ff.Summary) == 0 { - return nil, false - } - return ff.Summary, true - }) -} - -// NarrowViewFromFacts returns the canonical narrow-summary channel view derived -// from FunctionFacts. -func NarrowViewFromFacts(facts api.Facts) api.NarrowReturnSummaries { - return projectCanonicalFunctionFactChannel(facts, func(ff api.FunctionFact) ([]typ.Type, bool) { - if len(ff.Narrow) == 0 { - return nil, false - } - return ff.Narrow, true - }) -} - -// FuncTypeViewFromFacts returns the canonical function-type channel view -// derived from FunctionFacts. -func FuncTypeViewFromFacts(facts api.Facts) api.FuncTypes { - return projectCanonicalFunctionFactChannel(facts, func(ff api.FunctionFact) (typ.Type, bool) { - if ff.Func == nil { - return nil, false - } - return ff.Func, true - }) -} - -// NormalizeFunctionFactChannels reconciles legacy function channels into -// canonical FunctionFacts, then rewrites mirrors from canonical values. -func NormalizeFunctionFactChannels(facts *api.Facts) { - if facts == nil { - return - } - symbols := collectFunctionFactChannelSymbols( - facts.ReturnSummaries, - facts.NarrowReturns, - facts.FuncTypes, - facts.FunctionFacts, - ) - if len(symbols) == 0 { - return - } - for _, sym := range symbols { - ff := readFunctionFactFromFacts(facts, sym) - writeFunctionFactToFacts(facts, sym, ff) - } -} - -func canonicalFunctionFacts(facts api.Facts) api.FunctionFacts { - symbols := collectFunctionFactChannelSymbols( - facts.ReturnSummaries, - facts.NarrowReturns, - facts.FuncTypes, - facts.FunctionFacts, - ) - if len(symbols) == 0 { - return nil - } - - out := make(api.FunctionFacts, len(symbols)) - factsCopy := facts - for _, sym := range symbols { - ff := readFunctionFactFromFacts(&factsCopy, sym) - if len(ff.Summary) == 0 && len(ff.Narrow) == 0 && ff.Func == nil { - continue - } - out[sym] = ff - } - if len(out) == 0 { - return nil - } - return out -} diff --git a/compiler/check/returns/join.go b/compiler/check/returns/join.go deleted file mode 100644 index 37396615..00000000 --- a/compiler/check/returns/join.go +++ /dev/null @@ -1,1000 +0,0 @@ -package returns - -import ( - "github.com/wippyai/go-lua/types/kind" - "github.com/wippyai/go-lua/types/narrow" - "github.com/wippyai/go-lua/types/subtype" - "github.com/wippyai/go-lua/types/typ" - typjoin "github.com/wippyai/go-lua/types/typ/join" - "github.com/wippyai/go-lua/types/typ/unwrap" -) - -// ReturnTypesEqual checks if two return vectors are structurally equal. -func ReturnTypesEqual(a, b []typ.Type) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if !typ.TypeEquals(a[i], b[i]) { - return false - } - } - return true -} - -// ReturnTypesAllNil reports whether all slots are explicit nil. -func ReturnTypesAllNil(rets []typ.Type) bool { - if len(rets) == 0 { - return false - } - for _, t := range rets { - if t == nil || t.Kind() != kind.Nil { - return false - } - } - return true -} - -// ReturnTypesRefine reports whether a refines b (element-wise subtype). -func ReturnTypesRefine(a, b []typ.Type) bool { - if len(a) == 0 { - return false - } - if len(b) == 0 { - return true - } - if len(a) != len(b) { - return false - } - for i := range a { - ai := a[i] - bi := b[i] - if ai == nil || bi == nil { - if ai == nil && bi == nil { - continue - } - return false - } - if !subtype.IsSubtype(ai, bi) { - return false - } - } - return true -} - -// ReturnTypesExtendRecord reports whether a extends b by adding record fields. -// This treats record field supersets as refinements for return summaries. -func ReturnTypesExtendRecord(a, b []typ.Type) bool { - if len(a) == 0 || len(b) == 0 { - return false - } - if len(a) != len(b) { - return false - } - for i := range a { - ar, ok := a[i].(*typ.Record) - if !ok { - return false - } - switch br := b[i].(type) { - case *typ.Record: - if !recordSuperset(ar, br) { - return false - } - case *typ.Union: - if !recordSupersetUnion(ar, br) { - return false - } - default: - return false - } - } - return true -} - -// ReturnTypesElideOptional reports whether a refines b by removing nil/optional parts. -func ReturnTypesElideOptional(a, b []typ.Type) bool { - if len(a) == 0 || len(b) == 0 { - return false - } - if len(a) != len(b) { - return false - } - for i := range a { - if !typeElidesOptional(a[i], b[i]) { - return false - } - } - return true -} - -// SelectPreferredReturnVector picks a canonical winner when one return vector -// is strictly preferable to the other without requiring a join. -// -// Preference order: -// 1. subtype refinement (with nil-only regression protection) -// 2. record extension -// 3. optional elision -// -// The nil-only guard prevents a refined-but-empty-looking update from -// regressing an already informative summary to just nil. -func SelectPreferredReturnVector(a, b []typ.Type) ([]typ.Type, bool) { - if ReturnTypesRepairNever(a, b) { - return a, true - } - if ReturnTypesRepairNever(b, a) { - return b, true - } - if ReturnTypesRefine(a, b) { - if ReturnTypesAllNil(a) && !ReturnTypesAllNil(b) { - return b, true - } - return a, true - } - if ReturnTypesRefine(b, a) { - if ReturnTypesAllNil(b) && !ReturnTypesAllNil(a) { - return a, true - } - return b, true - } - if ReturnTypesFillNilSlots(a, b) { - return a, true - } - if ReturnTypesFillNilSlots(b, a) { - return b, true - } - if ReturnTypesExtendRecord(a, b) || ReturnTypesElideOptional(a, b) { - return a, true - } - if ReturnTypesExtendRecord(b, a) || ReturnTypesElideOptional(b, a) { - return b, true - } - return nil, false -} - -// SelectRefiningReturnVector prefers candidate only when it is a directional -// refinement of baseline. It never prefers baseline over candidate. -// -// This is used in iterative channels where an older baseline may be an -// under-constrained artifact; in those cases we must not lock in baseline just -// because it happens to be a subtype of the newer estimate. -func SelectRefiningReturnVector(candidate, baseline []typ.Type) ([]typ.Type, bool) { - if ReturnTypesRefine(candidate, baseline) { - if ReturnTypesAllNil(candidate) && !ReturnTypesAllNil(baseline) { - return baseline, true - } - return candidate, true - } - if ReturnTypesFillNilSlots(candidate, baseline) { - return candidate, true - } - if ReturnTypesExtendRecord(candidate, baseline) || ReturnTypesElideOptional(candidate, baseline) { - return candidate, true - } - return nil, false -} - -// ReturnTypesFillNilSlots reports whether a improves b by replacing nil-only -// slots with concrete return evidence while staying compatible on other slots. -func ReturnTypesFillNilSlots(a, b []typ.Type) bool { - if len(a) == 0 || len(b) == 0 || len(a) != len(b) { - return false - } - strict := false - for i := range a { - ai := a[i] - bi := b[i] - if ai == nil || bi == nil { - return false - } - if unwrap.IsNilType(bi) && !unwrap.IsNilType(ai) { - strict = true - continue - } - if typ.TypeEquals(ai, bi) { - continue - } - if subtype.IsSubtype(ai, bi) || TypeExtendsRecord(ai, bi) || typeElidesOptional(ai, bi) { - continue - } - return false - } - return strict -} - -// ReturnTypesRepairNever reports whether candidate is a runtime-possible repair -// of baseline by replacing nested never artifacts while otherwise widening -// compatibly. This lets post-flow summaries correct pre-flow bottoms such as -// `{data?: never}` -> `{data?: unknown}`. -func ReturnTypesRepairNever(candidate, baseline []typ.Type) bool { - if len(candidate) == 0 || len(baseline) == 0 || len(candidate) != len(baseline) { - return false - } - strict := false - for i := range candidate { - if candidate[i] == nil || baseline[i] == nil { - return false - } - if typ.TypeEquals(candidate[i], baseline[i]) { - continue - } - if !typeRepairsNever(candidate[i], baseline[i]) { - return false - } - strict = true - } - return strict -} - -// TypeExtendsRecord reports whether type a extends type b by adding record fields. -// This treats record field supersets as refinements when b is a record or union of records. -func TypeExtendsRecord(a, b typ.Type) bool { - if a == nil || b == nil { - return false - } - ar, ok := a.(*typ.Record) - if !ok { - return false - } - switch br := b.(type) { - case *typ.Record: - return recordSuperset(ar, br) - case *typ.Union: - return recordSupersetUnion(ar, br) - default: - return false - } -} - -func typeRepairsNever(candidate, baseline typ.Type) bool { - if candidate == nil || baseline == nil { - return false - } - if !typeContainsNever(baseline) || typeContainsNever(candidate) { - return false - } - ok, strict := typeNeverRepairRelation(candidate, baseline) - return ok && strict -} - -func typeNeverRepairRelation(candidate, baseline typ.Type) (bool, bool) { - if candidate == nil || baseline == nil { - return false, false - } - if typ.TypeEquals(candidate, baseline) { - return true, false - } - - candidate = unwrap.Alias(candidate) - baseline = unwrap.Alias(baseline) - if candidate == nil || baseline == nil { - return false, false - } - - if typ.IsNever(baseline) { - return !typ.IsNever(candidate), !typ.IsNever(candidate) - } - if !typeContainsNever(baseline) { - return false, false - } - - switch b := baseline.(type) { - case *typ.Optional: - c, ok := candidate.(*typ.Optional) - if !ok { - return false, false - } - return typeNeverRepairRelation(c.Inner, b.Inner) - case *typ.Union: - c, ok := candidate.(*typ.Union) - if !ok || len(c.Members) != len(b.Members) { - return false, false - } - used := make([]bool, len(c.Members)) - strict := false - for _, bm := range b.Members { - matched := false - for j, cm := range c.Members { - if used[j] || !typ.TypeEquals(cm, bm) { - continue - } - used[j] = true - matched = true - break - } - if matched { - continue - } - for j, cm := range c.Members { - if used[j] { - continue - } - ok, repaired := typeNeverRepairRelation(cm, bm) - if !ok { - continue - } - used[j] = true - matched = true - if repaired { - strict = true - } - break - } - if !matched { - return false, false - } - } - return true, strict - case *typ.Record: - c, ok := candidate.(*typ.Record) - if !ok || c.Open != b.Open || c.HasMapComponent() != b.HasMapComponent() || len(c.Fields) != len(b.Fields) { - return false, false - } - strict := false - for _, bf := range b.Fields { - cf := c.GetField(bf.Name) - if cf == nil || cf.Optional != bf.Optional || cf.Readonly != bf.Readonly { - return false, false - } - ok, repaired := typeNeverRepairRelation(cf.Type, bf.Type) - if !ok { - return false, false - } - if repaired { - strict = true - } - } - if b.HasMapComponent() { - ok, repaired := typeNeverRepairRelation(c.MapKey, b.MapKey) - if !ok { - return false, false - } - if repaired { - strict = true - } - ok, repaired = typeNeverRepairRelation(c.MapValue, b.MapValue) - if !ok { - return false, false - } - if repaired { - strict = true - } - } - if b.Metatable != nil || c.Metatable != nil { - if b.Metatable == nil || c.Metatable == nil { - return false, false - } - ok, repaired := typeNeverRepairRelation(c.Metatable, b.Metatable) - if !ok { - return false, false - } - if repaired { - strict = true - } - } - return true, strict - case *typ.Array: - c, ok := candidate.(*typ.Array) - if !ok { - return false, false - } - return typeNeverRepairRelation(c.Element, b.Element) - case *typ.Map: - c, ok := candidate.(*typ.Map) - if !ok { - return false, false - } - keyOK, keyStrict := typeNeverRepairRelation(c.Key, b.Key) - if !keyOK { - return false, false - } - valOK, valStrict := typeNeverRepairRelation(c.Value, b.Value) - if !valOK { - return false, false - } - return true, keyStrict || valStrict - case *typ.Tuple: - c, ok := candidate.(*typ.Tuple) - if !ok || len(c.Elements) != len(b.Elements) { - return false, false - } - strict := false - for i := range b.Elements { - ok, repaired := typeNeverRepairRelation(c.Elements[i], b.Elements[i]) - if !ok { - return false, false - } - if repaired { - strict = true - } - } - return true, strict - case *typ.Function: - c, ok := candidate.(*typ.Function) - if !ok || !sameFunctionShapeForFactMerge(c, b) || len(c.Returns) != len(b.Returns) { - return false, false - } - for i := range b.Params { - if c.Params[i].Name != b.Params[i].Name || - c.Params[i].Optional != b.Params[i].Optional || - !typ.TypeEquals(c.Params[i].Type, b.Params[i].Type) { - return false, false - } - } - switch { - case (c.Variadic == nil) != (b.Variadic == nil): - return false, false - case c.Variadic != nil && !typ.TypeEquals(c.Variadic, b.Variadic): - return false, false - } - strict := false - for i := range b.Returns { - ok, repaired := typeNeverRepairRelation(c.Returns[i], b.Returns[i]) - if !ok { - return false, false - } - if repaired { - strict = true - } - } - return true, strict - default: - return false, false - } -} - -func typeContainsNever(t typ.Type) bool { - seen := make(map[typ.Type]bool) - return typeContainsNeverMemo(t, seen) -} - -func typeContainsNeverMemo(t typ.Type, seen map[typ.Type]bool) bool { - if t == nil { - return false - } - if seen[t] { - return false - } - seen[t] = true - t = unwrap.Alias(t) - if t == nil { - return false - } - if typ.IsNever(t) { - return true - } - return typ.Visit(t, typ.Visitor[bool]{ - Optional: func(o *typ.Optional) bool { - return typeContainsNeverMemo(o.Inner, seen) - }, - Union: func(u *typ.Union) bool { - for _, m := range u.Members { - if typeContainsNeverMemo(m, seen) { - return true - } - } - return false - }, - Intersection: func(in *typ.Intersection) bool { - for _, m := range in.Members { - if typeContainsNeverMemo(m, seen) { - return true - } - } - return false - }, - Tuple: func(tup *typ.Tuple) bool { - for _, e := range tup.Elements { - if typeContainsNeverMemo(e, seen) { - return true - } - } - return false - }, - Array: func(a *typ.Array) bool { - return typeContainsNeverMemo(a.Element, seen) - }, - Map: func(m *typ.Map) bool { - return typeContainsNeverMemo(m.Key, seen) || typeContainsNeverMemo(m.Value, seen) - }, - Record: func(r *typ.Record) bool { - for _, f := range r.Fields { - if typeContainsNeverMemo(f.Type, seen) { - return true - } - } - if r.HasMapComponent() { - return typeContainsNeverMemo(r.MapKey, seen) || typeContainsNeverMemo(r.MapValue, seen) - } - return false - }, - Function: func(fn *typ.Function) bool { - for _, p := range fn.Params { - if typeContainsNeverMemo(p.Type, seen) { - return true - } - } - if fn.Variadic != nil && typeContainsNeverMemo(fn.Variadic, seen) { - return true - } - for _, ret := range fn.Returns { - if typeContainsNeverMemo(ret, seen) { - return true - } - } - return false - }, - Default: func(typ.Type) bool { - return false - }, - }) -} - -func typeElidesOptional(a, b typ.Type) bool { - if a == nil || b == nil { - return false - } - nonNil := narrow.RemoveNil(b) - if nonNil == nil || typ.TypeEquals(nonNil, b) { - return false - } - return subtype.IsSubtype(a, nonNil) -} - -func recordSuperset(newRec, oldRec *typ.Record) bool { - if newRec == nil || oldRec == nil { - return false - } - if oldRec.Metatable != nil { - if newRec.Metatable == nil || !subtype.IsSubtype(newRec.Metatable, oldRec.Metatable) { - return false - } - } - if oldRec.HasMapComponent() { - if !newRec.HasMapComponent() { - return false - } - if !subtype.IsSubtype(newRec.MapKey, oldRec.MapKey) || !subtype.IsSubtype(newRec.MapValue, oldRec.MapValue) { - return false - } - } - oldFields := make(map[string]typ.Field, len(oldRec.Fields)) - for _, f := range oldRec.Fields { - oldFields[f.Name] = f - } - for _, nf := range newRec.Fields { - if of, ok := oldFields[nf.Name]; ok { - if of.Optional && !nf.Optional { - // ok: stronger requirement - } else if !of.Optional && nf.Optional { - return false - } - if of.Readonly && !nf.Readonly { - return false - } - if of.Type != nil { - if isOpenTopRecordType(nf.Type) && isStructuredTableShape(of.Type) { - // Open-top table placeholders must not dominate structured - // collection/record fields when selecting preferred summaries. - return false - } - if nf.Type == nil || !subtype.IsSubtype(nf.Type, of.Type) { - return false - } - } - delete(oldFields, nf.Name) - } - } - return len(oldFields) == 0 -} - -func recordSupersetUnion(newRec *typ.Record, oldUnion *typ.Union) bool { - if newRec == nil || oldUnion == nil { - return false - } - if len(oldUnion.Members) == 0 { - return false - } - for _, member := range oldUnion.Members { - oldRec, ok := member.(*typ.Record) - if !ok { - return false - } - if !recordSuperset(newRec, oldRec) { - return false - } - } - return true -} - -// NormalizeReturnVector replaces nil slots with explicit nil types. -func NormalizeReturnVector(rets []typ.Type) []typ.Type { - if len(rets) == 0 { - return nil - } - out := make([]typ.Type, len(rets)) - for i, t := range rets { - if t == nil { - out[i] = typ.Nil - } else { - out[i] = t - } - } - return out -} - -func normalizeAndPruneReturnVector(rets []typ.Type) []typ.Type { - out := NormalizeReturnVector(rets) - if len(out) == 0 { - return nil - } - for i, ret := range out { - out[i] = typ.PruneSoftUnionMembers(ret) - } - return out -} - -// MergeReturnSummary applies the canonical return-summary merge policy shared by -// all iterative channels (SCC return inference, interproc fact widening, and -// summary-to-signature alignment). Centralizing this logic prevents divergent -// local merge behavior across phases. -func MergeReturnSummary(existing, candidate []typ.Type) []typ.Type { - existing = normalizeAndPruneReturnVector(existing) - candidate = normalizeAndPruneReturnVector(candidate) - if len(existing) == 0 { - return candidate - } - if len(candidate) == 0 { - return existing - } - // Canonical promotion: open-top record placeholders should not dominate - // concrete structured return evidence (array/map/record with fields). - if replaced, ok := replaceOpenTopWithStructured(existing, candidate); ok { - existing = normalizeAndPruneReturnVector(replaced) - } - if ReturnTypesRepairNever(existing, candidate) { - return existing - } - if ReturnTypesRepairNever(candidate, existing) { - return candidate - } - - // Higher-order summaries are merged monotonically for fixpoint stability. - if shouldUseMonotoneReturnJoin(existing, candidate) { - return normalizeAndPruneReturnVector(joinReturnVectorsMonotone(existing, candidate)) - } - - if preferred, ok := SelectPreferredReturnVector(existing, candidate); ok { - return normalizeAndPruneReturnVector(preferred) - } - - return normalizeAndPruneReturnVector(typjoin.ReturnVectors(existing, candidate)) -} - -// MergeFunctionFactType merges function-type facts through one canonical policy. -// This ensures all channels agree on when to preserve shape and how to merge -// returns, avoiding directional one-off behavior in individual phases. -func MergeFunctionFactType(existing, candidate typ.Type) typ.Type { - if existing == nil { - return candidate - } - if candidate == nil { - return existing - } - - existingFn := unwrap.Function(existing) - candidateFn := unwrap.Function(candidate) - if mergedFromVariants, ok := mergeFunctionFactVariants(existing, candidate); ok { - return mergedFromVariants - } - if existingFn != nil && candidateFn != nil { - if sameFunctionShapeForFactMerge(existingFn, candidateFn) { - return mergeFunctionFactsByShape(existingFn, candidateFn) - } - } - - if subtype.IsSubtype(existing, candidate) { - return candidate - } - if subtype.IsSubtype(candidate, existing) { - return existing - } - return typ.JoinPreferNonSoft(existing, candidate) -} - -func mergeFunctionFactVariants(existing, candidate typ.Type) (typ.Type, bool) { - existingFns := functionVariantsForFactMerge(existing) - candidateFns := functionVariantsForFactMerge(candidate) - if len(existingFns) == 0 || len(candidateFns) == 0 { - return nil, false - } - all := make([]*typ.Function, 0, len(existingFns)+len(candidateFns)) - all = append(all, existingFns...) - all = append(all, candidateFns...) - for i := 1; i < len(all); i++ { - if !sameFunctionShapeForFactMerge(all[0], all[i]) { - return nil, false - } - } - merged := all[0] - for i := 1; i < len(all); i++ { - next, _ := mergeFunctionFactsByShape(merged, all[i]).(*typ.Function) - if next == nil { - return nil, false - } - merged = next - } - return merged, true -} - -func functionVariantsForFactMerge(t typ.Type) []*typ.Function { - if t == nil { - return nil - } - switch v := unwrap.Alias(t).(type) { - case *typ.Optional: - // Optional function values include nil. Do not collapse them to a plain - // function fact or we lose optionality in merged facts. - return nil - case *typ.Function: - return []*typ.Function{v} - case *typ.Union: - if len(v.Members) == 0 { - return nil - } - var out []*typ.Function - for _, m := range v.Members { - fn := unwrap.Function(m) - if fn == nil { - // Only collapse union variants when the union is function-only. - // Mixed unions (for example function|nil) must stay untouched. - return nil - } - out = append(out, fn) - } - return out - } - if fn := unwrap.Function(t); fn != nil { - return []*typ.Function{fn} - } - return nil -} - -func sameFunctionShapeForFactMerge(a, b *typ.Function) bool { - if a == nil || b == nil { - return false - } - if len(a.TypeParams) != len(b.TypeParams) { - return false - } - if !typeParamsEqual(a.TypeParams, b.TypeParams) { - return false - } - if len(a.Params) != len(b.Params) { - return false - } - // Param type precision and optionality may differ across iterations. - // Treat those as mergeable slots and reconcile in mergeFunctionFactsByShape. - return true -} - -func mergeFunctionFactsByShape(existing, candidate *typ.Function) typ.Type { - if existing == nil { - return candidate - } - if candidate == nil { - return existing - } - - builder := typ.Func() - for _, tp := range existing.TypeParams { - builder = builder.TypeParam(tp.Name, tp.Constraint) - } - - for i, p := range existing.Params { - paramType := mergeFunctionParamFactType(p.Type, candidate.Params[i].Type) - name := p.Name - if name == "" { - name = candidate.Params[i].Name - } - optional := p.Optional || candidate.Params[i].Optional - if optional { - builder = builder.OptParam(name, paramType) - } else { - builder = builder.Param(name, paramType) - } - } - - if existing.Variadic != nil || candidate.Variadic != nil { - builder = builder.Variadic(mergeFunctionParamFactType(existing.Variadic, candidate.Variadic)) - } - - if mergedReturns := MergeReturnSummary(existing.Returns, candidate.Returns); len(mergedReturns) > 0 { - builder = builder.Returns(mergedReturns...) - } - - effects := existing.Effects - if effects == nil { - effects = candidate.Effects - } - if effects != nil { - builder = builder.Effects(effects) - } - spec := existing.Spec - if spec == nil { - spec = candidate.Spec - } - if spec != nil { - builder = builder.Spec(spec) - } - refinement := existing.Refinement - if refinement == nil { - refinement = candidate.Refinement - } - if refinement != nil { - builder = builder.WithRefinement(refinement) - } - - return builder.Build() -} - -func mergeFunctionParamFactType(existing, candidate typ.Type) typ.Type { - if existing == nil { - return candidate - } - if candidate == nil { - return existing - } - - existing = typ.PruneSoftUnionMembers(existing) - candidate = typ.PruneSoftUnionMembers(candidate) - if preferred, ok := preferStructuredRecordParam(existing, candidate); ok { - return preferred - } - if typ.IsUnknown(existing) { - return candidate - } - if typ.IsUnknown(candidate) { - return existing - } - if typ.IsAny(existing) && !typ.IsAny(candidate) { - return candidate - } - if typ.IsAny(candidate) && !typ.IsAny(existing) { - return existing - } - if typ.TypeEquals(existing, candidate) { - return existing - } - if subtype.IsSubtype(existing, candidate) && !subtype.IsSubtype(candidate, existing) { - return candidate - } - if subtype.IsSubtype(candidate, existing) && !subtype.IsSubtype(existing, candidate) { - return existing - } - return typ.JoinPreferNonSoft(existing, candidate) -} - -func preferStructuredRecordParam(existing, candidate typ.Type) (typ.Type, bool) { - existingRec, okExisting := unwrap.Alias(existing).(*typ.Record) - candidateRec, okCandidate := unwrap.Alias(candidate).(*typ.Record) - if !okExisting || !okCandidate { - return nil, false - } - - existingOpenTop := existingRec.Open && len(existingRec.Fields) == 0 && !existingRec.HasMapComponent() - candidateOpenTop := candidateRec.Open && len(candidateRec.Fields) == 0 && !candidateRec.HasMapComponent() - if existingOpenTop == candidateOpenTop { - return nil, false - } - if existingOpenTop { - if candidateRec.HasMapComponent() || len(candidateRec.Fields) > 0 { - return candidate, true - } - } - if candidateOpenTop { - if existingRec.HasMapComponent() || len(existingRec.Fields) > 0 { - return existing, true - } - } - return nil, false -} - -// AlignFunctionTypeWithSummary applies the canonical return-summary winner to a -// function type. It updates function returns only when the summary is the -// preferred vector under SelectPreferredReturnVector (or when function returns -// are missing). Returns the aligned function and whether it changed. -func AlignFunctionTypeWithSummary(fn *typ.Function, summary []typ.Type) (*typ.Function, bool) { - if fn == nil { - return nil, false - } - - normalizedSummary := normalizeAndPruneReturnVector(summary) - if len(normalizedSummary) == 0 { - return fn, false - } - - current := normalizeAndPruneReturnVector(fn.Returns) - if len(current) == 0 { - aligned := typjoin.WithReturns(fn, normalizedSummary) - return aligned, aligned != nil - } - // Keep one canonical merge path for summary-to-signature alignment. - // MergeReturnSummary already handles structured promotion and refinement - // policy, so AlignFunctionTypeWithSummary should not duplicate local logic. - merged := MergeReturnSummary(current, normalizedSummary) - if ReturnTypesEqual(current, merged) { - return fn, false - } - - aligned := typjoin.WithReturns(fn, merged) - if aligned == nil { - return fn, false - } - return aligned, true -} - -func replaceOpenTopWithStructured(current, summary []typ.Type) ([]typ.Type, bool) { - if len(current) == 0 || len(summary) == 0 || len(current) != len(summary) { - return nil, false - } - out := append([]typ.Type(nil), current...) - changed := false - for i := range out { - if !isOpenTopRecordType(out[i]) { - continue - } - if !isStructuredTableShape(summary[i]) { - continue - } - out[i] = summary[i] - changed = true - } - if !changed { - return nil, false - } - return out, true -} - -// WithSummaryOrUnknown applies summary-derived returns to a function signature. -// If summary is empty and the signature has no returns, a single unknown return -// is attached to preserve call-site conservatism. -func WithSummaryOrUnknown(fn *typ.Function, summary []typ.Type) *typ.Function { - if fn == nil { - return nil - } - if len(summary) == 0 { - if len(fn.Returns) > 0 { - return fn - } - return typjoin.WithReturns(fn, []typ.Type{typ.Unknown}) - } - if aligned, changed := AlignFunctionTypeWithSummary(fn, summary); changed { - return aligned - } - if len(fn.Returns) > 0 { - return fn - } - return typjoin.WithReturns(fn, normalizeAndPruneReturnVector(summary)) -} - -func isOpenTopRecordType(t typ.Type) bool { - rec, ok := unwrap.Alias(t).(*typ.Record) - if !ok || rec == nil { - return false - } - return rec.Open && len(rec.Fields) == 0 && !rec.HasMapComponent() -} - -func isStructuredTableShape(t typ.Type) bool { - switch v := unwrap.Alias(t).(type) { - case *typ.Array: - return true - case *typ.Map: - return true - case *typ.Record: - return v.HasMapComponent() || len(v.Fields) > 0 - default: - return false - } -} diff --git a/compiler/check/returns/kernel.go b/compiler/check/returns/kernel.go deleted file mode 100644 index 92363b84..00000000 --- a/compiler/check/returns/kernel.go +++ /dev/null @@ -1,137 +0,0 @@ -package returns - -import ( - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/typ" - "github.com/wippyai/go-lua/types/typ/unwrap" -) - -// ReconcileFunctionFactInput captures all channels that can influence a single -// local-function fact slot during one update step. -type ReconcileFunctionFactInput struct { - ExistingSummary []typ.Type - ExistingNarrow []typ.Type - ExistingFunc typ.Type - - CandidateSummary []typ.Type - CandidateNarrow []typ.Type - CandidateFunc typ.Type -} - -// ReconcileFunctionFactOutput is the canonical reconciled state for one symbol. -type ReconcileFunctionFactOutput struct { - Summary []typ.Type - Narrow []typ.Type - Func typ.Type -} - -// FunctionFactCandidate captures incoming candidate data for one symbol's -// function-related fact channels. -type FunctionFactCandidate struct { - Summary []typ.Type - Narrow []typ.Type - Func typ.Type -} - -// ReconcileFunctionFact centralizes reconciliation of return summary, narrow -// return summary, and function type for one symbol. -// -// This is the only policy entrypoint for function-fact channel convergence. -func ReconcileFunctionFact(in ReconcileFunctionFactInput) ReconcileFunctionFactOutput { - out := ReconcileFunctionFactOutput{ - Summary: NormalizeReturnVector(in.ExistingSummary), - Narrow: NormalizeReturnVector(in.ExistingNarrow), - Func: in.ExistingFunc, - } - - if len(in.CandidateSummary) > 0 { - out.Summary = MergeReturnSummary(out.Summary, in.CandidateSummary) - } - if len(in.CandidateNarrow) > 0 { - out.Narrow = MergeReturnSummary(out.Narrow, in.CandidateNarrow) - } - if in.CandidateFunc != nil { - out.Func = MergeFunctionFactType(out.Func, in.CandidateFunc) - } - - // Keep summary and narrow channels mutually refining when post-flow narrow - // provides first-order information. MergeReturnSummary is the canonical - // policy and already encodes directional refinement preference. - if len(out.Narrow) > 0 { - if len(out.Summary) == 0 { - out.Summary = NormalizeReturnVector(out.Narrow) - } else { - out.Summary = MergeReturnSummary(out.Summary, out.Narrow) - } - } - - if fn := unwrap.Function(out.Func); fn != nil { - alignedSummary := out.Summary - if len(out.Narrow) > 0 { - // Canonical tie-breaker: function facts track post-flow behavior. - // Narrow summaries are produced from solved flow and are authoritative - // for call-site typing in the current iteration. - alignedSummary = out.Narrow - } - if len(alignedSummary) > 0 { - if aligned, changed := AlignFunctionTypeWithSummary(fn, alignedSummary); changed { - out.Func = aligned - fn = aligned - } - } - if len(out.Summary) == 0 && fn != nil && len(fn.Returns) > 0 { - out.Summary = NormalizeReturnVector(fn.Returns) - } - } - - return out -} - -// MergeFunctionFactIntoFacts reconciles and writes function-related facts for -// one symbol into a facts bundle using canonical kernel policy. -func MergeFunctionFactIntoFacts(facts *api.Facts, sym cfg.SymbolID, candidate FunctionFactCandidate) { - if facts == nil || sym == 0 { - return - } - NormalizeFunctionFactChannels(facts) - mergeFunctionFactIntoNormalizedFacts(facts, sym, candidate) -} - -func mergeFunctionFactIntoNormalizedFacts(facts *api.Facts, sym cfg.SymbolID, candidate FunctionFactCandidate) { - existing := readFunctionFactFromFacts(facts, sym) - reconciled := ReconcileFunctionFact(ReconcileFunctionFactInput{ - ExistingSummary: existing.Summary, - ExistingNarrow: existing.Narrow, - ExistingFunc: existing.Func, - CandidateSummary: candidate.Summary, - CandidateNarrow: candidate.Narrow, - CandidateFunc: candidate.Func, - }) - writeFunctionFactToFacts(facts, sym, api.FunctionFact{ - Summary: reconciled.Summary, - Narrow: reconciled.Narrow, - Func: reconciled.Func, - }) -} - -// MergeFunctionFactsIntoFacts merges full function-fact channel maps into facts -// via the canonical single-symbol reconciliation path. -func MergeFunctionFactsIntoFacts( - facts *api.Facts, - summaries api.ReturnSummaries, - narrows api.NarrowReturnSummaries, - funcs api.FuncTypes, -) { - if facts == nil { - return - } - NormalizeFunctionFactChannels(facts) - for _, sym := range collectFunctionFactChannelSymbols(summaries, narrows, funcs, nil) { - mergeFunctionFactIntoNormalizedFacts(facts, sym, FunctionFactCandidate{ - Summary: summaries[sym], - Narrow: narrows[sym], - Func: funcs[sym], - }) - } -} diff --git a/compiler/check/returns/kernel_test.go b/compiler/check/returns/kernel_test.go deleted file mode 100644 index 665f285f..00000000 --- a/compiler/check/returns/kernel_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package returns - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/typ" -) - -func TestMergeFunctionFactIntoFacts_InitialWrite(t *testing.T) { - facts := &api.Facts{} - sym := cfg.SymbolID(11) - fn := typ.Func().Returns(typ.String).Build() - - MergeFunctionFactIntoFacts(facts, sym, FunctionFactCandidate{ - Summary: []typ.Type{typ.String}, - Narrow: []typ.Type{typ.String}, - Func: fn, - }) - - if got := facts.ReturnSummaries[sym]; !ReturnTypesEqual(got, []typ.Type{typ.String}) { - t.Fatalf("summary mismatch: got %v", got) - } - if got := facts.NarrowReturns[sym]; !ReturnTypesEqual(got, []typ.Type{typ.String}) { - t.Fatalf("narrow mismatch: got %v", got) - } - if got := facts.FuncTypes[sym]; !typ.TypeEquals(got, fn) { - t.Fatalf("func mismatch: got %v", got) - } -} - -func TestMergeFunctionFactIntoFacts_MatchesKernelReconcile(t *testing.T) { - sym := cfg.SymbolID(17) - existingFn := typ.Func().Returns(typ.Number).Build() - candidateFn := typ.Func().Returns(typ.String).Build() - facts := &api.Facts{ - FunctionFacts: api.FunctionFacts{ - sym: { - Summary: []typ.Type{typ.Number}, - Narrow: []typ.Type{typ.Number}, - Func: existingFn, - }, - }, - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - FuncTypes: api.FuncTypes{ - sym: existingFn, - }, - } - candidate := FunctionFactCandidate{ - Summary: []typ.Type{typ.String}, - Narrow: []typ.Type{typ.String}, - Func: candidateFn, - } - existing := readFunctionFactFromFacts(facts, sym) - expected := ReconcileFunctionFact(ReconcileFunctionFactInput{ - ExistingSummary: existing.Summary, - ExistingNarrow: existing.Narrow, - ExistingFunc: existing.Func, - CandidateSummary: candidate.Summary, - CandidateNarrow: candidate.Narrow, - CandidateFunc: candidate.Func, - }) - - MergeFunctionFactIntoFacts(facts, sym, candidate) - - if got := facts.ReturnSummaries[sym]; !ReturnTypesEqual(got, expected.Summary) { - t.Fatalf("summary mismatch: got %v want %v", got, expected.Summary) - } - if got := facts.NarrowReturns[sym]; !ReturnTypesEqual(got, expected.Narrow) { - t.Fatalf("narrow mismatch: got %v want %v", got, expected.Narrow) - } - if got := facts.FuncTypes[sym]; !typ.TypeEquals(got, expected.Func) { - t.Fatalf("func mismatch: got %v want %v", got, expected.Func) - } -} - -func TestMergeFunctionFactsIntoFacts_BatchMerge(t *testing.T) { - symSummary := cfg.SymbolID(21) - symNarrow := cfg.SymbolID(22) - symFunc := cfg.SymbolID(23) - facts := &api.Facts{} - funcType := typ.Func().Returns(typ.Boolean).Build() - - MergeFunctionFactsIntoFacts( - facts, - api.ReturnSummaries{ - symSummary: []typ.Type{typ.String}, - }, - api.NarrowReturnSummaries{ - symNarrow: []typ.Type{typ.Number}, - }, - api.FuncTypes{ - symFunc: funcType, - }, - ) - - if got := facts.ReturnSummaries[symSummary]; !ReturnTypesEqual(got, []typ.Type{typ.String}) { - t.Fatalf("summary mismatch: got %v", got) - } - if got := facts.NarrowReturns[symNarrow]; !ReturnTypesEqual(got, []typ.Type{typ.Number}) { - t.Fatalf("narrow mismatch: got %v", got) - } - if got := facts.FuncTypes[symFunc]; !typ.TypeEquals(got, funcType) { - t.Fatalf("func mismatch: got %v", got) - } -} - -func TestReconcileFunctionFact_NarrowSummaryReplacesOpenTopPlaceholder(t *testing.T) { - openTop := typ.NewRecord().SetOpen(true).Build() - existingFunc := typ.Func().Returns(openTop).Build() - candidateFunc := typ.Func().Returns(openTop).Build() - narrow := []typ.Type{typ.NewArray(typ.Unknown)} - - out := ReconcileFunctionFact(ReconcileFunctionFactInput{ - ExistingSummary: []typ.Type{openTop}, - ExistingNarrow: nil, - ExistingFunc: existingFunc, - CandidateSummary: []typ.Type{openTop}, - CandidateNarrow: narrow, - CandidateFunc: candidateFunc, - }) - - if !ReturnTypesEqual(normalizeAndPruneReturnVector(out.Summary), normalizeAndPruneReturnVector(narrow)) { - t.Fatalf("summary mismatch: got %v want %v", out.Summary, narrow) - } - - fn, ok := out.Func.(*typ.Function) - if !ok { - t.Fatalf("expected function fact, got %T", out.Func) - } - if !ReturnTypesEqual(normalizeAndPruneReturnVector(fn.Returns), normalizeAndPruneReturnVector(narrow)) { - t.Fatalf("func returns mismatch: got %v want %v", fn.Returns, narrow) - } -} - -func TestReconcileFunctionFact_NarrowSummaryRepairsNeverArtifact(t *testing.T) { - bad := []typ.Type{ - typ.NewUnion( - typ.NewRecord(). - Field("success", typ.True). - Field("result", typ.NewRecord().OptField("data", typ.Never).Build()). - Build(), - typ.NewRecord(). - Field("success", typ.False). - Field("error", typ.LiteralString("missing")). - Build(), - ), - } - good := []typ.Type{ - typ.NewUnion( - typ.NewRecord(). - Field("success", typ.True). - Field("result", typ.NewRecord().OptField("data", typ.Unknown).Build()). - Build(), - typ.NewRecord(). - Field("success", typ.False). - Field("error", typ.LiteralString("missing")). - Build(), - ), - } - existingFunc := typ.Func().Returns(bad...).Build() - - out := ReconcileFunctionFact(ReconcileFunctionFactInput{ - ExistingSummary: bad, - ExistingNarrow: nil, - ExistingFunc: existingFunc, - CandidateNarrow: good, - }) - - if !ReturnTypesEqual(out.Summary, good) { - t.Fatalf("summary mismatch: got %v want %v", out.Summary, good) - } - if !ReturnTypesEqual(out.Narrow, good) { - t.Fatalf("narrow mismatch: got %v want %v", out.Narrow, good) - } - fn, ok := out.Func.(*typ.Function) - if !ok { - t.Fatalf("expected function fact, got %T", out.Func) - } - if !ReturnTypesEqual(fn.Returns, good) { - t.Fatalf("func returns mismatch: got %v want %v", fn.Returns, good) - } -} - -func TestMergeFunctionFactIntoFacts_ReadsLegacyAndWritesCanonical(t *testing.T) { - sym := cfg.SymbolID(41) - facts := &api.Facts{ - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - FuncTypes: api.FuncTypes{ - sym: typ.Func().Returns(typ.Number).Build(), - }, - } - - MergeFunctionFactIntoFacts(facts, sym, FunctionFactCandidate{ - Summary: []typ.Type{typ.String}, - Narrow: []typ.Type{typ.String}, - Func: typ.Func().Returns(typ.String).Build(), - }) - - ff, ok := facts.FunctionFacts[sym] - if !ok { - t.Fatal("expected canonical FunctionFacts entry") - } - if !ReturnTypesEqual(ff.Summary, facts.ReturnSummaries[sym]) { - t.Fatalf("summary drift: canonical=%v legacy=%v", ff.Summary, facts.ReturnSummaries[sym]) - } - if !ReturnTypesEqual(ff.Narrow, facts.NarrowReturns[sym]) { - t.Fatalf("narrow drift: canonical=%v legacy=%v", ff.Narrow, facts.NarrowReturns[sym]) - } - if !typ.TypeEquals(ff.Func, facts.FuncTypes[sym]) { - t.Fatalf("func drift: canonical=%v legacy=%v", ff.Func, facts.FuncTypes[sym]) - } -} - -func TestNormalizeFunctionFactChannels_PromotesLegacyIntoCanonical(t *testing.T) { - sym := cfg.SymbolID(77) - fn := typ.Func().Returns(typ.Number).Build() - facts := &api.Facts{ - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.Number}, - }, - FuncTypes: api.FuncTypes{ - sym: fn, - }, - } - - NormalizeFunctionFactChannels(facts) - - ff, ok := facts.FunctionFacts[sym] - if !ok { - t.Fatal("expected canonical FunctionFacts entry from legacy channels") - } - if !ReturnTypesEqual(ff.Summary, facts.ReturnSummaries[sym]) { - t.Fatalf("summary drift: canonical=%v legacy=%v", ff.Summary, facts.ReturnSummaries[sym]) - } - if !ReturnTypesEqual(ff.Narrow, facts.NarrowReturns[sym]) { - t.Fatalf("narrow drift: canonical=%v legacy=%v", ff.Narrow, facts.NarrowReturns[sym]) - } - if !typ.TypeEquals(ff.Func, facts.FuncTypes[sym]) { - t.Fatalf("func drift: canonical=%v legacy=%v", ff.Func, facts.FuncTypes[sym]) - } -} - -func TestFunctionFactViews_UseLegacyChannelsWhenCanonicalMissing(t *testing.T) { - sym := cfg.SymbolID(88) - fn := typ.Func().Returns(typ.String).Build() - facts := api.Facts{ - ReturnSummaries: api.ReturnSummaries{ - sym: []typ.Type{typ.String}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - sym: []typ.Type{typ.String}, - }, - FuncTypes: api.FuncTypes{ - sym: fn, - }, - } - - summaries := SummaryViewFromFacts(facts) - if got := summaries[sym]; !ReturnTypesEqual(got, []typ.Type{typ.String}) { - t.Fatalf("summary view mismatch: got %v", got) - } - - narrows := NarrowViewFromFacts(facts) - if got := narrows[sym]; !ReturnTypesEqual(got, []typ.Type{typ.String}) { - t.Fatalf("narrow view mismatch: got %v", got) - } - - funcs := FuncTypeViewFromFacts(facts) - if got := funcs[sym]; !typ.TypeEquals(got, fn) { - t.Fatalf("func view mismatch: got %v", got) - } -} diff --git a/compiler/check/returns/overlay.go b/compiler/check/returns/overlay.go deleted file mode 100644 index 8388b922..00000000 --- a/compiler/check/returns/overlay.go +++ /dev/null @@ -1,113 +0,0 @@ -package returns - -import ( - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" - "github.com/wippyai/go-lua/compiler/check/overlaymut" - "github.com/wippyai/go-lua/types/typ" -) - -// This file provides utilities for applying type mutations (field assignments, -// indexer assignments, array mutations) to type overlays during return inference. -// -// When analyzing nested functions, field assignments and mutations performed -// by called functions must be reflected in the types visible to the caller. -// These utilities merge mutation information into type overlays. - -// MergeFieldAssignments merges src into dst. -// -// Field assignments from different sources (different called functions, -// different branches) are merged using join.Two to produce union types. -// This ensures that all possible field types are captured. -func MergeFieldAssignments( - dst map[cfg.SymbolID]map[string]typ.Type, - src map[cfg.SymbolID]map[string]typ.Type, -) { - overlaymut.MergeFieldAssignments(dst, src) -} - -// ApplyFieldMergeToOverlay merges collected field assignments into symbol types in the overlay. -// -// For each symbol with collected field assignments, this function: -// 1. Looks up the symbol's current type in the overlay -// 2. Merges the assigned fields into that type using MergeFieldsIntoType -// 3. Updates the overlay with the enriched type -// -// This enables field assignments from called nested functions to be reflected -// in the types visible during parent function analysis. -func ApplyFieldMergeToOverlay( - overlay map[cfg.SymbolID]typ.Type, - fieldAssignments map[cfg.SymbolID]map[string]typ.Type, -) { - overlaymut.ApplyFieldMergeToOverlay(overlay, fieldAssignments) -} - -// MergeFieldsIntoType merges a set of field types into a base type. -// -// The merge strategy depends on the base type: -// - nil base: Creates an open record with the given fields -// - Map base: Creates an open record with map component plus fields -// - Record base: Adds new fields, preserving existing fields and metadata -// - Other base: Creates an open record with just the fields -// -// Field names are sorted for deterministic output. Existing record fields -// are preserved (not overwritten) since they represent more precise type info. -func MergeFieldsIntoType(baseType typ.Type, fields map[string]typ.Type) typ.Type { - return overlaymut.MergeFieldsIntoType(baseType, fields) -} - -// ApplyIndexerMergeToOverlay adds map components to symbol types based on dynamic index assignments. -// -// Dynamic index assignments (t[k] = v where k is not a literal) indicate -// map-like behavior. This function collects all indexer assignments for each -// symbol, joins the key and value types, and adds a map component to the -// symbol's type. -// -// Key types are joined across all assignments; if all keys are numbers, the -// result is a numeric map. Value types are joined with special handling: -// empty records {} are replaced by arrays when array elements are assigned. -func ApplyIndexerMergeToOverlay( - overlay map[cfg.SymbolID]typ.Type, - indexerAssignments map[cfg.SymbolID][]mutator.IndexerInfo, -) { - overlaymut.ApplyIndexerMergeToOverlay(overlay, indexerAssignments) -} - -// JoinValueTypes joins two value types, preferring arrays over empty records. -// -// When {} (empty record) and T[] (array) are joined, the result is T[]. -// This models the common Lua pattern of initializing a variable as {} and -// then using it as an array via table.insert or indexed assignment. -// The array type takes precedence because it carries more specific information. -func JoinValueTypes(a, b typ.Type) typ.Type { - return overlaymut.JoinValueTypes(a, b) -} - -// MergeMapComponentIntoType adds a map component to a base type. -// -// The merge strategy depends on the base type: -// - nil base: Creates a new Map type -// - Map base: Joins key and value types with existing map types -// - Record base: Adds/updates the map component while preserving fields -// - Other base: Creates a new Map type -// -// This is used when dynamic index assignments are detected, indicating the -// variable is used as a map or has map-like access patterns. -func MergeMapComponentIntoType(baseType, keyType, valType typ.Type) typ.Type { - return overlaymut.MergeMapComponentIntoType(baseType, keyType, valType) -} - -// ApplyDirectMutationsToOverlay widens array element types based on table.insert mutations. -// -// When table.insert(t, v) is called, the array element type of t should include -// the type of v. This function applies such mutations by widening the element -// type of each affected symbol's type. -// -// This is separate from field assignments because table.insert modifies the -// array portion of a table, not named fields. -func ApplyDirectMutationsToOverlay( - overlay map[cfg.SymbolID]typ.Type, - mutations map[cfg.SymbolID]typ.Type, -) { - overlaymut.ApplyDirectMutationsToOverlay(overlay, mutations) -} diff --git a/compiler/check/returns/scc.go b/compiler/check/returns/scc.go index 3b2e5380..445383fd 100644 --- a/compiler/check/returns/scc.go +++ b/compiler/check/returns/scc.go @@ -18,7 +18,7 @@ import ( // and so on. This ordering ensures that when processing an SCC, all functions // it depends on have already been analyzed. // -// Type conversion is performed to bridge cfg.SymbolID and the uint64-based +// Type conversion maps cfg.SymbolID to the uint64-based // internal SCC implementation. func ComputeSymbolSCCs(adj map[cfg.SymbolID][]cfg.SymbolID) [][]cfg.SymbolID { if len(adj) == 0 { diff --git a/compiler/check/returns/signature.go b/compiler/check/returns/signature.go index 583332fe..c51a005f 100644 --- a/compiler/check/returns/signature.go +++ b/compiler/check/returns/signature.go @@ -9,7 +9,7 @@ import ( ) // BuildSeedFunctionTypeWithBindings builds a placeholder function type for an -// SCC sibling that has no return summary yet. +// SCC sibling that has no inferred return vector yet. // // Optional binder metadata enables implicit-self detection in method definitions. func BuildSeedFunctionTypeWithBindings( @@ -69,6 +69,7 @@ func BuildSeedFunctionTypeWithBindings( Expected: nil, ImplicitSelf: implicitSelf, ImplicitSelfType: implicitSelfType, + UntypedParamType: typ.Any, }) if len(fn.ReturnTypes) > 0 { diff --git a/compiler/check/returns/signature_test.go b/compiler/check/returns/signature_test.go index 30386961..8b9faf39 100644 --- a/compiler/check/returns/signature_test.go +++ b/compiler/check/returns/signature_test.go @@ -74,7 +74,7 @@ func TestBuildSeedFunctionTypeWithBindings_UnannotatedParamsStayOptional(t *test if !fnType.Params[0].Optional || !fnType.Params[1].Optional { t.Fatalf("expected unannotated params to be optional, got %+v", fnType.Params) } - if fnType.Variadic == nil || !typ.TypeEquals(fnType.Variadic, typ.Any) { - t.Fatalf("expected variadic any for unannotated seed function, got %v", fnType.Variadic) + if fnType.Variadic != nil { + t.Fatalf("unannotated params should not create a fake variadic seed slot, got %v", fnType.Variadic) } } diff --git a/compiler/check/returns/test_helpers_test.go b/compiler/check/returns/test_helpers_test.go new file mode 100644 index 00000000..459eda31 --- /dev/null +++ b/compiler/check/returns/test_helpers_test.go @@ -0,0 +1,17 @@ +package returns + +import ( + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" +) + +func callEvidenceForGraph(graph *cfg.Graph) []api.CallEvidence { + if graph == nil { + return nil + } + var calls []api.CallEvidence + graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + calls = append(calls, api.CallEvidence{Point: p, Info: info}) + }) + return calls +} diff --git a/compiler/check/returns/types.go b/compiler/check/returns/types.go index 809fc066..887a39f4 100644 --- a/compiler/check/returns/types.go +++ b/compiler/check/returns/types.go @@ -13,34 +13,34 @@ // an SCC, functions are processed together using fixpoint iteration until // return types stabilize. // -// # Return Summaries +// # Return Vectors // -// A return summary is a vector of types representing the types returned by -// a function. For `return a, b, c`, the summary would be [typeof(a), typeof(b), -// typeof(c)]. Summaries are accumulated across all return statements in a +// A return vector represents the types returned by a function. For +// `return a, b, c`, the vector is [typeof(a), typeof(b), typeof(c)]. Vectors +// are accumulated across all return statements in a // function body and joined to produce the final return type. // -// # Canonical vs Seed Summaries +// # Canonical Function Facts vs Iteration Vectors // -// Two summary stores are maintained: -// - Canonical: Fully computed return types from completed analysis -// - Seed: Provisional return types from the current iteration +// The stored authority is api.FunctionFacts. During SCC solving, the inferencer +// also keeps a provisional map of return vectors for the current iteration. // -// During analysis, seed summaries are used for functions in the current SCC -// (to avoid circular dependence), while canonical summaries are used for +// During analysis, iteration vectors are used for functions in the current SCC +// to avoid circular dependence, while canonical function facts are used for // functions outside the SCC (whose types are already known). // -// # Parameter Hint Propagation +// # Parameter Evidence Propagation // -// For unannotated parameters, the system propagates type hints from call sites. -// If function `f` is called as `f(42)`, the first parameter of `f` is hinted -// as `number`. Hints are joined across all call sites and propagated through +// For unannotated parameters, the system propagates evidence from call sites. +// If function `f` is called as `f(42)`, the first parameter of `f` records +// number evidence. Evidence is joined across all call sites and propagated through // the call graph until fixpoint. package returns import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/typ" ) @@ -49,7 +49,7 @@ import ( // // Each LocalFuncInfo represents a function that may participate in mutual // recursion with other local functions. The info includes the function's -// AST, CFG, definition context, and any parameter hints inferred from +// AST, CFG, definition context, and any parameter evidence inferred from // call sites. type LocalFuncInfo struct { Sym cfg.SymbolID @@ -57,15 +57,15 @@ type LocalFuncInfo struct { DefScope *scope.State Graph *cfg.Graph // ParentGraph is the graph where this local function is defined. - // Used for parent-scope callsite hint propagation. + // Used for parent-scope callsite evidence propagation. ParentGraph *cfg.Graph ParentFn *ast.FunctionExpr DefPoint cfg.Point - // ParamHints holds inferred parameter types from call sites in the parent graph. - // Index corresponds to parameter position. - ParamHints []typ.Type + // Evidence is the transfer-owned expression evidence for this function. + Evidence api.FlowEvidence + // ParentEvidence is the transfer-owned expression evidence for ParentGraph. + ParentEvidence api.FlowEvidence + // ParameterEvidence holds inferred effective-parameter types from call sites in the + // parent graph. For methods, index 0 is self. + ParameterEvidence []typ.Type } - -// MaxReturnSummaryIterations limits fixpoint iterations for ReturnSummaries. -// Exceeding this indicates a bug (non-monotonic merge) or pathological recursion. -const MaxReturnSummaryIterations = 10 diff --git a/compiler/check/returns/types_test.go b/compiler/check/returns/types_test.go index 99bee60a..5cfe6b81 100644 --- a/compiler/check/returns/types_test.go +++ b/compiler/check/returns/types_test.go @@ -12,13 +12,13 @@ import ( func TestLocalFuncInfoStructure(t *testing.T) { t.Run("struct fields are accessible", func(t *testing.T) { info := LocalFuncInfo{ - Sym: cfg.SymbolID(1), - Fn: &ast.FunctionExpr{}, - DefScope: scope.New(), - Graph: &cfg.Graph{}, - ParentFn: nil, - DefPoint: cfg.Point(0), - ParamHints: []typ.Type{typ.String, typ.Number}, + Sym: cfg.SymbolID(1), + Fn: &ast.FunctionExpr{}, + DefScope: scope.New(), + Graph: &cfg.Graph{}, + ParentFn: nil, + DefPoint: cfg.Point(0), + ParameterEvidence: []typ.Type{typ.String, typ.Number}, } if info.Sym != cfg.SymbolID(1) { @@ -39,8 +39,8 @@ func TestLocalFuncInfoStructure(t *testing.T) { if info.DefPoint != cfg.Point(0) { t.Fatalf("expected DefPoint=0, got %v", info.DefPoint) } - if len(info.ParamHints) != 2 { - t.Fatalf("expected 2 ParamHints, got %d", len(info.ParamHints)) + if len(info.ParameterEvidence) != 2 { + t.Fatalf("expected 2 ParameterEvidence, got %d", len(info.ParameterEvidence)) } }) @@ -52,22 +52,8 @@ func TestLocalFuncInfoStructure(t *testing.T) { if info.Fn != nil { t.Fatal("expected nil Fn") } - if info.ParamHints != nil { - t.Fatal("expected nil ParamHints") - } - }) -} - -func TestMaxReturnSummaryIterations(t *testing.T) { - t.Run("constant value", func(t *testing.T) { - if MaxReturnSummaryIterations != 10 { - t.Fatalf("expected MaxReturnSummaryIterations=10, got %d", MaxReturnSummaryIterations) - } - }) - - t.Run("constant is positive", func(t *testing.T) { - if MaxReturnSummaryIterations <= 0 { - t.Fatal("expected positive constant") + if info.ParameterEvidence != nil { + t.Fatal("expected nil ParameterEvidence") } }) } diff --git a/compiler/check/returns/widen.go b/compiler/check/returns/widen.go deleted file mode 100644 index d0a8ca76..00000000 --- a/compiler/check/returns/widen.go +++ /dev/null @@ -1,766 +0,0 @@ -package returns - -import ( - "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/internal" - "github.com/wippyai/go-lua/types/subtype" - "github.com/wippyai/go-lua/types/typ" - typjoin "github.com/wippyai/go-lua/types/typ/join" - "github.com/wippyai/go-lua/types/typ/unwrap" -) - -// WidenFacts merges two interproc fact bundles. -func WidenFacts(prev, next api.Facts) api.Facts { - NormalizeFunctionFactChannels(&prev) - NormalizeFunctionFactChannels(&next) - - out := api.Facts{ - ParamHints: WidenParamHints(prev.ParamHints, next.ParamHints), - LiteralSigs: WidenLiteralSigs(prev.LiteralSigs, next.LiteralSigs), - CapturedTypes: WidenCapturedTypes(prev.CapturedTypes, next.CapturedTypes), - CapturedFields: WidenCapturedFieldAssigns(prev.CapturedFields, next.CapturedFields), - CapturedContainers: WidenCapturedContainerMutations(prev.CapturedContainers, next.CapturedContainers), - ConstructorFields: WidenConstructorFields(prev.ConstructorFields, next.ConstructorFields), - } - - symbols := collectCanonicalFunctionFactSymbols(prev.FunctionFacts, next.FunctionFacts) - if len(symbols) == 0 { - return out - } - - out.FunctionFacts = make(api.FunctionFacts, len(symbols)) - for _, sym := range symbols { - prevFact := readFunctionFactFromFacts(&prev, sym) - nextFact := readFunctionFactFromFacts(&next, sym) - reconciled := ReconcileFunctionFact(ReconcileFunctionFactInput{ - ExistingSummary: prevFact.Summary, - ExistingNarrow: prevFact.Narrow, - ExistingFunc: prevFact.Func, - CandidateSummary: nextFact.Summary, - CandidateNarrow: nextFact.Narrow, - CandidateFunc: nextFact.Func, - }) - writeFunctionFactToFacts(&out, sym, api.FunctionFact{ - Summary: widenReturnVectorForConvergence(reconciled.Summary), - Narrow: widenReturnVectorForConvergence(reconciled.Narrow), - Func: maybeWidenTypeForConvergence(reconciled.Func), - }) - } - if len(out.FunctionFacts) == 0 { - out.FunctionFacts = nil - } - return out -} - -// WidenReturnSummaries merges return summaries through the canonical -// return-summary merge policy shared by all iterative channels. -func WidenReturnSummaries(prev, next api.ReturnSummaries) api.ReturnSummaries { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.ReturnSummaries, len(prev)+len(next)) - for _, sym := range cfg.SortedSymbolIDs(prev) { - merged[sym] = widenReturnVectorForConvergence(NormalizeReturnVector(prev[sym])) - } - for _, sym := range cfg.SortedSymbolIDs(next) { - rets := next[sym] - if existing := merged[sym]; existing != nil { - merged[sym] = widenReturnVectorForConvergence(MergeReturnSummary(existing, rets)) - } else { - merged[sym] = widenReturnVectorForConvergence(NormalizeReturnVector(rets)) - } - } - return merged -} - -func shouldUseMonotoneReturnJoin(a, b []typ.Type) bool { - for _, t := range a { - if hasHigherOrderGrowthRisk(t) { - return true - } - } - for _, t := range b { - if hasHigherOrderGrowthRisk(t) { - return true - } - } - return false -} - -func hasHigherOrderGrowthRisk(t typ.Type) bool { - if t == nil { - return false - } - return scanType(t, typ.NewGuard(), func(node typ.Type) (bool, bool) { - switch n := node.(type) { - case *typ.Function: - for _, ret := range n.Returns { - if typeContainsFunction(ret) { - return true, false - } - } - case *typ.Record: - if recordHasSelfRecursiveMethod(n) { - return true, false - } - } - return false, true - }) -} - -func typeContainsFunction(t typ.Type) bool { - if t == nil { - return false - } - return scanType(t, typ.NewGuard(), func(node typ.Type) (bool, bool) { - // Interface method signatures are behavioral contracts, not first-class - // returned function values. Ignore them for higher-order growth risk. - if _, ok := node.(*typ.Interface); ok { - return false, false - } - if _, ok := node.(*typ.Function); ok { - return true, false - } - return false, true - }) -} - -func recordHasSelfRecursiveMethod(r *typ.Record) bool { - if r == nil { - return false - } - for _, f := range r.Fields { - if methodTypeHasSelfRecursiveReturn(f.Type, r) { - return true - } - } - if r.HasMapComponent() && methodTypeHasSelfRecursiveReturn(r.MapValue, r) { - return true - } - return false -} - -func methodTypeHasSelfRecursiveReturn(t typ.Type, owner *typ.Record) bool { - if t == nil || owner == nil { - return false - } - return scanType(t, typ.NewGuard(), func(node typ.Type) (bool, bool) { - // Interface method signatures are behavioral contracts, not concrete - // record method bodies. Treating them as self-recursive growth risk - // over-applies monotone widening and blocks valid summary refinement. - if _, ok := node.(*typ.Interface); ok { - return false, false - } - fn, ok := node.(*typ.Function) - if !ok { - return false, true - } - for _, ret := range fn.Returns { - if ret == nil { - continue - } - if subtype.IsSubtype(ret, owner) || subtype.IsSubtype(owner, ret) || - TypeExtendsRecord(ret, owner) || TypeExtendsRecord(owner, ret) { - return true, false - } - } - return false, true - }) -} - -func scanType( - t typ.Type, - guard internal.RecursionGuard, - visit func(node typ.Type) (stop bool, descend bool), -) bool { - if t == nil { - return false - } - next, ok := guard.Enter(t) - if !ok { - return false - } - - node := t - for { - ann, ok := node.(*typ.Annotated) - if !ok || ann.Inner == nil || ann.Inner == node { - break - } - node = ann.Inner - } - - if stop, descend := visit(node); stop { - return true - } else if !descend { - return false - } - - switch n := node.(type) { - case *typ.Optional: - return scanType(n.Inner, next, visit) - case *typ.Union: - for _, m := range n.Members { - if scanType(m, next, visit) { - return true - } - } - return false - case *typ.Intersection: - for _, m := range n.Members { - if scanType(m, next, visit) { - return true - } - } - return false - case *typ.Array: - return scanType(n.Element, next, visit) - case *typ.Map: - return scanType(n.Key, next, visit) || scanType(n.Value, next, visit) - case *typ.Tuple: - for _, e := range n.Elements { - if scanType(e, next, visit) { - return true - } - } - return false - case *typ.Function: - for _, p := range n.Params { - if scanType(p.Type, next, visit) { - return true - } - } - for _, r := range n.Returns { - if scanType(r, next, visit) { - return true - } - } - return n.Variadic != nil && scanType(n.Variadic, next, visit) - case *typ.Record: - for _, f := range n.Fields { - if scanType(f.Type, next, visit) { - return true - } - } - if n.Metatable != nil && scanType(n.Metatable, next, visit) { - return true - } - if n.HasMapComponent() { - return scanType(n.MapKey, next, visit) || scanType(n.MapValue, next, visit) - } - return false - case *typ.Alias: - return scanType(n.Target, next, visit) - case *typ.Instantiated: - for _, a := range n.TypeArgs { - if scanType(a, next, visit) { - return true - } - } - return false - case *typ.Interface: - for _, m := range n.Methods { - if m.Type != nil && scanType(m.Type, next, visit) { - return true - } - } - return false - default: - return false - } -} - -func joinReturnVectorsMonotone(a, b []typ.Type) []typ.Type { - if len(a) == 0 { - return b - } - if len(b) == 0 { - return a - } - maxLen := len(a) - if len(b) > maxLen { - maxLen = len(b) - } - out := make([]typ.Type, maxLen) - for i := 0; i < maxLen; i++ { - var ai, bi typ.Type - if i < len(a) { - ai = a[i] - } - if i < len(b) { - bi = b[i] - } - out[i] = joinReturnTypeMonotone(ai, bi) - } - return out -} - -func joinReturnTypeMonotone(a, b typ.Type) typ.Type { - if a == nil { - return b - } - if b == nil { - return a - } - if typ.TypeEquals(a, b) { - return a - } - // Keep widening monotone: if one side is already an upper bound, keep it. - if subtype.IsSubtype(a, b) || TypeExtendsRecord(a, b) || typeElidesOptional(a, b) { - return b - } - if subtype.IsSubtype(b, a) || TypeExtendsRecord(b, a) || typeElidesOptional(b, a) { - return a - } - return typ.JoinPreferNonSoft(a, b) -} - -// WidenParamHints merges two param hint maps using monotone union. -func WidenParamHints(prev, next api.ParamHints) api.ParamHints { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return filterEmptyParamHints(next) - } - if next == nil { - return filterEmptyParamHints(prev) - } - merged := make(api.ParamHints, len(prev)+len(next)) - for _, sym := range cfg.SortedSymbolIDs(prev) { - hints := prev[sym] - if hasNonNilHint(hints) { - merged[sym] = hints - } - } - for _, sym := range cfg.SortedSymbolIDs(next) { - hints := next[sym] - if !hasNonNilHint(hints) { - continue - } - if existing := merged[sym]; existing != nil { - merged[sym] = joinParamHintVectors(existing, hints) - } else { - merged[sym] = hints - } - } - return merged -} - -func filterEmptyParamHints(hints api.ParamHints) api.ParamHints { - if hints == nil { - return nil - } - out := make(api.ParamHints, len(hints)) - for _, sym := range cfg.SortedSymbolIDs(hints) { - v := hints[sym] - if hasNonNilHint(v) { - out[sym] = v - } - } - if len(out) == 0 { - return nil - } - return out -} - -func hasNonNilHint(hints []typ.Type) bool { - for _, h := range hints { - if h != nil { - return true - } - } - return false -} - -// joinParamHintVectors joins two parameter hint vectors element-wise. -func joinParamHintVectors(a, b []typ.Type) []typ.Type { - if len(a) == 0 { - return b - } - if len(b) == 0 { - return a - } - maxLen := len(a) - if len(b) > maxLen { - maxLen = len(b) - } - result := make([]typ.Type, maxLen) - for i := 0; i < maxLen; i++ { - var ai, bi typ.Type - if i < len(a) { - ai = a[i] - } - if i < len(b) { - bi = b[i] - } - result[i] = joinParamHint(ai, bi) - } - return result -} - -func joinParamHint(a, b typ.Type) typ.Type { - if a == nil { - return b - } - if b == nil { - return a - } - if unwrap.IsNilType(a) && !unwrap.IsNilType(b) { - return b - } - if unwrap.IsNilType(b) && !unwrap.IsNilType(a) { - return a - } - if TypeExtendsRecord(a, b) { - return a - } - if TypeExtendsRecord(b, a) { - return b - } - return typ.JoinPreferNonSoft(a, b) -} - -// WidenLiteralSigs merges two literal signature maps. -func WidenLiteralSigs(prev, next api.LiteralSigs) api.LiteralSigs { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.LiteralSigs, len(prev)+len(next)) - for fn, sig := range prev { - merged[fn] = maybeWidenFunctionForConvergence(sig) - } - for fn, sig := range next { - if existing := merged[fn]; existing != nil { - merged[fn] = maybeWidenFunctionForConvergence(mergeLiteralSig(existing, sig)) - } else { - merged[fn] = maybeWidenFunctionForConvergence(sig) - } - } - return merged -} - -func mergeLiteralSig(prev, next *typ.Function) *typ.Function { - if prev == nil { - return next - } - if next == nil { - return prev - } - if merged, ok := mergeFunctionReturnsIfSameShape(prev, next); ok { - if fn, ok := merged.(*typ.Function); ok { - return fn - } - } - if subtype.IsSubtype(prev, next) { - return next - } - if subtype.IsSubtype(next, prev) { - return prev - } - // Literal signatures are constrained to *typ.Function. For incomparable - // function shapes, keep the prior stable signature instead of narrowing. - return prev -} - -// WidenCapturedTypes merges two captured type maps using monotone join. -func WidenCapturedTypes(prev, next api.CapturedTypes) api.CapturedTypes { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.CapturedTypes, len(prev)+len(next)) - for _, sym := range cfg.SortedSymbolIDs(prev) { - merged[sym] = prev[sym] - } - for _, sym := range cfg.SortedSymbolIDs(next) { - t := next[sym] - if existing := merged[sym]; existing != nil { - merged[sym] = maybeWidenTypeForConvergence(typ.JoinPreferNonSoft(existing, t)) - } else { - merged[sym] = maybeWidenTypeForConvergence(t) - } - } - return merged -} - -// WidenCapturedFieldAssigns merges captured field assignment maps using monotone union. -func WidenCapturedFieldAssigns(prev, next api.CapturedFieldAssigns) api.CapturedFieldAssigns { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.CapturedFieldAssigns, len(prev)+len(next)) - for _, callee := range cfg.SortedSymbolIDs(prev) { - merged[callee] = prev[callee] - } - for _, callee := range cfg.SortedSymbolIDs(next) { - captured := next[callee] - existing := merged[callee] - if existing == nil { - merged[callee] = captured - continue - } - merged[callee] = MergeCapturedFieldSymbolMaps(existing, captured, func(prev typ.Type, next typ.Type) typ.Type { - if prev != nil { - return maybeWidenTypeForConvergence(typ.JoinPreferNonSoft(prev, next)) - } - return maybeWidenTypeForConvergence(next) - }) - } - return merged -} - -// WidenCapturedContainerMutations merges captured container mutation maps using monotone union. -func WidenCapturedContainerMutations(prev, next api.CapturedContainerMutations) api.CapturedContainerMutations { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.CapturedContainerMutations, len(prev)+len(next)) - for _, sym := range cfg.SortedSymbolIDs(prev) { - merged[sym] = prev[sym] - } - for _, sym := range cfg.SortedSymbolIDs(next) { - muts := next[sym] - existing := merged[sym] - merged[sym] = MergeCapturedContainerMutationMaps(existing, muts, func(prev *api.ContainerMutation, next api.ContainerMutation) api.ContainerMutation { - if prev != nil { - next.ValueType = maybeWidenTypeForConvergence(typ.JoinPreferNonSoft(prev.ValueType, next.ValueType)) - } else { - next.ValueType = maybeWidenTypeForConvergence(next.ValueType) - } - return next - }) - } - return merged -} - -// WidenConstructorFields merges constructor field maps using monotone join. -func WidenConstructorFields(prev, next api.ConstructorFields) api.ConstructorFields { - if prev == nil && next == nil { - return nil - } - if prev == nil { - return next - } - if next == nil { - return prev - } - merged := make(api.ConstructorFields, len(prev)+len(next)) - for _, sym := range cfg.SortedSymbolIDs(prev) { - merged[sym] = prev[sym] - } - for _, sym := range cfg.SortedSymbolIDs(next) { - fields := next[sym] - existing := merged[sym] - if existing == nil { - merged[sym] = fields - continue - } - out := make(map[string]typ.Type, len(existing)+len(fields)) - for _, name := range cfg.SortedFieldNames(existing) { - out[name] = existing[name] - } - for _, name := range cfg.SortedFieldNames(fields) { - t := fields[name] - if prevType := out[name]; prevType != nil { - out[name] = maybeWidenTypeForConvergence(typ.JoinPreferNonSoft(prevType, t)) - } else { - out[name] = maybeWidenTypeForConvergence(t) - } - } - merged[sym] = out - } - return merged -} - -func mergeFunctionReturnsIfSameShape(prevFn, nextFn *typ.Function) (typ.Type, bool) { - if prevFn == nil || nextFn == nil { - return nil, false - } - if len(prevFn.TypeParams) != len(nextFn.TypeParams) { - return nil, false - } - if !typeParamsEqual(prevFn.TypeParams, nextFn.TypeParams) { - return nil, false - } - if len(prevFn.Params) != len(nextFn.Params) { - return nil, false - } - if (prevFn.Variadic == nil) != (nextFn.Variadic == nil) { - return nil, false - } - if prevFn.Variadic != nil && !typ.TypeEquals(prevFn.Variadic, nextFn.Variadic) { - return nil, false - } - for i := range prevFn.Params { - if prevFn.Params[i].Optional != nextFn.Params[i].Optional { - return nil, false - } - if !typ.TypeEquals(prevFn.Params[i].Type, nextFn.Params[i].Type) { - return nil, false - } - } - if len(prevFn.Returns) == 0 && len(nextFn.Returns) == 0 { - return prevFn, true - } - if len(prevFn.Returns) != len(nextFn.Returns) || len(prevFn.Returns) == 0 { - return nil, false - } - - allowedTypeParams := make(map[string]bool, len(prevFn.TypeParams)) - for _, tp := range prevFn.TypeParams { - if tp != nil && tp.Name != "" { - allowedTypeParams[tp.Name] = true - } - } - normalizeReturn := func(t typ.Type) typ.Type { - if t == nil { - return nil - } - return typ.Rewrite(t, func(node typ.Type) (typ.Type, bool) { - tp, ok := node.(*typ.TypeParam) - if !ok { - return node, false - } - if allowedTypeParams[tp.Name] { - return node, false - } - // Free type params in non-generic function returns are unstable placeholders. - return typ.Unknown, true - }) - } - normalizedPrev := make([]typ.Type, len(prevFn.Returns)) - normalizedNext := make([]typ.Type, len(nextFn.Returns)) - for i := range prevFn.Returns { - normalizedPrev[i] = normalizeReturn(prevFn.Returns[i]) - normalizedNext[i] = normalizeReturn(nextFn.Returns[i]) - } - - mergedReturns := typjoin.ReturnVectors(normalizedPrev, normalizedNext) - if ReturnTypesEqual(prevFn.Returns, mergedReturns) { - return prevFn, true - } - if ReturnTypesEqual(nextFn.Returns, mergedReturns) { - return nextFn, true - } - - effects := prevFn.Effects - if effects == nil { - effects = nextFn.Effects - } - spec := prevFn.Spec - if spec == nil { - spec = nextFn.Spec - } - refinement := prevFn.Refinement - if refinement == nil { - refinement = nextFn.Refinement - } - - builder := typ.Func(). - Effects(effects). - Spec(spec). - WithRefinement(refinement) - for _, tp := range prevFn.TypeParams { - builder = builder.TypeParam(tp.Name, tp.Constraint) - } - for _, p := range prevFn.Params { - if p.Optional { - builder = builder.OptParam(p.Name, p.Type) - } else { - builder = builder.Param(p.Name, p.Type) - } - } - if prevFn.Variadic != nil { - builder = builder.Variadic(prevFn.Variadic) - } - builder = builder.Returns(mergedReturns...) - return builder.Build(), true -} - -func typeParamsEqual(a, b []*typ.TypeParam) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] == nil || b[i] == nil { - if a[i] != b[i] { - return false - } - continue - } - if !a[i].Equals(b[i]) { - return false - } - } - return true -} - -func widenReturnVectorForConvergence(rets []typ.Type) []typ.Type { - if len(rets) == 0 { - return rets - } - out := make([]typ.Type, len(rets)) - changed := false - for i, t := range rets { - wt := maybeWidenTypeForConvergence(t) - out[i] = wt - if wt != t { - changed = true - } - } - if !changed { - return rets - } - return out -} - -func maybeWidenTypeForConvergence(t typ.Type) typ.Type { - if t == nil { - return nil - } - if !hasHigherOrderGrowthRisk(t) { - return t - } - return subtype.WidenForInference(t) -} - -func maybeWidenFunctionForConvergence(fn *typ.Function) *typ.Function { - if fn == nil { - return nil - } - if widened, ok := maybeWidenTypeForConvergence(fn).(*typ.Function); ok { - return widened - } - return fn -} diff --git a/compiler/check/returns/widen_test.go b/compiler/check/returns/widen_test.go deleted file mode 100644 index 0300c739..00000000 --- a/compiler/check/returns/widen_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package returns - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/types/subtype" - "github.com/wippyai/go-lua/types/typ" -) - -func TestWidenFacts_DoesNotOverrideReturnSummariesWithNarrowReturns(t *testing.T) { - prev := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.Integer}}, - }, - ReturnSummaries: api.ReturnSummaries{ - 1: []typ.Type{typ.Integer}, - }, - } - next := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Narrow: []typ.Type{typ.Nil}}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - 1: []typ.Type{typ.Nil}, - }, - } - - merged := WidenFacts(prev, next) - got := merged.ReturnSummaries[1] - if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { - t.Fatalf("expected ReturnSummaries[1]=integer, got %v", got) - } -} - -func TestWidenFacts_ElidesOptionalFromNarrowReturns(t *testing.T) { - prev := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.NewOptional(typ.Integer)}}, - }, - ReturnSummaries: api.ReturnSummaries{ - 1: []typ.Type{typ.NewOptional(typ.Integer)}, - }, - } - next := api.Facts{ - FunctionFacts: api.FunctionFacts{ - 1: {Narrow: []typ.Type{typ.Integer}}, - }, - NarrowReturns: api.NarrowReturnSummaries{ - 1: []typ.Type{typ.Integer}, - }, - } - - merged := WidenFacts(prev, next) - got := merged.ReturnSummaries[1] - if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { - t.Fatalf("expected ReturnSummaries[1]=integer, got %v", got) - } -} - -func TestWidenReturnSummaries_RefinesOptionalForFirstOrderTypes(t *testing.T) { - prev := api.ReturnSummaries{ - 1: []typ.Type{typ.NewOptional(typ.Integer)}, - } - next := api.ReturnSummaries{ - 1: []typ.Type{typ.Integer}, - } - - merged := WidenReturnSummaries(prev, next) - got := merged[1] - if len(got) != 1 || !typ.TypeEquals(got[0], typ.Integer) { - t.Fatalf("expected integer after first-order refinement, got %v", got) - } -} - -func TestWidenReturnSummaries_UsesMonotoneJoinForHigherOrderReturns(t *testing.T) { - nestedUnknown := typ.NewRecord(). - Field("next", typ.Func().Returns(typ.Unknown).Build()). - Build() - nestedString := typ.NewRecord(). - Field("next", typ.Func().Returns(typ.String).Build()). - Build() - - base := typ.NewRecord(). - Field("build", typ.Func().Returns(nestedUnknown).Build()). - Build() - refined := typ.NewRecord(). - Field("build", typ.Func().Returns(nestedString).Build()). - Build() - - prev := api.ReturnSummaries{ - 1: []typ.Type{base}, - } - next := api.ReturnSummaries{ - 1: []typ.Type{refined}, - } - - merged := WidenReturnSummaries(prev, next) - got := merged[1] - if len(got) != 1 || !typ.TypeEquals(got[0], base) { - t.Fatalf("expected stable upper bound for higher-order return, got %v", got) - } -} - -func TestWidenReturnSummaries_InterfaceMethodsDoNotBlockOptionalElision(t *testing.T) { - dbType := typ.NewInterface("sql.DB", []typ.Method{ - { - Name: "release", - Type: typ.Func(). - Param("self", typ.Self). - Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). - Build(), - }, - }) - - prev := api.ReturnSummaries{ - 1: []typ.Type{typ.NewOptional(dbType)}, - } - next := api.ReturnSummaries{ - 1: []typ.Type{dbType}, - } - - merged := WidenReturnSummaries(prev, next) - got := merged[1] - if len(got) != 1 || !typ.TypeEquals(got[0], dbType) { - t.Fatalf("expected optional elision for interface return, got %v", got) - } -} - -func TestMergeFunctionReturnsIfSameShape_GenericFunctions(t *testing.T) { - prev := typ.Func(). - TypeParam("T", nil). - Returns(typ.String). - Build() - next := typ.Func(). - TypeParam("T", nil). - Returns(typ.Integer). - Build() - - mergedType, ok := mergeFunctionReturnsIfSameShape(prev, next) - if !ok { - t.Fatal("expected generic same-shape functions to merge") - } - merged, ok := mergedType.(*typ.Function) - if !ok { - t.Fatalf("expected merged function type, got %T", mergedType) - } - if len(merged.TypeParams) != 1 || merged.TypeParams[0] == nil || merged.TypeParams[0].Name != "T" { - t.Fatalf("expected merged generic type parameter T, got %+v", merged.TypeParams) - } - if len(merged.Returns) != 1 { - t.Fatalf("expected one return, got %d", len(merged.Returns)) - } - want := typ.NewUnion(typ.String, typ.Integer) - if !typ.TypeEquals(merged.Returns[0], want) { - t.Fatalf("expected merged return %v, got %v", want, merged.Returns[0]) - } -} - -func TestMergeFunctionReturnsIfSameShape_GenericTypeParamsMustMatch(t *testing.T) { - prev := typ.Func(). - TypeParam("T", nil). - Returns(typ.String). - Build() - next := typ.Func(). - TypeParam("U", nil). - Returns(typ.Integer). - Build() - - _, ok := mergeFunctionReturnsIfSameShape(prev, next) - if ok { - t.Fatal("expected mismatched generic params not to merge") - } -} - -func TestMergeFuncTypes_DoesNotRegressToNarrowerNilReturn(t *testing.T) { - prev := typ.Func(). - Returns(typ.NewOptional(typ.Integer)). - Build() - next := typ.Func(). - Returns(typ.Nil). - Build() - - merged := MergeFunctionFactType(prev, next) - fn, ok := merged.(*typ.Function) - if !ok || len(fn.Returns) != 1 { - t.Fatalf("expected merged function return, got %T", merged) - } - if !typ.TypeEquals(fn.Returns[0], typ.NewOptional(typ.Integer)) { - t.Fatalf("expected integer? return after merge, got %v", fn.Returns[0]) - } -} - -func TestMergeFunctionReturnsIfSameShape_NormalizesLeakedTypeParams(t *testing.T) { - prev := typ.Func(). - Returns(typ.NewTypeParam("T", nil)). - Build() - next := typ.Func(). - Returns(typ.Integer). - Build() - - mergedType, ok := mergeFunctionReturnsIfSameShape(prev, next) - if !ok { - t.Fatal("expected same-shape functions to merge") - } - merged, ok := mergedType.(*typ.Function) - if !ok || len(merged.Returns) != 1 { - t.Fatalf("expected merged function return, got %T", mergedType) - } - if !typ.TypeEquals(merged.Returns[0], typ.Integer) { - t.Fatalf("expected leaked type param to normalize to integer, got %v", merged.Returns[0]) - } -} - -func TestMergeFuncTypes_PrefersWiderSupertypeOnSubtypeRelation(t *testing.T) { - merged := MergeFunctionFactType(typ.Integer, typ.Number) - if !typ.TypeEquals(merged, typ.Number) { - t.Fatalf("expected wider supertype number, got %v", merged) - } - - merged = MergeFunctionFactType(typ.Number, typ.Integer) - if !typ.TypeEquals(merged, typ.Number) { - t.Fatalf("expected wider supertype number, got %v", merged) - } -} - -func TestMergeFuncTypes_IsCommutativeForIncomparableSignatures(t *testing.T) { - coarse := typ.Func(). - Param("entries", typ.Any). - Returns(typ.Integer). - Build() - refined := typ.Func(). - Param("entries", typ.NewArray(typ.String)). - Returns(typ.Integer). - Build() - - forward := MergeFunctionFactType(coarse, refined) - reverse := MergeFunctionFactType(refined, coarse) - if !typ.TypeEquals(forward, reverse) { - t.Fatalf("expected commutative merge result, got forward=%v reverse=%v", forward, reverse) - } -} - -func TestMergeFuncTypes_AliasInputsUseCanonicalJoin(t *testing.T) { - coarse := typ.NewAlias("CoarseFn", typ.Func(). - Param("entries", typ.Any). - Returns(typ.Integer). - Build()) - refined := typ.NewAlias("RefinedFn", typ.Func(). - Param("entries", typ.NewArray(typ.String)). - Returns(typ.Integer). - Build()) - - forward := MergeFunctionFactType(coarse, refined) - reverse := MergeFunctionFactType(refined, coarse) - if !typ.TypeEquals(forward, reverse) { - t.Fatalf("expected commutative alias merge result, got forward=%v reverse=%v", forward, reverse) - } -} - -func TestMergeFuncTypes_MapVsOpenRecordUsesCanonicalJoin(t *testing.T) { - coarse := typ.Func(). - Param("t", typ.NewRecord().SetOpen(true).Build()). - Returns(typ.String). - Build() - refined := typ.Func(). - Param("t", typ.NewMap(typ.String, typ.NewArray(typ.String))). - Returns(typ.String). - Build() - - forward := MergeFunctionFactType(coarse, refined) - reverse := MergeFunctionFactType(refined, coarse) - if !typ.TypeEquals(forward, reverse) { - t.Fatalf("expected commutative map/open-record merge result, got forward=%v reverse=%v", forward, reverse) - } -} - -func TestWidenLiteralSigs_DoesNotNarrowComparableSignature(t *testing.T) { - lit := &ast.FunctionExpr{} - - prev := api.LiteralSigs{ - lit: typ.Func().Returns(typ.Number).Build(), - } - next := api.LiteralSigs{ - lit: typ.Func().Returns(typ.Integer).Build(), - } - - merged := WidenLiteralSigs(prev, next) - got := merged[lit] - if got == nil { - t.Fatal("expected merged literal signature") - } - if len(got.Returns) != 1 { - t.Fatalf("expected one return, got %d", len(got.Returns)) - } - if !subtype.IsSubtype(prev[lit].Returns[0], got.Returns[0]) { - t.Fatalf("expected merged return to be supertype of prev (%v), got %v", prev[lit].Returns[0], got.Returns[0]) - } - if !subtype.IsSubtype(next[lit].Returns[0], got.Returns[0]) { - t.Fatalf("expected merged return to be supertype of next (%v), got %v", next[lit].Returns[0], got.Returns[0]) - } - if typ.TypeEquals(got.Returns[0], next[lit].Returns[0]) { - t.Fatalf("expected merged return not to regress to narrower next-only type %v", got.Returns[0]) - } -} - -func TestWidenLiteralSigs_PrefersMergedSameShapeSignature(t *testing.T) { - lit := &ast.FunctionExpr{} - - prev := api.LiteralSigs{ - lit: typ.Func().Returns(typ.String).Build(), - } - next := api.LiteralSigs{ - lit: typ.Func().Returns(typ.Integer).Build(), - } - - merged := WidenLiteralSigs(prev, next) - got := merged[lit] - if got == nil { - t.Fatal("expected merged literal signature") - } - if len(got.Returns) != 1 { - t.Fatalf("expected one return, got %d", len(got.Returns)) - } - want := typ.NewUnion(typ.String, typ.Integer) - if !typ.TypeEquals(got.Returns[0], want) { - t.Fatalf("expected merged return %v, got %v", want, got.Returns[0]) - } -} - -func TestTypeContainsFunction_IgnoresInterfaceMethodSignatures(t *testing.T) { - iface := typ.NewInterface("Reader", []typ.Method{ - { - Name: "next", - Type: typ.Func(). - Param("self", typ.Self). - Returns(typ.Func().Returns(typ.String).Build()). - Build(), - }, - }) - if typeContainsFunction(iface) { - t.Fatalf("expected interface method signatures to be ignored, got true") - } -} - -func TestHasHigherOrderGrowthRisk_DetectsFunctionReturningFunction(t *testing.T) { - tp := typ.Func(). - Returns(typ.Func().Returns(typ.String).Build()). - Build() - if !hasHigherOrderGrowthRisk(tp) { - t.Fatalf("expected higher-order growth risk to be detected") - } -} - -func TestMethodTypeHasSelfRecursiveReturn_IgnoresInterfaceMethods(t *testing.T) { - owner := typ.NewRecord().Field("id", typ.String).Build() - methodType := typ.NewInterface("HasBuild", []typ.Method{ - { - Name: "build", - Type: typ.Func(). - Param("self", typ.Self). - Returns(owner). - Build(), - }, - }) - if methodTypeHasSelfRecursiveReturn(methodType, owner) { - t.Fatalf("expected interface method signatures to be ignored for self-recursive detection") - } -} diff --git a/compiler/check/scope/typedefs.go b/compiler/check/scope/typedefs.go index d792e9b0..d694e60a 100644 --- a/compiler/check/scope/typedefs.go +++ b/compiler/check/scope/typedefs.go @@ -98,8 +98,8 @@ func applyTypeDefAtPoint(graph *cfg.Graph, p cfg.Point, current *State, resolver // // CFG stores type parameters in a simplified form (TypeParamInfo) that captures // name and constraint. The AST representation (TypeParamExpr) is needed for -// type resolution, which operates on AST nodes. This function bridges the two -// representations. +// type resolution, which operates on AST nodes. This function converts between +// the two representations. func ToTypeParamExprs(params []cfg.TypeParamInfo) []ast.TypeParamExpr { if len(params) == 0 { return nil diff --git a/compiler/check/session.go b/compiler/check/session.go index 3475a9ae..3bac2775 100644 --- a/compiler/check/session.go +++ b/compiler/check/session.go @@ -1,7 +1,7 @@ // session.go defines the Session type that holds per-run state for type checking. // Session is the primary interface for accessing analysis results. SessionStore // lives in compiler/check/store and manages fixpoint iteration state and interproc -// snapshots. +// products. // // # LIFECYCLE SEPARATION // @@ -11,24 +11,19 @@ // Contains binding tables, CFG graphs, and module aliases. Never modified // during fixpoint iteration. // -// - IterationStore: Iteration-local state used during fixpoint convergence -// (revision counter and constructor field collection). +// - Fact inputs: query-tracked interprocedural products used to revalidate +// cached function analysis when facts change. // -// - IterationScratch: Single-iteration state cleared at each boundary. -// Tracks which literals have been analyzed, pending parameter hints, -// and change detection flags. +// # PRODUCT PROTOCOL // -// # SNAPSHOT PROTOCOL +// Interproc facts follow a product protocol: // -// Interproc facts and effects follow a snapshot protocol: +// - During iteration: functions read the visible product +// - At boundary: accumulated facts widen into the stable product +// - Convergence: iteration stops when the product is unchanged // -// - During iteration: Functions read from the previous snapshot -// - At boundary: New facts/effects replace the snapshot -// - Convergence: Iteration stops when snapshots are unchanged -// -// This protocol ensures all functions within an iteration see consistent -// cross-function information, enabling deterministic analysis regardless -// of function processing order. +// The visible product supports deterministic Gauss-Seidel propagation because +// function scheduling is deterministic. // // # PARALLELIZATION // @@ -42,6 +37,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/modules" "github.com/wippyai/go-lua/compiler/check/store" "github.com/wippyai/go-lua/types/constraint" @@ -53,7 +49,7 @@ import ( // Session holds all state and results for analyzing a single Lua module. // One Session is created per Check call and contains the complete analysis output -// including per-function results, diagnostics, and inter-function channel data. +// including per-function results, diagnostics, and interprocedural fact products. // // USAGE PATTERN: // @@ -66,7 +62,7 @@ import ( // CONCURRENCY: db.QueryContext is NOT safe for concurrent access. For parallel // analysis, create one Session per worker with independent QueryContexts, then // merge results. Functions within a single fixpoint iteration read from shared -// snapshots and write to independent per-iteration maps, enabling future parallelization. +// products and write to independent per-iteration maps, enabling future parallelization. // // MEMORY MANAGEMENT: Call Release() after extracting Manifest data to free heavy // allocations (CFGs, scopes, flow data). The Session remains valid for Diagnostics @@ -217,7 +213,11 @@ func (s *Session) ScopeDepthDiagState() map[*ast.FunctionExpr]bool { // New creates a session for checking a file. func New(ctx *db.QueryContext, name string) *Session { - store := store.NewSessionStore() + var database *db.DB + if ctx != nil { + database = ctx.DB() + } + store := store.NewSessionStoreWithDB(database) api.AttachStore(ctx, store) sess := &Session{ Ctx: ctx, @@ -247,6 +247,14 @@ func (s *Session) GetOrBuildCFG(fn *ast.FunctionExpr) *cfg.Graph { return g } +// EvidenceForGraph returns canonical graph evidence through the session store. +func (s *Session) EvidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if s == nil || s.Store == nil { + return api.FlowEvidence{} + } + return s.Store.EvidenceForGraph(graph) +} + // RegisterGraphHierarchy registers the root graph and all nested graphs. // This populates graph/function maps and nested metadata for query lookups. func (s *Session) RegisterGraphHierarchy(root *cfg.Graph) { @@ -272,56 +280,25 @@ func (s *Session) RegisterGraphHierarchy(root *cfg.Graph) { } } } - // Register local function assignments within this graph. - g.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { - if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return - } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { - return - } - if fnExpr, ok := source.(*ast.FunctionExpr); ok && fnExpr != nil { - child := s.GetOrBuildCFG(fnExpr) - if child == nil { - return - } - s.Store.RegisterGraph(child, fnExpr) - s.Store.RegisterNestedMeta(child.ID(), g.ID(), p) - s.Store.RegisterFunctionRef(target.Symbol, fnExpr, child, g.ID(), p) - } - }) - }) - g.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { - if info == nil || info.Symbol == 0 || info.FuncExpr == nil { - return - } - child := s.GetOrBuildCFG(info.FuncExpr) - if child == nil { - return - } - s.Store.RegisterGraph(child, info.FuncExpr) - s.Store.RegisterNestedMeta(child.ID(), g.ID(), p) - s.Store.RegisterFunctionRef(info.Symbol, info.FuncExpr, child, g.ID(), p) - }) - for _, nf := range g.NestedFunctions() { - if nf.Func == nil { + evidence := s.Store.EvidenceForGraph(g) + for _, def := range evidence.FunctionDefinitions { + if def.Nested.Func == nil { continue } - child := s.GetOrBuildCFG(nf.Func) + child := s.GetOrBuildCFG(def.Nested.Func) if child == nil { continue } - s.Store.RegisterGraph(child, nf.Func) - s.Store.RegisterNestedMeta(child.ID(), g.ID(), nf.Point) - nestedSym := nf.Symbol + s.Store.RegisterGraph(child, def.Nested.Func) + s.Store.RegisterNestedMeta(child.ID(), g.ID(), def.Nested.Point) + nestedSym := def.Symbol if nestedSym == 0 && child.Bindings() != nil { - if sym, ok := child.Bindings().FuncLitSymbol(nf.Func); ok { + if sym, ok := child.Bindings().FuncLitSymbol(def.Nested.Func); ok { nestedSym = sym } } if nestedSym != 0 { - s.Store.RegisterFunctionRef(nestedSym, nf.Func, child, g.ID(), nf.Point) + s.Store.RegisterFunctionRef(nestedSym, def.Nested.Func, child, g.ID(), def.Nested.Point) } walk(child) } @@ -373,10 +350,8 @@ func (s *Session) ExportType() typ.Type { return typ.Nil } var refinements map[cfg.SymbolID]*constraint.FunctionRefinement - if s.Store != nil { - if s.Store.InterprocPrev != nil { - refinements = s.Store.InterprocPrev.Refinements - } + if s.Store != nil && s.Store.InterprocPrev != nil { + refinements = s.RefinementsForExport() } return modules.ExportType(s.RootResult, refinements) } @@ -387,7 +362,7 @@ func (s *Session) ExportType() typ.Type { // WHAT IS FREED: // - CFG graphs and binding tables // - Scope states and flow solutions -// - Inter-function channel data +// - Interprocedural fact products // - Synthesis engines // // WHAT REMAINS VALID: @@ -413,20 +388,14 @@ func (s *Session) Release() { clear(s.Store.Module.ModuleAliases) } - // Clear interproc snapshots + // Clear interproc products if s.Store.InterprocPrev != nil { clear(s.Store.InterprocPrev.Facts) - clear(s.Store.InterprocPrev.Refinements) - clear(s.Store.InterprocPrev.ConstructorFields) } if s.Store.InterprocNext != nil { clear(s.Store.InterprocNext.Facts) - clear(s.Store.InterprocNext.Refinements) - clear(s.Store.InterprocNext.ConstructorFields) } - // Clear iteration scratch (empty placeholder) - _ = s.Store.Scratch } // Clear per-function results @@ -496,7 +465,7 @@ func (s *Session) ExportManifest(modulePath string) *io.Manifest { } // RefinementsForExport extracts computed function refinements for manifest generation. -// Returns refinements from the final converged interproc snapshot. +// Returns refinements from the final converged interproc product. // // The returned map associates each function's SymbolID with its computed refinement, // including IO effects (row), termination status, and conditional effects. @@ -508,7 +477,16 @@ func (s *Session) RefinementsForExport() map[cfg.SymbolID]*constraint.FunctionRe if s.Store.InterprocPrev == nil { return nil } - return modules.CopyRefinementsForExport(s.Store.InterprocPrev.Refinements) + refinements := make(map[cfg.SymbolID]*constraint.FunctionRefinement) + for _, key := range api.SortedGraphKeys(s.Store.InterprocPrev.Facts) { + facts := s.Store.InterprocPrev.Facts[key] + for _, sym := range cfg.SortedSymbolIDs(facts.FunctionFacts) { + if refinement := functionfact.RefinementFromMap(facts.FunctionFacts, sym); refinement != nil { + refinements[sym] = refinement + } + } + } + return modules.CopyRefinementsForExport(refinements) } // RootGraph returns the root function's control flow graph. diff --git a/compiler/check/session_test.go b/compiler/check/session_test.go index 703c3f47..6371b214 100644 --- a/compiler/check/session_test.go +++ b/compiler/check/session_test.go @@ -395,50 +395,78 @@ func TestAttachStore_NilStore(t *testing.T) { } } -func TestSessionStore_EffectMaps(t *testing.T) { +func TestSessionStore_ProductMaps(t *testing.T) { store := store.NewSessionStore() - if store.InterprocPrev == nil || store.InterprocPrev.Refinements == nil { - t.Error("InterprocPrev effects not initialized") + if store.InterprocPrev == nil || store.InterprocPrev.Facts == nil { + t.Error("InterprocPrev facts not initialized") } - if store.InterprocNext == nil || store.InterprocNext.Refinements == nil { - t.Error("InterprocNext effects not initialized") + if store.InterprocNext == nil || store.InterprocNext.Facts == nil { + t.Error("InterprocNext facts not initialized") } } -func TestFixpointChannelDiffs_IsolatedBetweenStores(t *testing.T) { +func TestFixpointDiffs_IsolatedBetweenStores(t *testing.T) { storeA := store.NewSessionStore() storeB := store.NewSessionStore() - storeA.StoreFunctionRefinement(cfg.SymbolID(42), &constraint.FunctionRefinement{}) - storeB.StoreFunctionRefinement(cfg.SymbolID(42), &constraint.FunctionRefinement{}) + keyA := registerSessionFunctionForRefinementTest(t, storeA, cfg.SymbolID(42)) + keyB := registerSessionFunctionForRefinementTest(t, storeB, cfg.SymbolID(42)) + storeA.MergeInterprocFactsNext(keyA, api.Facts{ + FunctionFacts: api.FunctionFacts{cfg.SymbolID(42): {Refinement: &constraint.FunctionRefinement{Terminates: true}}}, + }) + storeB.MergeInterprocFactsNext(keyB, api.Facts{ + FunctionFacts: api.FunctionFacts{cfg.SymbolID(42): {Refinement: &constraint.FunctionRefinement{Terminates: true}}}, + }) if !storeA.FixpointSwap() { t.Fatal("expected storeA FixpointSwap to report change") } - if diffs := storeA.FixpointChannelDiffs(); len(diffs) == 0 { + if diffs := storeA.FixpointDiffs(); len(diffs) == 0 { t.Fatal("expected storeA diffs to be non-empty") } - if diffs := storeB.FixpointChannelDiffs(); len(diffs) != 0 { + if diffs := storeB.FixpointDiffs(); len(diffs) != 0 { t.Fatalf("expected storeB diffs to be empty, got %v", diffs) } } -func TestSessionStore_ClearIterationChannels(t *testing.T) { +func registerSessionFunctionForRefinementTest(t *testing.T, st *store.SessionStore, sym cfg.SymbolID) api.GraphKey { + t.Helper() + fn := &ast.FunctionExpr{} + graph := cfg.Build(fn) + if graph == nil { + t.Fatal("expected graph") + } + parent := scope.New() + st.RegisterGraph(graph, fn) + st.RegisterFunctionRef(sym, fn, graph, graph.ID(), 1) + st.SetGraphParentHash(graph.ID(), parent.Hash()) + st.SetParentScope(parent.Hash(), parent) + return api.KeyForGraph(graph, parent.Hash()) +} + +func TestSessionStore_ClearInterprocState(t *testing.T) { store := store.NewSessionStore() - store.StoreConstructorFields(cfg.SymbolID(2), map[string]typ.Type{"name": typ.String}) - store.InterprocPrev.Refinements[cfg.SymbolID(4)] = &constraint.FunctionRefinement{} - store.StoreFunctionRefinement(cfg.SymbolID(5), &constraint.FunctionRefinement{}) + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{ + ConstructorFields: api.ConstructorFields{ + cfg.SymbolID(2): {"name": typ.String}, + }, + }) + store.InterprocPrev.Facts[api.GraphKey{GraphID: 1, ParentHash: 1}] = api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(4): {Refinement: &constraint.FunctionRefinement{Terminates: true}}, + }, + } - store.ClearIterationChannels() + store.ClearInterprocState() - if store.InterprocNext == nil || len(store.InterprocNext.ConstructorFields) != 0 { - t.Fatal("expected constructor fields to be cleared") + if store.InterprocPrev == nil || len(store.InterprocPrev.Facts) != 0 { + t.Fatal("expected previous product facts to be cleared") } - if len(store.InterprocPrev.Refinements) != 0 || len(store.InterprocNext.Refinements) != 0 { - t.Fatal("expected effects to be cleared") + if store.InterprocNext == nil || len(store.InterprocNext.Facts) != 0 { + t.Fatal("expected next product facts to be cleared") } } @@ -504,74 +532,88 @@ func TestSession_Release_Nil(t *testing.T) { sess.Release() // should not panic } -func TestStoreConstructorFields_ZeroSymbol(t *testing.T) { +func TestModuleConstructorFacts_EmptyDelta(t *testing.T) { store := store.NewSessionStore() - store.StoreConstructorFields(0, map[string]typ.Type{"x": typ.Number}) - if store.InterprocNext == nil || len(store.InterprocNext.ConstructorFields) != 0 { - t.Error("zero symbol should not store fields") + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{}) + if store.InterprocNext == nil || len(store.InterprocNext.Facts) != 0 { + t.Error("empty product delta should not store facts") } } -func TestStoreConstructorFields_EmptyFields(t *testing.T) { +func TestModuleConstructorFacts_EmptyFields(t *testing.T) { store := store.NewSessionStore() - store.StoreConstructorFields(1, nil) - if store.InterprocNext == nil || len(store.InterprocNext.ConstructorFields) != 0 { - t.Error("empty fields should not store") + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{ + ConstructorFields: api.ConstructorFields{1: nil}, + }) + got := store.GetModuleFacts().ConstructorFields[1] + if len(got) != 0 { + t.Fatalf("empty constructor field map should stay empty, got %#v", got) } } -func TestStoreConstructorFields_Basic(t *testing.T) { +func TestModuleConstructorFacts_Basic(t *testing.T) { store := store.NewSessionStore() fields := map[string]typ.Type{"x": typ.Number, "y": typ.String} - store.StoreConstructorFields(1, fields) + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{ + ConstructorFields: api.ConstructorFields{1: fields}, + }) next := store.InterprocNext - if next == nil || next.ConstructorFields == nil { - t.Fatal("ConstructorFieldsNext should be initialized") + if next == nil || next.Facts == nil { + t.Fatal("next product facts should be initialized") } - if len(next.ConstructorFields[1]) != 2 { - t.Errorf("expected 2 fields, got %d", len(next.ConstructorFields[1])) + gotFields := next.Facts[api.ModuleFactsKey()].ConstructorFields[1] + if len(gotFields) != 2 { + t.Errorf("expected 2 fields, got %d", len(gotFields)) } } -func TestStoreConstructorFields_Join(t *testing.T) { +func TestModuleConstructorFacts_Join(t *testing.T) { store := store.NewSessionStore() - store.StoreConstructorFields(1, map[string]typ.Type{"x": typ.Number}) - store.StoreConstructorFields(1, map[string]typ.Type{"x": typ.String}) + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{ + ConstructorFields: api.ConstructorFields{1: {"x": typ.Number}}, + }) + store.MergeInterprocFactsNext(api.ModuleFactsKey(), api.Facts{ + ConstructorFields: api.ConstructorFields{1: {"x": typ.String}}, + }) next := store.InterprocNext - if next == nil || next.ConstructorFields == nil || next.ConstructorFields[1]["x"] == typ.Number { + if next == nil { + t.Fatal("next product facts should be initialized") + } + fields := next.Facts[api.ModuleFactsKey()].ConstructorFields[1] + if fields == nil || fields["x"] == typ.Number { t.Error("field should be joined") } } -func TestLookupConstructorFields_ZeroSymbol(t *testing.T) { +func TestGetModuleFacts_AbsentConstructorClass(t *testing.T) { store := store.NewSessionStore() - result := store.LookupConstructorFields(0) + result := store.GetModuleFacts().ConstructorFields[0] if result != nil { - t.Error("zero symbol should return nil") + t.Error("absent constructor class should return nil") } } -func TestLookupConstructorFields_FromNext(t *testing.T) { +func TestGetModuleFacts_ConstructorFieldsFromNext(t *testing.T) { store := store.NewSessionStore() setConstructorFieldsNextForTest(store, map[cfg.SymbolID]map[string]typ.Type{ 1: {"x": typ.Number}, }) - result := store.LookupConstructorFields(1) - if result != nil { - t.Fatal("should not read constructor fields from Next snapshot") + result := store.GetModuleFacts().ConstructorFields[1] + if result == nil { + t.Fatal("should find same-iteration constructor fields from product overlay") } } -func TestLookupConstructorFields_FromPrev(t *testing.T) { +func TestGetModuleFacts_ConstructorFieldsFromPrev(t *testing.T) { store := store.NewSessionStore() setConstructorFieldsPrevForTest(store, map[cfg.SymbolID]map[string]typ.Type{ 1: {"y": typ.String}, }) - result := store.LookupConstructorFields(1) + result := store.GetModuleFacts().ConstructorFields[1] if result == nil { - t.Fatal("should find fields from snapshot") + t.Fatal("should find fields from stable product") } if result["y"] != typ.String { t.Error("wrong field type") diff --git a/compiler/check/session_testutil_test.go b/compiler/check/session_testutil_test.go index 6dc9fb01..8cbcbed7 100644 --- a/compiler/check/session_testutil_test.go +++ b/compiler/check/session_testutil_test.go @@ -2,6 +2,7 @@ package check import ( "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/store" "github.com/wippyai/go-lua/types/typ" ) @@ -13,7 +14,7 @@ func setConstructorFieldsNextForTest(s *store.SessionStore, fields map[cfg.Symbo if s.InterprocNext == nil { s.InterprocNext = store.NewInterprocState() } - s.InterprocNext.ConstructorFields = fields + s.InterprocNext.Facts[api.ModuleFactsKey()] = api.Facts{ConstructorFields: api.ConstructorFields(fields)} } func setConstructorFieldsPrevForTest(s *store.SessionStore, fields map[cfg.SymbolID]map[string]typ.Type) { @@ -23,5 +24,5 @@ func setConstructorFieldsPrevForTest(s *store.SessionStore, fields map[cfg.Symbo if s.InterprocPrev == nil { s.InterprocPrev = store.NewInterprocState() } - s.InterprocPrev.ConstructorFields = fields + s.InterprocPrev.Facts[api.ModuleFactsKey()] = api.Facts{ConstructorFields: api.ConstructorFields(fields)} } diff --git a/compiler/check/siblings/doc.go b/compiler/check/siblings/doc.go index 2f84ed92..5bcc60ec 100644 --- a/compiler/check/siblings/doc.go +++ b/compiler/check/siblings/doc.go @@ -21,7 +21,7 @@ // // # Overlay System // -// [Overlay] provides a view that combines: +// [Overlay] combines: // - Stable sibling types from previous iterations // - Pending updates from current iteration // diff --git a/compiler/check/siblings/overlay.go b/compiler/check/siblings/overlay.go index ee6f2d57..b35df311 100644 --- a/compiler/check/siblings/overlay.go +++ b/compiler/check/siblings/overlay.go @@ -22,8 +22,8 @@ type OverlayEntry struct { // sibling functions so that calls between them can be typed during // fixpoint iteration. type OverlayConfig struct { - // Summaries maps symbols to their return type summaries. - Summaries map[cfg.SymbolID][]typ.Type + // ReturnVectors maps symbols to their current inferred return vectors. + ReturnVectors map[cfg.SymbolID][]typ.Type // Siblings are the sibling functions in this scope group. Siblings []OverlayEntry @@ -31,7 +31,7 @@ type OverlayConfig struct { // CurrentSym is the symbol of the function being analyzed (excluded from overlay). CurrentSym cfg.SymbolID - // Services provides seed type resolution for siblings without summaries. + // Services provides seed type resolution for siblings without return vectors. Services OverlayServices } @@ -55,27 +55,37 @@ func (o OverlayServicesFuncs) SeedType(fn *ast.FunctionExpr) typ.Type { // BuildOverlay constructs an overlay map for return inference. // // This overlay is used during SCC-based return type inference. It provides -// function types for sibling functions based on their current return summaries. +// function types for sibling functions based on their current return vectors. // The current function (CurrentSym) is excluded from the overlay to avoid // circular dependence during its own analysis. // -// For siblings without summaries yet, placeholder function types are created +// For siblings without return vectors yet, placeholder function types are created // using seed type services to preserve parameter arity. This enables the fixpoint // to make progress even when not all return types are known. func BuildOverlay(c OverlayConfig) map[cfg.SymbolID]typ.Type { overlay := make(map[cfg.SymbolID]typ.Type) + siblingFuncs := make(map[cfg.SymbolID]*ast.FunctionExpr, len(c.Siblings)) + for _, sib := range c.Siblings { + if sib.Symbol != 0 && sib.Func != nil { + siblingFuncs[sib.Symbol] = sib.Func + } + } - // Add sibling function types with current return summaries. - for sym, returnTypes := range c.Summaries { + // Add sibling function types with current return vectors. + for sym, returnTypes := range c.ReturnVectors { if sym == c.CurrentSym { continue } if len(returnTypes) > 0 { - overlay[sym] = buildFunctionFromReturns(returnTypes) + var seedType typ.Type + if c.Services != nil { + seedType = c.Services.SeedType(siblingFuncs[sym]) + } + overlay[sym] = buildFunctionFromSeedAndReturns(seedType, returnTypes) } } - // Seed siblings without summaries with placeholder function types. + // Seed siblings without return vectors with placeholder function types. for _, sib := range c.Siblings { if sib.Symbol == c.CurrentSym { continue @@ -101,3 +111,38 @@ func buildFunctionFromReturns(returnTypes []typ.Type) typ.Type { } return typ.Func().Returns(returnTypes...).Build() } + +func buildFunctionFromSeedAndReturns(seed typ.Type, returnTypes []typ.Type) typ.Type { + if len(returnTypes) == 0 { + return seed + } + fn, ok := seed.(*typ.Function) + if !ok || fn == nil { + return buildFunctionFromReturns(returnTypes) + } + builder := typ.Func() + for _, tp := range fn.TypeParams { + builder.TypeParam(tp.Name, tp.Constraint) + } + for _, p := range fn.Params { + if p.Optional { + builder.OptParam(p.Name, p.Type) + } else { + builder.Param(p.Name, p.Type) + } + } + if fn.Variadic != nil { + builder.Variadic(fn.Variadic) + } + builder.Returns(returnTypes...) + if fn.Effects != nil { + builder.Effects(fn.Effects) + } + if fn.Spec != nil { + builder.Spec(fn.Spec) + } + if fn.Refinement != nil { + builder.WithRefinement(fn.Refinement) + } + return builder.Build() +} diff --git a/compiler/check/siblings/overlay_test.go b/compiler/check/siblings/overlay_test.go index 23c2872e..7ddc68a4 100644 --- a/compiler/check/siblings/overlay_test.go +++ b/compiler/check/siblings/overlay_test.go @@ -16,9 +16,9 @@ func TestBuildOverlay_Empty(t *testing.T) { } } -func TestBuildOverlay_WithSummaries(t *testing.T) { +func TestBuildOverlay_WithReturnVectors(t *testing.T) { conf := OverlayConfig{ - Summaries: map[cfg.SymbolID][]typ.Type{ + ReturnVectors: map[cfg.SymbolID][]typ.Type{ 1: {typ.String}, 2: {typ.Number}, }, @@ -35,7 +35,7 @@ func TestBuildOverlay_WithSummaries(t *testing.T) { func TestBuildOverlay_ExcludesCurrent(t *testing.T) { conf := OverlayConfig{ - Summaries: map[cfg.SymbolID][]typ.Type{ + ReturnVectors: map[cfg.SymbolID][]typ.Type{ 1: {typ.String}, }, CurrentSym: 1, @@ -56,7 +56,7 @@ func TestOverlayEntry(t *testing.T) { } } -func TestBuildOverlay_SeedsSiblingsWithoutSummaries(t *testing.T) { +func TestBuildOverlay_SeedsSiblingsWithoutReturnVectors(t *testing.T) { seedType := typ.Func().Param("x", typ.Number).Build() fn := &ast.FunctionExpr{} conf := OverlayConfig{ @@ -72,14 +72,14 @@ func TestBuildOverlay_SeedsSiblingsWithoutSummaries(t *testing.T) { } result := BuildOverlay(conf) if result[1] != seedType { - t.Error("should seed sibling without summary using SeedType") + t.Error("should seed sibling without a return vector using SeedType") } } -func TestBuildOverlay_SummaryOverridesSeed(t *testing.T) { +func TestBuildOverlay_ReturnVectorOverridesSeed(t *testing.T) { fn := &ast.FunctionExpr{} conf := OverlayConfig{ - Summaries: map[cfg.SymbolID][]typ.Type{ + ReturnVectors: map[cfg.SymbolID][]typ.Type{ 1: {typ.String}, }, Siblings: []OverlayEntry{ @@ -98,7 +98,7 @@ func TestBuildOverlay_SummaryOverridesSeed(t *testing.T) { t.Fatal("should produce function type") } if len(fn2.Returns) == 0 || fn2.Returns[0] != typ.String { - t.Error("summary should take precedence") + t.Error("return vector should take precedence") } } diff --git a/compiler/check/siblings/siblings.go b/compiler/check/siblings/siblings.go index e93e7b7f..3983868f 100644 --- a/compiler/check/siblings/siblings.go +++ b/compiler/check/siblings/siblings.go @@ -18,18 +18,18 @@ // // # Build Algorithm // -// The Build function constructs sibling types through four steps: +// The Build function constructs sibling types through three steps: // 1. Seed from previous iteration (monotonic accumulation across fixpoint iterations) // 2. Merge captured variable types from the parent scope -// 3. Add sibling function types enriched with return summaries -// 4. Overlay literal signatures for refined function types +// 3. Add sibling function types from canonical function facts // // The result is a SymbolID -> Type map that can be injected into the type environment // when analyzing any function in the group. // // # Integration with Fixpoint // -// Sibling types are recomputed on each fixpoint iteration as return summaries improve. +// Sibling types are recomputed on each fixpoint iteration as canonical function +// facts improve. // The monotonic accumulation (step 1) ensures that types only grow more precise, // guaranteeing convergence. package siblings @@ -37,7 +37,8 @@ package siblings import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" ) @@ -70,8 +71,8 @@ type BuildConfig struct { // SiblingTypesPrev are sibling types from the previous iteration (monotonic accumulation). SiblingTypesPrev map[cfg.SymbolID]typ.Type - // FuncTypes are canonical local function types for this scope group. - FuncTypes map[cfg.SymbolID]typ.Type + // FunctionFacts are canonical local function facts for this scope group. + FunctionFacts api.FunctionFacts // Services provides required lookups for sibling construction. Services BuildServices @@ -178,11 +179,11 @@ func Build(c BuildConfig) map[cfg.SymbolID]typ.Type { if !entry.IsLocal || entry.Symbol == 0 { continue } - fnType := c.FuncTypes[entry.Symbol] + fnType := functionfact.TypeFromMap(c.FunctionFacts, entry.Symbol) if fnType == nil { continue } - result[entry.Symbol] = returns.MergeFunctionFactType(result[entry.Symbol], fnType) + result[entry.Symbol] = functionfact.MergeType(result[entry.Symbol], fnType) } if len(result) == 0 { diff --git a/compiler/check/siblings/siblings_test.go b/compiler/check/siblings/siblings_test.go index eefc4a49..a29ea30f 100644 --- a/compiler/check/siblings/siblings_test.go +++ b/compiler/check/siblings/siblings_test.go @@ -4,7 +4,8 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/types/typ" ) @@ -22,7 +23,7 @@ func TestBuild_WithFuncs(t *testing.T) { Funcs: []FuncEntry{ {Symbol: 1, IsLocal: true}, }, - FuncTypes: map[cfg.SymbolID]typ.Type{1: fnType}, + FunctionFacts: api.FunctionFacts{1: {Type: fnType}}, } result := Build(conf) if result == nil { @@ -38,7 +39,7 @@ func TestBuild_WithPrev(t *testing.T) { Funcs: []FuncEntry{ {Symbol: 1, IsLocal: true}, }, - FuncTypes: map[cfg.SymbolID]typ.Type{1: typ.Func().Build()}, + FunctionFacts: api.FunctionFacts{1: {Type: typ.Func().Build()}}, SiblingTypesPrev: map[cfg.SymbolID]typ.Type{ 2: typ.String, }, @@ -50,21 +51,21 @@ func TestBuild_WithPrev(t *testing.T) { } func TestMergeSiblingType_BothNilViaBuildAPI(t *testing.T) { - result := returns.MergeFunctionFactType(nil, nil) + result := functionfact.MergeType(nil, nil) if result != nil { t.Error("both nil should return nil") } } func TestMergeSiblingType_PrevNilViaBuildAPI(t *testing.T) { - result := returns.MergeFunctionFactType(nil, typ.String) + result := functionfact.MergeType(nil, typ.String) if result != typ.String { t.Error("prev nil should return next") } } func TestMergeSiblingType_NextNilViaBuildAPI(t *testing.T) { - result := returns.MergeFunctionFactType(typ.String, nil) + result := functionfact.MergeType(typ.String, nil) if result != typ.String { t.Error("next nil should return prev") } @@ -73,7 +74,7 @@ func TestMergeSiblingType_NextNilViaBuildAPI(t *testing.T) { func TestMergeSiblingType_FunctionsViaBuildAPI(t *testing.T) { prevFn := typ.Func().Build() nextFn := typ.Func().Returns(typ.String).Build() - result := returns.MergeFunctionFactType(prevFn, nextFn) + result := functionfact.MergeType(prevFn, nextFn) if result == nil { t.Fatal("should return merged function") } @@ -89,7 +90,7 @@ func TestMergeSiblingType_FunctionsViaBuildAPI(t *testing.T) { func TestMergeSiblingType_FunctionAliasesViaBuildAPI(t *testing.T) { prevFn := typ.NewAlias("Prev", typ.Func().Build()) nextFn := typ.NewAlias("Next", typ.Func().Returns(typ.String).Build()) - result := returns.MergeFunctionFactType(prevFn, nextFn) + result := functionfact.MergeType(prevFn, nextFn) if !typ.TypeEquals(result, nextFn) { t.Fatalf("expected function alias with returns to be preferred, got %v", result) } diff --git a/compiler/check/store/doc.go b/compiler/check/store/doc.go index 1e7739ed..5a3768f3 100644 --- a/compiler/check/store/doc.go +++ b/compiler/check/store/doc.go @@ -10,17 +10,19 @@ // The store holds: // - Built CFGs indexed by graph ID // - Analysis results (types, flow facts, diagnostics) per function -// - Interprocedural facts (return summaries, parameter hints) +// - Interprocedural facts (canonical function facts, parameter evidence) // - Module-level bindings and alias maps +// - Query-tracked interprocedural fact inputs for precise function-result +// cache revalidation // // # Session Integration // -// The store implements [api.StoreView] and [api.IterationStore] interfaces, +// The store implements [api.StoreReader] and [api.IterationStore] interfaces, // providing read access for queries and write access for the fixpoint driver. // -// # Snapshot Isolation +// # Product Visibility // -// During fixpoint iteration, the store provides stable snapshots of -// interprocedural facts while allowing incremental updates. This ensures -// consistent reads during a single iteration pass. +// During fixpoint iteration, the store exposes the visible product: the stable +// product from completed iterations overlaid with facts already produced in the +// current iteration. Query inputs track that visible product per graph key. package store diff --git a/compiler/check/store/fact_inputs.go b/compiler/check/store/fact_inputs.go new file mode 100644 index 00000000..ef0404a3 --- /dev/null +++ b/compiler/check/store/fact_inputs.go @@ -0,0 +1,147 @@ +package store + +import ( + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/interproc" + "github.com/wippyai/go-lua/types/db" +) + +// factInputs are the Salsa-style source inputs for interprocedural reads. +// They snapshot the store's visible fact product so FuncResult queries depend +// on the exact graph fact products they read. +type factInputs struct { + database *db.DB + + facts *db.Input[api.GraphKey, api.Facts] + factValues map[api.GraphKey]api.Facts +} + +func newFactInputs(database *db.DB) *factInputs { + if database == nil { + return nil + } + return &factInputs{ + database: database, + facts: db.NewInput[api.GraphKey, api.Facts](database), + factValues: make(map[api.GraphKey]api.Facts), + } +} + +func (in *factInputs) reset() { + if in == nil || in.database == nil { + return + } + for key := range in.factValues { + in.facts.Set(key, api.Facts{}) + } + clear(in.factValues) +} + +func (in *factInputs) factsFor(ctx *db.QueryContext, key api.GraphKey) (api.Facts, bool) { + if in == nil || in.facts == nil { + return api.Facts{}, false + } + facts, ok := in.facts.Get(ctx, key) + if !ok { + return api.Facts{}, false + } + return cloneFacts(facts), true +} + +func (in *factInputs) setFacts(key api.GraphKey, facts api.Facts) { + if in == nil || in.facts == nil { + return + } + if interproc.Empty(facts) { + if _, ok := in.factValues[key]; !ok { + return + } + delete(in.factValues, key) + in.facts.Set(key, api.Facts{}) + return + } + next := cloneFacts(facts) + if prev, ok := in.factValues[key]; ok && interproc.FactsEqual(prev, next) { + return + } + in.factValues[key] = next + in.facts.Set(key, next) +} + +func (s *SessionStore) PushFactReadContext(ctx *db.QueryContext) func() { + if s == nil || ctx == nil || s.factInputs == nil { + return func() {} + } + prev := s.factCtx + s.factCtx = ctx + return func() { + s.factCtx = prev + } +} + +func (s *SessionStore) visibleInterprocFacts(key api.GraphKey) api.Facts { + if s == nil { + return api.Facts{} + } + var prev api.Facts + if s.InterprocPrev != nil && s.InterprocPrev.Facts != nil { + prev = s.InterprocPrev.Facts[key] + } + if s.InterprocNext != nil && s.InterprocNext.Facts != nil { + if next, ok := s.InterprocNext.Facts[key]; ok { + if interproc.Empty(prev) { + return cloneFacts(next) + } + if interproc.Empty(next) { + return cloneFacts(prev) + } + return cloneFacts(interproc.OverlayFacts(prev, next)) + } + } + return cloneFacts(prev) +} + +func (s *SessionStore) interprocFactsByKey(key api.GraphKey) api.Facts { + if s == nil { + return api.Facts{} + } + if s.factInputs != nil { + if facts, ok := s.factInputs.factsFor(s.factCtx, key); ok { + return facts + } + return api.Facts{} + } + return s.visibleInterprocFacts(key) +} + +func (s *SessionStore) syncFactsInput(key api.GraphKey) { + if s == nil || s.factInputs == nil { + return + } + s.factInputs.setFacts(key, s.visibleInterprocFacts(key)) +} + +func (s *SessionStore) syncFactInputs() { + if s == nil || s.factInputs == nil { + return + } + + factKeys := make(map[api.GraphKey]struct{}, len(s.factInputs.factValues)) + for key := range s.factInputs.factValues { + factKeys[key] = struct{}{} + } + if s.InterprocPrev != nil { + for key := range s.InterprocPrev.Facts { + factKeys[key] = struct{}{} + } + } + if s.InterprocNext != nil { + for key := range s.InterprocNext.Facts { + factKeys[key] = struct{}{} + } + } + for key := range factKeys { + s.syncFactsInput(key) + } + +} diff --git a/compiler/check/store/facts_clone.go b/compiler/check/store/facts_clone.go new file mode 100644 index 00000000..93044dd0 --- /dev/null +++ b/compiler/check/store/facts_clone.go @@ -0,0 +1,152 @@ +package store + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/interproc" + "github.com/wippyai/go-lua/types/typ" +) + +func cloneFacts(f api.Facts) api.Facts { + if interproc.Empty(f) { + return api.Facts{} + } + return api.Facts{ + FunctionFacts: cloneFunctionFacts(f.FunctionFacts), + LiteralSigs: cloneLiteralSigs(f.LiteralSigs), + CapturedTypes: cloneCapturedTypes(f.CapturedTypes), + CapturedFields: cloneCapturedFieldAssigns(f.CapturedFields), + CapturedContainers: cloneCapturedContainerMutations(f.CapturedContainers), + ConstructorFields: cloneConstructorFields(f.ConstructorFields), + } +} + +func cloneFunctionFacts(src api.FunctionFacts) api.FunctionFacts { + if len(src) == 0 { + return nil + } + out := make(api.FunctionFacts, len(src)) + for sym, fact := range src { + fact.Params = cloneTypeSlice(fact.Params) + fact.Summary = cloneTypeSlice(fact.Summary) + fact.Narrow = cloneTypeSlice(fact.Narrow) + out[sym] = fact + } + return out +} + +func cloneLiteralSigs(src api.LiteralSigs) api.LiteralSigs { + if len(src) == 0 { + return nil + } + out := make(map[*ast.FunctionExpr]*typ.Function, len(src)) + for fn, sig := range src { + out[fn] = sig + } + return out +} + +func cloneCapturedTypes(src api.CapturedTypes) api.CapturedTypes { + if len(src) == 0 { + return nil + } + out := make(api.CapturedTypes, len(src)) + for sym, t := range src { + out[sym] = t + } + return out +} + +func cloneCapturedFieldAssigns(src api.CapturedFieldAssigns) api.CapturedFieldAssigns { + if len(src) == 0 { + return nil + } + out := make(api.CapturedFieldAssigns, len(src)) + for callee, bySym := range src { + if len(bySym) == 0 { + continue + } + bySymOut := make(map[cfg.SymbolID]map[string]typ.Type, len(bySym)) + for sym, fields := range bySym { + if len(fields) == 0 { + continue + } + fieldOut := make(map[string]typ.Type, len(fields)) + for name, t := range fields { + fieldOut[name] = t + } + bySymOut[sym] = fieldOut + } + if len(bySymOut) > 0 { + out[callee] = bySymOut + } + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneCapturedContainerMutations(src api.CapturedContainerMutations) api.CapturedContainerMutations { + if len(src) == 0 { + return nil + } + out := make(api.CapturedContainerMutations, len(src)) + for callee, bySym := range src { + if len(bySym) == 0 { + continue + } + bySymOut := make(map[cfg.SymbolID][]api.ContainerMutation, len(bySym)) + for sym, muts := range bySym { + if len(muts) == 0 { + continue + } + mutsOut := make([]api.ContainerMutation, len(muts)) + copy(mutsOut, muts) + for i := range mutsOut { + if len(mutsOut[i].Segments) > 0 { + mutsOut[i].Segments = append(mutsOut[i].Segments[:0:0], mutsOut[i].Segments...) + } + } + bySymOut[sym] = mutsOut + } + if len(bySymOut) > 0 { + out[callee] = bySymOut + } + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneConstructorFields(src api.ConstructorFields) api.ConstructorFields { + if len(src) == 0 { + return nil + } + out := make(api.ConstructorFields, len(src)) + for sym, fields := range src { + if len(fields) == 0 { + continue + } + fieldOut := make(map[string]typ.Type, len(fields)) + for name, t := range fields { + fieldOut[name] = t + } + out[sym] = fieldOut + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneTypeSlice(src []typ.Type) []typ.Type { + if len(src) == 0 { + return nil + } + out := make([]typ.Type, len(src)) + copy(out, src) + return out +} diff --git a/compiler/check/store/store.go b/compiler/check/store/store.go index ebd1534a..e2d251d4 100644 --- a/compiler/check/store/store.go +++ b/compiler/check/store/store.go @@ -6,11 +6,11 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/domain/interproc" "github.com/wippyai/go-lua/compiler/check/scope" - "github.com/wippyai/go-lua/types/constraint" - "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/db" ) type SessionStore struct { @@ -18,14 +18,7 @@ type SessionStore struct { // Created once at the start of checking and shared by all CFG builds. Module *ModuleStore - // Iteration contains iteration-local state for fixpoint convergence. - Iteration *IterationStore - - // Scratch contains iteration-local state cleared each cycle. - Scratch *IterationScratch - - // InterprocPrev holds the stable interproc snapshot used during analysis. - // Updated at fixpoint boundaries to provide a consistent view. + // InterprocPrev holds the stable interproc product from completed iterations. InterprocPrev *InterprocState // InterprocNext accumulates facts/effects produced during the current iteration. InterprocNext *InterprocState @@ -33,26 +26,27 @@ type SessionStore struct { // GraphParentHash records the parent scope hash for each graph ID. GraphParentHash map[uint64]uint64 - // lastSwapDiffs records which channels changed during the most recent FixpointSwap. + // lastSwapDiffs records product components changed by the most recent FixpointSwap. // Stored per-session to avoid cross-session contamination. lastSwapDiffs []string + factInputs *factInputs + factCtx *db.QueryContext + + analysisContexts map[api.GraphKey]api.AnalysisContext + phase api.Phase } -// InterprocState holds interprocedural facts and refinements for an iteration snapshot. +// InterprocState holds the graph-keyed interprocedural fact product for one iteration side. type InterprocState struct { - Facts map[api.GraphKey]api.Facts - Refinements map[cfg.SymbolID]*constraint.FunctionRefinement - ConstructorFields api.ConstructorFields + Facts map[api.GraphKey]api.Facts } -// NewInterprocState creates an initialized interproc snapshot. +// NewInterprocState creates an initialized interproc product side. func NewInterprocState() *InterprocState { return &InterprocState{ - Facts: make(map[api.GraphKey]api.Facts), - Refinements: make(map[cfg.SymbolID]*constraint.FunctionRefinement), - ConstructorFields: make(api.ConstructorFields), + Facts: make(map[api.GraphKey]api.Facts), } } @@ -72,6 +66,8 @@ type ModuleStore struct { GraphToFunc map[*cfg.Graph]*ast.FunctionExpr // FuncToGraph maps FunctionExpr nodes to their CFG graphs for O(1) lookup. FuncToGraph map[*ast.FunctionExpr]*cfg.Graph + // Evidence maps graph IDs to canonical abstract-interpreter event evidence. + Evidence map[uint64]api.FlowEvidence // Functions stores canonical mappings between symbols and function graphs. Functions *FunctionRegistry @@ -93,91 +89,28 @@ type FunctionRegistry struct { ByGraphID map[uint64]*api.FunctionRef } -// IterationStore holds iteration-local state for fixpoint iteration. -type IterationStore struct { - // Revision is bumped at fixpoint iteration boundary. - // Included in FuncKey to invalidate cached results when interproc facts/effects change. - Revision uint64 -} - -// IterationScratch holds iteration-local state cleared each cycle. -// Not double-buffered; reset at each iteration boundary. -type IterationScratch struct { - // LiteralSigsByGraphID stores literal signatures computed this iteration. - LiteralSigsByGraphID map[uint64]map[*ast.FunctionExpr]*typ.Function -} - -// effectsEqual compares two FunctionRefinements for structural equality. -func effectsEqual(a, b *constraint.FunctionRefinement) bool { - if a == b { - return true - } - if a == nil || b == nil { - return false - } - return a.Equals(b) -} - -// effectsMapEqual compares two effect maps for structural equality. -func effectsMapEqual(a, b map[cfg.SymbolID]*constraint.FunctionRefinement) bool { - if len(a) != len(b) { - return false - } - for _, sym := range cfg.SortedSymbolIDs(a) { - if !effectsEqual(a[sym], b[sym]) { - return false - } - } - return true -} - -// interprocFactsMapEqual compares two interproc facts maps. -func interprocFactsMapEqual(a, b map[api.GraphKey]api.Facts) bool { - if len(a) != len(b) { - return false - } - for _, key := range api.SortedGraphKeys(a) { - if !returns.FactsEqual(a[key], b[key]) { - return false - } - } - return true -} - -// widenInterprocFacts merges next facts into prev using monotone union. -func widenInterprocFacts(prev, next map[api.GraphKey]api.Facts) map[api.GraphKey]api.Facts { - if len(prev) == 0 && len(next) == 0 { - return make(map[api.GraphKey]api.Facts) - } - out := make(map[api.GraphKey]api.Facts, len(prev)+len(next)) - for _, key := range api.SortedGraphKeys(prev) { - out[key] = prev[key] - } - for _, key := range api.SortedGraphKeys(next) { - facts := next[key] - if existing, ok := out[key]; ok { - out[key] = returns.WidenFacts(existing, facts) - } else { - out[key] = facts - } - } - return out -} - // NewSessionStore creates an initialized store with all sub-structs. func NewSessionStore() *SessionStore { + return NewSessionStoreWithDB(nil) +} + +// NewSessionStoreWithDB creates a store whose interproc fact products are +// tracked as query inputs. The checker uses this form so function-result queries +// are revalidated from the exact facts they read instead of from a coarse +// iteration revision key. +func NewSessionStoreWithDB(database *db.DB) *SessionStore { return &SessionStore{ - Module: NewModuleStore(), - Iteration: NewIterationStore(), - Scratch: NewIterationScratch(), - InterprocPrev: NewInterprocState(), - InterprocNext: NewInterprocState(), - GraphParentHash: make(map[uint64]uint64), - phase: api.PhaseScopeCompute, + Module: NewModuleStore(), + InterprocPrev: NewInterprocState(), + InterprocNext: NewInterprocState(), + GraphParentHash: make(map[uint64]uint64), + analysisContexts: make(map[api.GraphKey]api.AnalysisContext), + factInputs: newFactInputs(database), + phase: api.PhaseScopeCompute, } } -// SetPhase sets the current check phase for snapshot access checks. +// SetPhase sets the current check phase for fact-product access checks. func (s *SessionStore) SetPhase(phase api.Phase) { if s == nil { return @@ -193,18 +126,6 @@ func (s *SessionStore) Phase() api.Phase { return s.phase } -func (s *SessionStore) requirePhase(allowed ...api.Phase) { - if s == nil { - return - } - for _, phase := range allowed { - if s.phase == phase { - return - } - } - panic("store: snapshot accessed in wrong phase") -} - // WithPhase runs fn with a temporary phase, restoring the prior phase afterward. func (s *SessionStore) WithPhase(phase api.Phase, fn func()) { if fn == nil { @@ -229,6 +150,7 @@ func NewModuleStore() *ModuleStore { Funcs: make(map[uint64]*ast.FunctionExpr), GraphToFunc: make(map[*cfg.Graph]*ast.FunctionExpr), FuncToGraph: make(map[*ast.FunctionExpr]*cfg.Graph), + Evidence: make(map[uint64]api.FlowEvidence), Functions: newFunctionRegistry(), Parents: make(map[uint64]*scope.State), ModuleAliases: make(map[cfg.SymbolID]string), @@ -236,18 +158,6 @@ func NewModuleStore() *ModuleStore { } } -// NewIterationStore creates an initialized iteration store. -func NewIterationStore() *IterationStore { - return &IterationStore{} -} - -// NewIterationScratch creates an initialized iteration scratch. -func NewIterationScratch() *IterationScratch { - return &IterationScratch{ - LiteralSigsByGraphID: make(map[uint64]map[*ast.FunctionExpr]*typ.Function), - } -} - func (s *SessionStore) ensureInterprocStates() { if s == nil { return @@ -260,18 +170,7 @@ func (s *SessionStore) ensureInterprocStates() { } } -func (s *SessionStore) resetScratch() { - if s == nil { - return - } - if s.Scratch == nil { - s.Scratch = NewIterationScratch() - return - } - s.Scratch.LiteralSigsByGraphID = make(map[uint64]map[*ast.FunctionExpr]*typ.Function) -} - -func swapSnapshotChannel[T any]( +func swapProductMap[T any]( prev *T, next *T, merge func(prev, next T) T, @@ -288,104 +187,62 @@ func swapSnapshotChannel[T any]( return changed } -func (s *SessionStore) swapInterprocChannels() []string { +func (s *SessionStore) swapInterprocFacts() []string { s.ensureInterprocStates() - // Channel policies: - // - Refinements/ConstructorFields are overwrite channels (next snapshot replaces prev). - // - InterprocFacts is a widening channel (monotone merge across iterations). - channels := []struct { + products := []struct { name string swap func() bool }{ - { - name: "Refinements", - swap: func() bool { - return swapSnapshotChannel( - &s.InterprocPrev.Refinements, - &s.InterprocNext.Refinements, - func(_prev, next map[cfg.SymbolID]*constraint.FunctionRefinement) map[cfg.SymbolID]*constraint.FunctionRefinement { - return next - }, - effectsMapEqual, - func() map[cfg.SymbolID]*constraint.FunctionRefinement { - return make(map[cfg.SymbolID]*constraint.FunctionRefinement) - }, - ) - }, - }, { name: "InterprocFacts", swap: func() bool { - return swapSnapshotChannel( + return swapProductMap( &s.InterprocPrev.Facts, &s.InterprocNext.Facts, - widenInterprocFacts, - interprocFactsMapEqual, + interproc.WidenFactMap, + interproc.FactMapEqual, func() map[api.GraphKey]api.Facts { return make(map[api.GraphKey]api.Facts) }, ) }, }, - { - name: "ConstructorFields", - swap: func() bool { - return swapSnapshotChannel( - &s.InterprocPrev.ConstructorFields, - &s.InterprocNext.ConstructorFields, - func(_prev, next api.ConstructorFields) api.ConstructorFields { - return next - }, - returns.ConstructorFieldsEqual, - func() api.ConstructorFields { - return make(api.ConstructorFields) - }, - ) - }, - }, } - diffs := make([]string, 0, len(channels)) - for _, channel := range channels { - if channel.swap() { - diffs = append(diffs, channel.name) + diffs := make([]string, 0, len(products)) + for _, product := range products { + if product.swap() { + diffs = append(diffs, product.name) } } return diffs } -// FixpointSwap performs the iteration boundary swap for all iteration-local channels. -// This is the critical operation that advances the fixpoint iteration by making -// current results available for the next iteration. +// FixpointSwap advances the interproc product at an iteration boundary. // // OPERATIONS PERFORMED: -// 1. Compare each channel's prev and next for equality +// 1. Compare the stable product with the accumulated product // 2. Move next → prev (current results become baseline for next iteration) // 3. Allocate fresh next maps (empty for accumulating new results) -// 4. Clear iteration-local scratch state -// 5. Record which channels changed for diagnostic reporting +// 4. Record which product components changed for diagnostic reporting // -// CHANGE DETECTION: Each channel uses type-appropriate equality: -// - Refinements: FunctionRefinement.Equals (structural comparison) -// - ConstructorFields: typ.TypeEquals (structural equality) +// CHANGE DETECTION: The product uses domain-owned structural equality. // -// RETURN VALUE: Returns true if any channel changed, signaling another iteration -// is needed. Returns false when all channels stabilize (fixpoint reached). +// RETURN VALUE: Returns true if the product changed, signaling another iteration +// is needed. Returns false when the product stabilizes. func (s *SessionStore) FixpointSwap() bool { - diffs := s.swapInterprocChannels() + diffs := s.swapInterprocFacts() - s.resetScratch() + s.syncFactInputs() - // Record which channels changed for diagnostic reporting s.lastSwapDiffs = diffs return len(diffs) > 0 } -// FixpointChannelDiffs returns the names of channels that changed during the -// most recent FixpointSwap call. -func (s *SessionStore) FixpointChannelDiffs() []string { +// FixpointDiffs returns product components changed by the most recent swap. +func (s *SessionStore) FixpointDiffs() []string { if s == nil { return nil } @@ -397,110 +254,18 @@ func (s *SessionStore) FixpointChannelDiffs() []string { return out } -// Revision returns the current revision counter. -func (s *SessionStore) Revision() uint64 { - if s == nil || s.Iteration == nil { - return 0 - } - return s.Iteration.Revision -} - -// BumpRevision increments the revision counter. -func (s *SessionStore) BumpRevision() { +// ClearInterprocState clears all interproc product state for a fresh run. +func (s *SessionStore) ClearInterprocState() { if s == nil { return } - if s.Iteration == nil { - s.Iteration = NewIterationStore() - } - s.Iteration.Revision++ -} - -// LookupRefinementBySym returns the refinement for a function by its SymbolID. -// Reads from the stable interproc refinement snapshot for order-independent analysis. -func (s *SessionStore) LookupRefinementBySym(sym cfg.SymbolID) *constraint.FunctionRefinement { - if sym == 0 { - return nil - } - if s.InterprocPrev == nil || s.InterprocPrev.Refinements == nil { - return nil - } - return s.InterprocPrev.Refinements[sym] -} - -// StoreFunctionRefinement records a function refinement for the current iteration. -func (s *SessionStore) StoreFunctionRefinement(sym cfg.SymbolID, eff *constraint.FunctionRefinement) { - if s == nil || sym == 0 || eff == nil { - return - } - s.ensureInterprocStates() - if s.InterprocNext.Refinements == nil { - s.InterprocNext.Refinements = make(map[cfg.SymbolID]*constraint.FunctionRefinement) - } - if existing := s.InterprocNext.Refinements[sym]; effectsEqual(existing, eff) { - return - } - s.InterprocNext.Refinements[sym] = eff -} - -// StoreConstructorFields stores constructor fields for a class symbol. -func (s *SessionStore) StoreConstructorFields(classSym cfg.SymbolID, fields map[string]typ.Type) { - if classSym == 0 || len(fields) == 0 { - return - } - s.ensureInterprocStates() - if s.InterprocNext.ConstructorFields == nil { - s.InterprocNext.ConstructorFields = make(api.ConstructorFields) - } - dst := s.InterprocNext.ConstructorFields[classSym] - if dst == nil { - dst = make(map[string]typ.Type) - s.InterprocNext.ConstructorFields[classSym] = dst - } - for name, t := range fields { - if existing := dst[name]; existing != nil { - dst[name] = typ.JoinPreferNonSoft(existing, t) - } else { - dst[name] = t - } - } -} - -// LookupConstructorFields returns constructor fields from the stable snapshot. -func (s *SessionStore) LookupConstructorFields(classSym cfg.SymbolID) map[string]typ.Type { - if s == nil || classSym == 0 { - return nil - } - if s.InterprocPrev == nil { - return nil - } - return s.InterprocPrev.ConstructorFields[classSym] -} - -// ClearIterationChannels clears all inter-function channel state for a fresh run. -func (s *SessionStore) ClearIterationChannels() { - if s == nil { - return - } - if s.Iteration == nil { - s.Iteration = NewIterationStore() - } - if s.Scratch == nil { - s.Scratch = NewIterationScratch() - } s.InterprocPrev = NewInterprocState() s.InterprocNext = NewInterprocState() - s.resetScratch() - s.Iteration.Revision = 0 s.lastSwapDiffs = nil -} - -// RefinementStore returns a view over the stable interproc refinement snapshot. -func (s *SessionStore) RefinementStore() api.RefinementStore { - if s == nil || s.InterprocPrev == nil { - return &snapshotRefinementStore{refinements: nil} + if s.factInputs != nil { + s.factInputs.reset() } - return &snapshotRefinementStore{refinements: s.InterprocPrev.Refinements} + clear(s.analysisContexts) } // ModuleBindings returns the module binding table. @@ -521,6 +286,41 @@ func (s *SessionStore) Graphs() map[uint64]*cfg.Graph { return s.Module.Graphs } +// EvidenceForGraph returns the canonical abstract-interpreter evidence for graph. +func (s *SessionStore) EvidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + if s == nil || s.Module == nil { + return trace.GraphEvidence(graph, graph.Bindings()) + } + if s.Module.Evidence == nil { + s.Module.Evidence = make(map[uint64]api.FlowEvidence) + } + graphID := graph.ID() + if evidence, ok := s.Module.Evidence[graphID]; ok { + return evidence + } + bindings := graph.Bindings() + if bindings == nil { + bindings = s.Module.ModuleBindings + } + evidence := trace.GraphEvidence(graph, bindings) + s.Module.Evidence[graphID] = evidence + return evidence +} + +// SetEvidenceForGraph records canonical abstract-interpreter evidence for graph. +func (s *SessionStore) SetEvidenceForGraph(graph *cfg.Graph, evidence api.FlowEvidence) { + if s == nil || s.Module == nil || graph == nil { + return + } + if s.Module.Evidence == nil { + s.Module.Evidence = make(map[uint64]api.FlowEvidence) + } + s.Module.Evidence[graph.ID()] = evidence +} + // GraphKeyFor returns the interproc graph key for a graph and parent scope. func (s *SessionStore) GraphKeyFor(graph *cfg.Graph, parent *scope.State) (api.GraphKey, bool) { if s == nil || graph == nil { @@ -557,36 +357,28 @@ func (s *SessionStore) ParentGraphKeyForSymbol(sym cfg.SymbolID) (api.GraphKey, return api.KeyForGraph(graph, parentHash), true } -func initInterprocFacts(f *api.Facts) { - if f.FunctionFacts == nil { - f.FunctionFacts = make(api.FunctionFacts) - } - if f.ParamHints == nil { - f.ParamHints = make(map[cfg.SymbolID][]typ.Type) - } - if f.LiteralSigs == nil { - f.LiteralSigs = make(map[*ast.FunctionExpr]*typ.Function) - } - if f.CapturedTypes == nil { - f.CapturedTypes = make(api.CapturedTypes) - } - if f.CapturedFields == nil { - f.CapturedFields = make(api.CapturedFieldAssigns) - } -} - -// UpdateInterprocFactsNext updates interproc facts for the next iteration. -// This is the public entry point used by post-flow analysis to record results. -func (s *SessionStore) UpdateInterprocFactsNext(key api.GraphKey, update func(*api.Facts)) { +// MergeInterprocFactsNext merges a canonical fact delta into the next +// interprocedural product for the current iteration. +func (s *SessionStore) MergeInterprocFactsNext(key api.GraphKey, delta api.Facts) { if s == nil { return } s.ensureInterprocStates() - facts := s.InterprocNext.Facts[key] - initInterprocFacts(&facts) - update(&facts) - returns.NormalizeFunctionFactChannels(&facts) + existing := s.InterprocNext.Facts[key] + facts := interproc.JoinFacts(existing, delta) + if interproc.Empty(facts) { + if interproc.Empty(existing) { + return + } + delete(s.InterprocNext.Facts, key) + s.syncFactsInput(key) + return + } + if interproc.FactsEqual(existing, facts) { + return + } s.InterprocNext.Facts[key] = facts + s.syncFactsInput(key) } // Funcs returns the function map. @@ -784,6 +576,25 @@ func (s *SessionStore) SetParentScope(parentHash uint64, parent *scope.State) { s.Module.Parents[parentHash] = parent } +// SetGraphAnalysisContext records execution context for a graph analysis key. +func (s *SessionStore) SetGraphAnalysisContext(key api.GraphKey, ctx api.AnalysisContext) { + if s == nil || key.GraphID == 0 || ctx.Empty() { + return + } + if s.analysisContexts == nil { + s.analysisContexts = make(map[api.GraphKey]api.AnalysisContext) + } + s.analysisContexts[key] = api.MergeAnalysisContext(s.analysisContexts[key], ctx) +} + +// GraphAnalysisContext returns the execution context for a graph analysis key. +func (s *SessionStore) GraphAnalysisContext(key api.GraphKey) api.AnalysisContext { + if s == nil || len(s.analysisContexts) == 0 { + return api.AnalysisContext{} + } + return api.MergeAnalysisContext(api.AnalysisContext{}, s.analysisContexts[key]) +} + // ModuleAliases returns the module aliases map. func (s *SessionStore) ModuleAliases() map[cfg.SymbolID]string { return s.Module.ModuleAliases @@ -797,128 +608,25 @@ func (s *SessionStore) SetModuleAliases(aliases map[cfg.SymbolID]string) { s.Module.ModuleAliases = aliases } -// GetInterprocFactsSnapshot returns the stable interproc facts snapshot for a graph. -func (s *SessionStore) GetInterprocFactsSnapshot( +// GetInterprocFacts returns the visible interproc fact product for a graph. +// Visibility is the stable product overlaid with facts already produced in the +// current iteration, giving deterministic Gauss-Seidel propagation instead of +// forcing every local refinement through a full outer iteration. +func (s *SessionStore) GetInterprocFacts( graph *cfg.Graph, parent *scope.State, ) api.Facts { - if s == nil || s.InterprocPrev == nil || s.InterprocPrev.Facts == nil || graph == nil || parent == nil { + if s == nil || graph == nil { return api.Facts{} } - key := api.KeyForGraph(graph, parent.Hash()) - return s.InterprocPrev.Facts[key] -} - -// GetParamHintsSnapshot returns param hints from the stable interproc snapshot. -func (s *SessionStore) GetParamHintsSnapshot( - graph *cfg.Graph, - parent *scope.State, -) map[cfg.SymbolID][]typ.Type { - s.requirePhase(api.PhaseScopeCompute) - return s.GetInterprocFactsSnapshot(graph, parent).ParamHints -} - -// GetReturnSummariesSnapshot returns return summaries from the stable interproc snapshot. -func (s *SessionStore) GetReturnSummariesSnapshot( - graph *cfg.Graph, - parent *scope.State, -) map[cfg.SymbolID][]typ.Type { - s.requirePhase(api.PhaseScopeCompute) - return returns.SummaryViewFromFacts(s.GetInterprocFactsSnapshot(graph, parent)) -} - -// GetNarrowReturnSummariesSnapshot returns post-flow return summaries from the stable snapshot. -func (s *SessionStore) GetNarrowReturnSummariesSnapshot( - graph *cfg.Graph, - parent *scope.State, -) map[cfg.SymbolID][]typ.Type { - s.requirePhase(api.PhaseNarrowing) - return returns.NarrowViewFromFacts(s.GetInterprocFactsSnapshot(graph, parent)) -} - -// GetLocalFuncTypesSnapshot returns canonical local function types from the stable interproc snapshot. -func (s *SessionStore) GetLocalFuncTypesSnapshot( - graph *cfg.Graph, - parent *scope.State, -) map[cfg.SymbolID]typ.Type { - s.requirePhase(api.PhaseScopeCompute) - return returns.FuncTypeViewFromFacts(s.GetInterprocFactsSnapshot(graph, parent)) -} - -// GetLiteralSigsSnapshot returns literal signatures from the stable interproc snapshot. -func (s *SessionStore) GetLiteralSigsSnapshot( - graph *cfg.Graph, - parent *scope.State, -) map[*ast.FunctionExpr]*typ.Function { - s.requirePhase(api.PhaseScopeCompute, api.PhaseNarrowing) - return s.GetInterprocFactsSnapshot(graph, parent).LiteralSigs -} - -// GetCapturedTypesSnapshot returns captured variable types from the stable interproc snapshot. -func (s *SessionStore) GetCapturedTypesSnapshot( - graph *cfg.Graph, - parent *scope.State, -) api.CapturedTypes { - s.requirePhase(api.PhaseScopeCompute) - return s.GetInterprocFactsSnapshot(graph, parent).CapturedTypes -} - -// StoreLiteralSigs records literal signatures for the current iteration. -func (s *SessionStore) StoreLiteralSigs(graphID uint64, sigs map[*ast.FunctionExpr]*typ.Function) { - if s == nil || graphID == 0 || len(sigs) == 0 { - return - } - if s.Scratch == nil { - s.Scratch = NewIterationScratch() - } - if s.Scratch.LiteralSigsByGraphID == nil { - s.Scratch.LiteralSigsByGraphID = make(map[uint64]map[*ast.FunctionExpr]*typ.Function) - } - s.Scratch.LiteralSigsByGraphID[graphID] = sigs -} - -// ScratchLiteralSigs returns literal signatures computed in the current iteration. -// This is an iteration-local cache used to avoid re-synthesizing literal signatures -// for nested functions within the same fixpoint cycle. -func (s *SessionStore) ScratchLiteralSigs(graphID uint64) map[*ast.FunctionExpr]*typ.Function { - if s == nil || s.Scratch == nil || s.Scratch.LiteralSigsByGraphID == nil { - return nil + key, ok := s.GraphKeyFor(graph, parent) + if !ok { + return api.Facts{} } - return s.Scratch.LiteralSigsByGraphID[graphID] -} - -// GetCapturedFieldAssignsSnapshot returns captured field assignments from the stable interproc snapshot. -func (s *SessionStore) GetCapturedFieldAssignsSnapshot( - graph *cfg.Graph, - parent *scope.State, -) api.CapturedFieldAssigns { - s.requirePhase(api.PhaseScopeCompute, api.PhaseNarrowing) - return s.GetInterprocFactsSnapshot(graph, parent).CapturedFields + return s.interprocFactsByKey(key) } -// GetCapturedContainerMutationsSnapshot returns captured container mutations from the stable interproc snapshot. -func (s *SessionStore) GetCapturedContainerMutationsSnapshot( - graph *cfg.Graph, - parent *scope.State, -) api.CapturedContainerMutations { - s.requirePhase(api.PhaseScopeCompute, api.PhaseNarrowing) - return s.GetInterprocFactsSnapshot(graph, parent).CapturedContainers -} - -// snapshotRefinementStore implements api.RefinementStore using the stable snapshot. -type snapshotRefinementStore struct { - refinements map[cfg.SymbolID]*constraint.FunctionRefinement -} - -func (o *snapshotRefinementStore) LookupRefinementBySym(sym cfg.SymbolID) *constraint.FunctionRefinement { - if o == nil || sym == 0 { - return nil - } - if o.refinements == nil { - return nil - } - if refinement := o.refinements[sym]; refinement != nil { - return refinement - } - return nil +// GetModuleFacts returns module-wide interprocedural facts. +func (s *SessionStore) GetModuleFacts() api.Facts { + return s.interprocFactsByKey(api.ModuleFactsKey()) } diff --git a/compiler/check/store/store_test.go b/compiler/check/store/store_test.go index 7855c401..6f041fb4 100644 --- a/compiler/check/store/store_test.go +++ b/compiler/check/store/store_test.go @@ -6,9 +6,10 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/constraint" + "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/typ" ) @@ -20,170 +21,201 @@ func TestNewInterprocState(t *testing.T) { if state.Facts == nil { t.Error("Facts map should be initialized") } - if state.Refinements == nil { - t.Error("Refinements map should be initialized") - } - if state.ConstructorFields == nil { - t.Error("ConstructorFields map should be initialized") - } } -func TestEffectsEqual_BothNil(t *testing.T) { - if !effectsEqual(nil, nil) { - t.Error("two nils should be equal") - } -} - -func TestEffectsEqual_OneNil(t *testing.T) { - eff := &constraint.FunctionRefinement{} - if effectsEqual(eff, nil) { - t.Error("non-nil and nil should not be equal") - } - if effectsEqual(nil, eff) { - t.Error("nil and non-nil should not be equal") +func TestFunctionFactsSummaryProjection(t *testing.T) { + facts := api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(1): { + Summary: []typ.Type{typ.String}, + }, + }, } -} - -func TestEffectsEqual_Same(t *testing.T) { - eff := &constraint.FunctionRefinement{Terminates: true} - if !effectsEqual(eff, eff) { - t.Error("same reference should be equal") + got := functionfact.ReturnSummaryFromMap(facts.FunctionFacts, cfg.SymbolID(1)) + if len(got) != 1 || got[0] != typ.String { + t.Fatalf("unexpected summary: %#v", got) } } -func TestEffectsMapEqual_Empty(t *testing.T) { - if !effectsMapEqual(nil, nil) { - t.Error("two nils should be equal") - } - if !effectsMapEqual(map[cfg.SymbolID]*constraint.FunctionRefinement{}, map[cfg.SymbolID]*constraint.FunctionRefinement{}) { - t.Error("two empty maps should be equal") +func TestFunctionFactsNarrowProjection(t *testing.T) { + facts := api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(2): { + Narrow: []typ.Type{typ.Number}, + }, + }, } -} - -func TestEffectsMapEqual_DifferentLength(t *testing.T) { - a := map[cfg.SymbolID]*constraint.FunctionRefinement{1: {}} - b := map[cfg.SymbolID]*constraint.FunctionRefinement{} - if effectsMapEqual(a, b) { - t.Error("maps of different length should not be equal") + got := functionfact.NarrowSummaryFromMap(facts.FunctionFacts, cfg.SymbolID(2)) + if len(got) != 1 || got[0] != typ.Number { + t.Fatalf("unexpected narrow summary: %#v", got) } } -func TestInterprocFactsMapEqual_Empty(t *testing.T) { - if !interprocFactsMapEqual(nil, nil) { - t.Error("two nils should be equal") +func TestFunctionFactsTypeProjection(t *testing.T) { + fn := typ.Func().Returns(typ.Boolean).Build() + facts := api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(3): { + Type: fn, + }, + }, } - if !interprocFactsMapEqual(map[api.GraphKey]api.Facts{}, map[api.GraphKey]api.Facts{}) { - t.Error("two empty maps should be equal") + got := functionfact.TypeFromMap(facts.FunctionFacts, cfg.SymbolID(3)) + if !typ.TypeEquals(got, fn) { + t.Fatalf("unexpected function type: %#v", got) } } -func TestInterprocFactsMapEqual_DifferentLength(t *testing.T) { - a := map[api.GraphKey]api.Facts{{GraphID: 1}: {}} - b := map[api.GraphKey]api.Facts{} - if interprocFactsMapEqual(a, b) { - t.Error("maps of different length should not be equal") +func TestGetInterprocFacts_UsesStoredGraphParentHash(t *testing.T) { + graph := cfg.Build(&ast.FunctionExpr{}) + if graph == nil || graph.ID() == 0 { + t.Fatal("expected graph with stable ID") } -} -func TestWidenInterprocFacts_Empty(t *testing.T) { - result := widenInterprocFacts(nil, nil) - if result == nil { - t.Fatal("expected non-nil result") + storedParent := scope.New().WithType("T", typ.String) + currentParent := scope.New().WithType("T", typ.Number) + if storedParent.Hash() == currentParent.Hash() { + t.Fatal("test requires different parent hashes") } - if len(result) != 0 { - t.Error("expected empty map") - } -} -func TestWidenInterprocFacts_OnlyPrev(t *testing.T) { - prev := map[api.GraphKey]api.Facts{ - {GraphID: 1}: { - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, - }, + s := NewSessionStore() + s.SetGraphParentHash(graph.ID(), storedParent.Hash()) + s.SetParentScope(storedParent.Hash(), storedParent) + key := api.KeyForGraph(graph, storedParent.Hash()) + s.InterprocPrev.Facts[key] = api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(1): {Summary: []typ.Type{typ.String}}, }, } - result := widenInterprocFacts(prev, nil) - if len(result) != 1 { - t.Errorf("expected 1 entry, got %d", len(result)) + + got := s.GetInterprocFacts(graph, currentParent) + summary := functionfact.ReturnSummaryFromMap(got.FunctionFacts, cfg.SymbolID(1)) + if len(summary) != 1 || !typ.TypeEquals(summary[0], typ.String) { + t.Fatalf("expected facts from stored parent hash, got %#v", summary) } } -func TestWidenInterprocFacts_OnlyNext(t *testing.T) { - next := map[api.GraphKey]api.Facts{ - {GraphID: 1}: { - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.Number}}, - }, - }, - } - result := widenInterprocFacts(nil, next) - if len(result) != 1 { - t.Errorf("expected 1 entry, got %d", len(result)) +func TestGetInterprocFacts_OverlaysCurrentIterationFacts(t *testing.T) { + graph := cfg.Build(&ast.FunctionExpr{}) + if graph == nil || graph.ID() == 0 { + t.Fatal("expected graph with stable ID") } -} -func TestWidenInterprocFacts_Merge(t *testing.T) { - prev := map[api.GraphKey]api.Facts{ - {GraphID: 1}: { - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, - }, + parent := scope.New().WithType("T", typ.String) + s := NewSessionStore() + s.SetGraphParentHash(graph.ID(), parent.Hash()) + s.SetParentScope(parent.Hash(), parent) + key := api.KeyForGraph(graph, parent.Hash()) + s.InterprocPrev.Facts[key] = api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(1): {Summary: []typ.Type{typ.String}}, }, } - next := map[api.GraphKey]api.Facts{ - {GraphID: 2}: { - FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.Number}}, - }, + s.InterprocNext.Facts[key] = api.Facts{ + FunctionFacts: api.FunctionFacts{ + cfg.SymbolID(1): {Summary: []typ.Type{typ.Number}}, }, } - result := widenInterprocFacts(prev, next) - if len(result) != 2 { - t.Errorf("expected 2 entries, got %d", len(result)) + + got := s.GetInterprocFacts(graph, parent) + summary := functionfact.ReturnSummaryFromMap(got.FunctionFacts, cfg.SymbolID(1)) + want := typ.NewUnion(typ.String, typ.Number) + if len(summary) != 1 || !typ.TypeEquals(summary[0], want) { + t.Fatalf("expected widened visible facts %v, got %#v", want, summary) } } -func TestReturnSummariesFromFacts_FallsBackToCanonical(t *testing.T) { - facts := api.Facts{ +func TestGetInterprocFacts_ReturnsImmutableFactContainers(t *testing.T) { + graph := cfg.Build(&ast.FunctionExpr{}) + if graph == nil || graph.ID() == 0 { + t.Fatal("expected graph with stable ID") + } + + parent := scope.New().WithType("T", typ.String) + s := NewSessionStore() + s.SetGraphParentHash(graph.ID(), parent.Hash()) + s.SetParentScope(parent.Hash(), parent) + key := api.KeyForGraph(graph, parent.Hash()) + sym := cfg.SymbolID(7) + s.InterprocPrev.Facts[key] = api.Facts{ FunctionFacts: api.FunctionFacts{ - cfg.SymbolID(1): { + sym: { + Params: []typ.Type{typ.String, typ.NewMap(typ.String, typ.Any)}, Summary: []typ.Type{typ.String}, }, }, } - got := returns.SummaryViewFromFacts(facts) - if len(got) != 1 || len(got[cfg.SymbolID(1)]) != 1 || got[cfg.SymbolID(1)][0] != typ.String { - t.Fatalf("unexpected summary view: %#v", got) + + facts := s.GetInterprocFacts(graph, parent) + fact := facts.FunctionFacts[sym] + fact.Params[1] = typ.Nil + facts.FunctionFacts[sym] = api.FunctionFact{Summary: []typ.Type{typ.Number}} + + again := s.GetInterprocFacts(graph, parent) + if got := functionfact.ParameterEvidenceFromMap(again.FunctionFacts, sym)[1]; !typ.TypeEquals(got, typ.NewMap(typ.String, typ.Any)) { + t.Fatalf("fact parameter evidence mutation leaked into store: %v", got) + } + if got := functionfact.ReturnSummaryFromMap(again.FunctionFacts, sym); len(got) != 1 || !typ.TypeEquals(got[0], typ.String) { + t.Fatalf("function fact mutation leaked into store: %v", got) } } -func TestNarrowReturnSummariesFromFacts_FallsBackToCanonical(t *testing.T) { - facts := api.Facts{ +func TestMergeInterprocFactsNext_ReconcilesDeltasWithinIteration(t *testing.T) { + key := api.GraphKey{GraphID: 1, ParentHash: 2} + sym := cfg.SymbolID(7) + refined := typ.Func().Param("path", typ.String).Returns(typ.String).Build() + broad := typ.Func().Param("path", typ.Any).Returns(typ.String).Build() + + s := NewSessionStore() + first := api.Facts{FunctionFacts: api.FunctionFacts{sym: {Type: refined}}} + s.MergeInterprocFactsNext(key, first) + s.MergeInterprocFactsNext(key, api.Facts{ FunctionFacts: api.FunctionFacts{ - cfg.SymbolID(2): { - Narrow: []typ.Type{typ.Number}, - }, + sym: {Type: broad}, }, - } - got := returns.NarrowViewFromFacts(facts) - if len(got) != 1 || len(got[cfg.SymbolID(2)]) != 1 || got[cfg.SymbolID(2)][0] != typ.Number { - t.Fatalf("unexpected narrow view: %#v", got) + }) + + got := functionfact.TypeFromMap(s.InterprocNext.Facts[key].FunctionFacts, sym) + if !typ.TypeEquals(got, refined) { + t.Fatalf("expected update boundary to keep canonical refined function fact, got %v", got) } } -func TestLocalFuncTypesFromFacts_FallsBackToCanonical(t *testing.T) { - fn := typ.Func().Returns(typ.Boolean).Build() - facts := api.Facts{ - FunctionFacts: api.FunctionFacts{ - cfg.SymbolID(3): { - Func: fn, - }, - }, +func TestFactInputs_RevalidateFactQueries(t *testing.T) { + database := db.New() + ctx := db.NewQueryContext(database) + s := NewSessionStoreWithDB(database) + key := api.GraphKey{GraphID: 1, ParentHash: 2} + sym := cfg.SymbolID(7) + + calls := 0 + q := db.NewQuery("trackedFactsTest", func(ctx *db.QueryContext, key api.GraphKey) int { + calls++ + facts, _ := s.factInputs.factsFor(ctx, key) + if len(functionfact.ReturnSummaryFromMap(facts.FunctionFacts, sym)) == 0 { + return 0 + } + return 1 + }, func(a, b int) bool { return a == b }) + + if got := q.Get(ctx, key); got != 0 { + t.Fatalf("initial query = %d, want 0", got) + } + if got := q.Get(ctx, key); got != 0 || calls != 1 { + t.Fatalf("unchanged query = %d calls=%d, want 0/1", got, calls) } - got := returns.FuncTypeViewFromFacts(facts) - if len(got) != 1 || !typ.TypeEquals(got[cfg.SymbolID(3)], fn) { - t.Fatalf("unexpected func type view: %#v", got) + + delta := api.Facts{FunctionFacts: api.FunctionFacts{ + sym: {Summary: []typ.Type{typ.String}}, + }} + s.MergeInterprocFactsNext(key, delta) + if got := q.Get(ctx, key); got != 1 || calls != 2 { + t.Fatalf("changed query = %d calls=%d, want 1/2", got, calls) + } + + s.MergeInterprocFactsNext(key, delta) + if got := q.Get(ctx, key); got != 1 || calls != 2 { + t.Fatalf("equal update query = %d calls=%d, want 1/2", got, calls) } } @@ -192,16 +224,10 @@ func TestSessionStore_Fields(t *testing.T) { Module: &ModuleStore{ Graphs: make(map[uint64]*cfg.Graph), }, - Iteration: &IterationStore{ - Revision: 5, - }, } if s.Module == nil { t.Error("Module should be set") } - if s.Iteration.Revision != 5 { - t.Error("Revision should be 5") - } } func TestModuleStore_Fields(t *testing.T) { @@ -209,12 +235,36 @@ func TestModuleStore_Fields(t *testing.T) { Graphs: make(map[uint64]*cfg.Graph), Parents: make(map[uint64]*scope.State), ModuleAliases: map[cfg.SymbolID]string{1: "test"}, + Evidence: make(map[uint64]api.FlowEvidence), } if m.ModuleAliases[1] != "test" { t.Error("ModuleAliases not set correctly") } } +func TestSessionStore_EvidenceForGraph(t *testing.T) { + s := NewSessionStore() + fn := &ast.FunctionExpr{ + ParList: &ast.ParList{Names: []string{"x"}}, + Stmts: []ast.Stmt{ + &ast.ReturnStmt{Exprs: []ast.Expr{&ast.IdentExpr{Value: "x"}}}, + }, + } + graph := cfg.Build(fn) + evidence := s.EvidenceForGraph(graph) + if len(evidence.ParameterUses) != 1 { + t.Fatalf("expected parameter-use evidence, got %#v", evidence.ParameterUses) + } + + overridden := api.FlowEvidence{ + NormalExit: api.NormalExitEvidence{Point: cfg.Point(99), Valid: true}, + } + s.SetEvidenceForGraph(graph, overridden) + if got := s.EvidenceForGraph(graph); got.NormalExit.Point != cfg.Point(99) || !got.NormalExit.Valid { + t.Fatalf("expected cached override, got %#v", got.NormalExit) + } +} + func TestFunctionRegistry_Fields(t *testing.T) { r := &FunctionRegistry{ BySym: make(map[cfg.SymbolID]*api.FunctionRef), @@ -226,122 +276,94 @@ func TestFunctionRegistry_Fields(t *testing.T) { } } -func TestIterationStore_Fields(t *testing.T) { - i := &IterationStore{Revision: 10} - if i.Revision != 10 { - t.Error("Revision not set") - } -} - -func TestIterationScratch_Fields(t *testing.T) { - s := &IterationScratch{ - LiteralSigsByGraphID: make(map[uint64]map[*ast.FunctionExpr]*typ.Function), - } - if s.LiteralSigsByGraphID == nil { - t.Error("LiteralSigsByGraphID should be initialized") - } -} - func TestFixpointSwap_TracksChannelDiffsAndResetsNext(t *testing.T) { s := NewSessionStore() - s.InterprocNext.Refinements[1] = &constraint.FunctionRefinement{Terminates: true} - s.InterprocNext.Facts[api.GraphKey{GraphID: 7, ParentHash: 11}] = api.Facts{ + key := api.GraphKey{GraphID: 7, ParentHash: 11} + s.InterprocNext.Facts[key] = api.Facts{ FunctionFacts: api.FunctionFacts{ - 1: {Summary: []typ.Type{typ.String}}, + 1: { + Summary: []typ.Type{typ.String}, + Refinement: &constraint.FunctionRefinement{Terminates: true}, + }, }, } - s.InterprocNext.ConstructorFields[3] = map[string]typ.Type{ - "v": typ.Number, + s.InterprocNext.Facts[api.ModuleFactsKey()] = api.Facts{ + ConstructorFields: api.ConstructorFields{ + 3: {"v": typ.Number}, + }, } if !s.FixpointSwap() { t.Fatal("expected fixpoint swap to report changes") } - diffs := s.FixpointChannelDiffs() - if len(diffs) != 3 { - t.Fatalf("expected 3 channel diffs, got %v", diffs) + diffs := s.FixpointDiffs() + if len(diffs) != 1 { + t.Fatalf("expected one product diff, got %v", diffs) } - if diffs[0] != "Refinements" || diffs[1] != "InterprocFacts" || diffs[2] != "ConstructorFields" { + if diffs[0] != "InterprocFacts" { t.Fatalf("unexpected diff order/content: %v", diffs) } - if len(s.InterprocPrev.Refinements) != 1 || s.InterprocPrev.Refinements[1] == nil { - t.Fatalf("expected prev effects populated, got %#v", s.InterprocPrev.Refinements) - } - if len(s.InterprocNext.Refinements) != 0 { - t.Fatalf("expected next effects reset, got %#v", s.InterprocNext.Refinements) - } - if len(s.InterprocPrev.Facts) != 1 { + if len(s.InterprocPrev.Facts) != 2 { t.Fatalf("expected prev facts populated, got %#v", s.InterprocPrev.Facts) } if len(s.InterprocNext.Facts) != 0 { t.Fatalf("expected next facts reset, got %#v", s.InterprocNext.Facts) } - if len(s.InterprocPrev.ConstructorFields) != 1 { - t.Fatalf("expected prev constructor fields populated, got %#v", s.InterprocPrev.ConstructorFields) + if functionfact.RefinementFromMap(s.InterprocPrev.Facts[key].FunctionFacts, 1) == nil { + t.Fatalf("expected function refinement in product fact, got %#v", s.InterprocPrev.Facts[key]) } - if len(s.InterprocNext.ConstructorFields) != 0 { - t.Fatalf("expected next constructor fields reset, got %#v", s.InterprocNext.ConstructorFields) + if len(s.InterprocPrev.Facts[api.ModuleFactsKey()].ConstructorFields[3]) != 1 { + t.Fatalf("expected constructor fields in module product fact, got %#v", s.InterprocPrev.Facts[api.ModuleFactsKey()]) } } -func TestClearIterationChannels_InitializesMissingState(t *testing.T) { +func TestClearInterprocState_InitializesMissingState(t *testing.T) { s := &SessionStore{} - s.ClearIterationChannels() + s.ClearInterprocState() - if s.Iteration == nil { - t.Fatal("expected iteration store to be initialized") - } - if s.Scratch == nil { - t.Fatal("expected scratch to be initialized") - } if s.InterprocPrev == nil || s.InterprocNext == nil { t.Fatal("expected interproc states to be initialized") } - if s.Scratch.LiteralSigsByGraphID == nil { - t.Fatal("expected scratch literal signatures map to be initialized") - } -} - -func TestBumpRevision_InitializesIterationStore(t *testing.T) { - s := &SessionStore{} - s.BumpRevision() - if got := s.Revision(); got != 1 { - t.Fatalf("expected revision 1, got %d", got) - } } -func TestFixpointChannelDiffs_ReturnsCopy(t *testing.T) { +func TestFixpointDiffs_ReturnsCopy(t *testing.T) { s := NewSessionStore() - s.StoreFunctionRefinement(1, &constraint.FunctionRefinement{Terminates: true}) + key := registerFunctionForRefinementTest(t, s, 1) + s.MergeInterprocFactsNext(key, api.Facts{ + FunctionFacts: api.FunctionFacts{ + 1: {Refinement: &constraint.FunctionRefinement{Terminates: true}}, + }, + }) if !s.FixpointSwap() { t.Fatal("expected change from effect swap") } - diffs := s.FixpointChannelDiffs() + diffs := s.FixpointDiffs() if len(diffs) == 0 { t.Fatal("expected non-empty diffs") } diffs[0] = "MUTATED" - diffs2 := s.FixpointChannelDiffs() + diffs2 := s.FixpointDiffs() if len(diffs2) == 0 || diffs2[0] == "MUTATED" { t.Fatalf("expected defensive copy, got %v", diffs2) } } -func TestClearIterationChannels_ResetsRevision(t *testing.T) { - s := NewSessionStore() - s.BumpRevision() - s.BumpRevision() - if got := s.Revision(); got != 2 { - t.Fatalf("expected revision 2, got %d", got) - } - - s.ClearIterationChannels() - if got := s.Revision(); got != 0 { - t.Fatalf("expected revision reset to 0, got %d", got) - } +func registerFunctionForRefinementTest(t *testing.T, s *SessionStore, sym cfg.SymbolID) api.GraphKey { + t.Helper() + fn := &ast.FunctionExpr{} + graph := cfg.Build(fn) + if graph == nil { + t.Fatal("expected graph") + } + parent := scope.New() + s.RegisterGraph(graph, fn) + s.RegisterFunctionRef(sym, fn, graph, graph.ID(), 1) + s.SetGraphParentHash(graph.ID(), parent.Hash()) + s.SetParentScope(parent.Hash(), parent) + return api.KeyForGraph(graph, parent.Hash()) } diff --git a/compiler/check/synth/callarg/resynth.go b/compiler/check/synth/callarg/resynth.go new file mode 100644 index 00000000..a9f84335 --- /dev/null +++ b/compiler/check/synth/callarg/resynth.go @@ -0,0 +1,137 @@ +package callarg + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/check/synth/ops" + "github.com/wippyai/go-lua/types/cfg" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// ReSynth is called to re-synthesize an AST argument with contextual typing. +type ReSynth func(idx int, arg ast.Expr, expected typ.Type) typ.Type + +// ForArgs binds AST arguments to the pure call pipeline re-synthesis hook. +func ForArgs(args []ast.Expr, reSynth ReSynth) ops.ArgReSynth { + if reSynth == nil || len(args) == 0 { + return nil + } + return func(idx int, expected typ.Type) typ.Type { + if idx < 0 || idx >= len(args) { + return nil + } + return reSynth(idx, args[idx], expected) + } +} + +// TableCompatChecker checks if a table literal is compatible with an expected type. +type TableCompatChecker func(table *ast.TableExpr, expected typ.Type, p cfg.Point) bool + +// Full creates a ReSynth for arguments whose type can safely use the callee's +// expected parameter type as local context. It deliberately avoids arbitrary +// nested function calls: those calls run their own inference pipeline, and +// forcing an outer expected type into them can erase generic payload precision +// before the inner call has completed. +func Full( + synthWithExpected func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type, + tableChecker TableCompatChecker, + p cfg.Point, +) ReSynth { + return func(idx int, arg ast.Expr, expected typ.Type) typ.Type { + switch a := arg.(type) { + case *ast.FunctionExpr: + return synthWithExpected(a, p, expected) + case *ast.TableExpr: + if tableChecker != nil && tableChecker(a, expected, p) { + return expected + } + return synthWithExpected(a, p, expected) + case *ast.IdentExpr: + inferred := synthWithExpected(a, p, expected) + if shouldRefineWithExpected(inferred, expected) { + return expected + } + return inferred + case *ast.AttrGetExpr: + inferred := synthWithExpected(a, p, expected) + if shouldRefineWithExpected(inferred, expected) { + return expected + } + return inferred + case *ast.CastExpr, *ast.LogicalOpExpr, *ast.NonNilAssertExpr: + return synthWithExpected(a, p, expected) + } + return nil + } +} + +func shouldRefineWithExpected(inferred, expected typ.Type) bool { + if inferred == nil || expected == nil { + return false + } + if typ.IsAny(inferred) || typ.IsAny(expected) || expected.Kind().IsPlaceholder() { + return false + } + if typ.IsUnknown(unwrap.Alias(inferred)) { + return true + } + if subtype.IsSubtype(inferred, expected) { + return true + } + inferredRec := unwrap.Record(inferred) + expectedRec := unwrap.Record(expected) + if inferredRec == nil || expectedRec == nil { + return false + } + return recordEvidenceMatchesExpected(inferredRec, expectedRec) +} + +func recordEvidenceMatchesExpected(inferred, expected *typ.Record) bool { + if inferred == nil || expected == nil { + return false + } + for _, field := range inferred.Fields { + expectedField := expected.GetField(field.Name) + if expectedField == nil { + if expected.Open { + continue + } + return false + } + if unresolvedRecordEvidence(field.Type) { + continue + } + inferredType := field.Type + if field.Optional { + inferredType = typ.NewOptional(inferredType) + } + expectedType := expectedField.Type + if expectedField.Optional { + expectedType = typ.NewOptional(expectedType) + } + if !subtype.IsSubtype(inferredType, expectedType) { + return false + } + } + if inferred.HasMapComponent() { + if !expected.HasMapComponent() { + return false + } + if !unresolvedRecordEvidence(inferred.MapKey) && !subtype.IsSubtype(inferred.MapKey, expected.MapKey) { + return false + } + if !unresolvedRecordEvidence(inferred.MapValue) && !subtype.IsSubtype(inferred.MapValue, expected.MapValue) { + return false + } + } + return true +} + +func unresolvedRecordEvidence(t typ.Type) bool { + if typ.IsAbsentOrUnknown(t) { + return true + } + rec := unwrap.Record(t) + return rec != nil && len(rec.Fields) == 0 && !rec.HasMapComponent() +} diff --git a/compiler/check/synth/callarg/resynth_test.go b/compiler/check/synth/callarg/resynth_test.go new file mode 100644 index 00000000..d3d8718f --- /dev/null +++ b/compiler/check/synth/callarg/resynth_test.go @@ -0,0 +1,143 @@ +package callarg + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/types/cfg" + "github.com/wippyai/go-lua/types/typ" +) + +func TestForArgsAdaptsIndexedExpression(t *testing.T) { + args := []ast.Expr{&ast.NumberExpr{Value: "1"}} + called := false + reSynth := ForArgs(args, func(idx int, arg ast.Expr, expected typ.Type) typ.Type { + called = true + if idx != 0 || arg != args[0] { + t.Fatalf("unexpected arg idx=%d expr=%T", idx, arg) + } + if expected != typ.String { + t.Fatalf("got expected %v, want string", expected) + } + return typ.String + }) + + if got := reSynth(0, typ.String); got != typ.String { + t.Fatalf("got %v, want string", got) + } + if !called { + t.Fatal("expected adapter to call re-synthesizer") + } + if got := reSynth(1, typ.String); got != nil { + t.Fatalf("out-of-range arg got %v, want nil", got) + } +} + +func TestFull_Function(t *testing.T) { + called := false + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + called = true + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.FunctionExpr{}, typ.Func().Build()) + + if !called { + t.Fatal("expected callback to be called") + } + if result != typ.String { + t.Fatalf("got %v, want string", result) + } +} + +func TestFull_Table(t *testing.T) { + called := false + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + called = true + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.TableExpr{}, typ.NewRecord().Build()) + + if !called { + t.Fatal("expected callback to be called") + } + if result != typ.String { + t.Fatalf("got %v, want string", result) + } +} + +func TestFull_Other(t *testing.T) { + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.NumberExpr{}, typ.Integer) + + if result != nil { + t.Fatal("expected nil for expression that does not benefit from contextual re-synthesis") + } +} + +func TestFull_Cast(t *testing.T) { + called := false + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + called = true + if _, ok := arg.(*ast.CastExpr); !ok { + t.Fatalf("got %T, want CastExpr", arg) + } + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.CastExpr{}, typ.String) + + if !called { + t.Fatal("expected callback to be called for cast expression") + } + if result != typ.String { + t.Fatalf("got %v, want string", result) + } +} + +func TestFull_Logical(t *testing.T) { + called := false + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + called = true + if _, ok := arg.(*ast.LogicalOpExpr); !ok { + t.Fatalf("got %T, want LogicalOpExpr", arg) + } + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.LogicalOpExpr{}, typ.String) + + if !called { + t.Fatal("expected callback to be called for logical expression") + } + if result != typ.String { + t.Fatalf("got %v, want string", result) + } +} + +func TestFull_Identifier(t *testing.T) { + called := false + synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { + called = true + return typ.String + } + + reSynth := Full(synthWithExpected, nil, 0) + result := reSynth(0, &ast.IdentExpr{Value: "cb"}, typ.Func().Build()) + + if !called { + t.Fatal("expected callback to be called for identifier") + } + if result != typ.String { + t.Fatalf("got %v, want string", result) + } +} diff --git a/compiler/check/synth/engine.go b/compiler/check/synth/engine.go index 42246409..5f72ffaa 100644 --- a/compiler/check/synth/engine.go +++ b/compiler/check/synth/engine.go @@ -51,8 +51,10 @@ type Config struct { Scopes api.ScopeMap Manifests io.ManifestQuerier Env api.BaseEnv + FunctionFacts api.FunctionFacts Flow api.FlowOps Paths api.PathFromExprFunc + Evidence api.FlowEvidence PreCache api.Cache NarrowCache api.Cache Graphs api.GraphProvider @@ -117,9 +119,11 @@ func New(cfg Config) *Engine { Scopes: cfg.Scopes, Manifests: cfg.Manifests, CheckCtx: cfg.Env, + FunctionFacts: cfg.FunctionFacts, Graphs: graphs, Flow: cfg.Flow, Paths: cfg.Paths, + Evidence: cfg.Evidence, PreCache: preCache, NarrowCache: narrowCache, ModuleBindings: cfg.ModuleBindings, diff --git a/compiler/check/synth/engine_core_test.go b/compiler/check/synth/engine_core_test.go index 5f575aec..793bfc41 100644 --- a/compiler/check/synth/engine_core_test.go +++ b/compiler/check/synth/engine_core_test.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" ccfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/cfg" @@ -15,11 +16,39 @@ import ( "github.com/wippyai/go-lua/types/typ" ) +type testGraphProvider struct { + cache map[*ast.FunctionExpr]*ccfg.Graph +} + +func newTestGraphProvider() *testGraphProvider { + return &testGraphProvider{cache: make(map[*ast.FunctionExpr]*ccfg.Graph)} +} + +func (p *testGraphProvider) GetOrBuildCFG(fn *ast.FunctionExpr) *ccfg.Graph { + if fn == nil { + return nil + } + if graph := p.cache[fn]; graph != nil { + return graph + } + graph := ccfg.Build(fn) + p.cache[fn] = graph + return graph +} + +func (p *testGraphProvider) EvidenceForGraph(graph *ccfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + func newTestEngine() *Engine { return New(Config{ Ctx: db.NewQueryContext(db.New()), Types: mockTypeQuerier{}, Scopes: make(api.ScopeMap), + Graphs: newTestGraphProvider(), }) } @@ -30,6 +59,7 @@ func newTestEngineWithScope(sc *scope.State) *Engine { Ctx: db.NewQueryContext(db.New()), Types: mockTypeQuerier{}, Scopes: scopes, + Graphs: newTestGraphProvider(), }) } @@ -52,6 +82,7 @@ func newTestEngineWithSymbol(name string, t typ.Type) (*Engine, *ast.IdentExpr) Types: mockTypeQuerier{}, Scopes: make(api.ScopeMap), Env: checkCtx, + Graphs: newTestGraphProvider(), }), ident } diff --git a/compiler/check/synth/engine_test.go b/compiler/check/synth/engine_test.go index 02afd661..89b33199 100644 --- a/compiler/check/synth/engine_test.go +++ b/compiler/check/synth/engine_test.go @@ -87,6 +87,10 @@ func (m mockFlowOps) ArrayLenBoundWithOffsetAt(p cfg.Point, varName string) (arr return "", 0, false } +func (m mockFlowOps) LengthBoundsAt(p cfg.Point, path constraint.Path) (lower, upper int64, ok bool) { + return 0, 0, false +} + func (m mockFlowOps) IsPointDead(p cfg.Point) bool { return false } diff --git a/compiler/check/synth/intercept/chain.go b/compiler/check/synth/intercept/chain.go index e6651b5d..df84fd87 100644 --- a/compiler/check/synth/intercept/chain.go +++ b/compiler/check/synth/intercept/chain.go @@ -43,6 +43,7 @@ func (b *ChainBuilder) Build() *Chain { callIntercepts := []CallIntercept{ &SelectIntercept{VariadicResolver: b.variadicResolver}, &RequireIntercept{Manifests: b.manifests}, + &SetMetatableIntercept{}, &TypeCastIntercept{}, } diff --git a/compiler/check/synth/intercept/intercept.go b/compiler/check/synth/intercept/intercept.go index b830f467..25c6d296 100644 --- a/compiler/check/synth/intercept/intercept.go +++ b/compiler/check/synth/intercept/intercept.go @@ -63,6 +63,10 @@ type CallEnv struct { // For type names (Number, Point): returns synthetic callable function type. // Returns nil if the name is not a recognized function or type. TypeLookup func(name string) typ.Type + + // StableType resolves an expression to its graph-stable value shape when + // point-local synthesis is too early to see later field assignments. + StableType func(expr ast.Expr, current typ.Type) typ.Type } // CallIntercept handles AST-specific patterns in direct function calls. diff --git a/compiler/check/synth/intercept/setmetatable.go b/compiler/check/synth/intercept/setmetatable.go new file mode 100644 index 00000000..b931f478 --- /dev/null +++ b/compiler/check/synth/intercept/setmetatable.go @@ -0,0 +1,128 @@ +package intercept + +import ( + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/types/kind" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// SetMetatableIntercept models Lua's setmetatable(table, metatable) primitive. +// +// The normal stdlib signature can express the value identity of the first +// argument, but the abstract state also needs the metatable edge on the returned +// table value so method/field queries see the prototype chain. +type SetMetatableIntercept struct{} + +func (s *SetMetatableIntercept) InterceptCall(ex *ast.FuncCallExpr, ctx CallEnv) Result { + if ex == nil || len(ex.Args) < 2 || ctx.Recurse == nil { + return Result{} + } + ident, ok := ex.Func.(*ast.IdentExpr) + if !ok || ident.Value != "setmetatable" { + return Result{} + } + + tableType := ctx.Recurse(ex.Args[0]) + metaType := ctx.Recurse(ex.Args[1]) + if ctx.StableType != nil { + metaType = ctx.StableType(ex.Args[1], metaType) + } + if tableType == nil { + return Result{Skip: true, Types: []typ.Type{typ.Unknown}} + } + + return Result{Skip: true, Types: []typ.Type{withMetatable(tableType, metaType)}} +} + +func withMetatable(tableType, metaType typ.Type) typ.Type { + tableType = unwrap.Alias(tableType) + if tableType == nil { + return typ.Unknown + } + + switch t := tableType.(type) { + case *typ.Record: + return recordWithMetatableVariants(t, metaType) + case *typ.Optional: + return typ.NewOptional(withMetatable(t.Inner, metaType)) + case *typ.Union: + members := make([]typ.Type, 0, len(t.Members)) + for _, member := range t.Members { + if member == nil || member.Kind() == kind.Nil { + members = append(members, member) + continue + } + members = append(members, withMetatable(member, metaType)) + } + return typ.NewUnion(members...) + default: + return tableType + } +} + +func recordWithMetatableVariants(rec *typ.Record, metaType typ.Type) typ.Type { + var variants []typ.Type + for _, meta := range metatableVariants(metaType) { + variants = append(variants, rebuildRecordWithMetatable(rec, meta)) + } + if len(variants) == 0 { + return rebuildRecordWithMetatable(rec, nil) + } + if len(variants) == 1 { + return variants[0] + } + return typ.NewUnion(variants...) +} + +func metatableVariants(metaType typ.Type) []typ.Type { + metaType = unwrap.Alias(metaType) + if metaType == nil { + return []typ.Type{nil} + } + switch m := metaType.(type) { + case *typ.Optional: + return []typ.Type{nil, unwrap.Alias(m.Inner)} + case *typ.Union: + var variants []typ.Type + hasNil := false + for _, member := range m.Members { + member = unwrap.Alias(member) + if member == nil || member.Kind() == kind.Nil { + if !hasNil { + variants = append(variants, nil) + hasNil = true + } + continue + } + variants = append(variants, member) + } + return variants + default: + if metaType.Kind() == kind.Nil { + return []typ.Type{nil} + } + return []typ.Type{metaType} + } +} + +func rebuildRecordWithMetatable(rec *typ.Record, meta typ.Type) typ.Type { + if rec == nil { + return typ.Unknown + } + builder := typ.NewRecord() + for _, field := range rec.Fields { + if field.Optional { + builder.OptField(field.Name, field.Type) + } else { + builder.Field(field.Name, field.Type) + } + } + if rec.HasMapComponent() { + builder.MapComponent(rec.MapKey, rec.MapValue) + } + if meta != nil { + builder.Metatable(meta) + } + return builder.SetOpen(rec.Open).Build() +} diff --git a/compiler/check/synth/intercept/setmetatable_test.go b/compiler/check/synth/intercept/setmetatable_test.go new file mode 100644 index 00000000..fef2c583 --- /dev/null +++ b/compiler/check/synth/intercept/setmetatable_test.go @@ -0,0 +1,93 @@ +package intercept + +import ( + "testing" + + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/types/kind" + querycore "github.com/wippyai/go-lua/types/query/core" + "github.com/wippyai/go-lua/types/typ" +) + +func TestSetMetatableIntercept_AttachesMetatableToReturnedRecord(t *testing.T) { + table := typ.NewRecord().Field("nodes", typ.NewMap(typ.String, typ.Any)).Build() + method := typ.Func().Param("self", typ.Any).Returns(typ.Boolean).Build() + prototype := typ.NewRecord().Field("has_cycles", method).Build() + meta := typ.NewRecord().Field("__index", prototype).Build() + + ex := &ast.FuncCallExpr{ + Func: &ast.IdentExpr{Value: "setmetatable"}, + Args: []ast.Expr{ + &ast.IdentExpr{Value: "table"}, + &ast.IdentExpr{Value: "meta"}, + }, + } + result := (&SetMetatableIntercept{}).InterceptCall(ex, CallEnv{ + Recurse: func(expr ast.Expr) typ.Type { + if ident, ok := expr.(*ast.IdentExpr); ok && ident.Value == "meta" { + return meta + } + return table + }, + }) + + if !result.Skip || len(result.Types) != 1 { + t.Fatalf("expected intercepted single return, got %#v", result) + } + if _, ok := querycore.Method(result.Types[0], "has_cycles"); !ok { + t.Fatalf("expected returned record to expose metatable method, got %s", typ.FormatShort(result.Types[0])) + } +} + +func TestSetMetatableIntercept_OptionalMetatableKeepsNilVariantSound(t *testing.T) { + table := typ.NewRecord().Field("nodes", typ.NewMap(typ.String, typ.Any)).Build() + method := typ.Func().Param("self", typ.Any).Returns(typ.Boolean).Build() + prototype := typ.NewRecord().Field("has_cycles", method).Build() + meta := typ.NewRecord().Field("__index", prototype).Build() + + got := withMetatable(table, typ.NewOptional(meta)) + union, ok := got.(*typ.Union) + if !ok || len(union.Members) != 2 { + t.Fatalf("expected optional metatable to produce two variants, got %s", typ.FormatShort(got)) + } + + hasPlain := false + hasMeta := false + for _, member := range union.Members { + rec, ok := member.(*typ.Record) + if !ok { + t.Fatalf("expected record variants, got %T", member) + } + if rec.Metatable == nil { + hasPlain = true + continue + } + if _, ok := querycore.Method(rec, "has_cycles"); ok { + hasMeta = true + } + } + if !hasPlain || !hasMeta { + t.Fatalf("expected plain and metatabled variants, got %s", typ.FormatShort(got)) + } + if _, ok := querycore.Method(got, "has_cycles"); ok { + t.Fatal("optional metatable must not prove method exists on all variants") + } +} + +func TestSetMetatableIntercept_RemovesMetatableForNil(t *testing.T) { + method := typ.Func().Param("self", typ.Any).Returns(typ.Boolean).Build() + meta := typ.NewRecord().Field("has_cycles", method).Build() + table := typ.NewRecord().Metatable(meta).Build() + + got := withMetatable(table, typ.Nil) + rec, ok := got.(*typ.Record) + if !ok { + t.Fatalf("expected record, got %T", got) + } + if rec.Metatable != nil { + t.Fatalf("expected nil metatable to remove metatable, got %s", typ.FormatShort(rec.Metatable)) + } + if got.Kind() == kind.Never { + t.Fatal("setmetatable nil removal should not produce never") + } +} diff --git a/compiler/check/synth/literals.go b/compiler/check/synth/literals.go index a46b9f45..45a74a7f 100644 --- a/compiler/check/synth/literals.go +++ b/compiler/check/synth/literals.go @@ -4,8 +4,12 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/synth/ops" phasecore "github.com/wippyai/go-lua/compiler/check/synth/phase/core" + "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/flow" + "github.com/wippyai/go-lua/types/kind" querycore "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" @@ -27,15 +31,21 @@ import ( // types before applying any narrowing from control flow. // // Returns nil if graph or synth is nil, or if no literals are found. -func FunctionLiteralTypes(graph *cfg.Graph, synth api.ExprSynth) flow.DeclaredTypes { +func FunctionLiteralTypes(graph *cfg.Graph, evidence api.FlowEvidence, synth api.ExprSynth) flow.DeclaredTypes { if graph == nil || synth == nil { return nil } types := make(flow.DeclaredTypes) + assignmentsByPoint := make(map[cfg.Point]*cfg.AssignInfo, len(evidence.Assignments)) + for _, assign := range evidence.Assignments { + if assign.Info != nil { + assignmentsByPoint[assign.Point] = assign.Info + } + } for _, p := range graph.RPO() { - info := graph.Assign(p) + info := assignmentsByPoint[p] if info == nil || !info.IsLocal { continue } @@ -65,28 +75,30 @@ func FunctionLiteralTypes(graph *cfg.Graph, synth api.ExprSynth) flow.DeclaredTy }) } - graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + for _, def := range evidence.FunctionDefinitions { + p := def.Nested.Point + info := def.FuncDef if info == nil { - return + continue } if info.TargetKind == cfg.FuncDefGlobal { if info.Symbol == 0 || info.FuncExpr == nil || len(info.FuncExpr.ReturnTypes) > 0 { - return + continue } if t := synth(info.FuncExpr, p); t != nil { types[info.Symbol] = t } - return + continue } if info.TargetKind != cfg.FuncDefField && info.TargetKind != cfg.FuncDefMethod { - return + continue } if info.Name == "" { - return + continue } receiverSym := info.ReceiverSymbol if receiverSym == 0 { - return + continue } fnType := typ.Unknown if info.FuncExpr != nil { @@ -99,7 +111,7 @@ func FunctionLiteralTypes(graph *cfg.Graph, synth api.ExprSynth) flow.DeclaredTy baseType = synth(info.Receiver, p) } types[receiverSym] = typ.ExtendRecordWithField(baseType, info.Name, fnType) - }) + } if len(types) == 0 { return nil @@ -126,7 +138,7 @@ func FunctionLiteralTypes(graph *cfg.Graph, synth api.ExprSynth) flow.DeclaredTy // - For return expressions, uses declared return types as expected types // // Returns nil if graph or engine is nil, or if no function literals found. -func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredReturns []typ.Type) map[*ast.FunctionExpr]*typ.Function { +func FunctionLiteralSignatures(graph *cfg.Graph, evidence api.FlowEvidence, engine LiteralSynth, declaredReturns []typ.Type) map[*ast.FunctionExpr]*typ.Function { if graph == nil || engine == nil { return nil } @@ -134,6 +146,7 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe out := make(map[*ast.FunctionExpr]*typ.Function) scopes := engine.Scopes() entry := engine.Entry() + callCtx := db.NewQueryContext(db.New()) addSig := func(fn *ast.FunctionExpr, p cfg.Point, expected *typ.Function) { if fn == nil { @@ -148,6 +161,18 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe out[fn] = sig } } + receiverSelfType := func(expr ast.Expr, p cfg.Point) typ.Type { + sc := scopes[p] + if sc == nil { + sc = scopes[entry] + } + if ident, ok := expr.(*ast.IdentExpr); ok && ident != nil && sc != nil { + if named, ok := sc.LookupType(ident.Value); ok && named != nil { + return named + } + } + return widenMutableReceiverState(engine.TypeOf(expr, p)) + } var collectExpr func(expr ast.Expr, p cfg.Point, expected typ.Type) var collectTable func(tbl *ast.TableExpr, p cfg.Point, expected typ.Type) @@ -158,10 +183,7 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe } switch v := expr.(type) { case *ast.FunctionExpr: - var expectedFn *typ.Function - if expected != nil { - expectedFn, _ = unwrap.Alias(expected).(*typ.Function) - } + expectedFn := phasecore.ExpectedFunctionLiteralSignature(v, expected) addSig(v, p, expectedFn) case *ast.TableExpr: collectTable(v, p, expected) @@ -194,7 +216,7 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe } } if fieldCount > 0 { - selfType = selfBuilder.Build() + selfType = widenMutableReceiverState(selfBuilder.Build()) } } @@ -240,8 +262,14 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe } } + assignmentsByPoint := make(map[cfg.Point]*cfg.AssignInfo, len(evidence.Assignments)) + for _, assign := range evidence.Assignments { + if assign.Info != nil { + assignmentsByPoint[assign.Point] = assign.Info + } + } for _, p := range graph.RPO() { - info := graph.Assign(p) + info := assignmentsByPoint[p] if info == nil || !info.IsLocal { continue } @@ -255,26 +283,54 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe }) } - graph.EachFuncDef(func(p cfg.Point, info *cfg.FuncDefInfo) { + for _, def := range evidence.FunctionDefinitions { + p := def.Nested.Point + info := def.FuncDef if info == nil || info.FuncExpr == nil { - return + continue } var expectedFn *typ.Function if info.TargetKind == cfg.FuncDefField || info.TargetKind == cfg.FuncDefMethod { if info.Receiver != nil { - recvType := engine.TypeOf(info.Receiver, p) + recvType := receiverSelfType(info.Receiver, p) if recvType != nil && phasecore.HasSelfParam(info.FuncExpr, graph.Bindings()) { expectedFn = typ.Func().Param("self", recvType).Build() } } } addSig(info.FuncExpr, p, expectedFn) - }) + } + + for _, call := range evidence.Calls { + p := call.Point + info := call.Info + expectedArgs, expectedVariadic := expectedArgsForDirectCallbackLiteral(callCtx, engine, graph, evidence, info, p) + if len(expectedArgs) == 0 && expectedVariadic == nil { + continue + } + for i, arg := range info.Args { + fn, ok := arg.(*ast.FunctionExpr) + if !ok { + continue + } + var expected typ.Type + if i < len(expectedArgs) { + expected = expectedArgs[i] + } else { + expected = expectedVariadic + } + if expectedFn := phasecore.ExpectedFunctionLiteralSignature(fn, expected); expectedFn != nil { + addSig(fn, p, expectedFn) + } + } + } if len(declaredReturns) > 0 { - graph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range evidence.Returns { + p := ret.Point + info := ret.Info if info == nil { - return + continue } for i, expr := range info.Exprs { if i >= len(declaredReturns) { @@ -283,7 +339,7 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe expected := declaredReturns[i] collectExpr(expr, p, expected) } - }) + } } if len(out) == 0 { @@ -291,3 +347,113 @@ func FunctionLiteralSignatures(graph *cfg.Graph, engine LiteralSynth, declaredRe } return out } + +type callQueryProvider interface { + GetCallQuery() querycore.TypeOps +} + +func expectedArgsForDirectCallbackLiteral( + ctx *db.QueryContext, + engine LiteralSynth, + graph *cfg.Graph, + evidence api.FlowEvidence, + info *cfg.CallInfo, + p cfg.Point, +) ([]typ.Type, typ.Type) { + provider, ok := engine.(callQueryProvider) + if !ok || provider == nil || graph == nil || info == nil { + return nil, nil + } + query := provider.GetCallQuery() + if query == nil { + return nil, nil + } + + def := ops.CallDef{ + Args: shallowCallArgTypes(engine, info.Args, p), + Query: query, + ForceMethodReceiver: callsite.ForceMethodReceiverAtPoint(graph.Bindings(), graph, evidence, p, info.Call), + } + if callsite.IsMethodCallInfo(info) { + def.IsMethod = true + def.Receiver = engine.TypeOf(info.Receiver, p) + def.MethodName = info.Method + } else { + def.Callee = engine.TypeOf(info.Callee, p) + } + inferred := ops.InferCall(ctx, def) + return inferred.ExpectedArgs, inferred.ExpectedVariadic +} + +func shallowCallArgTypes(engine LiteralSynth, args []ast.Expr, p cfg.Point) []typ.Type { + if len(args) == 0 { + return nil + } + out := make([]typ.Type, len(args)) + for i, arg := range args { + if fn, ok := arg.(*ast.FunctionExpr); ok { + out[i] = phasecore.ShallowFunctionLiteralSignature(fn) + continue + } + out[i] = engine.TypeOf(arg, p) + } + return out +} + +func widenMutableReceiverState(t typ.Type) typ.Type { + rec, ok := unwrap.Alias(t).(*typ.Record) + if !ok { + return t + } + + builder := typ.NewRecord() + if rec.Open { + builder.SetOpen(true) + } + for _, f := range rec.Fields { + fieldType := widenMutableReceiverField(f.Type) + switch { + case f.Optional && f.Readonly: + builder.OptReadonlyField(f.Name, fieldType) + case f.Optional: + builder.OptField(f.Name, fieldType) + case f.Readonly: + builder.ReadonlyField(f.Name, fieldType) + default: + builder.Field(f.Name, fieldType) + } + } + if rec.Metatable != nil { + builder.Metatable(rec.Metatable) + } + if rec.HasMapComponent() { + builder.MapComponent(rec.MapKey, rec.MapValue) + } + return builder.Build() +} + +func widenMutableReceiverField(t typ.Type) typ.Type { + if t == nil { + return typ.Unknown + } + unaliased := unwrap.Alias(t) + if unaliased == nil { + return typ.Unknown + } + if unaliased.Kind() == kind.Nil { + return typ.Unknown + } + if v, ok := unaliased.(*typ.Literal); ok { + switch v.Base { + case kind.Boolean: + return typ.Boolean + case kind.String: + return typ.String + case kind.Integer: + return typ.Integer + case kind.Number: + return typ.Number + } + } + return t +} diff --git a/compiler/check/synth/literals_test.go b/compiler/check/synth/literals_test.go index 86a41f7d..1e9faf4b 100644 --- a/compiler/check/synth/literals_test.go +++ b/compiler/check/synth/literals_test.go @@ -2,31 +2,33 @@ package synth import ( "testing" + + "github.com/wippyai/go-lua/compiler/check/api" ) func TestFunctionLiteralTypes_NilGraph(t *testing.T) { - result := FunctionLiteralTypes(nil, nil) + result := FunctionLiteralTypes(nil, api.FlowEvidence{}, nil) if result != nil { t.Fatal("expected nil for nil graph") } } func TestFunctionLiteralTypes_NilSynth(t *testing.T) { - result := FunctionLiteralTypes(nil, nil) + result := FunctionLiteralTypes(nil, api.FlowEvidence{}, nil) if result != nil { t.Fatal("expected nil for nil synth") } } func TestFunctionLiteralSignatures_NilGraph(t *testing.T) { - result := FunctionLiteralSignatures(nil, nil, nil) + result := FunctionLiteralSignatures(nil, api.FlowEvidence{}, nil, nil) if result != nil { t.Fatal("expected nil for nil graph") } } func TestFunctionLiteralSignatures_NilEngine(t *testing.T) { - result := FunctionLiteralSignatures(nil, nil, nil) + result := FunctionLiteralSignatures(nil, api.FlowEvidence{}, nil, nil) if result != nil { t.Fatal("expected nil for nil engine") } diff --git a/compiler/check/synth/ops/call.go b/compiler/check/synth/ops/call.go index 000772f1..92539a75 100644 --- a/compiler/check/synth/ops/call.go +++ b/compiler/check/synth/ops/call.go @@ -122,6 +122,11 @@ type CallDef struct { // When set, the expected return type guides type parameter inference // for generic functions (e.g., inferring T in `get(): T?` from `local x: string? = get()`). ExpectedReturn typ.Type + + // AllowExtraArgs models Lua's source-level "surplus arguments are + // discarded" rule for unannotated local functions without pretending those + // functions expose an actual `...` value. + AllowExtraArgs bool } // InferKind identifies the type of callee after resolution. @@ -291,7 +296,7 @@ func inferAndCall(ctx *db.QueryContext, fn *typ.Function, def CallDef, isMethod instantiated := InstantiateFunction(fn, typeArgs) - return callFunction(ctx, def.Query, instantiated, def.Args, receiver, isMethod, def.ForceMethodReceiver, errors) + return callFunction(ctx, def.Query, instantiated, def.Args, receiver, isMethod, def.ForceMethodReceiver, def.AllowExtraArgs, errors) } // InferCall performs the first phase of call synthesis: callee resolution, @@ -360,14 +365,7 @@ func InferCall(ctx *db.QueryContext, def CallDef) InferResult { } if callee.Kind() == kind.Intersection { - return InferResult{ - Kind: InferKindIntersection, - Callee: callee, - Receiver: receiver, - IsMethod: isMethod, - ForceMethodReceiver: def.ForceMethodReceiver, - Errors: errors, - } + return inferIntersection(ctx, callee.(*typ.Intersection), def, isMethod, receiver, errors) } fn, ok := callee.(*typ.Function) @@ -420,6 +418,69 @@ func inferFunction(ctx *db.QueryContext, fn *typ.Function, def CallDef, isMethod return result } +// inferIntersection aggregates contextual argument expectations from every +// callable intersection member. FinishCall still validates every member, so +// these expectations guide expression synthesis without weakening checking. +func inferIntersection(ctx *db.QueryContext, inter *typ.Intersection, def CallDef, isMethod bool, receiver typ.Type, errors []CallError) InferResult { + result := InferResult{ + Kind: InferKindIntersection, + Callee: inter, + Receiver: receiver, + IsMethod: isMethod, + ForceMethodReceiver: def.ForceMethodReceiver, + Errors: errors, + } + if inter == nil { + return result + } + + var ( + aggExpected []typ.Type + aggVariadic typ.Type + found bool + ) + for _, member := range inter.Members { + fn, ok := member.(*typ.Function) + if !ok { + continue + } + + instantiated := fn + typeArgs := []typ.Type(nil) + if len(fn.TypeParams) > 0 { + if len(def.TypeArgs) > 0 { + typeArgs = def.TypeArgs + } else { + var err error + typeArgs, err = InferTypeArgsWithExpectedAndMode(fn, def.Args, isMethod, receiver, def.ExpectedReturn, def.ForceMethodReceiver) + if err != nil { + continue + } + } + instantiated = InstantiateFunction(fn, typeArgs) + } + + expectedArgs, expectedVariadic := computeExpectedArgs(ctx, def.Query, instantiated, isMethod, receiver, def.ForceMethodReceiver) + if !found { + found = true + result.Function = fn + result.TypeArgs = typeArgs + result.Instantiated = instantiated + aggExpected = append([]typ.Type(nil), expectedArgs...) + aggVariadic = expectedVariadic + continue + } + aggExpected = mergeExpectedArgVectors(aggExpected, expectedArgs) + aggVariadic = typ.JoinPreferNonSoft(aggVariadic, expectedVariadic) + } + + if found { + result.ExpectedArgs = aggExpected + result.ExpectedVariadic = aggVariadic + } + return result +} + // inferUnion handles inference for union callees. // For unions, we attempt to infer each member separately and return // expected types from the first successful inference. @@ -527,7 +588,7 @@ func computeExpectedArgs(ctx *db.QueryContext, query core.TypeOps, fn *typ.Funct for i := 0; i < numArgs; i++ { paramIdx := i + paramOffset if paramIdx < len(fn.Params) { - expected[i] = fn.Params[paramIdx].Type + expected[i] = paramRuntimeType(fn.Params[paramIdx]) if isMethod && receiver != nil { expected[i] = subst.Self(expected[i], receiver) } @@ -585,7 +646,7 @@ func FinishCall(ctx *db.QueryContext, def CallDef, infer InferResult) CallResult if fn == nil { return singleValueCallResult(typ.Unknown, infer.Errors) } - return callFunction(ctx, def.Query, fn, def.Args, infer.Receiver, infer.IsMethod, infer.ForceMethodReceiver, infer.Errors) + return callFunction(ctx, def.Query, fn, def.Args, infer.Receiver, infer.IsMethod, infer.ForceMethodReceiver, def.AllowExtraArgs, infer.Errors) } return singleValueCallResult(typ.Unknown, infer.Errors) @@ -658,7 +719,7 @@ func callIntersection(ctx *db.QueryContext, query core.TypeOps, inter *typ.Inter } seedErrors := append([]CallError(nil), baseErrors...) - result := callFunction(ctx, query, fn, args, receiver, isMethod, forceMethodReceiver, seedErrors) + result := callFunction(ctx, query, fn, args, receiver, isMethod, forceMethodReceiver, false, seedErrors) if hasHardErrors(result.Errors[len(seedErrors):]) { return result } @@ -704,7 +765,7 @@ func callUnionWithGenericInference(ctx *db.QueryContext, u *typ.Union, def CallD seedErrors := append([]CallError(nil), baseErrors...) var result CallResult if len(fn.TypeParams) == 0 { - result = callFunction(ctx, def.Query, fn, def.Args, receiver, isMethod, forceMethodReceiver, seedErrors) + result = callFunction(ctx, def.Query, fn, def.Args, receiver, isMethod, forceMethodReceiver, def.AllowExtraArgs, seedErrors) } else { result = inferAndCall(ctx, fn, def, isMethod, receiver, seedErrors) } @@ -782,7 +843,7 @@ func methodConsumesReceiverSimple(fn *typ.Function, receiver typ.Type, isMethod return hasExplicitSelfSimple(fn, receiver) } -func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, args []typ.Type, receiver typ.Type, isMethod bool, forceMethodReceiver bool, errors []CallError) CallResult { +func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, args []typ.Type, receiver typ.Type, isMethod bool, forceMethodReceiver bool, allowExtraArgs bool, errors []CallError) CallResult { if fn == nil { return singleValueCallResult(typ.Unknown, append(errors, CallError{Kind: ErrNotCallable, Message: "nil function"})) } @@ -795,14 +856,14 @@ func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, ar minArgs := typ.MinRequiredArgs(fn) hasVariadic := fn.Variadic != nil - allowExtraArgs := len(fn.Params) == 0 && !hasVariadic + allowDiscardedExtraArgs := allowExtraArgs || (len(fn.Params) == 0 && !hasVariadic) if argCount < minArgs { errors = append(errors, CallError{ Kind: ErrWrongArity, Message: "not enough arguments", }) - } else if !hasVariadic && !allowExtraArgs && argCount > len(fn.Params) { + } else if !hasVariadic && !allowDiscardedExtraArgs && argCount > len(fn.Params) { errors = append(errors, CallError{ Kind: ErrWrongArity, Message: "too many arguments", @@ -817,7 +878,7 @@ func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, ar if methodHasReceiver { var expectedReceiver typ.Type if len(fn.Params) > 0 { - expectedReceiver = fn.Params[0].Type + expectedReceiver = paramRuntimeType(fn.Params[0]) } else if hasVariadic { expectedReceiver = fn.Variadic } @@ -838,7 +899,7 @@ func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, ar var expectedType typ.Type if paramIdx < len(fn.Params) { - expectedType = fn.Params[paramIdx].Type + expectedType = paramRuntimeType(fn.Params[paramIdx]) } else if hasVariadic { expectedType = fn.Variadic } else { @@ -873,6 +934,13 @@ func callFunction(ctx *db.QueryContext, query core.TypeOps, fn *typ.Function, ar return callResultFromReturns(returns, errors) } +func paramRuntimeType(param typ.Param) typ.Type { + if param.Type == nil || !param.Optional || unwrap.IsOptionalLike(param.Type) { + return param.Type + } + return typ.NewOptional(param.Type) +} + func normalizedCallReturns(result CallResult) []typ.Type { if len(result.Returns) > 0 { return copyTypeSlice(result.Returns) @@ -1104,11 +1172,9 @@ func resolveSelf(returns []typ.Type, receiver typ.Type) []typ.Type { } // CallWithGenericInference synthesizes a call result with generic type inference. -// Wraps InferCall + FinishCall for cases where contextual re-synthesis is not needed. -// For the full two-phase flow with re-synthesis, use InferCall -> ReInfer -> FinishCall. +// It runs the canonical call pipeline without contextual argument re-synthesis. func CallWithGenericInference(ctx *db.QueryContext, def CallDef) CallResult { - infer := InferCall(ctx, def) - return FinishCall(ctx, def, infer) + return NewCallPipeline(ctx, def, len(def.Args)).Run() } // formatUnionMethodError creates a detailed error message for method access on union types. diff --git a/compiler/check/synth/ops/call_test.go b/compiler/check/synth/ops/call_test.go index 61541ad9..179d234c 100644 --- a/compiler/check/synth/ops/call_test.go +++ b/compiler/check/synth/ops/call_test.go @@ -84,6 +84,25 @@ func TestCallWithGenericInference_ZeroParamAllowsExtraArgs(t *testing.T) { } } +func TestCallWithGenericInference_AllowDiscardedExtraArgs(t *testing.T) { + fn := typ.Func(). + Param("x", typ.Integer). + Returns(typ.Boolean). + Build() + + ctx := db.NewQueryContext(db.New()) + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.Integer, typ.String}, + AllowExtraArgs: true, + } + + result := CallWithGenericInference(ctx, def) + if len(result.Errors) > 0 { + t.Fatalf("source-discarded extra args should be allowed, got: %v", result.Errors) + } +} + func TestCallWithGenericInference_Variadic(t *testing.T) { fn := typ.Func(). Param("x", typ.Integer). @@ -553,6 +572,7 @@ func TestCallFunction_MethodOnLiteralReceiverConsumesSelf(t *testing.T) { typ.LiteralString("abc"), true, false, + false, nil, ) @@ -571,7 +591,7 @@ func TestCallFunction_UnknownParamStillRequired(t *testing.T) { Build() ctx := db.NewQueryContext(db.New()) - result := callFunction(ctx, nil, fn, nil, nil, false, false, nil) + result := callFunction(ctx, nil, fn, nil, nil, false, false, false, nil) if len(result.Errors) == 0 { t.Fatal("expected arity error for missing required unknown param") @@ -586,7 +606,7 @@ func TestCallFunction_RequiredAfterOptionalStillRequiresPosition(t *testing.T) { Build() ctx := db.NewQueryContext(db.New()) - result := callFunction(ctx, nil, fn, []typ.Type{typ.Number}, nil, false, false, nil) + result := callFunction(ctx, nil, fn, []typ.Type{typ.Number}, nil, false, false, false, nil) if len(result.Errors) == 0 { t.Fatal("expected arity error when required param appears after optional") @@ -608,6 +628,7 @@ func TestCallFunction_MethodAlwaysConsumesReceiver(t *testing.T) { typ.String, true, true, + false, nil, ) @@ -622,7 +643,7 @@ func TestCallFunction_ZeroParamAllowsExtraArgs(t *testing.T) { Build() ctx := db.NewQueryContext(db.New()) - result := callFunction(ctx, nil, fn, []typ.Type{typ.Number, typ.String}, nil, false, false, nil) + result := callFunction(ctx, nil, fn, []typ.Type{typ.Number, typ.String}, nil, false, false, false, nil) if len(result.Errors) != 0 { t.Fatalf("zero-param function should accept extra args, got: %v", result.Errors) diff --git a/compiler/check/synth/ops/check.go b/compiler/check/synth/ops/check.go index e4529c14..c5f17cd5 100644 --- a/compiler/check/synth/ops/check.go +++ b/compiler/check/synth/ops/check.go @@ -324,10 +324,14 @@ func checkTableAsRecord(fields []FieldDef, elems []typ.Type, expected *typ.Recor continue } - if !subtype.IsSubtype(pf, ef.Type) { + expectedFieldType := ef.Type + if ef.Optional && !unwrap.IsOptionalLike(expectedFieldType) { + expectedFieldType = typ.NewOptional(expectedFieldType) + } + if !subtype.IsSubtype(pf, expectedFieldType) { errors = append(errors, CheckError{ Message: "field type mismatch", - Expected: ef.Type, + Expected: expectedFieldType, Got: pf, Field: ef.Name, }) diff --git a/compiler/check/synth/ops/check_test.go b/compiler/check/synth/ops/check_test.go index 8e376bc4..d1724e84 100644 --- a/compiler/check/synth/ops/check_test.go +++ b/compiler/check/synth/ops/check_test.go @@ -76,6 +76,24 @@ func TestCheckTable_ExpectedRecord_OptionalField(t *testing.T) { } } +func TestCheckTable_ExpectedRecord_OptionalFieldAcceptsOptionalValue(t *testing.T) { + fields := []FieldDef{ + {Name: "x", Type: typ.Integer}, + {Name: "y", Type: typ.NewOptional(typ.String)}, + } + expected := &typ.Record{ + Fields: []typ.Field{ + {Name: "x", Type: typ.Integer}, + {Name: "y", Type: typ.String, Optional: true}, + }, + } + + result := CheckTable(fields, nil, expected) + if len(result.Errors) > 0 { + t.Errorf("optional field should accept optional value expression: %v", result.Errors) + } +} + func TestCheckTable_ExpectedRecord_ExtraField(t *testing.T) { fields := []FieldDef{ {Name: "x", Type: typ.Integer}, diff --git a/compiler/check/synth/ops/logical.go b/compiler/check/synth/ops/logical.go index e3a2fd39..48fa7d21 100644 --- a/compiler/check/synth/ops/logical.go +++ b/compiler/check/synth/ops/logical.go @@ -33,10 +33,6 @@ func LogicalAndTyped(left, right typ.Type) typ.Type { return typ.Never } - if right != nil && right.Kind().IsNever() { - return typ.Never - } - // If left is definitely truthy (cannot be nil or false), result is right if !CanBeFalsy(left) { return right @@ -52,6 +48,9 @@ func LogicalAndTyped(left, right typ.Type) typ.Type { if falsyLeft == nil || falsyLeft.Kind().IsNever() { return right } + if right != nil && right.Kind().IsNever() { + return falsyLeft + } // Unknown/any right branch must remain dominant. Using plain union here can // collapse to falsy-only because typ.NewUnion treats unknown as non-informative. if typ.IsUnknown(right) { @@ -90,10 +89,6 @@ func LogicalOrTyped(left, right typ.Type) typ.Type { return typ.Never } - if right != nil && right.Kind().IsNever() { - return typ.Never - } - // If left is definitely truthy, result is left if !CanBeFalsy(left) { return left @@ -109,6 +104,9 @@ func LogicalOrTyped(left, right typ.Type) typ.Type { if truthyLeft == nil || truthyLeft.Kind().IsNever() { return right } + if right != nil && right.Kind().IsNever() { + return truthyLeft + } return typ.JoinBranchOutcome(truthyLeft, right) } diff --git a/compiler/check/synth/ops/logical_test.go b/compiler/check/synth/ops/logical_test.go index 2927edc8..7e7f5d59 100644 --- a/compiler/check/synth/ops/logical_test.go +++ b/compiler/check/synth/ops/logical_test.go @@ -57,6 +57,13 @@ func TestLogicalAndTyped_Never(t *testing.T) { } } +func TestLogicalAndTyped_RightNeverPreservesFalsyShortCircuit(t *testing.T) { + result := LogicalAndTyped(typ.NewOptional(typ.Integer), typ.Never) + if !typ.TypeEquals(result, typ.Nil) { + t.Errorf("optional(integer) and never should preserve nil short-circuit, got %v", result) + } +} + func TestLogicalOrTyped_LeftTruthy(t *testing.T) { result := LogicalOrTyped(typ.Integer, typ.String) if result != typ.Integer { @@ -87,12 +94,21 @@ func TestLogicalOrTyped_LeftOptional(t *testing.T) { } } -func TestLogicalOrTyped_SoftOptionalPrefersRight(t *testing.T) { +func TestLogicalOrTyped_NestedOptionalFallback(t *testing.T) { + nested := &typ.Optional{Inner: typ.NewOptional(typ.String)} + result := LogicalOrTyped(nested, typ.LiteralString("")) + if !typ.TypeEquals(result, typ.String) { + t.Fatalf("nested optional string fallback = %v, want string", result) + } +} + +func TestLogicalOrTyped_SoftOptionalPreservesLeftRuntimeAlternative(t *testing.T) { left := typ.NewOptional(typ.NewArray(typ.Any)) right := typ.NewArray(typ.Number) result := LogicalOrTyped(left, right) - if result == nil || result.String() != "number[]" { - t.Errorf("expected right to win for soft optional, got %v", result) + want := typ.NewUnion(typ.NewArray(typ.Any), right) + if !typ.TypeEquals(result, want) { + t.Errorf("expected truthy left and right alternatives, got %v, want %v", result, want) } } @@ -103,8 +119,15 @@ func TestLogicalOrTyped_Never(t *testing.T) { } result = LogicalOrTyped(typ.Integer, typ.Never) - if result.Kind() != kind.Never { - t.Errorf("X or never should return never, got %v", result) + if result != typ.Integer { + t.Errorf("truthy or never should preserve truthy short-circuit, got %v", result) + } +} + +func TestLogicalOrTyped_RightNeverPreservesTruthyShortCircuit(t *testing.T) { + result := LogicalOrTyped(typ.NewOptional(typ.Integer), typ.Never) + if !typ.TypeEquals(result, typ.Integer) { + t.Errorf("optional(integer) or never should preserve truthy short-circuit, got %v", result) } } diff --git a/compiler/check/synth/ops/pipeline.go b/compiler/check/synth/ops/pipeline.go new file mode 100644 index 00000000..846018cc --- /dev/null +++ b/compiler/check/synth/ops/pipeline.go @@ -0,0 +1,133 @@ +package ops + +import ( + "github.com/wippyai/go-lua/types/db" + "github.com/wippyai/go-lua/types/subtype" + "github.com/wippyai/go-lua/types/typ" +) + +// ArgReSynth is called to re-synthesize an argument with contextual typing. +type ArgReSynth func(idx int, expected typ.Type) typ.Type + +// CallPipeline executes the two-phase call synthesis flow. +type CallPipeline struct { + ctx *db.QueryContext + def CallDef + argCount int + reSynth ArgReSynth + infer InferResult + finished bool +} + +// NewCallPipeline creates a new call pipeline with the given definition. +func NewCallPipeline(ctx *db.QueryContext, def CallDef, argCount int) *CallPipeline { + return &CallPipeline{ + ctx: ctx, + def: def, + argCount: argCount, + } +} + +// WithReSynth sets the re-synthesis callback for contextual typing. +func (p *CallPipeline) WithReSynth(reSynth ArgReSynth) *CallPipeline { + p.reSynth = reSynth + return p +} + +// WithExpected sets the expected return type for bidirectional generic inference. +func (p *CallPipeline) WithExpected(expected typ.Type) *CallPipeline { + p.def.ExpectedReturn = expected + return p +} + +// Infer runs Phase 1: callee resolution and type argument inference. +func (p *CallPipeline) Infer() InferResult { + p.infer = InferCall(p.ctx, p.def) + return p.infer +} + +// ExpectedArgType returns the expected type for argument at index idx. +func (p *CallPipeline) ExpectedArgType(idx int) typ.Type { + if idx < len(p.infer.ExpectedArgs) { + return p.infer.ExpectedArgs[idx] + } + return p.infer.ExpectedVariadic +} + +// ReSynthAndReInfer runs Phase 2: re-synthesizes arguments and re-infers if needed. +func (p *CallPipeline) ReSynthAndReInfer() bool { + if p.reSynth == nil || p.argCount == 0 { + return false + } + + updatedArgs, changed := p.reSynthArgs() + if !changed { + return false + } + + p.def.Args = updatedArgs + if len(p.def.TypeArgs) == 0 { + p.infer = ReInfer(p.ctx, p.def, p.infer) + } + return true +} + +// Finish runs Phase 3: completes the call and returns the result. +func (p *CallPipeline) Finish() CallResult { + p.finished = true + return FinishCall(p.ctx, p.def, p.infer) +} + +// Run executes the full pipeline: Infer -> ReSynthAndReInfer -> Finish. +func (p *CallPipeline) Run() CallResult { + p.Infer() + p.ReSynthAndReInfer() + return p.Finish() +} + +// reSynthArgs re-synthesizes arguments using the callback. +func (p *CallPipeline) reSynthArgs() ([]typ.Type, bool) { + result := make([]typ.Type, p.argCount) + copy(result, p.def.Args) + changed := false + + for i := 0; i < p.argCount; i++ { + expected := p.ExpectedArgType(i) + if expected == nil { + continue + } + + reSynthed := p.reSynth(i, expected) + if selected, ok := refinedArg(result[i], reSynthed); ok { + result[i] = selected + changed = true + } + } + + return result, changed +} + +func refinedArg(existing, candidate typ.Type) (typ.Type, bool) { + if candidate == nil || typ.TypeEquals(existing, candidate) { + return existing, false + } + if typ.IsAbsentOrUnknown(existing) { + return candidate, true + } + if typ.IsAbsentOrUnknown(candidate) { + return existing, false + } + if typ.IsAny(existing) && !typ.IsAny(candidate) { + return candidate, true + } + if typ.IsAny(candidate) && !typ.IsAny(existing) { + return existing, false + } + if subtype.IsSubtype(candidate, existing) { + return candidate, true + } + if subtype.IsSubtype(existing, candidate) { + return existing, false + } + return candidate, true +} diff --git a/compiler/check/synth/ops/pipeline_test.go b/compiler/check/synth/ops/pipeline_test.go new file mode 100644 index 00000000..2be5425e --- /dev/null +++ b/compiler/check/synth/ops/pipeline_test.go @@ -0,0 +1,290 @@ +package ops + +import ( + "testing" + + "github.com/wippyai/go-lua/types/db" + "github.com/wippyai/go-lua/types/typ" +) + +func TestNewCallPipeline(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + def := CallDef{ + Callee: typ.Func().Build(), + } + pipeline := NewCallPipeline(ctx, def, 1) + if pipeline == nil { + t.Fatal("expected non-nil pipeline") + } + if pipeline.ctx != ctx { + t.Fatal("context mismatch") + } +} + +func TestCallPipeline_WithReSynth(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + def := CallDef{ + Callee: typ.Func().Build(), + } + pipeline := NewCallPipeline(ctx, def, 0) + + reSynth := func(idx int, expected typ.Type) typ.Type { + return typ.String + } + + result := pipeline.WithReSynth(reSynth) + if result != pipeline { + t.Fatal("expected same pipeline returned") + } + if pipeline.reSynth == nil { + t.Fatal("expected reSynth to be set") + } +} + +func TestCallPipeline_WithExpected(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + def := CallDef{ + Callee: typ.Func().Build(), + } + pipeline := NewCallPipeline(ctx, def, 0) + + expected := typ.String + result := pipeline.WithExpected(expected) + if result != pipeline { + t.Fatal("expected same pipeline returned") + } + if pipeline.def.ExpectedReturn != expected { + t.Fatal("expected return type not set") + } +} + +func TestCallPipeline_Infer(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func(). + Param("x", typ.Integer). + Returns(typ.String). + Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.Integer}, + } + pipeline := NewCallPipeline(ctx, def, 0) + + infer := pipeline.Infer() + if infer.Callee == nil { + t.Fatal("expected callee to be resolved") + } +} + +func TestCallPipeline_ExpectedArgType_InRange(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func(). + Param("x", typ.Integer). + Param("y", typ.String). + Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.Integer, typ.String}, + } + pipeline := NewCallPipeline(ctx, def, 0) + pipeline.Infer() + + arg0 := pipeline.ExpectedArgType(0) + if arg0 != typ.Integer { + t.Fatalf("got %v, want integer", arg0) + } + arg1 := pipeline.ExpectedArgType(1) + if arg1 != typ.String { + t.Fatalf("got %v, want string", arg1) + } +} + +func TestCallPipeline_ExpectedArgType_OutOfRange(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func(). + Param("x", typ.Integer). + Variadic(typ.String). + Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.Integer, typ.String, typ.String}, + } + pipeline := NewCallPipeline(ctx, def, 0) + pipeline.Infer() + + arg5 := pipeline.ExpectedArgType(5) + if arg5 != typ.String { + t.Fatalf("got %v, want string (variadic)", arg5) + } +} + +func TestCallPipeline_ExpectedArgType_Intersection(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fnA := typ.Func().Param("x", typ.String).Returns(typ.Any).Build() + fnB := typ.Func().Param("x", typ.String).Returns(typ.Unknown).Build() + def := CallDef{ + Callee: typ.NewIntersection(fnA, fnB), + Args: []typ.Type{typ.NewOptional(typ.String)}, + } + pipeline := NewCallPipeline(ctx, def, 1) + pipeline.Infer() + + arg0 := pipeline.ExpectedArgType(0) + if arg0 != typ.String { + t.Fatalf("got %v, want string", arg0) + } +} + +func TestCallPipeline_IntersectionReSynthesizesLogicalArg(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fnA := typ.Func().Param("x", typ.String).Returns(typ.Any).Build() + fnB := typ.Func().Param("x", typ.String).Returns(typ.Unknown).Build() + def := CallDef{ + Callee: typ.NewIntersection(fnA, fnB), + Args: []typ.Type{typ.NewOptional(typ.String)}, + } + pipeline := NewCallPipeline(ctx, def, 1). + WithReSynth(func(idx int, expected typ.Type) typ.Type { + if idx != 0 { + t.Fatalf("got idx %d, want 0", idx) + } + if expected != typ.String { + t.Fatalf("got expected %v, want string", expected) + } + return typ.String + }) + + result := pipeline.Run() + if len(result.Errors) != 0 { + t.Fatalf("expected no errors after contextual re-synthesis, got %v", result.Errors) + } +} + +func TestCallPipeline_ReSynthAndReInfer_NoReSynth(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Build() + def := CallDef{Callee: fn} + pipeline := NewCallPipeline(ctx, def, 0) + pipeline.Infer() + + changed := pipeline.ReSynthAndReInfer() + if changed { + t.Fatal("expected no change without reSynth") + } +} + +func TestCallPipeline_ReSynthAndReInfer_NoArgs(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Build() + def := CallDef{Callee: fn} + pipeline := NewCallPipeline(ctx, def, 0) + pipeline.WithReSynth(func(idx int, expected typ.Type) typ.Type { + return typ.String + }) + pipeline.Infer() + + changed := pipeline.ReSynthAndReInfer() + if changed { + t.Fatal("expected no change without args") + } +} + +func TestCallPipeline_ReSynthAndReInfer_UnchangedArgDoesNotReInfer(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Param("value", typ.String).Returns(typ.Any).Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.String}, + } + pipeline := NewCallPipeline(ctx, def, 1). + WithReSynth(func(idx int, expected typ.Type) typ.Type { + if idx != 0 { + t.Fatalf("unexpected re-synth arg idx=%d", idx) + } + if expected != typ.String { + t.Fatalf("expected arg type = %v, want string", expected) + } + return typ.String + }) + pipeline.Infer() + + if changed := pipeline.ReSynthAndReInfer(); changed { + t.Fatal("unchanged contextual type should not force re-inference") + } +} + +func TestCallPipeline_ReSynthAndReInfer_DoesNotWeakenExistingArg(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Param("value", typ.String).Returns(typ.Any).Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.String}, + } + pipeline := NewCallPipeline(ctx, def, 1). + WithReSynth(func(idx int, expected typ.Type) typ.Type { + return typ.Any + }) + pipeline.Infer() + + if changed := pipeline.ReSynthAndReInfer(); changed { + t.Fatal("weaker contextual type should not replace existing argument evidence") + } + if pipeline.def.Args[0] != typ.String { + t.Fatalf("arg type = %v, want string", pipeline.def.Args[0]) + } +} + +func TestCallPipeline_ReSynthAndReInfer_FillsUnknownArg(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Param("value", typ.String).Returns(typ.Any).Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{typ.Unknown}, + } + pipeline := NewCallPipeline(ctx, def, 1). + WithReSynth(func(idx int, expected typ.Type) typ.Type { + return typ.String + }) + pipeline.Infer() + + if changed := pipeline.ReSynthAndReInfer(); !changed { + t.Fatal("more precise contextual type should replace unknown argument evidence") + } + if pipeline.def.Args[0] != typ.String { + t.Fatalf("arg type = %v, want string", pipeline.def.Args[0]) + } +} + +func TestCallPipeline_Finish(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Returns(typ.String).Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{}, + } + pipeline := NewCallPipeline(ctx, def, 0) + pipeline.Infer() + + result := pipeline.Finish() + if result.Type == nil { + t.Fatal("expected non-nil result type") + } + if !pipeline.finished { + t.Fatal("expected finished flag to be set") + } +} + +func TestCallPipeline_Run(t *testing.T) { + ctx := db.NewQueryContext(db.New()) + fn := typ.Func().Returns(typ.Integer).Build() + def := CallDef{ + Callee: fn, + Args: []typ.Type{}, + } + pipeline := NewCallPipeline(ctx, def, 0) + + result := pipeline.Run() + if result.Type == nil { + t.Fatal("expected non-nil result type") + } +} diff --git a/compiler/check/synth/ops/truthy.go b/compiler/check/synth/ops/truthy.go index 651bd421..e1d62d44 100644 --- a/compiler/check/synth/ops/truthy.go +++ b/compiler/check/synth/ops/truthy.go @@ -89,25 +89,58 @@ func canBeFalsyGuard(t typ.Type, guard internal.RecursionGuard) bool { // IsFalsy reports whether a type is definitely falsy (always nil or false). // -// Returns true only for: -// - typ.Nil -// - Literal false -// -// For types that may or may not be falsy (like boolean or optional), -// returns false. Use CanBeFalsy for that check. +// Lua has exactly two falsy values. A compound type is definitely falsy only +// when every runtime value it can represent is nil or false, for example +// `false?` (`nil | false`). For types that may or may not be falsy, like +// boolean or string?, this returns false. Use CanBeFalsy for that check. func IsFalsy(t typ.Type) bool { + return isFalsyGuard(t, typ.NewGuard()) +} + +func isFalsyGuard(t typ.Type, guard internal.RecursionGuard) bool { if t == nil { return false } + next, ok := guard.Enter(t) + if !ok { + return false + } + t = typ.UnwrapAnnotated(t) if t.Kind() == kind.Nil { return true } + if t.Kind() == kind.Never { + return true + } - if lit, ok := t.(*typ.Literal); ok { - if b, isBool := lit.Value.(bool); isBool && !b { + switch v := t.(type) { + case *typ.Literal: + b, isBool := v.Value.(bool) + return isBool && !b + case *typ.Optional: + return isFalsyGuard(v.Inner, next) + case *typ.Union: + if len(v.Members) == 0 { return true } + for _, m := range v.Members { + if !isFalsyGuard(m, next) { + return false + } + } + return true + case *typ.Intersection: + for _, m := range v.Members { + if isFalsyGuard(m, next) { + return true + } + } + return false + case *typ.Alias: + return v.Target != nil && isFalsyGuard(v.Target, next) + case *typ.TypeParam: + return v.Constraint != nil && isFalsyGuard(v.Constraint, next) } return false diff --git a/compiler/check/synth/ops/truthy_test.go b/compiler/check/synth/ops/truthy_test.go index d5965ead..67887ce8 100644 --- a/compiler/check/synth/ops/truthy_test.go +++ b/compiler/check/synth/ops/truthy_test.go @@ -100,6 +100,18 @@ func TestIsFalsy(t *testing.T) { t.Error("false literal should be falsy") } + if !IsFalsy(typ.NewOptional(typ.LiteralBool(false))) { + t.Error("false? should be definitely falsy") + } + + if !IsFalsy(typ.NewUnion(typ.Nil, typ.LiteralBool(false))) { + t.Error("nil | false should be definitely falsy") + } + + if IsFalsy(typ.NewOptional(typ.Boolean)) { + t.Error("boolean? can be truthy and should not be definitely falsy") + } + if IsFalsy(typ.Integer) { t.Error("integer should not be falsy") } diff --git a/compiler/check/synth/phase/core/function_literal.go b/compiler/check/synth/phase/core/function_literal.go new file mode 100644 index 00000000..7481b7f8 --- /dev/null +++ b/compiler/check/synth/phase/core/function_literal.go @@ -0,0 +1,46 @@ +package core + +import ( + "github.com/wippyai/go-lua/compiler/ast" + querycore "github.com/wippyai/go-lua/types/query/core" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" +) + +// ExpectedFunctionLiteralSignature returns the contextual function signature +// for a function literal, including arity-compatible function members inside +// an expected union. +func ExpectedFunctionLiteralSignature(fn *ast.FunctionExpr, expected typ.Type) *typ.Function { + if expected == nil { + return nil + } + if expectedFn, ok := unwrap.Alias(expected).(*typ.Function); ok { + return expectedFn + } + return querycore.CompatibleFunctionFromUnion(functionLiteralParamCount(fn), expected) +} + +func functionLiteralParamCount(fn *ast.FunctionExpr) int { + if fn == nil || fn.ParList == nil { + return 0 + } + return len(fn.ParList.Names) +} + +// ShallowFunctionLiteralSignature is the non-recursive probe type for a +// function literal before a call site provides contextual parameter types. +func ShallowFunctionLiteralSignature(fn *ast.FunctionExpr) *typ.Function { + builder := typ.Func() + if fn != nil && fn.ParList != nil { + builder.ReserveParams(len(fn.ParList.Names)) + for _, name := range fn.ParList.Names { + builder.OptParam(name, typ.Any) + } + if fn.ParList.HasVargs { + builder.Variadic(typ.Any) + } + } else { + builder.Variadic(typ.Any) + } + return builder.Returns(typ.Any).Build() +} diff --git a/compiler/check/synth/phase/core/params.go b/compiler/check/synth/phase/core/params.go index 5d9ba389..b2ee3cd2 100644 --- a/compiler/check/synth/phase/core/params.go +++ b/compiler/check/synth/phase/core/params.go @@ -21,6 +21,9 @@ type ParamListConfig struct { // ImplicitSelfType is used for the prepended `self` parameter. // When nil, `unknown` is used. ImplicitSelfType typ.Type + // UntypedParamType is used for unannotated source parameters without an + // expected type. When nil, `unknown` is used. + UntypedParamType typ.Type } // ParamSymbolLookup exposes parameter symbol layout for a function expression. @@ -85,12 +88,14 @@ func HasUnannotatedSelfParam(fn *ast.FunctionExpr, bindings ParamSymbolLookup) b return HasImplicitSelfParam(fn, bindings) } -// ApplyParamList applies parameter and vararg rules to the builder. -// Unannotated params are optional in Lua and unannotated functions accept extra args. +// ApplyParamList applies parameter and source-vararg rules to the builder. +// Unannotated params are optional in Lua; discarded extra-argument acceptance is +// a call-site rule, not a fake variadic slot in the function type. func ApplyParamList(builder *typ.FunctionBuilder, fn *ast.FunctionExpr, cfg ParamListConfig) { if builder == nil || fn == nil || fn.ParList == nil { return } + builder.ReserveParams(paramListCapacity(fn, cfg.ImplicitSelf)) shiftExpected := false if cfg.ImplicitSelf { @@ -104,7 +109,6 @@ func ApplyParamList(builder *typ.FunctionBuilder, fn *ast.FunctionExpr, cfg Para } } - hasUntyped := false for i, name := range fn.ParList.Names { paramType := typ.Unknown isOptional := false @@ -113,29 +117,35 @@ func ApplyParamList(builder *typ.FunctionBuilder, fn *ast.FunctionExpr, cfg Para expectedIdx = i + 1 } + var typeExpr ast.TypeExpr if fn.ParList.Types != nil && i < len(fn.ParList.Types) { - if typeExpr := fn.ParList.Types[i]; typeExpr != nil { - if _, ok := typeExpr.(*ast.OptionalTypeExpr); ok { - isOptional = true - } - if cfg.ResolveType != nil && cfg.ResolveScope != nil { - if t := cfg.ResolveType(typeExpr, cfg.ResolveScope); t != nil { - paramType = t - // Soft annotations allow expected types to refine the parameter. - if cfg.Expected != nil && expectedIdx < len(cfg.Expected.Params) && typ.IsRefinableAnnotation(t) { - paramType = cfg.Expected.Params[expectedIdx].Type - isOptional = cfg.Expected.Params[expectedIdx].Optional + typeExpr = fn.ParList.Types[i] + } + if typeExpr != nil { + if _, ok := typeExpr.(*ast.OptionalTypeExpr); ok { + isOptional = true + } + if cfg.ResolveType != nil && cfg.ResolveScope != nil { + if t := cfg.ResolveType(typeExpr, cfg.ResolveScope); t != nil { + paramType = t + // Soft annotations allow expected types to refine the parameter. + if typ.IsRefinableAnnotation(t) { + if expectedType, expectedOptional, ok := expectedParamTypeAt(cfg.Expected, expectedIdx); ok { + paramType = expectedType + isOptional = expectedOptional } } } } - } else if cfg.Expected != nil && expectedIdx < len(cfg.Expected.Params) { - paramType = cfg.Expected.Params[expectedIdx].Type - isOptional = cfg.Expected.Params[expectedIdx].Optional + } else if expectedType, expectedOptional, ok := expectedParamTypeAt(cfg.Expected, expectedIdx); ok { + paramType = expectedType + isOptional = expectedOptional } else { // Unannotated params are optional in Lua (missing args become nil). + if cfg.UntypedParamType != nil { + paramType = cfg.UntypedParamType + } isOptional = true - hasUntyped = true } if isOptional { @@ -155,8 +165,30 @@ func ApplyParamList(builder *typ.FunctionBuilder, fn *ast.FunctionExpr, cfg Para varargType = cfg.Expected.Variadic } builder.Variadic(varargType) - } else if hasUntyped { - // Unannotated functions accept extra args; treat as variadic any. - builder.Variadic(typ.Any) } } + +func expectedParamTypeAt(expected *typ.Function, idx int) (typ.Type, bool, bool) { + if expected == nil || idx < 0 { + return nil, false, false + } + if idx < len(expected.Params) { + p := expected.Params[idx] + return p.Type, p.Optional, true + } + if expected.Variadic != nil { + return expected.Variadic, true, true + } + return nil, false, false +} + +func paramListCapacity(fn *ast.FunctionExpr, implicitSelf bool) int { + if fn == nil || fn.ParList == nil { + return 0 + } + n := len(fn.ParList.Names) + if implicitSelf { + n++ + } + return n +} diff --git a/compiler/check/synth/phase/core/params_test.go b/compiler/check/synth/phase/core/params_test.go index 927f24a5..ee140dcb 100644 --- a/compiler/check/synth/phase/core/params_test.go +++ b/compiler/check/synth/phase/core/params_test.go @@ -96,8 +96,8 @@ func TestApplyParamList_UntypedParams(t *testing.T) { t.Errorf("param %d: expected optional (untyped params default to optional)", i) } } - if result.Variadic == nil { - t.Error("expected variadic type for untyped function") + if result.Variadic != nil { + t.Fatalf("untyped params should not create a fake variadic slot, got %v", result.Variadic) } } @@ -149,6 +149,82 @@ func TestApplyParamList_WithExpected(t *testing.T) { } } +func TestApplyParamList_WithExpectedVariadicTypesNamedParams(t *testing.T) { + expected := typ.Func(). + Param("first", typ.String). + Variadic(typ.Number). + Build() + + builder := typ.Func() + fn := &ast.FunctionExpr{ + ParList: &ast.ParList{ + Names: []string{"first", "extra"}, + }, + } + ApplyParamList(builder, fn, ParamListConfig{ + Expected: expected, + }) + result := builder.Build() + if len(result.Params) != 2 { + t.Fatalf("expected 2 params, got %d", len(result.Params)) + } + if result.Params[0].Type != typ.String || result.Params[0].Optional { + t.Fatalf("expected required string first param, got %+v", result.Params[0]) + } + if result.Params[1].Type != typ.Number || !result.Params[1].Optional { + t.Fatalf("expected optional number variadic-derived second param, got %+v", result.Params[1]) + } +} + +func TestApplyParamList_NilAnnotationSlotUsesExpected(t *testing.T) { + sc := scope.New() + resolveType := func(expr ast.TypeExpr, _ *scope.State) typ.Type { + if ref, ok := expr.(*ast.TypeRefExpr); ok && len(ref.Path) == 1 { + switch ref.Path[0] { + case "State": + return typ.NewRecord().Field("id", typ.String).Build() + case "Event": + return typ.NewRecord().Field("kind", typ.String).Build() + } + } + return nil + } + + expected := typ.Func(). + Param("state", typ.Any). + Param("event", typ.Any). + Param("at", typ.Integer). + Build() + builder := typ.Func() + fn := &ast.FunctionExpr{ + ParList: &ast.ParList{ + Names: []string{"state", "event", "at"}, + Types: []ast.TypeExpr{ + &ast.TypeRefExpr{Path: []string{"State"}}, + &ast.TypeRefExpr{Path: []string{"Event"}}, + nil, + }, + }, + } + + ApplyParamList(builder, fn, ParamListConfig{ + ResolveType: resolveType, + ResolveScope: sc, + Expected: expected, + }) + + result := builder.Build() + if len(result.Params) != 3 { + t.Fatalf("expected 3 params, got %d", len(result.Params)) + } + if result.Params[2].Type != typ.Integer { + t.Fatalf("expected nil annotation slot to use expected integer, got %v", result.Params[2].Type) + } + if result.Params[2].Optional { + t.Fatal("expected optional flag to come from expected parameter") + } +} + func TestApplyParamList_WithResolveType(t *testing.T) { sc := scope.New() resolveType := func(expr ast.TypeExpr, s *scope.State) typ.Type { diff --git a/compiler/check/synth/phase/extract/call.go b/compiler/check/synth/phase/extract/call.go index e5a4a904..383e56dc 100644 --- a/compiler/check/synth/phase/extract/call.go +++ b/compiler/check/synth/phase/extract/call.go @@ -1,13 +1,18 @@ package extract import ( + "sort" + "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" compcfg "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" "github.com/wippyai/go-lua/compiler/check/scope" + "github.com/wippyai/go-lua/compiler/check/synth/callarg" "github.com/wippyai/go-lua/compiler/check/synth/intercept" "github.com/wippyai/go-lua/compiler/check/synth/ops" + phasecore "github.com/wippyai/go-lua/compiler/check/synth/phase/core" "github.com/wippyai/go-lua/compiler/check/synth/transform" "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/contract" @@ -16,6 +21,7 @@ import ( "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" + typjoin "github.com/wippyai/go-lua/types/typ/join" "github.com/wippyai/go-lua/types/typ/subst" "github.com/wippyai/go-lua/types/typ/unwrap" ) @@ -141,6 +147,9 @@ func (s *Synthesizer) synthCallCoreWithCaptureTypes( Scope: sc, Recurse: intercept.ExprSynth(recurse), TypeLookup: s.declaredTypeLookup(sc), + StableType: func(expr ast.Expr, current typ.Type) typ.Type { + return s.stablePrototypeType(expr, p, sc, current, recurse) + }, } chain := s.buildInterceptChain(sc) @@ -152,8 +161,16 @@ func (s *Synthesizer) synthCallCoreWithCaptureTypes( if specialized := s.specializedLocalFunctionCalleeType(ex, p, sc, calleeType, captureTypes); specialized != nil { calleeType = specialized } - args := synthArgs(ex.Args, recurse) typeArgs := s.resolveTypeArgs(ex.TypeArgs, sc) + allowExtraArgs := s.localFunctionAllowsDiscardedExtraArgs(ex, p) + probeDef := ops.CallDef{ + Callee: calleeType, + TypeArgs: typeArgs, + Query: s.GetCallQuery(), + ExpectedReturn: expected, + AllowExtraArgs: allowExtraArgs, + } + args := s.synthArgsWithCallContext(ex.Args, p, sc, recurse, probeDef) def := ops.CallDef{ Callee: calleeType, @@ -161,10 +178,11 @@ func (s *Synthesizer) synthCallCoreWithCaptureTypes( TypeArgs: typeArgs, Query: s.GetCallQuery(), ExpectedReturn: expected, + AllowExtraArgs: allowExtraArgs, } - pipeline := NewCallPipeline(s.deps.Ctx, def, ex.Args). - WithReSynth(s.callbackAwareReSynth(calleeType, sc)) + pipeline := ops.NewCallPipeline(s.deps.Ctx, def, len(ex.Args)). + WithReSynth(s.contextualArgReSynth(calleeType, ex.Args, sc, p)) if expected != nil { pipeline = pipeline.WithExpected(expected) @@ -203,23 +221,74 @@ func (s *Synthesizer) specializedLocalFunctionCalleeType( if bindings == nil { return nil } + evidence := s.graphEvidence(graph) info := graph.CallSiteAt(p, ex) - if info == nil { - return nil + candidates := callsite.CallableCalleeSymbolCandidates(info, graph, bindings, nil) + if len(candidates) == 0 { + if sym := callsite.SymbolFromExpr(ex.Func, bindings); sym != 0 { + candidates = append(candidates, sym) + } + if s.deps.ModuleBindings != nil && s.deps.ModuleBindings != bindings { + if sym := callsite.SymbolFromExpr(ex.Func, s.deps.ModuleBindings); sym != 0 { + candidates = append(candidates, sym) + } + } } - for _, sym := range callsite.CallableCalleeSymbolCandidates(info, graph, bindings, nil) { - fn := callsite.FunctionLiteralForGraphSymbol(graph, sym) - if fn == nil { - continue + for _, sym := range candidates { + fn := callsite.FunctionLiteralForGraphSymbol(evidence, sym) + if fn != nil && !s.hasDominatingDirectFunctionRebind(sym, fn, p) { + factType := s.functionFactType(sym) + hasCallPointCaptureMutation := hasNonGlobalFunctionCaptures(bindings, fn) && s.hasDominatingCapturedMutation(fn, p) + if factType != nil && !hasCallPointCaptureMutation { + return factType + } + expectedFn, _ := unwrap.Optional(unwrap.Alias(current)).(*typ.Function) + if expectedFn == nil { + expectedFn, _ = unwrap.Optional(unwrap.Alias(factType)).(*typ.Function) + } + if fnType := s.synthFunctionTypeWithCapturePoint(fn, sc, expectedFn, p, captureTypes); fnType != nil { + return fnType + } + if factType != nil { + return factType + } } - expectedFn, _ := unwrap.Optional(unwrap.Alias(current)).(*typ.Function) - if fnType := s.synthFunctionTypeWithCapturePoint(fn, sc, expectedFn, p, captureTypes); fnType != nil { - return fnType + if typ.IsUnknownOrNil(current) { + if t := s.functionFactType(sym); t != nil { + return t + } } } return nil } +func (s *Synthesizer) localFunctionAllowsDiscardedExtraArgs(ex *ast.FuncCallExpr, p cfg.Point) bool { + if s == nil || ex == nil || s.deps.CheckCtx == nil { + return false + } + graph, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph) + if !ok || graph == nil { + return false + } + bindings := graph.Bindings() + if bindings == nil { + bindings = s.deps.ModuleBindings + } + evidence := s.graphEvidence(graph) + info := graph.CallSiteAt(p, ex) + for _, sym := range callsite.CallableCalleeSymbolCandidates(info, graph, bindings, nil) { + if fn := callsite.FunctionLiteralForGraphSymbol(evidence, sym); callsite.AllowsDiscardedExtraArgs(fn) { + return true + } + } + if bindings != nil { + if sym := callsite.SymbolFromExpr(ex.Func, bindings); sym != 0 { + return callsite.AllowsDiscardedExtraArgs(callsite.FunctionLiteralForGraphSymbol(evidence, sym)) + } + } + return false +} + // SynthCallCoreWithExpected synthesizes call with optional expected return type for generic inference. func (s *Synthesizer) SynthCallCoreWithExpected(ex *ast.FuncCallExpr, p cfg.Point, sc *scope.State, recurse ExprSynth, expected typ.Type) []typ.Type { return s.synthCallCoreWithNarrower(ex, p, sc, nil, recurse, expected) @@ -231,6 +300,9 @@ func (s *Synthesizer) synthMethodCallCoreWithExpected(ex *ast.FuncCallExpr, p cf Scope: sc, Recurse: intercept.ExprSynth(recurse), TypeLookup: s.declaredTypeLookup(sc), + StableType: func(expr ast.Expr, current typ.Type) typ.Type { + return s.stablePrototypeType(expr, p, sc, current, recurse) + }, } chain := s.buildInterceptChain(sc) @@ -239,8 +311,17 @@ func (s *Synthesizer) synthMethodCallCoreWithExpected(ex *ast.FuncCallExpr, p cf } recvType := recurse(ex.Receiver) - args := synthArgs(ex.Args, recurse) calleeType := s.resolveMethodCallee(recvType, ex.Method) + forceReceiver := s.forceMethodReceiverAtPoint(p, ex) + probeDef := ops.CallDef{ + IsMethod: true, + Receiver: recvType, + MethodName: ex.Method, + Query: s.GetCallQuery(), + ExpectedReturn: expected, + ForceMethodReceiver: forceReceiver, + } + args := s.synthArgsWithCallContext(ex.Args, p, sc, recurse, probeDef) def := ops.CallDef{ IsMethod: true, @@ -249,11 +330,11 @@ func (s *Synthesizer) synthMethodCallCoreWithExpected(ex *ast.FuncCallExpr, p cf Args: args, Query: s.GetCallQuery(), ExpectedReturn: expected, - ForceMethodReceiver: s.forceMethodReceiverAtPoint(p, ex), + ForceMethodReceiver: forceReceiver, } - pipeline := NewCallPipeline(s.deps.Ctx, def, ex.Args). - WithReSynth(s.callbackAwareReSynth(calleeType, sc)) + pipeline := ops.NewCallPipeline(s.deps.Ctx, def, len(ex.Args)). + WithReSynth(s.contextualArgReSynth(calleeType, ex.Args, sc, p)) if expected != nil { pipeline = pipeline.WithExpected(expected) @@ -273,6 +354,9 @@ func (s *Synthesizer) SynthCallWithReceiverType(ex *ast.FuncCallExpr, p cfg.Poin Scope: sc, Recurse: intercept.ExprSynth(recurse), TypeLookup: s.declaredTypeLookup(sc), + StableType: func(expr ast.Expr, current typ.Type) typ.Type { + return s.stablePrototypeType(expr, p, sc, current, recurse) + }, } chain := s.buildInterceptChain(sc) @@ -280,8 +364,16 @@ func (s *Synthesizer) SynthCallWithReceiverType(ex *ast.FuncCallExpr, p cfg.Poin return result.Types } - args := synthArgs(ex.Args, recurse) calleeType := s.resolveMethodCallee(recvType, ex.Method) + forceReceiver := s.forceMethodReceiverAtPoint(p, ex) + probeDef := ops.CallDef{ + IsMethod: true, + Receiver: recvType, + MethodName: ex.Method, + Query: s.GetCallQuery(), + ForceMethodReceiver: forceReceiver, + } + args := s.synthArgsWithCallContext(ex.Args, p, sc, recurse, probeDef) def := ops.CallDef{ IsMethod: true, @@ -289,11 +381,11 @@ func (s *Synthesizer) SynthCallWithReceiverType(ex *ast.FuncCallExpr, p cfg.Poin MethodName: ex.Method, Args: args, Query: s.GetCallQuery(), - ForceMethodReceiver: s.forceMethodReceiverAtPoint(p, ex), + ForceMethodReceiver: forceReceiver, } - pipeline := NewCallPipeline(s.deps.Ctx, def, ex.Args). - WithReSynth(s.callbackAwareReSynth(calleeType, sc)) + pipeline := ops.NewCallPipeline(s.deps.Ctx, def, len(ex.Args)). + WithReSynth(s.contextualArgReSynth(calleeType, ex.Args, sc, p)) result := pipeline.Run() returns := unwrapCallResult(result) @@ -329,6 +421,213 @@ func (s *Synthesizer) declaredTypeLookup(sc *scope.State) func(string) typ.Type } } +func (s *Synthesizer) stablePrototypeType(expr ast.Expr, p cfg.Point, sc *scope.State, current typ.Type, recurse ExprSynth) typ.Type { + if s == nil || expr == nil || s.deps.CheckCtx == nil { + return current + } + ident, ok := expr.(*ast.IdentExpr) + if !ok || ident.Value == "" { + return current + } + graph, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph) + if !ok || graph == nil { + return current + } + bindings := graph.Bindings() + if bindings == nil { + bindings = s.deps.ModuleBindings + } + if bindings == nil { + return current + } + sym, ok := bindings.SymbolOf(ident) + if !ok || sym == 0 { + return current + } + + fields := s.stablePrototypeFields(graph, sym, sc, recurse) + if len(fields) == 0 { + return current + } + + var base *typ.Record + if rec := unwrap.Record(current); rec != nil && !typ.IsUnknown(rec.Metatable) { + base = rec + } + builder := typ.NewRecord() + if base != nil { + for _, field := range base.Fields { + fields[field.Name] = typ.JoinPreferNonSoft(field.Type, fields[field.Name]) + } + if base.Metatable != nil { + builder.Metatable(base.Metatable) + } + if base.HasMapComponent() { + builder.MapComponent(base.MapKey, base.MapValue) + } + builder.SetOpen(base.Open) + } + + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + t := fields[name] + if t == nil { + t = typ.Unknown + } + builder.Field(name, t) + } + return builder.Build() +} + +func (s *Synthesizer) stablePrototypeFields(graph *compcfg.Graph, sym compcfg.SymbolID, sc *scope.State, recurse ExprSynth) map[string]typ.Type { + if graph == nil || sym == 0 { + return nil + } + bindings := graph.Bindings() + evidence := s.graphEvidence(graph) + var fields map[string]typ.Type + addField := func(name string, t typ.Type) { + if name == "" { + return + } + if t == nil { + t = typ.Unknown + } + if fields == nil { + fields = make(map[string]typ.Type) + } + if existing := fields[name]; existing != nil { + fields[name] = typ.JoinPreferNonSoft(existing, t) + } else { + fields[name] = t + } + } + for _, assign := range evidence.Assignments { + p := assign.Point + info := assign.Info + if info == nil { + continue + } + sources := info.Sources + for i, target := range info.Targets { + fieldName := stablePrototypeFieldName(target, sym) + if fieldName == "" { + continue + } + var source ast.Expr + if i < len(sources) { + source = sources[i] + } + addField(fieldName, s.stablePrototypeFieldType(source, p, sc, bindings, recurse)) + } + } + for _, def := range evidence.FunctionDefinitions { + p := def.Nested.Point + info := def.FuncDef + fieldName := stablePrototypeFuncDefFieldName(info, sym) + if fieldName == "" { + continue + } + addField(fieldName, s.stablePrototypeFuncDefType(info, p, sc, bindings, recurse)) + } + for _, field := range bindings.DirectFieldSymbols(sym) { + if field.Symbol == 0 { + continue + } + if t := s.functionFactType(field.Symbol); t != nil { + addField(field.Name, t) + continue + } + if fn, ok := bindings.FuncLitBySymbol(field.Symbol); ok { + addField(field.Name, s.stablePrototypeFieldType(fn, graph.Entry(), sc, bindings, recurse)) + } + } + return fields +} + +func stablePrototypeFieldName(target compcfg.AssignTarget, sym compcfg.SymbolID) string { + if target.BaseSymbol != sym { + return "" + } + switch target.Kind { + case compcfg.TargetField: + if len(target.FieldPath) == 1 { + return target.FieldPath[0] + } + case compcfg.TargetIndex: + if key, ok := target.Key.(*ast.StringExpr); ok { + return key.Value + } + } + return "" +} + +func stablePrototypeFuncDefFieldName(info *compcfg.FuncDefInfo, sym compcfg.SymbolID) string { + if info == nil || info.ReceiverSymbol != sym || info.Name == "" { + return "" + } + switch info.TargetKind { + case compcfg.FuncDefField, compcfg.FuncDefMethod: + return info.Name + default: + return "" + } +} + +func (s *Synthesizer) stablePrototypeFuncDefType(info *compcfg.FuncDefInfo, p compcfg.Point, sc *scope.State, bindings *bind.BindingTable, recurse ExprSynth) typ.Type { + if info == nil { + return nil + } + var factType typ.Type + if info.Symbol != 0 { + factType = s.functionFactType(info.Symbol) + } + if info.TargetKind == compcfg.FuncDefMethod && info.FuncExpr != nil && s != nil { + expected := typ.Func().Param("self", typ.Self).Build() + if sourceType := s.SynthFunctionTypeWithExpected(info.FuncExpr, sc, expected); sourceType != nil { + if sourceFn := unwrap.Function(sourceType); sourceFn != nil { + if factFn := unwrap.Function(factType); factFn != nil && len(factFn.Returns) > 0 { + if aligned := typjoin.WithReturns(sourceFn, factFn.Returns); aligned != nil { + return aligned + } + } + } + return sourceType + } + } + if factType != nil { + return factType + } + return s.stablePrototypeFieldType(info.FuncExpr, p, sc, bindings, recurse) +} + +func (s *Synthesizer) stablePrototypeFieldType(source ast.Expr, p compcfg.Point, sc *scope.State, bindings *bind.BindingTable, recurse ExprSynth) typ.Type { + if source == nil { + return nil + } + if fn, ok := source.(*ast.FunctionExpr); ok && bindings != nil { + if sym, ok := bindings.FuncLitSymbol(fn); ok && sym != 0 { + if t := s.functionFactType(sym); t != nil { + return t + } + } + if s != nil { + expected := typ.Func().Param("self", typ.Self).Build() + if t := s.SynthFunctionTypeWithExpected(fn, sc, expected); t != nil { + return t + } + } + } + if recurse != nil { + return recurse(source) + } + return nil +} + // buildInterceptChain creates the intercept chain for call synthesis. func (s *Synthesizer) buildInterceptChain(sc *scope.State) *intercept.Chain { builder := intercept.NewChainBuilder() @@ -353,6 +652,61 @@ func synthArgs(exprs []ast.Expr, recurse ExprSynth) []typ.Type { return args } +func (s *Synthesizer) synthArgsWithCallContext( + exprs []ast.Expr, + p cfg.Point, + sc *scope.State, + recurse ExprSynth, + def ops.CallDef, +) []typ.Type { + if len(exprs) == 0 { + return nil + } + if !hasDirectFunctionLiteralArg(exprs) { + return synthArgs(exprs, recurse) + } + shallow := synthShallowFunctionArgs(exprs, recurse) + def.Args = shallow + inferred := ops.InferCall(s.deps.Ctx, def) + + args := make([]typ.Type, len(exprs)) + for i, arg := range exprs { + if fn, ok := arg.(*ast.FunctionExpr); ok { + if expectedFn := phasecore.ExpectedFunctionLiteralSignature(fn, inferred.ExpectedArgType(i)); expectedFn != nil { + if t := s.SynthFunctionTypeWithExpected(fn, sc, expectedFn); t != nil { + args[i] = t + continue + } + } + args[i] = recurse(arg) + continue + } + args[i] = shallow[i] + } + return args +} + +func hasDirectFunctionLiteralArg(exprs []ast.Expr) bool { + for _, arg := range exprs { + if _, ok := arg.(*ast.FunctionExpr); ok { + return true + } + } + return false +} + +func synthShallowFunctionArgs(exprs []ast.Expr, recurse ExprSynth) []typ.Type { + args := make([]typ.Type, len(exprs)) + for i, arg := range exprs { + if fn, ok := arg.(*ast.FunctionExpr); ok { + args[i] = phasecore.ShallowFunctionLiteralSignature(fn) + continue + } + args[i] = recurse(arg) + } + return args +} + // resolveTypeArgs resolves explicit type arguments. func (s *Synthesizer) resolveTypeArgs(typeExprs []ast.TypeExpr, sc *scope.State) []typ.Type { if len(typeExprs) == 0 || sc == nil { @@ -388,7 +742,7 @@ func (s *Synthesizer) forceMethodReceiverAtPoint(p cfg.Point, ex *ast.FuncCallEx if s.deps.CheckCtx != nil { graph, _ = s.deps.CheckCtx.Graph().(*compcfg.Graph) } - return callsite.ForceMethodReceiverAtPoint(bindings, graph, p, ex) + return callsite.ForceMethodReceiverAtPoint(bindings, graph, s.graphEvidence(graph), p, ex) } // Method looks up a method type on a receiver type. @@ -495,26 +849,43 @@ func (s *Synthesizer) applyPostCallTransforms(calleeType typ.Type, args []typ.Ty return returns } +// contextualArgReSynth creates the canonical call-site re-synthesizer. +// +// Calls are checked in two phases: first infer the callee and expected +// parameter types, then re-synthesize arguments that are sensitive to expected +// type context. Callback function literals additionally need spec-provided +// environment overlays, so they are handled before the general argument path. +func (s *Synthesizer) contextualArgReSynth(calleeType typ.Type, args []ast.Expr, sc *scope.State, p cfg.Point) ops.ArgReSynth { + callbacks := s.callbackAwareReSynth(calleeType, sc) + values := callarg.Full( + func(arg ast.Expr, pt cfg.Point, expected typ.Type) typ.Type { + return s.TypeOfWithExpected(arg, pt, expected) + }, + nil, + p, + ) + return callarg.ForArgs(args, func(idx int, arg ast.Expr, expected typ.Type) typ.Type { + if callbacks != nil { + if t := callbacks(idx, arg, expected); t != nil { + return t + } + } + return values(idx, arg, expected) + }) +} + // callbackAwareReSynth creates an ArgReSynth that applies EnvOverlay from callback specs. // For callback parameters with an EnvOverlay, the overlay globals are merged into the // synthesizer's context so they are visible inside the callback body only. -func (s *Synthesizer) callbackAwareReSynth(calleeType typ.Type, sc *scope.State) ArgReSynth { +func (s *Synthesizer) callbackAwareReSynth(calleeType typ.Type, sc *scope.State) callarg.ReSynth { return func(idx int, arg ast.Expr, expected typ.Type) typ.Type { - expectedFn, ok := unwrap.Alias(expected).(*typ.Function) - if !ok { + fnExpr := functionExprForCallbackArg(s, arg) + if fnExpr == nil { return nil } - - fnExpr, ok := arg.(*ast.FunctionExpr) - if !ok { - ident, isIdent := arg.(*ast.IdentExpr) - if !isIdent { - return nil - } - fnExpr = s.functionLiteralForIdent(ident) - if fnExpr == nil { - return nil - } + expectedFn := phasecore.ExpectedFunctionLiteralSignature(fnExpr, expected) + if expectedFn == nil { + return nil } synthFn := s.SynthFunctionTypeWithExpected @@ -529,6 +900,17 @@ func (s *Synthesizer) callbackAwareReSynth(calleeType typ.Type, sc *scope.State) } } +func functionExprForCallbackArg(s *Synthesizer, arg ast.Expr) *ast.FunctionExpr { + if fnExpr, ok := arg.(*ast.FunctionExpr); ok { + return fnExpr + } + ident, ok := arg.(*ast.IdentExpr) + if !ok || s == nil { + return nil + } + return s.functionLiteralForIdent(ident) +} + // callbackEnvOverlay extracts the EnvOverlay for a callback at the given parameter index. func callbackEnvOverlay(calleeType typ.Type, paramIdx int) map[string]typ.Type { fn := intercept.ResolveSpecFunction(calleeType) @@ -558,12 +940,14 @@ func (s *Synthesizer) withEnvOverlay(overlay map[string]typ.Type) *Synthesizer { Scopes: s.deps.Scopes, Manifests: s.deps.Manifests, CheckCtx: overlaidCtx, + FunctionFacts: s.deps.FunctionFacts, Graphs: s.deps.Graphs, Flow: s.deps.Flow, Paths: s.deps.Paths, PreCache: make(api.Cache), NarrowCache: make(api.Cache), FunctionTypeInProgress: s.deps.FunctionTypeInProgress, + FunctionFactCache: s.deps.FunctionFactCache, ModuleBindings: s.deps.ModuleBindings, ModuleAliases: s.deps.ModuleAliases, } diff --git a/compiler/check/synth/phase/extract/callback_env_infer.go b/compiler/check/synth/phase/extract/callback_env_infer.go index 310d4f33..900d4ad3 100644 --- a/compiler/check/synth/phase/extract/callback_env_infer.go +++ b/compiler/check/synth/phase/extract/callback_env_infer.go @@ -5,6 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/cfg/analysis" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" "github.com/wippyai/go-lua/types/typ" ) @@ -28,11 +29,12 @@ type paramCall struct { paramIndex int } -// inferCallbackEnvOverlays detects the "setup -> param call -> cleanup" pattern +// InferCallbackEnvOverlays detects the "setup -> param call -> cleanup" pattern // using dominance and post-dominance to bracket the callback call. // Returns map[paramIndex]map[globalName]typ.Type, or nil if no pattern detected. -func inferCallbackEnvOverlays( +func InferCallbackEnvOverlays( graph *cfg.Graph, + evidence api.FlowEvidence, paramSlots []cfg.ParamSlot, synthExpr func(ast.Expr, cfg.Point) typ.Type, moduleBindings *bind.BindingTable, @@ -51,11 +53,12 @@ func inferCallbackEnvOverlays( var setups []globalSetup var clears []globalClear var calls []paramCall - // Collect global setups and clears from assignments. - graph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + for _, assign := range evidence.Assignments { + p := assign.Point + info := assign.Info if info == nil || info.IsLocal { - return + continue } info.EachTargetSource(func(_ int, target cfg.AssignTarget, src ast.Expr) { if target.Kind != cfg.TargetField { @@ -74,12 +77,14 @@ func inferCallbackEnvOverlays( setups = append(setups, globalSetup{point: p, name: name, expr: src}) } }) - }) + } // Collect parameter calls. - graph.EachCallSite(func(p cfg.Point, info *cfg.CallInfo) { + for _, call := range evidence.Calls { + p := call.Point + info := call.Info if info == nil { - return + continue } sym := callsite.SelectPreferredSymbol( callsite.CallableCalleeSymbolCandidates(info, graph, graph.Bindings(), moduleBindings), @@ -91,7 +96,7 @@ func inferCallbackEnvOverlays( if idx, ok := paramSet[sym]; ok { calls = append(calls, paramCall{point: p, paramIndex: idx}) } - }) + } if len(setups) == 0 || len(clears) == 0 || len(calls) == 0 { return nil @@ -102,8 +107,8 @@ func inferCallbackEnvOverlays( return nil } - idom, _ := analysis.ComputeDominators(baseCFG) - postIdom, _ := analysis.ComputePostDominators(baseCFG) + idom := analysis.ComputeImmediateDominators(baseCFG) + postIdom := analysis.ComputeImmediatePostDominators(baseCFG) result := make(map[int]map[string]typ.Type) diff --git a/compiler/check/synth/phase/extract/callback_env_infer_test.go b/compiler/check/synth/phase/extract/callback_env_infer_test.go index ac4c87f8..0d135c7a 100644 --- a/compiler/check/synth/phase/extract/callback_env_infer_test.go +++ b/compiler/check/synth/phase/extract/callback_env_infer_test.go @@ -6,6 +6,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/typ" ) @@ -44,15 +46,22 @@ func TestParamCall(t *testing.T) { } } +func evidenceForGraph(graph *cfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + func TestInferCallbackEnvOverlays_NilGraph(t *testing.T) { - result := inferCallbackEnvOverlays(nil, nil, nil, nil) + result := InferCallbackEnvOverlays(nil, api.FlowEvidence{}, nil, nil, nil) if result != nil { t.Error("expected nil result for nil graph") } } func TestInferCallbackEnvOverlays_EmptyParams(t *testing.T) { - result := inferCallbackEnvOverlays(nil, []cfg.ParamSlot{}, nil, nil) + result := InferCallbackEnvOverlays(nil, api.FlowEvidence{}, []cfg.ParamSlot{}, nil, nil) if result != nil { t.Error("expected nil result for empty params") } @@ -62,7 +71,7 @@ func TestInferCallbackEnvOverlays_NilSynthExpr(t *testing.T) { synthExpr := func(expr ast.Expr, p cfg.Point) typ.Type { return nil } - result := inferCallbackEnvOverlays(nil, []cfg.ParamSlot{ + result := InferCallbackEnvOverlays(nil, api.FlowEvidence{}, []cfg.ParamSlot{ { Symbol: cfg.SymbolID(1), SourceIndex: 0, @@ -105,7 +114,7 @@ func TestInferCallbackEnvOverlays_AssignmentCallSite(t *testing.T) { return typ.Unknown } - result := inferCallbackEnvOverlays(graph, paramSlots, synthExpr, nil) + result := InferCallbackEnvOverlays(graph, evidenceForGraph(graph), paramSlots, synthExpr, nil) if result == nil { t.Fatal("expected callback overlay result") } @@ -158,7 +167,7 @@ func TestInferCallbackEnvOverlays_UsesCanonicalCandidatesWhenRawCallSymbolMissin return typ.Unknown } - result := inferCallbackEnvOverlays(graph, paramSlots, synthExpr, nil) + result := InferCallbackEnvOverlays(graph, evidenceForGraph(graph), paramSlots, synthExpr, nil) if result == nil { t.Fatal("expected callback overlay result") } @@ -171,7 +180,7 @@ func TestInferCallbackEnvOverlays_UsesCanonicalCandidatesWhenRawCallSymbolMissin } } -func TestInferCallbackEnvOverlays_UsesModuleBindingNameFallback(t *testing.T) { +func TestInferCallbackEnvOverlays_UsesModuleBindingNameResolution(t *testing.T) { code := ` _G.ctx = 1 local x = cb() @@ -199,7 +208,7 @@ func TestInferCallbackEnvOverlays_UsesModuleBindingNameFallback(t *testing.T) { moduleBindings := bind.NewBindingTable() moduleBindings.SetName(paramSlots[0].Symbol, "cb_alias") - // Force callback identity recovery through module-binding name fallback. + // Force callback identity recovery through module-binding name resolution. graph.EachCallSite(func(_ cfg.Point, info *cfg.CallInfo) { if info != nil { info.CalleeSymbol = 0 @@ -215,7 +224,7 @@ func TestInferCallbackEnvOverlays_UsesModuleBindingNameFallback(t *testing.T) { return typ.Unknown } - result := inferCallbackEnvOverlays(graph, paramSlots, synthExpr, moduleBindings) + result := InferCallbackEnvOverlays(graph, evidenceForGraph(graph), paramSlots, synthExpr, moduleBindings) if result == nil { t.Fatal("expected callback overlay result") } @@ -261,7 +270,7 @@ func TestInferCallbackEnvOverlays_UsesDirectAliasCandidate(t *testing.T) { return typ.Unknown } - result := inferCallbackEnvOverlays(graph, paramSlots, synthExpr, nil) + result := InferCallbackEnvOverlays(graph, evidenceForGraph(graph), paramSlots, synthExpr, nil) if result == nil { t.Fatal("expected callback overlay result") } diff --git a/compiler/check/synth/phase/extract/deps.go b/compiler/check/synth/phase/extract/deps.go index 53ab09c0..cd5ffd53 100644 --- a/compiler/check/synth/phase/extract/deps.go +++ b/compiler/check/synth/phase/extract/deps.go @@ -4,11 +4,13 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/db" "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/query/core" + "github.com/wippyai/go-lua/types/typ" ) // Deps aggregates all dependencies needed by the Synthesizer. @@ -29,13 +31,17 @@ type Deps struct { Scopes api.ScopeMap // DefaultScope is used when a point has no explicit scope entry. // This allows sparse scope maps for transient inference passes. - DefaultScope *scope.State - Manifests io.ManifestQuerier - CheckCtx api.BaseEnv - Graphs api.GraphProvider + DefaultScope *scope.State + Manifests io.ManifestQuerier + CheckCtx api.BaseEnv + FunctionFacts api.FunctionFacts + Graphs api.GraphProvider Flow api.FlowOps Paths api.PathFromExprFunc + // Evidence is the transfer-owned event trace for CheckCtx.Graph when + // available. Synth reducers use it instead of rediscovering CFG events. + Evidence api.FlowEvidence PreCache api.Cache NarrowCache api.Cache @@ -43,6 +49,8 @@ type Deps struct { // FunctionTypeInProgress guards call-point local function specialization // against recursion across temporary synthesizers. FunctionTypeInProgress map[functionTypeProgressKey]bool + FunctionTypeCache map[functionTypeCacheKey]*typ.Function + FunctionFactCache *functionfact.Cache // Module-level bindings for nested function CFG building. ModuleBindings *bind.BindingTable @@ -56,6 +64,14 @@ type functionTypeProgressKey struct { CapturePoint cfg.Point } +type functionTypeCacheKey struct { + Func *ast.FunctionExpr + Scope *scope.State + Expected *typ.Function + CapturePoint cfg.Point + Phase api.Phase +} + // NewDeps creates a new Deps instance. func NewDeps(ctx *db.QueryContext, types core.TypeOps, scopes api.ScopeMap, manifests io.ManifestQuerier, checkCtx api.BaseEnv) *Deps { return &Deps{ @@ -68,6 +84,8 @@ func NewDeps(ctx *db.QueryContext, types core.TypeOps, scopes api.ScopeMap, mani PreCache: make(api.Cache), NarrowCache: make(api.Cache), FunctionTypeInProgress: make(map[functionTypeProgressKey]bool), + FunctionTypeCache: make(map[functionTypeCacheKey]*typ.Function), + FunctionFactCache: functionfact.NewCache(), } } @@ -87,7 +105,7 @@ func (d *Deps) Entry() cfg.Point { return 0 } -// ScopeAt returns scope for point p with optional default fallback. +// ScopeAt returns scope for point p, using DefaultScope when point scope is absent. func (d *Deps) ScopeAt(p cfg.Point) *scope.State { if d == nil { return nil diff --git a/compiler/check/synth/phase/extract/doc.go b/compiler/check/synth/phase/extract/doc.go index 6ba53b86..f4e8a892 100644 --- a/compiler/check/synth/phase/extract/doc.go +++ b/compiler/check/synth/phase/extract/doc.go @@ -36,6 +36,6 @@ // // # Integration // -// This package bridges the CFG representation with the expression-level +// This package connects the CFG representation with the expression-level // type synthesis performed by the synth engine. package extract diff --git a/compiler/check/synth/phase/extract/evidence.go b/compiler/check/synth/phase/extract/evidence.go new file mode 100644 index 00000000..4ab6bba1 --- /dev/null +++ b/compiler/check/synth/phase/extract/evidence.go @@ -0,0 +1,25 @@ +package extract + +import ( + compcfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" +) + +func (s *Synthesizer) graphEvidence(graph *compcfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + if s != nil && s.deps != nil && !s.deps.Evidence.IsZero() { + if s.deps.CheckCtx != nil { + if current, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph); ok && current == graph { + return s.deps.Evidence + } + } + } + if s != nil && s.deps != nil { + if s.deps.Graphs != nil { + return s.deps.Graphs.EvidenceForGraph(graph) + } + } + return api.FlowEvidence{} +} diff --git a/compiler/check/synth/phase/extract/expr.go b/compiler/check/synth/phase/extract/expr.go index c6d54a3a..52200c15 100644 --- a/compiler/check/synth/phase/extract/expr.go +++ b/compiler/check/synth/phase/extract/expr.go @@ -25,6 +25,7 @@ package extract import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/guard" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth/ops" "github.com/wippyai/go-lua/types/cfg" @@ -39,17 +40,29 @@ import ( "github.com/wippyai/go-lua/types/typ/unwrap" ) -// localNarrowOps wraps a api.FlowOps and overrides NarrowedTypeAt for one path. +type localTypeOverride struct { + path constraint.Path + t typ.Type +} + +// localNarrowOps wraps a api.FlowOps and overrides NarrowedTypeAt for +// expression-local facts proven by short-circuit evaluation. type localNarrowOps struct { inner api.FlowOps overridePath constraint.Path overrideType typ.Type + overrides []localTypeOverride } func (n *localNarrowOps) NarrowedTypeAt(p cfg.Point, path constraint.Path) typ.Type { - if path.Equal(n.overridePath) { + if !n.overridePath.IsEmpty() && path.Equal(n.overridePath) { return n.overrideType } + for i := len(n.overrides) - 1; i >= 0; i-- { + if path.Equal(n.overrides[i].path) { + return n.overrides[i].t + } + } if n.inner != nil { return n.inner.NarrowedTypeAt(p, path) } @@ -77,6 +90,13 @@ func (n *localNarrowOps) ArrayLenBoundWithOffsetAt(p cfg.Point, varName string) return "", 0, false } +func (n *localNarrowOps) LengthBoundsAt(p cfg.Point, path constraint.Path) (int64, int64, bool) { + if n.inner != nil { + return n.inner.LengthBoundsAt(p, path) + } + return 0, 0, false +} + func (n *localNarrowOps) IsPointDead(p cfg.Point) bool { if n.inner != nil { return n.inner.IsPointDead(p) @@ -100,12 +120,27 @@ func (s *Synthesizer) synthAttrGetCore(ex *ast.AttrGetExpr, p cfg.Point, sc *sco if !path.IsEmpty() { narrowed := narrower.NarrowedTypeAt(p, path) if narrowed != nil { + if key, ok := ex.Key.(*ast.NumberExpr); ok && narrow.NilPresenceIsOnlyFlowUncertainty(narrowed) && s.literalLengthBoundProvesIndex(objType, ex.Object, key.Value, p, sc, narrower) { + goto skipNarrowedAttr + } + if refined := s.narrowArrayIndexByLengthExpr(objType, narrowed, ex.Object, ex.Key, p, sc, narrower); refined != nil { + return refined + } if specialized := s.stableLocalFunctionValueType(ex, p, sc, narrowed, nil); specialized != nil { return specialized } if typ.IsUnknown(unwrap.Alias(narrowed)) && typ.IsAny(unwrap.Alias(objType)) { goto skipNarrowedAttr } + if key, ok := ex.Key.(*ast.StringExpr); ok { + if declaredField, ok := s.deps.Types.Field(s.deps.Ctx, objType, key.Value); ok && declaredField != nil { + refined, ok := s.refineNarrowedFieldFact(narrowed, declaredField) + if !ok { + goto skipNarrowedAttr + } + narrowed = refined + } + } return narrowed } } @@ -151,6 +186,11 @@ skipNarrowedAttr: case *ast.NumberExpr: keyType := ops.ParseNumber(key.Value) if it, ok := s.deps.Types.Index(s.deps.Ctx, objType, keyType); ok { + if narrower != nil { + if narrowedResult := s.narrowArrayIndexByLiteralLenBound(objType, it, ex.Object, key.Value, p, sc, narrower); narrowedResult != nil { + return narrowedResult + } + } if specialized := s.stableLocalFunctionValueType(ex, p, sc, it, nil); specialized != nil { return specialized } @@ -163,7 +203,7 @@ skipNarrowedAttr: if narrowedResult := s.narrowTupleIndex(objType, key.Value, it, p, narrower); narrowedResult != nil { return narrowedResult } - if narrowedResult := s.narrowArrayIndexByLenBound(it, ex.Object, key.Value, 0, p, sc, narrower); narrowedResult != nil { + if narrowedResult := s.narrowArrayIndexByLenBound(objType, it, ex.Object, key.Value, 0, p, sc, narrower); narrowedResult != nil { return narrowedResult } // Check for KeyOf constraint to unwrap optional on map index @@ -215,10 +255,13 @@ skipNarrowedAttr: if it, ok := s.deps.Types.Index(s.deps.Ctx, objType, keyType); ok { if narrower != nil { if varName, offset, ok := indexVarOffsetFromExpr(ex.Key); ok { - if narrowedResult := s.narrowArrayIndexByLenBound(it, ex.Object, varName, offset, p, sc, narrower); narrowedResult != nil { + if narrowedResult := s.narrowArrayIndexByLenBound(objType, it, ex.Object, varName, offset, p, sc, narrower); narrowedResult != nil { return narrowedResult } } + if narrowedResult := s.narrowArrayIndexByLengthExpr(objType, it, ex.Object, ex.Key, p, sc, narrower); narrowedResult != nil { + return narrowedResult + } } if specialized := s.stableLocalFunctionValueType(ex, p, sc, it, nil); specialized != nil { return specialized @@ -230,6 +273,35 @@ skipNarrowedAttr: return typ.Unknown } +func (s *Synthesizer) refineNarrowedFieldFact(narrowed, declared typ.Type) (typ.Type, bool) { + if narrowed == nil || declared == nil { + return narrowed, true + } + declared = unwrap.Alias(declared) + narrowed = unwrap.Alias(narrowed) + if declared == nil || narrowed == nil { + return narrowed, true + } + if declared.Kind().IsPlaceholder() { + return narrowed, true + } + if s.deps.Types != nil { + if s.deps.Types.IsSubtype(s.deps.Ctx, narrowed, declared) { + return narrowed, true + } + declaredNonNil := narrow.RemoveNil(declared) + if !typ.IsNever(declaredNonNil) { + if s.deps.Types.IsSubtype(s.deps.Ctx, declaredNonNil, narrowed) { + return declaredNonNil, true + } + if unwrap.Function(declaredNonNil) != nil && unwrap.Function(narrowed) != nil { + return declaredNonNil, true + } + } + } + return nil, false +} + func (s *Synthesizer) indexFromKeyOf(objType typ.Type, objExpr ast.Expr, key *ast.IdentExpr, p cfg.Point, sc *scope.State, narrower api.FlowOps) typ.Type { if s == nil || key == nil || narrower == nil || s.deps.Paths == nil || s.deps.CheckCtx == nil { return nil @@ -321,9 +393,8 @@ func (s *Synthesizer) narrowTupleIndex(objType typ.Type, varName string, indexRe return nil } -func (s *Synthesizer) narrowArrayIndexByLenBound(indexResult typ.Type, objExpr ast.Expr, varName string, offset int64, p cfg.Point, sc *scope.State, narrower api.FlowOps) typ.Type { - opt, ok := indexResult.(*typ.Optional) - if !ok || narrower == nil || s == nil || s.deps.Paths == nil { +func (s *Synthesizer) narrowArrayIndexByLenBound(objType, indexResult typ.Type, objExpr ast.Expr, varName string, offset int64, p cfg.Point, sc *scope.State, narrower api.FlowOps) typ.Type { + if narrower == nil || s == nil || s.deps.Paths == nil { return nil } lower, _, hasBounds := narrower.BoundsAt(p, varName) @@ -347,7 +418,78 @@ func (s *Synthesizer) narrowArrayIndexByLenBound(indexResult typ.Type, objExpr a if lenOffset > -offset { return nil } - return opt.Inner + return narrow.RefineSequenceIndex(objType, indexResult, lower+offset) +} + +func (s *Synthesizer) narrowArrayIndexByLiteralLenBound(objType, indexResult typ.Type, objExpr ast.Expr, indexLiteral string, p cfg.Point, sc *scope.State, narrower api.FlowOps) typ.Type { + index, ok := numparse.ParseIntegerLiteral(indexLiteral) + if !ok { + return nil + } + if !s.literalLengthBoundProvesIndex(objType, objExpr, indexLiteral, p, sc, narrower) { + return nil + } + return narrow.RefineSequenceIndex(objType, indexResult, index) +} + +func (s *Synthesizer) narrowArrayIndexByLengthExpr(objType, indexResult typ.Type, objExpr, keyExpr ast.Expr, p cfg.Point, sc *scope.State, narrower api.FlowOps) typ.Type { + if narrower == nil || s == nil || s.deps.Paths == nil { + return nil + } + tablePath := s.deps.Paths(p, objExpr, sc) + if tablePath.IsEmpty() { + return nil + } + lenPath, offset, ok := lenIndexPathFromExpr(keyExpr, p, s.deps.Paths, sc) + if !ok || !lenPath.Equal(tablePath) { + return nil + } + lower, _, ok := narrower.LengthBoundsAt(p, tablePath) + if !ok { + return nil + } + return narrow.RefineLengthIndex(objType, indexResult, lower, offset) +} + +func lenIndexPathFromExpr(expr ast.Expr, p cfg.Point, paths func(cfg.Point, ast.Expr, *scope.State) constraint.Path, sc *scope.State) (constraint.Path, int64, bool) { + switch e := expr.(type) { + case *ast.UnaryLenOpExpr: + path := paths(p, e.Expr, sc) + return path, 0, !path.IsEmpty() + case *ast.ArithmeticOpExpr: + if e.Operator != "+" && e.Operator != "-" { + return constraint.Path{}, 0, false + } + path, offset, ok := lenIndexPathFromExpr(e.Lhs, p, paths, sc) + if !ok { + return constraint.Path{}, 0, false + } + k, ok := intConstFromExpr(e.Rhs) + if !ok { + return constraint.Path{}, 0, false + } + if e.Operator == "-" { + k = -k + } + return path, offset + k, true + } + return constraint.Path{}, 0, false +} + +func (s *Synthesizer) literalLengthBoundProvesIndex(objType typ.Type, objExpr ast.Expr, indexLiteral string, p cfg.Point, sc *scope.State, narrower api.FlowOps) bool { + if narrower == nil || s == nil || s.deps.Paths == nil { + return false + } + index, ok := numparse.ParseIntegerLiteral(indexLiteral) + if !ok || index < 1 { + return false + } + tablePath := s.deps.Paths(p, objExpr, sc) + if tablePath.IsEmpty() { + return false + } + lower, _, ok := narrower.LengthBoundsAt(p, tablePath) + return ok && lower >= index && narrow.LengthBoundProvesSequenceIndex(objType, index) } func indexVarOffsetFromExpr(expr ast.Expr) (string, int64, bool) { @@ -469,20 +611,45 @@ func (s *Synthesizer) synthLogicalOpCore(ex *ast.LogicalOpExpr, recurse ExprSynt func (s *Synthesizer) synthLogicalOpWithNarrowing(ex *ast.LogicalOpExpr, p cfg.Point, sc *scope.State, narrower api.FlowOps, recurse ExprSynth) typ.Type { left := recurse(ex.Lhs) - // Extract path for LHS expression - var lhsPath constraint.Path - if s.deps.Paths != nil { - lhsPath = s.deps.Paths(p, ex.Lhs, sc) - } else if ident, ok := ex.Lhs.(*ast.IdentExpr); ok { - if s.deps.CheckCtx != nil { - if bindings := s.deps.CheckCtx.Bindings(); bindings != nil { - if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { - lhsPath = constraint.Path{Root: ident.Value, Symbol: sym} + if ex.Operator == "and" { + if probe, ok := guard.ExtractTypeEqualityProbe(ex.Lhs); ok { + probePath := s.logicalNarrowPath(p, probe.Expr, sc) + if !probePath.IsEmpty() { + wrapped := &localNarrowOps{ + inner: narrower, + overridePath: probePath, + overrideType: guard.TypeForTypeKey(probe.Key), } + right := s.SynthExpr(ex.Rhs, p, wrapped) + return ops.LogicalAndTyped(left, right) } } } + if ex.Operator == "and" { + if overrides := s.truthyLocalOverrides(ex.Lhs, p, sc, narrower); len(overrides) > 0 { + wrapped := &localNarrowOps{ + inner: narrower, + overrides: overrides, + } + right := s.SynthExpr(ex.Rhs, p, wrapped) + return ops.LogicalAndTyped(left, right) + } + } + if ex.Operator == "or" { + if overrides := s.falsyLocalOverrides(ex.Lhs, p, sc, narrower); len(overrides) > 0 { + wrapped := &localNarrowOps{ + inner: narrower, + overrides: overrides, + } + right := s.SynthExpr(ex.Rhs, p, wrapped) + return ops.LogicalOrTyped(left, right) + } + } + + // Extract path for LHS expression + lhsPath := s.logicalNarrowPath(p, ex.Lhs, sc) + if !lhsPath.IsEmpty() && ops.CanBeFalsy(left) { var narrowedType typ.Type switch ex.Operator { @@ -514,6 +681,122 @@ func (s *Synthesizer) synthLogicalOpWithNarrowing(ex *ast.LogicalOpExpr, p cfg.P return s.synthLogicalOpCore(ex, recurse) } +func (s *Synthesizer) truthyLocalOverrides(expr ast.Expr, p cfg.Point, sc *scope.State, narrower api.FlowOps) []localTypeOverride { + return s.logicalLocalOverrides(expr, p, sc, narrower, true) +} + +func (s *Synthesizer) falsyLocalOverrides(expr ast.Expr, p cfg.Point, sc *scope.State, narrower api.FlowOps) []localTypeOverride { + return s.logicalLocalOverrides(expr, p, sc, narrower, false) +} + +func (s *Synthesizer) logicalLocalOverrides(expr ast.Expr, p cfg.Point, sc *scope.State, narrower api.FlowOps, truthy bool) []localTypeOverride { + if expr == nil { + return nil + } + if logical, ok := expr.(*ast.LogicalOpExpr); ok { + switch { + case truthy && logical.Operator == "and": + left := s.logicalLocalOverrides(logical.Lhs, p, sc, narrower, true) + leftNarrower := composeLocalNarrower(narrower, left) + right := s.logicalLocalOverrides(logical.Rhs, p, sc, leftNarrower, true) + return append(left, right...) + case !truthy && logical.Operator == "or": + left := s.logicalLocalOverrides(logical.Lhs, p, sc, narrower, false) + leftNarrower := composeLocalNarrower(narrower, left) + right := s.logicalLocalOverrides(logical.Rhs, p, sc, leftNarrower, false) + return append(left, right...) + } + } + if truthy { + if probe, ok := guard.ExtractTypeEqualityProbe(expr); ok { + probePath := s.logicalNarrowPath(p, probe.Expr, sc) + if !probePath.IsEmpty() { + return []localTypeOverride{{ + path: probePath, + t: guard.TypeForTypeKey(probe.Key), + }} + } + } + } + path := s.logicalNarrowPath(p, expr, sc) + if path.IsEmpty() { + return nil + } + t := s.SynthExpr(expr, p, narrower) + if t == nil { + return nil + } + var narrowed typ.Type + if truthy { + narrowed = narrow.ToTruthy(t) + } else { + narrowed = narrow.ToFalsy(t) + } + if typ.IsNever(narrowed) || typ.TypeEquals(narrowed, t) { + return nil + } + return []localTypeOverride{{ + path: path, + t: narrowed, + }} +} + +func composeLocalNarrower(inner api.FlowOps, overrides []localTypeOverride) api.FlowOps { + if len(overrides) == 0 { + return inner + } + return &localNarrowOps{ + inner: inner, + overrides: overrides, + } +} + +func (s *Synthesizer) logicalNarrowPath(p cfg.Point, expr ast.Expr, sc *scope.State) constraint.Path { + if s == nil { + return constraint.Path{} + } + if s.deps.Paths != nil { + return s.deps.Paths(p, expr, sc) + } + if ident, ok := expr.(*ast.IdentExpr); ok { + if s.deps.CheckCtx != nil { + if bindings := s.deps.CheckCtx.Bindings(); bindings != nil { + if sym, ok := bindings.SymbolOf(ident); ok && sym != 0 { + return constraint.Path{Root: ident.Value, Symbol: sym} + } + } + } + } + return constraint.Path{} +} + +func (s *Synthesizer) synthLogicalOpWithExpected(ex *ast.LogicalOpExpr, sc *scope.State, p cfg.Point, recurse ExprSynth, expected typ.Type) typ.Type { + if ex == nil { + return typ.Unknown + } + if expected == nil || ex.Operator != "or" && ex.Operator != "and" { + return s.synthLogicalOpCore(ex, recurse) + } + + branch := func(expr ast.Expr) typ.Type { + if expr == nil { + return typ.Unknown + } + return s.SynthExprWithExpectedCore(expr, sc, p, recurse, expected) + } + + left := recurse(ex.Lhs) + right := branch(ex.Rhs) + switch ex.Operator { + case "and": + return ops.LogicalAndTyped(left, right) + case "or": + return ops.LogicalOrTyped(left, right) + default: + return typ.Unknown + } +} + // synthArithmeticOpCore synthesizes type for arithmetic operators. func (s *Synthesizer) synthArithmeticOpCore(ex *ast.ArithmeticOpExpr, recurse ExprSynth) typ.Type { left := recurse(ex.Lhs) @@ -532,10 +815,10 @@ func (s *Synthesizer) expandValuesCore(exprs []ast.Expr, needed int, single func if len(exprs) == 0 { return nil } - result := make([]typ.Type, 0, needed) + result := make([]typ.Type, 0, exprListResultCapacity(exprs, needed)) for i, expr := range exprs { - if i == len(exprs)-1 { + if i == len(exprs)-1 && ast.CanProduceMultipleValues(expr) { result = append(result, multi(expr)...) } else { result = append(result, single(expr)) @@ -549,6 +832,13 @@ func (s *Synthesizer) expandValuesCore(exprs []ast.Expr, needed int, single func return result } +func exprListResultCapacity(exprs []ast.Expr, needed int) int { + if needed > len(exprs) { + return needed + } + return len(exprs) +} + // expandValues expands expression list to types. func (s *Synthesizer) expandValues(exprs []ast.Expr, needed int, p cfg.Point, narrower api.FlowOps) []typ.Type { return s.expandValuesCore(exprs, needed, diff --git a/compiler/check/synth/phase/extract/function.go b/compiler/check/synth/phase/extract/function.go index ed3f9293..e5968328 100644 --- a/compiler/check/synth/phase/extract/function.go +++ b/compiler/check/synth/phase/extract/function.go @@ -12,7 +12,7 @@ // CONTEXTUAL TYPING (EXPECTED TYPES) // // When an expected function type is available (e.g., from callback parameter context), -// it provides default types for unannotated parameters and fallback return types. +// it provides default types for unannotated parameters and return types. // This enables idioms like: // // items:filter(function(x) return x > 0 end) -- x inferred from filter's param @@ -21,7 +21,7 @@ // // Return types are inferred by analyzing all return statements in the function body. // The algorithm: -// 1. Check ReturnSummaries for pre-computed results (from prior iterations) +// 1. Check FunctionFacts for pre-computed results (from prior iterations) // 2. Build CFG and create type overlay with parameter types // 3. Create a temporary synthesizer environment // 4. Visit each return statement, synthesizing expression types @@ -37,10 +37,12 @@ package extract import ( "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" "github.com/wippyai/go-lua/compiler/cfg" "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/calleffect" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/erreffect" - "github.com/wippyai/go-lua/compiler/check/flowbuild/mutator" "github.com/wippyai/go-lua/compiler/check/overlaymut" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth/phase/core" @@ -63,7 +65,7 @@ func (s *Synthesizer) FunctionType(fn *ast.FunctionExpr, sc *scope.State) *typ.F // // When an expected function type is provided, it guides inference for: // - Unannotated parameter types (uses expected parameter types) -// - Unannotated return types (uses expected return types as fallback) +// - Unannotated return types (uses expected return types) // - Self parameter in methods (infers from expected first param) // // Processing order: @@ -98,6 +100,13 @@ func (s *Synthesizer) getOrBuildFunctionGraph(fn *ast.FunctionExpr) *cfg.Graph { return cfg.Build(fn) } +func (s *Synthesizer) functionFactsInput() api.FunctionFacts { + if s == nil || s.deps == nil { + return nil + } + return s.deps.FunctionFacts +} + func (s *Synthesizer) synthFunctionTypeWithCapturePoint( fn *ast.FunctionExpr, sc *scope.State, @@ -108,12 +117,18 @@ func (s *Synthesizer) synthFunctionTypeWithCapturePoint( if fn == nil { return nil } + cacheKey, cacheable := s.functionTypeCacheKey(fn, sc, expected, capturePoint, captureTypes) + if cacheable && s.deps.FunctionTypeCache != nil { + if cached, ok := s.deps.FunctionTypeCache[cacheKey]; ok { + return cached + } + } if s.deps.FunctionTypeInProgress == nil { s.deps.FunctionTypeInProgress = make(map[functionTypeProgressKey]bool) } progressKey := functionTypeProgressKey{Func: fn, CapturePoint: capturePoint} if s.deps.FunctionTypeInProgress[progressKey] { - return s.buildFunctionTypeSummaryFallback(fn, sc, expected) + return s.buildFunctionTypeFromAvailableFacts(fn, sc, expected) } s.deps.FunctionTypeInProgress[progressKey] = true defer delete(s.deps.FunctionTypeInProgress, progressKey) @@ -159,7 +174,7 @@ func (s *Synthesizer) synthFunctionTypeWithCapturePoint( // Build CFG once, shared between overlay inference and return inference. var fnGraph *cfg.Graph - if fn.Stmts != nil && len(fn.Stmts) > 0 { + if len(fn.Stmts) > 0 { fnGraph = s.getOrBuildFunctionGraph(fn) } @@ -188,11 +203,36 @@ func (s *Synthesizer) synthFunctionTypeWithCapturePoint( fnType := builder.Build() if inferredErrorReturn { - fnType = erreffect.AttachErrorReturnSpec(fnType, 0, 1) + fnType = erreffect.CanonicalLuaValueErrorConvention().Attach(fnType) + } + if cacheable { + if s.deps.FunctionTypeCache == nil { + s.deps.FunctionTypeCache = make(map[functionTypeCacheKey]*typ.Function) + } + s.deps.FunctionTypeCache[cacheKey] = fnType } return fnType } +func (s *Synthesizer) functionTypeCacheKey( + fn *ast.FunctionExpr, + sc *scope.State, + expected *typ.Function, + capturePoint cfg.Point, + captureTypes map[cfg.SymbolID]typ.Type, +) (functionTypeCacheKey, bool) { + if s == nil || s.deps == nil || fn == nil || len(captureTypes) != 0 { + return functionTypeCacheKey{}, false + } + return functionTypeCacheKey{ + Func: fn, + Scope: sc, + Expected: expected, + CapturePoint: capturePoint, + Phase: s.phase, + }, true +} + // inferReturnTypesFromBody infers return types from the function body. // If fnGraph is non-nil, it reuses the pre-built CFG instead of building a new one. func (s *Synthesizer) inferReturnTypesFromBody( @@ -207,34 +247,24 @@ func (s *Synthesizer) inferReturnTypesFromBody( return nil, false } - var returnSummaries map[cfg.SymbolID][]typ.Type - if s.deps.CheckCtx != nil { - if s.IsNarrowing() { - if ctx, ok := s.deps.CheckCtx.(api.NarrowEnv); ok { - returnSummaries = ctx.NarrowReturnSummaries() - } - } else { - if ctx, ok := s.deps.CheckCtx.(api.DeclaredEnv); ok { - returnSummaries = ctx.ReturnSummaries() - } - } - } + functionFacts := s.functionFactsInput() var fnSym cfg.SymbolID if s.deps.CheckCtx != nil { if pg, ok := s.deps.CheckCtx.Graph().(*cfg.Graph); ok && pg != nil { - fnSym = localFunctionSymbol(pg, fn) + fnSym = localFunctionSymbol(pg, s.graphEvidence(pg), fn) } } - // If a return summary exists for this function symbol, declared phase can - // use it directly. Narrowing phase must still infer from the body so flow - // predicates can remove stale union members from pre-flow summaries. - var summaryFallback []typ.Type - if len(returnSummaries) > 0 && fnSym != 0 { - if rt := returnSummaries[fnSym]; len(rt) > 0 { + // If canonical facts already know this function's returns, declared phase + // can use them directly. Narrowing phase still analyzes the body so flow + // predicates can refine the pre-flow fact. + var canonicalReturns []typ.Type + if len(functionFacts) > 0 && fnSym != 0 { + rt := functionfact.ReturnsForPhase(functionFacts, fnSym, s.phase) + if len(rt) > 0 { if typ.HasKnownType(rt) { - summaryFallback = rt + canonicalReturns = rt if !s.IsNarrowing() && capturePoint == 0 && len(captureTypes) == 0 { return rt, false } @@ -264,26 +294,20 @@ func (s *Synthesizer) inferReturnTypesFromBody( overlay := s.buildParamOverlay(fnGraph, resolveScope, expected) - // Collect local function types from assignments using return summaries. - // Uses annotations for params and looks up return types from summaries. - // returnSummaries resolved above (pre-flow or post-flow depending on phase). + // Collect local function types from assignments using canonical function facts. + // Uses annotations for params and looks up return types from the product fact. - fnGraph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + graphEvidence := s.graphEvidence(fnGraph) + + for _, def := range graphEvidence.FunctionDefinitions { + if !def.IsLocal || def.Symbol == 0 || def.Nested.Func == nil { + continue } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { - return - } - if fnExpr, ok := source.(*ast.FunctionExpr); ok { - fnType := s.buildFunctionTypeWithSummary(fnExpr, resolveScope, target.Symbol, returnSummaries) - if fnType != nil { - overlay[target.Symbol] = fnType - } - } - }) - }) + fnType := s.buildLocalFunctionTypeFromFacts(def.Nested.Func, resolveScope, def.Symbol, functionFacts) + if fnType != nil { + overlay[def.Symbol] = fnType + } + } // Include captured symbol types from the parent context. // This allows nested local functions to call sibling locals defined in the parent scope. @@ -322,50 +346,35 @@ func (s *Synthesizer) inferReturnTypesFromBody( } // Include local function types from the parent graph that are visible at this function's definition point. - // Uses return summaries for return types instead of recursive inference. + // Uses canonical function facts for return types instead of recursive inference. if s.deps.CheckCtx != nil { if pg, ok := s.deps.CheckCtx.Graph().(*cfg.Graph); ok && pg != nil { + parentEvidence := s.graphEvidence(pg) var defPoint cfg.Point - for _, nf := range pg.NestedFunctions() { - if nf.Func == fn { - defPoint = nf.Point + for _, def := range parentEvidence.FunctionDefinitions { + if def.Nested.Func == fn { + defPoint = def.Nested.Point break } } if defPoint != 0 { visible := pg.AllSymbolsAt(defPoint) if len(visible) > 0 { - visibleSyms := make(map[cfg.SymbolID]struct{}, len(visible)) - for _, sym := range visible { - if sym != 0 { - visibleSyms[sym] = struct{}{} + for _, def := range parentEvidence.FunctionDefinitions { + if !def.IsLocal || def.Nested.Func == fn || def.Name == "" || def.Symbol == 0 || def.Nested.Func == nil { + continue } - } - pg.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + if visibleSym, ok := visible[def.Name]; !ok || visibleSym != def.Symbol { + continue } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { - return - } - if _, ok := visibleSyms[target.Symbol]; !ok { - return - } - if _, ok := overlay[target.Symbol]; ok { - return - } - if fnExpr, ok := source.(*ast.FunctionExpr); ok { - if fnExpr == fn { - return - } - fnType := s.buildFunctionTypeWithSummary(fnExpr, parentScope, target.Symbol, returnSummaries) - if fnType != nil { - overlay[target.Symbol] = fnType - } - } - }) - }) + if _, ok := overlay[def.Symbol]; ok { + continue + } + fnType := s.buildLocalFunctionTypeFromFacts(def.Nested.Func, parentScope, def.Symbol, functionFacts) + if fnType != nil { + overlay[def.Symbol] = fnType + } + } } } } @@ -373,7 +382,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( // Infer basic ordered-comparison hints (x > 0, name <= "zz") so unannotated // params don't stay unknown when return typing depends on guarded branches. - enrichOverlayWithOrderedComparisonHints(fnGraph, overlay) + enrichOverlayWithOrderedComparisonHints(graphEvidence.Branches, fnGraph.Bindings(), overlay) var globalTypes map[string]typ.Type var moduleAliases map[cfg.SymbolID]string @@ -400,6 +409,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( DeclaredTypes: overlay, GlobalTypes: globalTypes, ModuleAliases: moduleAliases, + FunctionType: functionfact.TypeLookup(functionFacts), }) prelimDeps := &Deps{ @@ -408,10 +418,11 @@ func (s *Synthesizer) inferReturnTypesFromBody( DefaultScope: resolveScope, Manifests: s.deps.Manifests, CheckCtx: prelimCtx, + FunctionFacts: functionFacts, Graphs: s.deps.Graphs, - PreCache: make(api.Cache), - NarrowCache: make(api.Cache), + Evidence: graphEvidence, FunctionTypeInProgress: s.deps.FunctionTypeInProgress, + FunctionFactCache: s.deps.FunctionFactCache, ModuleBindings: s.deps.ModuleBindings, ModuleAliases: moduleAliases, Paths: s.deps.Paths, @@ -422,9 +433,22 @@ func (s *Synthesizer) inferReturnTypesFromBody( // Single-pass local inference from assignments (best-effort). var localInferred map[cfg.SymbolID]typ.Type - fnGraph.EachAssign(func(p cfg.Point, info *cfg.AssignInfo) { + ensureLocalInferred := func() map[cfg.SymbolID]typ.Type { + if localInferred != nil { + return localInferred + } + capHint := overlaySymbolCapacity(fnGraph, 1) - len(overlay) + if capHint < 1 { + capHint = 1 + } + localInferred = make(map[cfg.SymbolID]typ.Type, capHint) + return localInferred + } + for _, assign := range graphEvidence.Assignments { + p := assign.Point + info := assign.Info if info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + continue } needsInference := false for _, target := range info.Targets { @@ -437,7 +461,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( } } if !needsInference { - return + continue } if len(info.Targets) == 1 && len(info.Sources) == 1 { target := info.Targets[0] @@ -469,12 +493,9 @@ func (s *Synthesizer) inferReturnTypesFromBody( t = ensurePrelimSynth().SynthExpr(src, p, nil) } if t != nil { - if localInferred == nil { - localInferred = make(map[cfg.SymbolID]typ.Type) - } - localInferred[target.Symbol] = t + ensureLocalInferred()[target.Symbol] = t } - return + continue } } } @@ -488,13 +509,10 @@ func (s *Synthesizer) inferReturnTypesFromBody( return } if i < len(values) && values[i] != nil { - if localInferred == nil { - localInferred = make(map[cfg.SymbolID]typ.Type) - } - localInferred[target.Symbol] = values[i] + ensureLocalInferred()[target.Symbol] = values[i] } }) - }) + } for sym, t := range localInferred { if _, exists := overlay[sym]; !exists { overlay[sym] = t @@ -520,15 +538,15 @@ func (s *Synthesizer) inferReturnTypesFromBody( return ensurePrelimSynth().SynthExpr(expr, p, nil) } - fieldAssignments := overlaymut.CollectFieldAssignments(fnGraph, enrichedSynth, nil) + fieldAssignments := overlaymut.CollectFieldAssignments(graphEvidence.Assignments, enrichedSynth, nil) overlaymut.ApplyFieldMergeToOverlay(overlay, fieldAssignments) - indexerAssignments := overlaymut.CollectIndexerAssignments(fnGraph, enrichedSynth, mutationBindings, nil) - tableMutations := mutator.CollectTableInsertMutations(fnGraph, enrichedSynth, mutationBindings) - mutator.MergeIndexerMutations(indexerAssignments, tableMutations) + indexerAssignments := overlaymut.CollectIndexerAssignments(graphEvidence.Assignments, enrichedSynth, mutationBindings, nil) + tableMutations := calleffect.CollectTableInsertMutations(graphEvidence.Calls, fnGraph, enrichedSynth, mutationBindings) + overlaymut.MergeIndexerMutations(indexerAssignments, tableMutations) overlaymut.ApplyIndexerMergeToOverlay(overlay, indexerAssignments) - directMutations := mutator.CollectTableInsertOnDirect(fnGraph, enrichedSynth, mutationBindings) + directMutations := calleffect.CollectTableInsertOnDirect(graphEvidence.Calls, fnGraph, enrichedSynth, mutationBindings) overlaymut.ApplyDirectMutationsToOverlay(overlay, directMutations) } @@ -540,6 +558,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( DeclaredTypes: overlay, GlobalTypes: globalTypes, ModuleAliases: moduleAliases, + FunctionType: functionfact.TypeLookup(functionFacts), }) tempDeps := &Deps{ @@ -548,10 +567,11 @@ func (s *Synthesizer) inferReturnTypesFromBody( DefaultScope: resolveScope, Manifests: s.deps.Manifests, CheckCtx: fnCheckCtx, + FunctionFacts: functionFacts, Graphs: s.deps.Graphs, - PreCache: make(api.Cache), - NarrowCache: make(api.Cache), + Evidence: graphEvidence, FunctionTypeInProgress: s.deps.FunctionTypeInProgress, + FunctionFactCache: s.deps.FunctionFactCache, ModuleBindings: s.deps.ModuleBindings, ModuleAliases: moduleAliases, Paths: s.deps.Paths, @@ -565,13 +585,15 @@ func (s *Synthesizer) inferReturnTypesFromBody( var returnTypes []typ.Type seenReturn := false - fnGraph.EachReturn(func(p cfg.Point, info *cfg.ReturnInfo) { + for _, ret := range graphEvidence.Returns { + p := ret.Point + info := ret.Info if info == nil { - return + continue } if tempDeps.Flow != nil { if tempDeps.Flow.IsPointDead(p) { - return + continue } } types := tempSynth.inferReturnExprTypes(info.Exprs, p) @@ -579,7 +601,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( if !seenReturn { seenReturn = true returnTypes = types - return + continue } // Extend returnTypes for new positions: previous returns contributed nil there. @@ -597,7 +619,7 @@ func (s *Synthesizer) inferReturnTypesFromBody( } returnTypes[i] = typ.JoinReturnSlot(returnTypes[i], t) } - }) + } // Normalize nil elements to typ.Unknown so downstream builders never see nil. for i, t := range returnTypes { @@ -606,18 +628,21 @@ func (s *Synthesizer) inferReturnTypesFromBody( } } - if typ.IsUnknownOnlyOrEmpty(returnTypes) && len(summaryFallback) > 0 { - return summaryFallback, false + if len(returnTypes) == 0 && len(canonicalReturns) > 0 { + return canonicalReturns, false } - return returnTypes, erreffect.HasStrictInverseReturnPattern(fnGraph, nil, tempSynth, 0, 1) + convention := erreffect.CanonicalLuaValueErrorConvention() + if !convention.CanClassifyReturns(returnTypes) { + return returnTypes, false + } + return returnTypes, convention.HasStrictInversePattern(graphEvidence.Returns, nil, tempSynth) } -func enrichOverlayWithOrderedComparisonHints(fnGraph *cfg.Graph, overlay map[cfg.SymbolID]typ.Type) { - if fnGraph == nil || len(overlay) == 0 { +func enrichOverlayWithOrderedComparisonHints(branches []api.BranchEvidence, bindings *bind.BindingTable, overlay map[cfg.SymbolID]typ.Type) { + if len(branches) == 0 || len(overlay) == 0 { return } - bindings := fnGraph.Bindings() if bindings == nil { return } @@ -657,12 +682,13 @@ func enrichOverlayWithOrderedComparisonHints(fnGraph *cfg.Graph, overlay map[cfg } } - fnGraph.EachBranch(func(_ cfg.Point, info *cfg.BranchInfo) { + for _, branch := range branches { + info := branch.Info if info == nil || info.Condition == nil { - return + continue } visit(info.Condition) - }) + } } func orderedLiteralType(expr ast.Expr) typ.Type { @@ -676,7 +702,7 @@ func orderedLiteralType(expr ast.Expr) typ.Type { } } -func localFunctionSymbol(graph *cfg.Graph, fn *ast.FunctionExpr) cfg.SymbolID { +func localFunctionSymbol(graph *cfg.Graph, evidence api.FlowEvidence, fn *ast.FunctionExpr) cfg.SymbolID { if graph == nil || fn == nil { return 0 } @@ -687,36 +713,17 @@ func localFunctionSymbol(graph *cfg.Graph, fn *ast.FunctionExpr) cfg.SymbolID { } } } - var fnSym cfg.SymbolID - graph.EachAssign(func(_ cfg.Point, info *cfg.AssignInfo) { - if fnSym != 0 || info == nil || !info.IsLocal || len(info.Targets) == 0 { - return + for _, def := range evidence.FunctionDefinitions { + if def.Symbol == 0 || def.Nested.Func != fn { + continue } - info.EachTargetSource(func(_ int, target cfg.AssignTarget, source ast.Expr) { - if target.Kind != cfg.TargetIdent || target.Symbol == 0 { - return - } - if source == fn { - fnSym = target.Symbol - } - }) - }) - if fnSym != 0 { - return fnSym + return def.Symbol } - graph.EachFuncDef(func(_ cfg.Point, info *cfg.FuncDefInfo) { - if fnSym != 0 || info == nil || info.Symbol == 0 { - return - } - if info.FuncExpr == fn { - fnSym = info.Symbol - } - }) - return fnSym + return 0 } // inferReturnExprTypes synthesizes types from return expressions using CFG point. -// The last expression is expanded via MultiTypeOf to support multi-return calls. +// Lua expands only the final multivalue expression in a return list. func (s *Synthesizer) inferReturnExprTypes(exprs []ast.Expr, p cfg.Point) []typ.Type { if len(exprs) == 0 { return nil @@ -725,9 +732,9 @@ func (s *Synthesizer) inferReturnExprTypes(exprs []ast.Expr, p cfg.Point) []typ. if s.IsNarrowing() && s.deps.Flow != nil { narrower = s.deps.Flow } - var result []typ.Type + result := make([]typ.Type, 0, len(exprs)) for i, expr := range exprs { - if i == len(exprs)-1 { + if i == len(exprs)-1 && ast.CanProduceMultipleValues(expr) { multi := s.multiTypeOf(expr, p, narrower) if len(multi) == 0 { multi = []typ.Type{typ.Unknown} @@ -750,13 +757,13 @@ func (s *Synthesizer) inferReturnExprTypes(exprs []ast.Expr, p cfg.Point) []typ. return result } -// buildFunctionTypeWithSummary builds a function type using annotations for parameters -// and ReturnSummaries for return types. Does not recursively infer return types. -func (s *Synthesizer) buildFunctionTypeWithSummary( +// buildLocalFunctionTypeFromFacts builds a local function type from annotations +// and canonical function facts. It does not recursively infer returns. +func (s *Synthesizer) buildLocalFunctionTypeFromFacts( fn *ast.FunctionExpr, sc *scope.State, sym cfg.SymbolID, - returnSummaries map[cfg.SymbolID][]typ.Type, + functionFacts api.FunctionFacts, ) *typ.Function { if fn == nil { return nil @@ -773,16 +780,15 @@ func (s *Synthesizer) buildFunctionTypeWithSummary( return sig } - // Look up return types from summaries var returnTypes []typ.Type - if returnSummaries != nil && sym != 0 { - returnTypes = returnSummaries[sym] + if functionFacts != nil && sym != 0 { + returnTypes = functionfact.ReturnsForPhase(functionFacts, sym, api.PhaseScopeCompute) } return join.WithReturnsOrUnknown(sig, returnTypes) } -func (s *Synthesizer) buildFunctionTypeSummaryFallback( +func (s *Synthesizer) buildFunctionTypeFromAvailableFacts( fn *ast.FunctionExpr, sc *scope.State, expected *typ.Function, @@ -797,39 +803,34 @@ func (s *Synthesizer) buildFunctionTypeSummaryFallback( if expected != nil && len(sig.Returns) == 0 && len(expected.Returns) > 0 { sig = join.WithReturns(sig, expected.Returns) } - var summaries map[cfg.SymbolID][]typ.Type - if s.deps.CheckCtx != nil { - if s.IsNarrowing() { - if ctx, ok := s.deps.CheckCtx.(api.NarrowEnv); ok { - summaries = ctx.NarrowReturnSummaries() - } - } else if ctx, ok := s.deps.CheckCtx.(api.DeclaredEnv); ok { - summaries = ctx.ReturnSummaries() - } - } + functionFacts := s.functionFactsInput() var fnSym cfg.SymbolID if s.deps.CheckCtx != nil { if pg, ok := s.deps.CheckCtx.Graph().(*cfg.Graph); ok && pg != nil { - fnSym = localFunctionSymbol(pg, fn) + fnSym = localFunctionSymbol(pg, s.graphEvidence(pg), fn) } } if fnSym != 0 { - return join.WithReturnsOrUnknown(sig, summaries[fnSym]) + rets := functionfact.ReturnsForPhase(functionFacts, fnSym, s.phase) + return join.WithReturnsOrUnknown(sig, rets) } return join.WithReturnsOrUnknown(sig, nil) } func (s *Synthesizer) buildParamOverlay(fnGraph *cfg.Graph, sc *scope.State, expected *typ.Function) map[cfg.SymbolID]typ.Type { paramSlots := fnGraph.ParamSlotsReadOnly() - overlay := make(map[cfg.SymbolID]typ.Type, len(paramSlots)) - for _, slot := range paramSlots { + overlay := make(map[cfg.SymbolID]typ.Type, overlaySymbolCapacity(fnGraph, len(paramSlots))) + for paramIdx, slot := range paramSlots { if slot.Symbol == 0 { continue } - srcIdx, hasSource := slot.SourceParamIndex() + _, hasSource := slot.SourceParamIndex() if !hasSource { - if selfType := sc.SelfType(); selfType != nil { + if expected != nil && paramIdx < len(expected.Params) && expected.Params[paramIdx].Type != nil { + overlay[slot.Symbol] = expected.Params[paramIdx].Type + } else if sc != nil && sc.SelfType() != nil { + selfType := sc.SelfType() overlay[slot.Symbol] = selfType } else { overlay[slot.Symbol] = typ.Unknown @@ -837,12 +838,11 @@ func (s *Synthesizer) buildParamOverlay(fnGraph *cfg.Graph, sc *scope.State, exp continue } - i := srcIdx paramType := typ.Unknown if slot.TypeAnnotation != nil { paramType = s.ResolveType(slot.TypeAnnotation, sc) - } else if expected != nil && i < len(expected.Params) { - paramType = expected.Params[i].Type + } else if expected != nil && paramIdx < len(expected.Params) { + paramType = expected.Params[paramIdx].Type } else if slot.Name == "self" && sc != nil && sc.SelfType() != nil { paramType = sc.SelfType() } @@ -851,6 +851,16 @@ func (s *Synthesizer) buildParamOverlay(fnGraph *cfg.Graph, sc *scope.State, exp return overlay } +func overlaySymbolCapacity(fnGraph *cfg.Graph, floor int) int { + if fnGraph == nil { + return floor + } + if count := fnGraph.SymbolCount(); count > floor { + return count + } + return floor +} + // inferCallbackOverlaySpec detects the "setup -> param call -> cleanup" pattern // and builds a contract.Spec with EnvOverlay for each callback parameter. func (s *Synthesizer) inferCallbackOverlaySpec( @@ -869,6 +879,7 @@ func (s *Synthesizer) inferCallbackOverlaySpec( synthExpr := func(expr ast.Expr, p cfg.Point) typ.Type { if tempSynth == nil { overlay := s.buildParamOverlay(fnGraph, sc, expected) + functionFacts := s.functionFactsInput() var globalTypes map[string]typ.Type var moduleAliases map[cfg.SymbolID]string @@ -887,25 +898,27 @@ func (s *Synthesizer) inferCallbackOverlaySpec( DeclaredTypes: overlay, GlobalTypes: globalTypes, ModuleAliases: moduleAliases, + FunctionType: functionfact.TypeLookup(functionFacts), }) tempDeps := &Deps{ - Ctx: s.deps.Ctx, - Types: s.deps.Types, - DefaultScope: sc, - Manifests: s.deps.Manifests, - CheckCtx: fnCheckCtx, - Graphs: s.deps.Graphs, - PreCache: make(api.Cache), - NarrowCache: make(api.Cache), - ModuleBindings: s.deps.ModuleBindings, - ModuleAliases: moduleAliases, + Ctx: s.deps.Ctx, + Types: s.deps.Types, + DefaultScope: sc, + Manifests: s.deps.Manifests, + CheckCtx: fnCheckCtx, + FunctionFacts: functionFacts, + Graphs: s.deps.Graphs, + Evidence: s.graphEvidence(fnGraph), + FunctionFactCache: s.deps.FunctionFactCache, + ModuleBindings: s.deps.ModuleBindings, + ModuleAliases: moduleAliases, } tempSynth = NewSynthesizer(tempDeps, api.PhaseTypeResolution) } return tempSynth.SynthExpr(expr, p, nil) } - overlays := inferCallbackEnvOverlays(fnGraph, paramSlots, synthExpr, s.deps.ModuleBindings) + overlays := InferCallbackEnvOverlays(fnGraph, s.graphEvidence(fnGraph), paramSlots, synthExpr, s.deps.ModuleBindings) if len(overlays) == 0 { return nil } diff --git a/compiler/check/synth/phase/extract/function_test.go b/compiler/check/synth/phase/extract/function_test.go index 2131e4a5..dfb34aa9 100644 --- a/compiler/check/synth/phase/extract/function_test.go +++ b/compiler/check/synth/phase/extract/function_test.go @@ -21,6 +21,10 @@ func (p *countingGraphProvider) GetOrBuildCFG(*ast.FunctionExpr) *ccfg.Graph { return p.graph } +func (p *countingGraphProvider) EvidenceForGraph(*ccfg.Graph) api.FlowEvidence { + return api.FlowEvidence{} +} + func TestSynthFunctionType_Nil(t *testing.T) { s := newTestSynthesizer() result := s.FunctionType(nil, nil) diff --git a/compiler/check/synth/phase/extract/manifest_enrich_test.go b/compiler/check/synth/phase/extract/manifest_enrich_test.go index 938f5efe..afaa77ce 100644 --- a/compiler/check/synth/phase/extract/manifest_enrich_test.go +++ b/compiler/check/synth/phase/extract/manifest_enrich_test.go @@ -31,7 +31,7 @@ func TestEnrichWithManifest_DirectLookup(t *testing.T) { } } -func TestEnrichWithManifest_ImportsFallback(t *testing.T) { +func TestEnrichWithManifest_ImportsLookup(t *testing.T) { manifest := io.NewManifest("m") manifest.SetExport(typ.NewRecord().Field("name", typ.String).Build()) got := enrichWithManifest(manifestQuerierStub{ @@ -39,7 +39,7 @@ func TestEnrichWithManifest_ImportsFallback(t *testing.T) { imports: map[string]*io.Manifest{"m": manifest}, }, typ.Number, "m", "name") if !typ.TypeEquals(got, typ.String) { - t.Fatalf("enrichWithManifest imports fallback = %v, want string", got) + t.Fatalf("enrichWithManifest imports lookup = %v, want string", got) } } diff --git a/compiler/check/synth/phase/extract/named_function.go b/compiler/check/synth/phase/extract/named_function.go index 2721b2f9..de35da61 100644 --- a/compiler/check/synth/phase/extract/named_function.go +++ b/compiler/check/synth/phase/extract/named_function.go @@ -2,14 +2,15 @@ package extract import ( "github.com/wippyai/go-lua/compiler/ast" + "github.com/wippyai/go-lua/compiler/bind" compcfg "github.com/wippyai/go-lua/compiler/cfg" cfganalysis "github.com/wippyai/go-lua/compiler/cfg/analysis" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/callsite" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/flow" - "github.com/wippyai/go-lua/types/subtype" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" ) @@ -33,16 +34,17 @@ func (s *Synthesizer) functionLiteralForIdent(ident *ast.IdentExpr) *ast.Functio bindings = graph.Bindings() } moduleBindings := s.deps.ModuleBindings + evidence := s.graphEvidence(graph) hasFunctionLiteral := func(sym compcfg.SymbolID) bool { if sym == 0 { return false } - if fn := callsite.FunctionLiteralForSymbol(graph, bindings, sym); fn != nil { + if fn := callsite.FunctionLiteralForSymbol(bindings, evidence, sym); fn != nil { return true } if moduleBindings != nil && moduleBindings != bindings { - return callsite.FunctionLiteralForSymbol(graph, moduleBindings, sym) != nil + return callsite.FunctionLiteralForSymbol(moduleBindings, evidence, sym) != nil } return false } @@ -51,11 +53,11 @@ func (s *Synthesizer) functionLiteralForIdent(ident *ast.IdentExpr) *ast.Functio if sym == 0 { return nil } - if fn := callsite.FunctionLiteralForSymbol(graph, bindings, sym); fn != nil { + if fn := callsite.FunctionLiteralForSymbol(bindings, evidence, sym); fn != nil { return fn } if moduleBindings != nil && moduleBindings != bindings { - if fn := callsite.FunctionLiteralForSymbol(graph, moduleBindings, sym); fn != nil { + if fn := callsite.FunctionLiteralForSymbol(moduleBindings, evidence, sym); fn != nil { return fn } } @@ -86,9 +88,10 @@ func (s *Synthesizer) graphLocalFunctionForExpr(expr ast.Expr) (compcfg.SymbolID bindings = s.deps.ModuleBindings } moduleBindings := s.deps.ModuleBindings + evidence := s.graphEvidence(graph) hasGraphLocalLiteral := func(sym compcfg.SymbolID) bool { - return callsite.FunctionLiteralForGraphSymbol(graph, sym) != nil + return callsite.FunctionLiteralForGraphSymbol(evidence, sym) != nil } raw := callsite.SymbolFromExpr(expr, bindings) @@ -108,7 +111,7 @@ func (s *Synthesizer) graphLocalFunctionForExpr(expr ast.Expr) (compcfg.SymbolID return 0, nil, false } - fn := callsite.FunctionLiteralForGraphSymbol(graph, sym) + fn := callsite.FunctionLiteralForGraphSymbol(evidence, sym) if fn == nil { return 0, nil, false } @@ -117,11 +120,33 @@ func (s *Synthesizer) graphLocalFunctionForExpr(expr ast.Expr) (compcfg.SymbolID if captureBindings == nil { captureBindings = moduleBindings } - hasCaptures := captureBindings != nil && len(captureBindings.CapturedSymbols(fn)) > 0 + hasCaptures := hasNonGlobalFunctionCaptures(captureBindings, fn) return sym, fn, hasCaptures } +func hasNonGlobalFunctionCaptures(bindings *bind.BindingTable, fn *ast.FunctionExpr) bool { + return len(nonGlobalFunctionCaptures(bindings, fn)) > 0 +} + +func nonGlobalFunctionCaptures(bindings *bind.BindingTable, fn *ast.FunctionExpr) map[cfg.SymbolID]struct{} { + captures := make(map[cfg.SymbolID]struct{}) + if bindings == nil || fn == nil { + return captures + } + for _, sym := range bindings.CapturedSymbols(fn) { + if sym == 0 { + continue + } + kind, ok := bindings.Kind(sym) + if ok && kind == cfg.SymbolGlobal { + continue + } + captures[sym] = struct{}{} + } + return captures +} + func (s *Synthesizer) graphLocalFunctionLiteralForExpr(expr ast.Expr) *ast.FunctionExpr { _, fn, _ := s.graphLocalFunctionForExpr(expr) return fn @@ -137,12 +162,15 @@ func (s *Synthesizer) hasDominatingDirectFunctionRebind(sym compcfg.SymbolID, st return false } - idom, _ := cfganalysis.ComputeDominators(graph.CFG()) + idom := cfganalysis.ComputeImmediateDominators(graph.CFG()) + evidence := s.graphEvidence(graph) rebound := false - graph.EachAssign(func(assignPoint cfg.Point, info *compcfg.AssignInfo) { + for _, assign := range evidence.Assignments { + assignPoint := assign.Point + info := assign.Info if rebound || info == nil || assignPoint == p || !cfganalysis.StrictlyDominates(idom, assignPoint, p) { - return + continue } info.EachTarget(func(_ int, target compcfg.AssignTarget) { @@ -153,23 +181,25 @@ func (s *Synthesizer) hasDominatingDirectFunctionRebind(sym compcfg.SymbolID, st rebound = true } }) - }) + } if rebound { return true } - graph.EachFuncDef(func(defPoint cfg.Point, info *compcfg.FuncDefInfo) { + for _, def := range evidence.FunctionDefinitions { + defPoint := def.Nested.Point + info := def.FuncDef if rebound || info == nil || info.Symbol != sym || info.FuncExpr == nil || info.FuncExpr == stableFn { - return + continue } if !cfganalysis.StrictlyDominates(idom, defPoint, p) { - return + continue } if info.TargetKind == compcfg.FuncDefField || info.TargetKind == compcfg.FuncDefGlobal { rebound = true } - }) + } return rebound } @@ -196,44 +226,32 @@ func (s *Synthesizer) expectedGraphLocalFunctionValueType( return s.synthFunctionTypeWithCapturePoint(fn, sc, expected, p, captureTypes) } -func (s *Synthesizer) stableGraphLocalFunctionSnapshotType(sym compcfg.SymbolID) typ.Type { - if s == nil || sym == 0 || s.deps == nil || s.deps.Ctx == nil || s.deps.CheckCtx == nil { +func (s *Synthesizer) functionFactType(sym compcfg.SymbolID) typ.Type { + if s == nil || sym == 0 { return nil } - - store := api.StoreFrom(s.deps.Ctx) - if store == nil { - return nil + if t := functionfact.TypeFromMap(s.functionFactsInput(), sym); t != nil { + return t } - - graph, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph) - if !ok || graph == nil { + if s.deps == nil || s.deps.Ctx == nil { return nil } - - fallbackParent := s.deps.DefaultScope - if fallbackParent == nil { - fallbackParent = s.deps.CheckCtx.TypeNames() - } - parent := api.ParentScopeForGraph(store, graph.ID(), fallbackParent) - if parent == nil { + store := api.StoreFrom(s.deps.Ctx) + if store == nil { return nil } - - var fnTypes map[cfg.SymbolID]typ.Type - load := func() { - fnTypes = store.GetLocalFuncTypesSnapshot(graph, parent) - } - if phaser, ok := store.(interface{ WithPhase(api.Phase, func()) }); ok { - phaser.WithPhase(api.PhaseScopeCompute, load) - } else { - load() + defaultParent := s.deps.DefaultScope + if defaultParent == nil && s.deps.CheckCtx != nil { + defaultParent = s.deps.CheckCtx.TypeNames() } - if len(fnTypes) == 0 { - return nil + if s.deps.CheckCtx != nil { + if graph, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph); ok { + if t := functionfact.TypeForGraph(store, graph, sym, defaultParent, s.deps.FunctionFactCache); t != nil { + return t + } + } } - - return fnTypes[sym] + return functionfact.TypeForSymbol(store, sym, defaultParent, s.deps.FunctionFactCache) } func (s *Synthesizer) stableLocalFunctionValueType( @@ -247,6 +265,9 @@ func (s *Synthesizer) stableLocalFunctionValueType( if fn == nil { return nil } + if s.hasDominatingDirectFunctionRebind(sym, fn, p) { + return nil + } authoritative := current if s.deps != nil && s.deps.CheckCtx != nil { @@ -256,25 +277,165 @@ func (s *Synthesizer) stableLocalFunctionValueType( } } } - if snapshot := s.stableGraphLocalFunctionSnapshotType(sym); snapshot != nil { - if authoritative == nil || subtype.IsSubtype(snapshot, authoritative) { - authoritative = snapshot - } + if factType := s.functionFactType(sym); factType != nil { + authoritative = factType } if !hasCaptures && authoritative != nil { return authoritative } + hasCallPointCaptureMutation := hasCaptures && s.hasDominatingCapturedMutation(fn, p) + if !hasCallPointCaptureMutation && authoritative != nil && !functionTypeNeedsBodyRepair(authoritative) { + return authoritative + } + expectedFn, _ := unwrap.Optional(unwrap.Alias(authoritative)).(*typ.Function) specialized := s.synthFunctionTypeWithCapturePoint(fn, sc, expectedFn, p, captureTypes) - if authoritative != nil && specialized != nil { - if subtype.IsSubtype(specialized, authoritative) { - return specialized - } - return authoritative + if specialized != nil { + return specialized } if authoritative != nil { return authoritative } return specialized } + +func (s *Synthesizer) hasDominatingCapturedMutation(fn *ast.FunctionExpr, p cfg.Point) bool { + if s == nil || fn == nil || p == 0 || s.deps == nil || s.deps.CheckCtx == nil { + return false + } + graph, ok := s.deps.CheckCtx.Graph().(*compcfg.Graph) + if !ok || graph == nil { + return false + } + bindings := graph.Bindings() + if bindings == nil { + bindings = s.deps.ModuleBindings + } + captures := nonGlobalFunctionCaptures(bindings, fn) + if len(captures) == 0 { + return false + } + + var defPoint cfg.Point + evidence := s.graphEvidence(graph) + for _, def := range evidence.FunctionDefinitions { + if defPoint != 0 { + break + } + info := def.FuncDef + if info != nil && info.FuncExpr == fn { + defPoint = def.Nested.Point + } + } + if defPoint == 0 { + for _, assign := range evidence.Assignments { + if defPoint != 0 { + break + } + point := assign.Point + info := assign.Info + if info == nil { + continue + } + info.EachTargetSource(func(_ int, _ compcfg.AssignTarget, source ast.Expr) { + if defPoint == 0 && source == fn { + defPoint = point + } + }) + } + } + if defPoint == 0 { + return false + } + + idom := cfganalysis.ComputeImmediateDominators(graph.CFG()) + mutated := false + for _, assign := range evidence.Assignments { + point := assign.Point + info := assign.Info + if mutated || info == nil || point == defPoint { + continue + } + if !cfganalysis.StrictlyDominates(idom, defPoint, point) || !cfganalysis.StrictlyDominates(idom, point, p) { + continue + } + info.EachTarget(func(_ int, target compcfg.AssignTarget) { + if mutated { + return + } + if _, ok := captures[target.Symbol]; ok && target.Symbol != 0 { + mutated = true + return + } + if _, ok := captures[target.BaseSymbol]; ok && target.BaseSymbol != 0 { + mutated = true + } + }) + } + return mutated +} + +func functionTypeNeedsBodyRepair(t typ.Type) bool { + fn := unwrap.Function(t) + if fn == nil { + return false + } + if typeContainsAny(fn.Variadic, 0) { + return true + } + for _, ret := range fn.Returns { + if typeContainsAny(ret, 0) { + return true + } + } + return false +} + +func typeContainsAny(t typ.Type, depth int) bool { + if t == nil || depth > typ.DefaultRecursionDepth { + return false + } + t = unwrap.Alias(t) + if typ.IsAny(t) { + return true + } + switch v := t.(type) { + case *typ.Optional: + return typeContainsAny(v.Inner, depth+1) + case *typ.Union: + for _, member := range v.Members { + if typeContainsAny(member, depth+1) { + return true + } + } + case *typ.Intersection: + for _, member := range v.Members { + if typeContainsAny(member, depth+1) { + return true + } + } + case *typ.Array: + return typeContainsAny(v.Element, depth+1) + case *typ.Map: + return typeContainsAny(v.Key, depth+1) || typeContainsAny(v.Value, depth+1) + case *typ.Tuple: + for _, elem := range v.Elements { + if typeContainsAny(elem, depth+1) { + return true + } + } + case *typ.Record: + if typeContainsAny(v.MapKey, depth+1) || typeContainsAny(v.MapValue, depth+1) || typeContainsAny(v.Metatable, depth+1) { + return true + } + for _, field := range v.Fields { + if typeContainsAny(field.Type, depth+1) { + return true + } + } + case *typ.Function: + return functionTypeNeedsBodyRepair(v) + } + return false +} diff --git a/compiler/check/synth/phase/extract/named_function_test.go b/compiler/check/synth/phase/extract/named_function_test.go index 2a25e35d..cd3c6d28 100644 --- a/compiler/check/synth/phase/extract/named_function_test.go +++ b/compiler/check/synth/phase/extract/named_function_test.go @@ -6,6 +6,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" ccfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/parse" ) @@ -24,7 +25,7 @@ func newNamedFunctionSynth(localBindings, moduleBindings *bind.BindingTable) *Sy }, api.PhaseTypeResolution) } -func TestFunctionLiteralForIdent_UsesModuleFallbackSymbolWhenPrimaryHasNoLiteral(t *testing.T) { +func TestFunctionLiteralForIdent_UsesModuleSymbolWhenPrimaryHasNoLiteral(t *testing.T) { ident := &ast.IdentExpr{Value: "f"} localBindings := bind.NewBindingTable() @@ -90,6 +91,7 @@ func TestFunctionLiteralForIdent_ResolvesAliasChainLiteral(t *testing.T) { }) synth := NewSynthesizer(&Deps{ CheckCtx: checkCtx, + Evidence: trace.GraphEvidence(graph, localBindings), ModuleBindings: localBindings, PreCache: make(api.Cache), NarrowCache: make(api.Cache), @@ -143,6 +145,7 @@ func TestGraphLocalFunctionLiteralForExpr_ResolvesFieldDefinitionAttr(t *testing }) synth := NewSynthesizer(&Deps{ CheckCtx: checkCtx, + Evidence: trace.GraphEvidence(graph, localBindings), ModuleBindings: localBindings, PreCache: make(api.Cache), NarrowCache: make(api.Cache), @@ -209,6 +212,7 @@ func TestGraphLocalFunctionLiteralForExpr_IgnoresMutableFieldPathAttr(t *testing }) synth := NewSynthesizer(&Deps{ CheckCtx: checkCtx, + Evidence: trace.GraphEvidence(graph, localBindings), ModuleBindings: localBindings, PreCache: make(api.Cache), NarrowCache: make(api.Cache), @@ -268,6 +272,7 @@ func TestHasDominatingDirectFunctionRebind_FalseWhenOnlyCapturedFieldChanges(t * }) synth := NewSynthesizer(&Deps{ CheckCtx: checkCtx, + Evidence: trace.GraphEvidence(graph, localBindings), ModuleBindings: localBindings, PreCache: make(api.Cache), NarrowCache: make(api.Cache), @@ -333,6 +338,7 @@ func TestHasDominatingDirectFunctionRebind_TrueWhenFieldIsReassigned(t *testing. }) synth := NewSynthesizer(&Deps{ CheckCtx: checkCtx, + Evidence: trace.GraphEvidence(graph, localBindings), ModuleBindings: localBindings, PreCache: make(api.Cache), NarrowCache: make(api.Cache), diff --git a/compiler/check/synth/phase/extract/pipeline.go b/compiler/check/synth/phase/extract/pipeline.go deleted file mode 100644 index 11a27517..00000000 --- a/compiler/check/synth/phase/extract/pipeline.go +++ /dev/null @@ -1,151 +0,0 @@ -package extract - -import ( - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/synth/ops" - "github.com/wippyai/go-lua/types/cfg" - "github.com/wippyai/go-lua/types/db" - "github.com/wippyai/go-lua/types/typ" - "github.com/wippyai/go-lua/types/typ/unwrap" -) - -// ArgReSynth is called to re-synthesize an argument with contextual typing. -type ArgReSynth func(idx int, arg ast.Expr, expected typ.Type) typ.Type - -// CallPipeline executes the two-phase call synthesis flow. -type CallPipeline struct { - ctx *db.QueryContext - def ops.CallDef - astArgs []ast.Expr - reSynth ArgReSynth - infer ops.InferResult - finished bool -} - -// NewCallPipeline creates a new call pipeline with the given definition. -func NewCallPipeline(ctx *db.QueryContext, def ops.CallDef, astArgs []ast.Expr) *CallPipeline { - return &CallPipeline{ - ctx: ctx, - def: def, - astArgs: astArgs, - } -} - -// WithReSynth sets the re-synthesis callback for contextual typing. -func (p *CallPipeline) WithReSynth(reSynth ArgReSynth) *CallPipeline { - p.reSynth = reSynth - return p -} - -// WithExpected sets the expected return type for bidirectional generic inference. -func (p *CallPipeline) WithExpected(expected typ.Type) *CallPipeline { - p.def.ExpectedReturn = expected - return p -} - -// Infer runs Phase 1: callee resolution and type argument inference. -func (p *CallPipeline) Infer() ops.InferResult { - p.infer = ops.InferCall(p.ctx, p.def) - return p.infer -} - -// ExpectedArgType returns the expected type for argument at index idx. -func (p *CallPipeline) ExpectedArgType(idx int) typ.Type { - if idx < len(p.infer.ExpectedArgs) { - return p.infer.ExpectedArgs[idx] - } - return p.infer.ExpectedVariadic -} - -// ReSynthAndReInfer runs Phase 2: re-synthesizes arguments and re-infers if needed. -func (p *CallPipeline) ReSynthAndReInfer() bool { - if p.reSynth == nil || len(p.astArgs) == 0 { - return false - } - - updatedArgs, changed := p.reSynthArgs() - if !changed { - return false - } - - p.def.Args = updatedArgs - if len(p.def.TypeArgs) == 0 { - p.infer = ops.ReInfer(p.ctx, p.def, p.infer) - } - return true -} - -// Finish runs Phase 3: completes the call and returns the result. -func (p *CallPipeline) Finish() ops.CallResult { - p.finished = true - return ops.FinishCall(p.ctx, p.def, p.infer) -} - -// Run executes the full pipeline: Infer -> ReSynthAndReInfer -> Finish. -func (p *CallPipeline) Run() ops.CallResult { - p.Infer() - p.ReSynthAndReInfer() - return p.Finish() -} - -// reSynthArgs re-synthesizes arguments using the callback. -func (p *CallPipeline) reSynthArgs() ([]typ.Type, bool) { - result := make([]typ.Type, len(p.astArgs)) - copy(result, p.def.Args) - changed := false - - for i, arg := range p.astArgs { - expected := p.ExpectedArgType(i) - if expected == nil { - continue - } - - reSynthed := p.reSynth(i, arg, expected) - if reSynthed != nil { - result[i] = reSynthed - changed = true - } - } - - return result, changed -} - -// FunctionLiteralReSynth creates an ArgReSynth that only re-synthesizes function literals. -func FunctionLiteralReSynth(synthFn func(fn *ast.FunctionExpr, expected *typ.Function) typ.Type) ArgReSynth { - return func(idx int, arg ast.Expr, expected typ.Type) typ.Type { - fnExpr, ok := arg.(*ast.FunctionExpr) - if !ok { - return nil - } - expectedFn, ok := unwrap.Alias(expected).(*typ.Function) - if !ok { - return nil - } - return synthFn(fnExpr, expectedFn) - } -} - -// TableCompatChecker checks if a table literal is compatible with an expected type. -type TableCompatChecker func(table *ast.TableExpr, expected typ.Type, p cfg.Point) bool - -// FullArgReSynth creates an ArgReSynth that re-synthesizes function and table literals. -func FullArgReSynth( - synthWithExpected func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type, - tableChecker TableCompatChecker, - p cfg.Point, -) ArgReSynth { - return func(idx int, arg ast.Expr, expected typ.Type) typ.Type { - switch a := arg.(type) { - case *ast.FunctionExpr: - return synthWithExpected(a, p, expected) - case *ast.TableExpr: - if tableChecker != nil && tableChecker(a, expected, p) { - return expected - } - return synthWithExpected(a, p, expected) - case *ast.IdentExpr: - return synthWithExpected(a, p, expected) - } - return nil - } -} diff --git a/compiler/check/synth/phase/extract/pipeline_test.go b/compiler/check/synth/phase/extract/pipeline_test.go deleted file mode 100644 index 8258cde2..00000000 --- a/compiler/check/synth/phase/extract/pipeline_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package extract - -import ( - "testing" - - "github.com/wippyai/go-lua/compiler/ast" - "github.com/wippyai/go-lua/compiler/check/synth/ops" - "github.com/wippyai/go-lua/types/cfg" - "github.com/wippyai/go-lua/types/db" - "github.com/wippyai/go-lua/types/typ" -) - -func TestNewCallPipeline(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - def := ops.CallDef{ - Callee: typ.Func().Build(), - } - args := []ast.Expr{&ast.NumberExpr{Value: "1"}} - - pipeline := NewCallPipeline(ctx, def, args) - if pipeline == nil { - t.Fatal("expected non-nil pipeline") - } - if pipeline.ctx != ctx { - t.Fatal("context mismatch") - } -} - -func TestCallPipeline_WithReSynth(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - def := ops.CallDef{ - Callee: typ.Func().Build(), - } - pipeline := NewCallPipeline(ctx, def, nil) - - reSynth := func(idx int, arg ast.Expr, expected typ.Type) typ.Type { - return typ.String - } - - result := pipeline.WithReSynth(reSynth) - if result != pipeline { - t.Fatal("expected same pipeline returned") - } - if pipeline.reSynth == nil { - t.Fatal("expected reSynth to be set") - } -} - -func TestCallPipeline_WithExpected(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - def := ops.CallDef{ - Callee: typ.Func().Build(), - } - pipeline := NewCallPipeline(ctx, def, nil) - - expected := typ.String - result := pipeline.WithExpected(expected) - if result != pipeline { - t.Fatal("expected same pipeline returned") - } - if pipeline.def.ExpectedReturn != expected { - t.Fatal("expected return type not set") - } -} - -func TestCallPipeline_Infer(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func(). - Param("x", typ.Integer). - Returns(typ.String). - Build() - def := ops.CallDef{ - Callee: fn, - Args: []typ.Type{typ.Integer}, - } - pipeline := NewCallPipeline(ctx, def, nil) - - infer := pipeline.Infer() - if infer.Callee == nil { - t.Fatal("expected callee to be resolved") - } -} - -func TestCallPipeline_ExpectedArgType_InRange(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func(). - Param("x", typ.Integer). - Param("y", typ.String). - Build() - def := ops.CallDef{ - Callee: fn, - Args: []typ.Type{typ.Integer, typ.String}, - } - pipeline := NewCallPipeline(ctx, def, nil) - pipeline.Infer() - - arg0 := pipeline.ExpectedArgType(0) - if arg0 != typ.Integer { - t.Fatalf("got %v, want integer", arg0) - } - arg1 := pipeline.ExpectedArgType(1) - if arg1 != typ.String { - t.Fatalf("got %v, want string", arg1) - } -} - -func TestCallPipeline_ExpectedArgType_OutOfRange(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func(). - Param("x", typ.Integer). - Variadic(typ.String). - Build() - def := ops.CallDef{ - Callee: fn, - Args: []typ.Type{typ.Integer, typ.String, typ.String}, - } - pipeline := NewCallPipeline(ctx, def, nil) - pipeline.Infer() - - arg5 := pipeline.ExpectedArgType(5) - if arg5 != typ.String { - t.Fatalf("got %v, want string (variadic)", arg5) - } -} - -func TestCallPipeline_ReSynthAndReInfer_NoReSynth(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func().Build() - def := ops.CallDef{Callee: fn} - pipeline := NewCallPipeline(ctx, def, nil) - pipeline.Infer() - - changed := pipeline.ReSynthAndReInfer() - if changed { - t.Fatal("expected no change without reSynth") - } -} - -func TestCallPipeline_ReSynthAndReInfer_NoArgs(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func().Build() - def := ops.CallDef{Callee: fn} - pipeline := NewCallPipeline(ctx, def, nil) - pipeline.WithReSynth(func(idx int, arg ast.Expr, expected typ.Type) typ.Type { - return typ.String - }) - pipeline.Infer() - - changed := pipeline.ReSynthAndReInfer() - if changed { - t.Fatal("expected no change without args") - } -} - -func TestCallPipeline_Finish(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func().Returns(typ.String).Build() - def := ops.CallDef{ - Callee: fn, - Args: []typ.Type{}, - } - pipeline := NewCallPipeline(ctx, def, nil) - pipeline.Infer() - - result := pipeline.Finish() - if result.Type == nil { - t.Fatal("expected non-nil result type") - } - if !pipeline.finished { - t.Fatal("expected finished flag to be set") - } -} - -func TestCallPipeline_Run(t *testing.T) { - ctx := db.NewQueryContext(db.New()) - fn := typ.Func().Returns(typ.Integer).Build() - def := ops.CallDef{ - Callee: fn, - Args: []typ.Type{}, - } - pipeline := NewCallPipeline(ctx, def, nil) - - result := pipeline.Run() - if result.Type == nil { - t.Fatal("expected non-nil result type") - } -} - -func TestFunctionLiteralReSynth_NotFunction(t *testing.T) { - reSynth := FunctionLiteralReSynth(func(fn *ast.FunctionExpr, expected *typ.Function) typ.Type { - return typ.String - }) - - result := reSynth(0, &ast.NumberExpr{Value: "1"}, typ.Integer) - if result != nil { - t.Fatal("expected nil for non-function arg") - } -} - -func TestFunctionLiteralReSynth_NotFunctionExpected(t *testing.T) { - reSynth := FunctionLiteralReSynth(func(fn *ast.FunctionExpr, expected *typ.Function) typ.Type { - return typ.String - }) - - result := reSynth(0, &ast.FunctionExpr{}, typ.Integer) - if result != nil { - t.Fatal("expected nil for non-function expected") - } -} - -func TestFunctionLiteralReSynth_Match(t *testing.T) { - called := false - reSynth := FunctionLiteralReSynth(func(fn *ast.FunctionExpr, expected *typ.Function) typ.Type { - called = true - return typ.String - }) - - fnExpr := &ast.FunctionExpr{} - expectedFn := typ.Func().Build() - result := reSynth(0, fnExpr, expectedFn) - - if !called { - t.Fatal("expected callback to be called") - } - if result != typ.String { - t.Fatalf("got %v, want string", result) - } -} - -func TestFullArgReSynth_Function(t *testing.T) { - called := false - synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { - called = true - return typ.String - } - - reSynth := FullArgReSynth(synthWithExpected, nil, 0) - result := reSynth(0, &ast.FunctionExpr{}, typ.Func().Build()) - - if !called { - t.Fatal("expected callback to be called") - } - if result != typ.String { - t.Fatalf("got %v, want string", result) - } -} - -func TestFullArgReSynth_Table(t *testing.T) { - called := false - synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { - called = true - return typ.String - } - - reSynth := FullArgReSynth(synthWithExpected, nil, 0) - result := reSynth(0, &ast.TableExpr{}, typ.NewRecord().Build()) - - if !called { - t.Fatal("expected callback to be called") - } - if result != typ.String { - t.Fatalf("got %v, want string", result) - } -} - -func TestFullArgReSynth_Other(t *testing.T) { - synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { - return typ.String - } - - reSynth := FullArgReSynth(synthWithExpected, nil, 0) - result := reSynth(0, &ast.NumberExpr{}, typ.Integer) - - if result != nil { - t.Fatal("expected nil for non-function/table") - } -} - -func TestFullArgReSynth_Identifier(t *testing.T) { - called := false - synthWithExpected := func(arg ast.Expr, p cfg.Point, expected typ.Type) typ.Type { - called = true - return typ.String - } - - reSynth := FullArgReSynth(synthWithExpected, nil, 0) - result := reSynth(0, &ast.IdentExpr{Value: "cb"}, typ.Func().Build()) - - if !called { - t.Fatal("expected callback to be called for identifier") - } - if result != typ.String { - t.Fatalf("got %v, want string", result) - } -} diff --git a/compiler/check/synth/phase/extract/synthesizer.go b/compiler/check/synth/phase/extract/synthesizer.go index f745063d..60253607 100644 --- a/compiler/check/synth/phase/extract/synthesizer.go +++ b/compiler/check/synth/phase/extract/synthesizer.go @@ -44,6 +44,27 @@ import ( // Used as callback to allow synthExprCore to recursively synthesize sub-expressions. type ExprSynth func(expr ast.Expr) typ.Type +type exprRecurser struct { + s *Synthesizer + p cfg.Point + sc *scope.State + narrower api.FlowOps + recurse ExprSynth +} + +func newExprRecurser(s *Synthesizer, p cfg.Point, sc *scope.State, narrower api.FlowOps) *exprRecurser { + r := &exprRecurser{s: s, p: p, sc: sc, narrower: narrower} + r.recurse = r.synth + return r +} + +func (r *exprRecurser) synth(expr ast.Expr) typ.Type { + if expr == nil { + return typ.Nil + } + return r.s.synthExprCore(expr, r.sc, r.p, r.narrower, r.recurse) +} + // Synthesizer is the core type synthesis engine for expressions. // // It implements a recursive descent over the AST, computing types for each @@ -125,8 +146,8 @@ func (s *Synthesizer) TypeOfWithExpected(expr ast.Expr, p cfg.Point, expected ty return s.TypeOf(expr, p) } sc := s.deps.ScopeAt(p) - recurse := func(ex ast.Expr) typ.Type { return s.SynthExpr(ex, p, nil) } - return s.SynthExprWithExpectedCore(expr, sc, p, recurse, expected) + recurser := newExprRecurser(s, p, sc, nil) + return s.SynthExprWithExpectedCore(expr, sc, p, recurser.recurse, expected) } // MultiTypeOf synthesizes multiple types for multi-value expressions (no narrowing). @@ -141,10 +162,13 @@ func (s *Synthesizer) SynthMulti(expr ast.Expr, p cfg.Point, narrower api.FlowOp func (s *Synthesizer) multiTypeOf(expr ast.Expr, p cfg.Point, narrower api.FlowOps) []typ.Type { sc := s.deps.ScopeAt(p) - recurse := func(ex ast.Expr) typ.Type { return s.SynthExpr(ex, p, narrower) } - return s.synthMultiCore(expr, sc, recurse, + if t, ok := s.synthNonRecursiveExpr(expr, sc, p, narrower); ok { + return []typ.Type{t} + } + recurser := newExprRecurser(s, p, sc, narrower) + return s.synthMultiCore(expr, sc, recurser.recurse, func(call *ast.FuncCallExpr) []typ.Type { - return s.SynthCallCore(call, p, sc, narrower, recurse) + return s.SynthCallCore(call, p, sc, narrower, recurser.recurse) }, ) } @@ -180,8 +204,11 @@ func (s *Synthesizer) SynthExprAt(expr ast.Expr, p cfg.Point, sc *scope.State) t if expr == nil { return typ.Nil } - recurse := func(ex ast.Expr) typ.Type { return s.SynthExprAt(ex, p, sc) } - return s.synthExprCore(expr, sc, p, nil, recurse) + if t, ok := s.synthNonRecursiveExpr(expr, sc, p, nil); ok { + return t + } + recurser := newExprRecurser(s, p, sc, nil) + return recurser.synth(expr) } // Resolver returns a type resolver. @@ -223,27 +250,54 @@ func (s *Synthesizer) SynthExpr(expr ast.Expr, p cfg.Point, narrower api.FlowOps return typ.Nil } sc := s.deps.ScopeAt(p) - recurse := func(ex ast.Expr) typ.Type { return s.SynthExpr(ex, p, narrower) } - return s.synthExprCore(expr, sc, p, narrower, recurse) + if t, ok := s.synthNonRecursiveExpr(expr, sc, p, narrower); ok { + return t + } + recurser := newExprRecurser(s, p, sc, narrower) + return recurser.synth(expr) } -// synthExprCore is the shared expression synthesizer implementation. -func (s *Synthesizer) synthExprCore(expr ast.Expr, sc *scope.State, p cfg.Point, narrower api.FlowOps, recurse ExprSynth) typ.Type { +// synthNonRecursiveExpr handles expression forms whose type does not depend on +// recursively synthesizing child expressions. +func (s *Synthesizer) synthNonRecursiveExpr(expr ast.Expr, sc *scope.State, p cfg.Point, narrower api.FlowOps) (typ.Type, bool) { switch ex := expr.(type) { case *ast.NilExpr: - return typ.Nil + return typ.Nil, true case *ast.TrueExpr: - return typ.True + return typ.True, true case *ast.FalseExpr: - return typ.False + return typ.False, true case *ast.NumberExpr: - return ops.ParseNumber(ex.Value) + return ops.ParseNumber(ex.Value), true case *ast.StringExpr: - return typ.LiteralString(ex.Value) + return typ.LiteralString(ex.Value), true case *ast.Comma3Expr: - return s.synthComma3(sc) + return s.synthComma3(sc), true case *ast.IdentExpr: - return s.synthIdentCore(ex, p, sc, narrower) + return s.synthIdentCore(ex, p, sc, narrower), true + case *ast.FunctionExpr: + return s.FunctionType(ex, sc), true + case *ast.RelationalOpExpr: + return typ.Boolean, true + case *ast.StringConcatOpExpr: + return typ.String, true + case *ast.UnaryNotOpExpr: + return typ.Boolean, true + case *ast.UnaryBNotOpExpr: + return typ.Integer, true + case *ast.CastExpr: + return s.ResolveType(ex.Type, sc), true + default: + return nil, false + } +} + +// synthExprCore is the shared expression synthesizer implementation. +func (s *Synthesizer) synthExprCore(expr ast.Expr, sc *scope.State, p cfg.Point, narrower api.FlowOps, recurse ExprSynth) typ.Type { + if t, ok := s.synthNonRecursiveExpr(expr, sc, p, narrower); ok { + return t + } + switch ex := expr.(type) { case *ast.AttrGetExpr: return s.synthAttrGetCore(ex, p, sc, narrower, recurse) case *ast.TableExpr: @@ -254,30 +308,15 @@ func (s *Synthesizer) synthExprCore(expr ast.Expr, sc *scope.State, p cfg.Point, return types[0] } return typ.Nil - case *ast.FunctionExpr: - return s.FunctionType(ex, sc) case *ast.LogicalOpExpr: - if s.IsNarrowing() && narrower != nil { - return s.synthLogicalOpWithNarrowing(ex, p, sc, narrower, recurse) - } - return s.synthLogicalOpCore(ex, recurse) - case *ast.RelationalOpExpr: - return typ.Boolean - case *ast.StringConcatOpExpr: - return typ.String + return s.synthLogicalOpWithNarrowing(ex, p, sc, narrower, recurse) case *ast.ArithmeticOpExpr: return s.synthArithmeticOpCore(ex, recurse) case *ast.UnaryMinusOpExpr: return s.synthUnaryMinusCore(ex, recurse) - case *ast.UnaryNotOpExpr: - return typ.Boolean case *ast.UnaryLenOpExpr: operand := recurse(ex.Expr) return s.deps.Types.UnaryOp(s.deps.Ctx, "#", operand) - case *ast.UnaryBNotOpExpr: - return typ.Integer - case *ast.CastExpr: - return s.ResolveType(ex.Type, sc) case *ast.NonNilAssertExpr: inner := recurse(ex.Expr) return narrow.RemoveNil(inner) @@ -349,7 +388,7 @@ func (s *Synthesizer) synthIdentCore(ex *ast.IdentExpr, p cfg.Point, sc *scope.S // For "self" identifier, check scope's self type first. // This ensures methods assigned via field assignment (obj.method = function(self)...) - // get the correct self type before falling back to parameter type lookup. + // get the correct self type before parameter lookup. if ex.Value == "self" && sc != nil { if selfType := sc.SelfType(); selfType != nil { return selfType @@ -377,7 +416,7 @@ func (s *Synthesizer) synthIdentCore(ex *ast.IdentExpr, p cfg.Point, sc *scope.S requireSubtype = unwrap.Function(declared.Type) != nil } if requireSubtype && !subtype.IsSubtype(narrowed, declared.Type) { - goto fallback + goto declaredLookup } } } @@ -385,7 +424,7 @@ func (s *Synthesizer) synthIdentCore(ex *ast.IdentExpr, p cfg.Point, sc *scope.S } } -fallback: +declaredLookup: if types := ctx.Types(); types != nil { tv := types.EffectiveTypeAt(p, sym) if tv.State == flow.StateResolved && tv.Type != nil { @@ -423,7 +462,7 @@ fallback: } } - // Module alias lookup (require("mod")) as fallback when no concrete type is resolved. + // Module alias lookup (require("mod")) when no concrete type is resolved. moduleAliasSym := sym if moduleAliasSym == 0 { moduleAliasSym = moduleSym diff --git a/compiler/check/synth/phase/extract/synthesizer_test.go b/compiler/check/synth/phase/extract/synthesizer_test.go index f669fd01..611dc47d 100644 --- a/compiler/check/synth/phase/extract/synthesizer_test.go +++ b/compiler/check/synth/phase/extract/synthesizer_test.go @@ -5,6 +5,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/bind" + ccfg "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/abstract/trace" "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/types/cfg" @@ -109,12 +111,40 @@ func (m mockGraph) SymbolKind(sym cfg.SymbolID) (cfg.SymbolKind, bool) { return cfg.SymbolUnknown, false } +type testGraphProvider struct { + cache map[*ast.FunctionExpr]*ccfg.Graph +} + +func newTestGraphProvider() *testGraphProvider { + return &testGraphProvider{cache: make(map[*ast.FunctionExpr]*ccfg.Graph)} +} + +func (p *testGraphProvider) GetOrBuildCFG(fn *ast.FunctionExpr) *ccfg.Graph { + if fn == nil { + return nil + } + if graph := p.cache[fn]; graph != nil { + return graph + } + graph := ccfg.Build(fn) + p.cache[fn] = graph + return graph +} + +func (p *testGraphProvider) EvidenceForGraph(graph *ccfg.Graph) api.FlowEvidence { + if graph == nil { + return api.FlowEvidence{} + } + return trace.GraphEvidence(graph, graph.Bindings()) +} + func newTestSynthesizer() *Synthesizer { deps := &Deps{ Ctx: db.NewQueryContext(db.New()), Types: mockTypeQuerier{}, Scopes: make(api.ScopeMap), PreCache: make(api.Cache), + Graphs: newTestGraphProvider(), } return NewSynthesizer(deps, api.PhaseTypeResolution) } @@ -220,11 +250,11 @@ func TestSynthesizer_TypeOf_Ident(t *testing.T) { } } -func TestSynthesizer_TypeOf_IdentFallsBackToGraphSymbolAt(t *testing.T) { +func TestSynthesizer_TypeOf_IdentUsesGraphSymbolAt(t *testing.T) { s, _ := newTestSynthesizerWithSymbol("x", typ.Integer) result := s.TypeOf(&ast.IdentExpr{Value: "x"}, 0) if result != typ.Integer { - t.Fatalf("got %v, want integer via SymbolAt fallback", result) + t.Fatalf("got %v, want integer via SymbolAt", result) } } diff --git a/compiler/check/synth/phase/extract/table.go b/compiler/check/synth/phase/extract/table.go index a07f8c03..2f8cc2d9 100644 --- a/compiler/check/synth/phase/extract/table.go +++ b/compiler/check/synth/phase/extract/table.go @@ -5,6 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/check/scope" "github.com/wippyai/go-lua/compiler/check/synth/ops" phasecore "github.com/wippyai/go-lua/compiler/check/synth/phase/core" + "github.com/wippyai/go-lua/types/narrow" querycore "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/typ" "github.com/wippyai/go-lua/types/typ/unwrap" @@ -37,6 +38,9 @@ func (s *Synthesizer) SynthTableCore(ex *ast.TableExpr, sc *scope.State, recurse // Empty tables return an open record (can have any additional fields assigned). func (s *Synthesizer) SynthTableWithExpected(ex *ast.TableExpr, sc *scope.State, recurse ExprSynth, expected typ.Type) typ.Type { if len(ex.Fields) == 0 { + if result := emptyTableExpectedResult(expected); result != nil { + return result + } return typ.NewRecord().SetOpen(true).Build() } @@ -152,19 +156,47 @@ func (s *Synthesizer) SynthTableWithExpected(ex *ast.TableExpr, sc *scope.State, } else { result = typ.NewTuple(arrayElements...) } - if expected != nil && len(ops.CheckTable(nil, arrayElements, expected).Errors) == 0 { + if useExpectedTableResult(expected) && len(ops.CheckTable(nil, arrayElements, expected).Errors) == 0 { return expected } return result } result := builder.Build() - if expected != nil && len(ops.CheckTable(fieldDefs, arrayElements, expected).Errors) == 0 { + if useExpectedTableResult(expected) && len(ops.CheckTable(fieldDefs, arrayElements, expected).Errors) == 0 { return expected } return result } +func emptyTableExpectedResult(expected typ.Type) typ.Type { + if expected == nil { + return nil + } + nonNil := narrow.RemoveNil(expected) + if nonNil == nil || typ.IsNever(nonNil) || typ.IsAbsentOrUnknown(nonNil) || typ.IsAny(nonNil) { + return nil + } + if !useExpectedTableResult(nonNil) { + return nil + } + if len(ops.CheckTable(nil, nil, nonNil).Errors) != 0 { + return nil + } + return nonNil +} + +func useExpectedTableResult(expected typ.Type) bool { + if expected == nil { + return false + } + unwrapped := unwrap.Alias(expected) + if unwrapped == nil { + return false + } + return !unwrapped.Kind().IsPlaceholder() +} + // synthFieldValueWithExpected synthesizes type for a table field value with optional expected type. func (s *Synthesizer) synthFieldValueWithExpected(value ast.Expr, sc *scope.State, recurse ExprSynth, expected typ.Type, selfType typ.Type) typ.Type { if tbl, ok := value.(*ast.TableExpr); ok { diff --git a/compiler/check/synth/phase/extract/table_test.go b/compiler/check/synth/phase/extract/table_test.go index 4de65874..1bffe107 100644 --- a/compiler/check/synth/phase/extract/table_test.go +++ b/compiler/check/synth/phase/extract/table_test.go @@ -70,6 +70,59 @@ func TestSynthTableCore_ArrayLike(t *testing.T) { } } +func TestSynthTableWithExpectedAnyPreservesTuplePrecision(t *testing.T) { + s := newTestSynthesizer() + sc := scope.New() + recurse := func(ex ast.Expr) typ.Type { return s.TypeOf(ex, 0) } + + table := &ast.TableExpr{ + Fields: []*ast.Field{ + {Value: &ast.StringExpr{Value: "first"}}, + }, + } + result := s.SynthTableWithExpected(table, sc, recurse, typ.Any) + + tuple, ok := result.(*typ.Tuple) + if !ok { + t.Fatalf("got %T, want tuple", result) + } + if len(tuple.Elements) != 1 { + t.Fatalf("got %d elements, want 1", len(tuple.Elements)) + } +} + +func TestSynthTableWithExpectedEmptyMapUsesNonNilExpected(t *testing.T) { + s := newTestSynthesizer() + sc := scope.New() + recurse := func(ex ast.Expr) typ.Type { return s.TypeOf(ex, 0) } + + expected := typ.NewOptional(typ.NewMap(typ.String, typ.Any)) + table := &ast.TableExpr{} + result := s.SynthTableWithExpected(table, sc, recurse, expected) + + if !typ.TypeEquals(result, typ.NewMap(typ.String, typ.Any)) { + t.Fatalf("got %v, want non-nil expected map", result) + } +} + +func TestSynthTableWithExpectedEmptyRecordRequiresFields(t *testing.T) { + s := newTestSynthesizer() + sc := scope.New() + recurse := func(ex ast.Expr) typ.Type { return s.TypeOf(ex, 0) } + + expected := typ.NewRecord().Field("name", typ.String).Build() + table := &ast.TableExpr{} + result := s.SynthTableWithExpected(table, sc, recurse, expected) + + rec, ok := result.(*typ.Record) + if !ok { + t.Fatalf("got %T, want synthesized open record", result) + } + if !rec.Open || len(rec.Fields) != 0 { + t.Fatalf("got %v, want empty open record for missing required fields", result) + } +} + func TestSynthTableWithExpected_Record(t *testing.T) { s := newTestSynthesizer() sc := scope.New() diff --git a/compiler/check/synth/phase/extract/union_expected.go b/compiler/check/synth/phase/extract/union_expected.go index 6e581e67..57cb6a36 100644 --- a/compiler/check/synth/phase/extract/union_expected.go +++ b/compiler/check/synth/phase/extract/union_expected.go @@ -3,6 +3,7 @@ package extract import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/check/scope" + phasecore "github.com/wippyai/go-lua/compiler/check/synth/phase/core" "github.com/wippyai/go-lua/types/cfg" "github.com/wippyai/go-lua/types/query/core" "github.com/wippyai/go-lua/types/subtype" @@ -30,11 +31,7 @@ func (s *Synthesizer) synthExprWithUnionExpected( } if fn, ok := expr.(*ast.FunctionExpr); ok { - paramCount := 0 - if fn.ParList != nil { - paramCount = len(fn.ParList.Names) - } - if compatible := core.CompatibleFunctionFromUnion(paramCount, expected); compatible != nil { + if compatible := phasecore.ExpectedFunctionLiteralSignature(fn, expected); compatible != nil { return s.SynthFunctionTypeWithExpected(fn, sc, compatible) } } @@ -85,6 +82,8 @@ func (s *Synthesizer) synthExprWithExpectedSingle( return typ.Nil } return types[0] + case *ast.LogicalOpExpr: + return s.synthLogicalOpWithExpected(ex, sc, p, recurse, expected) case *ast.IdentExpr: if expectedFn, ok := unwrap.Alias(expected).(*typ.Function); ok { if fnExpr := s.functionLiteralForIdent(ex); fnExpr != nil { diff --git a/compiler/check/synth/transform/spec_return.go b/compiler/check/synth/transform/spec_return.go index 4140c4d3..29d21277 100644 --- a/compiler/check/synth/transform/spec_return.go +++ b/compiler/check/synth/transform/spec_return.go @@ -11,7 +11,7 @@ import ( // ApplySpecReturnCases evaluates contract.ReturnSpec cases against argument types. // This is pure type-based matching that works when argument types are resolved -// to literal types. The compiler uses this as a fallback when AST-pattern +// to literal types. The compiler uses this when AST-pattern // matching (for inline table constructors) doesn't produce a result. // // Ownership: This function provides the pure type-based logic. The compiler diff --git a/compiler/check/tests/core/constructor_instance_fields_test.go b/compiler/check/tests/core/constructor_instance_fields_test.go index 52b7d5cd..5a5dfcd6 100644 --- a/compiler/check/tests/core/constructor_instance_fields_test.go +++ b/compiler/check/tests/core/constructor_instance_fields_test.go @@ -227,7 +227,7 @@ end Stdlib: true, }, { - Name: "constructor with early return nil does not propagate fields", + Name: "constructor with early return nil propagates successful instance fields", Code: ` local session_writer = {} session_writer.__index = session_writer @@ -245,7 +245,7 @@ function session_writer:get_session_id(): string return self.session_id end `, - WantError: true, + WantError: false, Stdlib: true, }, } diff --git a/compiler/check/tests/core/env_overlay_test.go b/compiler/check/tests/core/env_overlay_test.go index b5b6da11..8f259789 100644 --- a/compiler/check/tests/core/env_overlay_test.go +++ b/compiler/check/tests/core/env_overlay_test.go @@ -1,6 +1,7 @@ package core import ( + "strings" "testing" "github.com/wippyai/go-lua/compiler/check/tests/testutil" @@ -51,6 +52,233 @@ func migrationManifest() *io.Manifest { return m } +func migrationManifestWithTransactionDB() *io.Manifest { + txType := typ.NewInterface("migration.Transaction", []typ.Method{ + { + Name: "query", + Type: typ.Func(). + Param("self", typ.Self). + Param("sql", typ.String). + OptParam("params", typ.Any). + Returns(typ.Any, typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "execute", + Type: typ.Func(). + Param("self", typ.Self). + Param("sql", typ.String). + OptParam("params", typ.Any). + Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). + Build(), + }, + }) + stepFn := typ.Func().Param("db", txType).Returns(typ.Nil).Build() + upFn := typ.Func().Param("fn", stepFn).Returns(typ.Nil).Build() + overlay := map[string]typ.Type{} + databaseCallbackSpec := (&contract.CallbackSpec{ + InputSource: effect.ParamRef{Index: 1}, + Cardinality: contract.CardExactlyOnce, + }).WithEnvOverlay(overlay) + databaseFn := typ.Func(). + Param("db_type", typ.String). + Param("fn", typ.Func().Returns(typ.Nil).Build()). + Returns(typ.Nil). + Spec(contract.NewSpec().WithCallback(1, databaseCallbackSpec)). + Build() + migrationFn := typ.Func(). + Param("description", typ.String). + Param("fn", typ.Func().Returns(typ.Nil).Build()). + Returns(typ.Nil). + Build() + + callbackSpec := (&contract.CallbackSpec{ + InputSource: effect.ParamRef{Index: 0}, + Cardinality: contract.CardExactlyOnce, + }).WithEnvOverlay(overlay) + overlay["migration"] = migrationFn + overlay["database"] = databaseFn + overlay["up"] = upFn + overlay["down"] = upFn + overlay["after"] = upFn + + defineFn := typ.Func(). + Param("fn", typ.Func().Returns(typ.Nil).Build()). + Returns(typ.Nil). + Spec(contract.NewSpec().WithCallback(0, callbackSpec)). + Build() + + moduleType := typ.NewRecord(). + Field("define", defineFn). + Build() + + m := io.NewManifest("migration_lib") + m.SetExport(moduleType) + m.DefineType("Transaction", txType) + return m +} + +func sqlManifestWithServiceDB() *io.Manifest { + txType := typ.NewInterface("sql.Transaction", []typ.Method{ + { + Name: "query", + Type: typ.Func(). + Param("self", typ.Self). + Param("sql", typ.String). + Variadic(typ.Any). + Returns(typ.NewArray(typ.NewMap(typ.String, typ.Any)), typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "execute", + Type: typ.Func(). + Param("self", typ.Self). + Param("sql", typ.String). + Variadic(typ.Any). + Returns(typ.NewRecord(). + Field("rows_affected", typ.Integer). + Field("last_insert_id", typ.Integer). + Build(), typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "commit", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "rollback", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.Boolean, typ.NewOptional(typ.LuaError)). + Build(), + }, + }) + dbType := typ.NewInterface("sql.DB", []typ.Method{ + { + Name: "type", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.String, typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "begin", + Type: typ.Func(). + Param("self", typ.Self). + Returns(txType, typ.NewOptional(typ.LuaError)). + Build(), + }, + }) + + spec := contract.NewSpec().WithEffects(effect.ErrorReturn{ValueIndex: 0, ErrorIndex: 1}) + sqlTypes := typ.NewRecord(). + Field("POSTGRES", typ.LiteralString("postgres")). + Field("SQLITE", typ.LiteralString("sqlite")). + Build() + moduleType := typ.NewRecord(). + Field("get", typ.Func(). + Param("dsn", typ.String). + Returns(dbType, typ.NewOptional(typ.LuaError)). + Spec(spec). + Build()). + Field("type", sqlTypes). + Build() + + m := io.NewManifest("sql") + m.SetExport(moduleType) + m.DefineType("DB", dbType) + m.DefineType("Transaction", txType) + return m +} + +func sqlManifestWithServiceDBAndBuilder() *io.Manifest { + m := sqlManifestWithServiceDB() + txType := m.Types["Transaction"] + dbType := m.Types["DB"] + runnerType := typ.NewUnion(dbType, txType) + execResult := typ.NewRecord(). + Field("rows_affected", typ.Integer). + Field("last_insert_id", typ.Integer). + Build() + queryExecutorType := typ.NewInterface("sql.QueryExecutor", []typ.Method{ + { + Name: "exec", + Type: typ.Func(). + Param("self", typ.Self). + Returns(execResult, typ.NewOptional(typ.LuaError)). + Build(), + }, + { + Name: "query", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.NewArray(typ.NewMap(typ.String, typ.Any)), typ.NewOptional(typ.LuaError)). + Build(), + }, + }) + insertBuilderType := typ.NewInterface("sql.InsertBuilder", []typ.Method{ + { + Name: "set_map", + Type: typ.Func(). + Param("self", typ.Self). + Param("values", typ.NewMap(typ.String, typ.Any)). + Returns(typ.Self). + Build(), + }, + { + Name: "run_with", + Type: typ.Func(). + Param("self", typ.Self). + Param("runner", runnerType). + Returns(queryExecutorType). + Build(), + }, + }) + selectBuilderType := typ.NewInterface("sql.SelectBuilder", []typ.Method{ + { + Name: "from", + Type: typ.Func(). + Param("self", typ.Self). + Param("table", typ.String). + Returns(typ.Self). + Build(), + }, + { + Name: "where", + Type: typ.Func(). + Param("self", typ.Self). + Param("clause", typ.String). + Variadic(typ.Any). + Returns(typ.Self). + Build(), + }, + { + Name: "run_with", + Type: typ.Func(). + Param("self", typ.Self). + Param("runner", runnerType). + Returns(queryExecutorType). + Build(), + }, + }) + builderType := typ.NewRecord(). + Field("insert", typ.Func().Param("table", typ.String).Returns(insertBuilderType).Build()). + Field("select", typ.Func().Variadic(typ.String).Returns(selectBuilderType).Build()). + Build() + m.SetExport(typ.NewRecord(). + Field("get", typ.Func().Param("dsn", typ.String).Returns(dbType, typ.NewOptional(typ.LuaError)).Build()). + Field("type", typ.NewRecord(). + Field("POSTGRES", typ.LiteralString("postgres")). + Field("SQLITE", typ.LiteralString("sqlite")). + Build()). + Field("builder", builderType). + Build()) + return m +} + // testFrameworkManifest creates a manifest for a test DSL module. // The "it" function accepts a callback with "expect" injected via EnvOverlay. func testFrameworkManifest() *io.Manifest { @@ -98,6 +326,447 @@ func TestEnvOverlay_MigrationDSL(t *testing.T) { } } +func TestEnvOverlay_MigrationDSLTypedTransactionRejectsServiceTypeMethod(t *testing.T) { + source := ` + migration_lib.define(function() + database("postgres", function() + up(function(db) + local db_type, err = db:type() + end) + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if !result.HasError() { + t.Fatal("expected migration transaction db to reject service-only type() method") + } + messages := strings.Join(testutil.ErrorMessages(result.Diagnostics), "\n") + if !strings.Contains(messages, "type") { + t.Fatalf("expected diagnostic to mention missing type method, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_MigrationDSLTypedTransactionAllowsQueryAndExecute(t *testing.T) { + source := ` + migration_lib.define(function() + database("postgres", function() + up(function(db) + local rows, qerr = db:query("SELECT 1") + if qerr then return end + local ok, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + end) + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if result.HasError() { + t.Fatalf("expected migration transaction query/execute methods to type-check, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_MigrationCallbackParameterFeedsOuterHelper(t *testing.T) { + source := ` + local function create_admin_user(db) + local result, err = db:execute("INSERT INTO users (id) VALUES (?)", {"admin"}) + if err then error(err) end + return result + end + + migration_lib.define(function() + migration("seed admin", function() + database("postgres", function() + up(function(db) + create_admin_user(db) + end) + end) + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if result.HasError() { + t.Fatalf("expected migration callback parameter to feed outer helper parameter evidence, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_MigrationCallbackParameterFeedsHelperArgumentDemand(t *testing.T) { + source := ` + local function run_with(db: migration_lib.Transaction) + return db + end + + local function create_admin_user(db) + local executor = run_with(db) + return executor + end + + migration_lib.define(function() + migration("seed admin", function() + database("postgres", function() + up(function(db) + create_admin_user(db) + end) + end) + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if result.HasError() { + t.Fatalf("expected helper parameter to satisfy nested typed argument demand, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_MigrationCallbackParameterFeedsHelperMethodArgumentDemand(t *testing.T) { + source := ` + type Query = { + run_with: (any, migration_lib.Transaction) -> any, + } + + local query: Query = { + run_with = function(self, db) + return db + end + } + + local function create_admin_user(db) + local executor = query:run_with(db) + return executor + end + + migration_lib.define(function() + migration("seed admin", function() + database("postgres", function() + up(function(db) + create_admin_user(db) + end) + end) + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if result.HasError() { + t.Fatalf("expected helper parameter to satisfy nested typed method argument demand, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_MigrationCallbackParameterSatisfiesSQLBuilderRunnerUnion(t *testing.T) { + source := ` + local sql = require("sql") + + local function admin_exists(db) + local query = sql.builder.select("COUNT(*) as count") + :from("app_user_groups") + :where("group_id = ?", "app.security:admin") + local executor = query:run_with(db) + local results, err = executor:query() + if err then error(err) end + return results[1].count > 0 + end + + local function create_admin_user(db) + if admin_exists(db) then + return nil + end + local user_query = sql.builder.insert("app_users") + :set_map({ + user_id = "admin", + email = "admin@example.test", + status = "active", + }) + local user_executor = user_query:run_with(db) + local result, err = user_executor:exec() + if err then error(err) end + return result + end + + migration_lib.define(function() + migration("seed admin", function() + database("postgres", function() + up(function(db) + create_admin_user(db) + end) + end) + database("sqlite", function() + up(function(db) + create_admin_user(db) + end) + end) + end) + end) + ` + result := testutil.Check( + source, + testutil.WithStdlib(), + testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB()), + testutil.WithManifest("sql", sqlManifestWithServiceDBAndBuilder()), + ) + if result.HasError() { + t.Fatalf("expected migration callback parameter to satisfy SQL builder runner union, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_HelperParameterSatisfiesOwnMethodArgumentDemand(t *testing.T) { + source := ` + type Query = { + run_with: (any, migration_lib.Transaction) -> any, + } + + local query: Query = { + run_with = function(self, db) + return db + end + } + + local function create_admin_user(db) + local executor = query:run_with(db) + return executor + end + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("migration_lib", migrationManifestWithTransactionDB())) + if result.HasError() { + t.Fatalf("expected helper parameter to satisfy its own typed method argument demand, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_RuntimeSQLServiceDBAllowsTypeMethod(t *testing.T) { + source := ` + local sql = require("sql") + local db, err = sql.get("app:db") + if err then return end + + local db_type, type_err = db:type() + if type_err then return end + if db_type == sql.type.POSTGRES then + return + end + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected runtime sql service db:type() to type-check, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_CallbackParameterUsesImportedModuleType(t *testing.T) { + source := ` + local sql = require("sql") + + local function up(fn: (sql.Transaction) -> any) + end + + up(function(db) + local rows, qerr = db:query("SELECT 1") + if qerr then return end + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected callback db to infer sql.Transaction from imported module type, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_CallbackParameterImportedTransactionRejectsServiceDBMethod(t *testing.T) { + source := ` + local sql = require("sql") + + local function up(fn: (sql.Transaction) -> any) + end + + up(function(db) + local db_type, err = db:type() + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if !result.HasError() { + t.Fatal("expected callback db inferred as sql.Transaction to reject sql.DB-only type() method") + } + messages := strings.Join(testutil.ErrorMessages(result.Diagnostics), "\n") + if !strings.Contains(messages, "type") { + t.Fatalf("expected diagnostic to mention missing type method, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_CallbackParameterUsesImportedModuleTypeAlias(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + + local function up(fn: MigrationStep) + end + + up(function(db) + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected callback type alias to preserve sql.Transaction parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_FactoryReturnCallbackUsesImportedModuleType(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + + local function create_up_fn(): (MigrationStep) -> () + return function(fn: MigrationStep) + end + end + + local up = create_up_fn() + up(function(db) + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected factory-returned callback API to preserve sql.Transaction parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_FactoryReturnCallbackUsesPlainCallbackType(t *testing.T) { + source := ` + type Step = (number) -> any + + local function create_step_fn(): (Step) -> () + return function(fn: Step) + end + end + + local step = create_step_fn() + step(function(value) + local n: number = value + end) + ` + result := testutil.Check(source, testutil.WithStdlib()) + if result.HasError() { + t.Fatalf("expected factory-returned callback API to preserve plain callback parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_FactoryReturnCallbackWithExplicitLocalType(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + type UpFn = (MigrationStep) -> () + + local function create_up_fn(): UpFn + return function(fn: MigrationStep) + end + end + + local up: UpFn = create_up_fn() + up(function(db) + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected explicitly typed factory local to preserve sql.Transaction parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_FactoryReturnCallbackImmediateCallUsesImportedModuleType(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + + local function create_up_fn(): (MigrationStep) -> () + return function(fn: MigrationStep) + end + end + + create_up_fn()(function(db) + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected immediate factory-returned callback API to preserve sql.Transaction parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_InferredGlobalCallbackUsesImportedModuleType(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + + local function create_up_fn(): (MigrationStep) -> () + return function(fn: MigrationStep) + end + end + + local function define(fn: () -> any) + _G.up = create_up_fn() + fn() + _G.up = nil + end + + define(function() + up(function(db) + local rows, qerr = db:query("SELECT 1") + if qerr then return end + local result, xerr = db:execute("CREATE TABLE users(id TEXT)") + if xerr then return end + local changed: integer = result.rows_affected + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if result.HasError() { + t.Fatalf("expected inferred global callback to preserve sql.Transaction parameter, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestEnvOverlay_InferredGlobalCallbackImportedTransactionRejectsServiceDBMethod(t *testing.T) { + source := ` + local sql = require("sql") + + type MigrationStep = (sql.Transaction) -> any + + local function create_up_fn(): (MigrationStep) -> () + return function(fn: MigrationStep) + end + end + + local function define(fn: () -> any) + _G.up = create_up_fn() + fn() + _G.up = nil + end + + define(function() + up(function(db) + local db_type, err = db:type() + end) + end) + ` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithManifest("sql", sqlManifestWithServiceDB())) + if !result.HasError() { + t.Fatal("expected inferred global callback db to reject sql.DB-only type() method") + } + messages := strings.Join(testutil.ErrorMessages(result.Diagnostics), "\n") + if !strings.Contains(messages, "type") { + t.Fatalf("expected diagnostic to mention missing type method, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + func TestEnvOverlay_TestDSL(t *testing.T) { manifest := testFrameworkManifest() diff --git a/compiler/check/tests/core/return_field_merge_test.go b/compiler/check/tests/core/return_field_merge_test.go index bb4c3e87..10d209e1 100644 --- a/compiler/check/tests/core/return_field_merge_test.go +++ b/compiler/check/tests/core/return_field_merge_test.go @@ -254,10 +254,10 @@ func TestReturnFieldMerge_ModuleImport(t *testing.T) { } } -// TestParamHintsSeesEnrichedReturns verifies that param hints are computed +// TestParameterEvidenceSeesEnrichedReturns verifies that parameter evidence is computed // using enriched return types (with field merges applied), not raw returns. -// This test fails with the timing bug (param hints see {} instead of {value: number}). -func TestParamHintsSeesEnrichedReturns(t *testing.T) { +// This test fails with the timing bug (parameter evidence sees {} instead of {value: number}). +func TestParameterEvidenceSeesEnrichedReturns(t *testing.T) { code := ` local function make_obj() local obj = {} @@ -277,8 +277,8 @@ func TestParamHintsSeesEnrichedReturns(t *testing.T) { } } -// TestParamHintsSeesEnrichedReturns_Method verifies method calls work through param hints. -func TestParamHintsSeesEnrichedReturns_Method(t *testing.T) { +// TestParameterEvidenceSeesEnrichedReturns_Method verifies method calls work through parameter evidence. +func TestParameterEvidenceSeesEnrichedReturns_Method(t *testing.T) { code := ` local function make_obj() local obj = {} diff --git a/compiler/check/tests/errors/error_correlation_test.go b/compiler/check/tests/errors/error_correlation_test.go index cfac14ca..a4c421ac 100644 --- a/compiler/check/tests/errors/error_correlation_test.go +++ b/compiler/check/tests/errors/error_correlation_test.go @@ -5,7 +5,8 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" - "github.com/wippyai/go-lua/compiler/check/returns" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" + "github.com/wippyai/go-lua/compiler/check/domain/returnsummary" "github.com/wippyai/go-lua/compiler/check/tests/testutil" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/contract" @@ -139,9 +140,9 @@ db:release() if getDbSym != 0 && result.Session.Store != nil { parentHash := result.Session.Store.GraphParentHashOf(root.Graph.ID()) parent := result.Session.Store.Parents()[parentHash] - if summaries := result.Session.Store.GetReturnSummariesSnapshot(root.Graph, parent); summaries != nil { - if returns, ok := summaries[getDbSym]; ok { - t.Logf("ReturnSummaries[%d][get_db]=%v", parentHash, returns) + if facts := result.Session.Store.GetInterprocFacts(root.Graph, parent).FunctionFacts; facts != nil { + if fact, ok := facts[getDbSym]; ok { + t.Logf("FunctionFacts[%d][get_db].Summary=%v", parentHash, fact.Summary) } } } @@ -287,14 +288,14 @@ db:release() root := result.Session.RootResult.Graph parentHash := result.Session.Store.GraphParentHashOf(root.ID()) parent := result.Session.Store.Parents()[parentHash] - summaries := result.Session.Store.GetReturnSummariesSnapshot(root, parent) + functionFacts := result.Session.Store.GetInterprocFacts(root, parent).FunctionFacts for _, name := range []string{"connect", "get_connection"} { sym, ok := root.SymbolAt(root.Exit(), name) if !ok || sym == 0 { t.Fatalf("missing symbol for %s", name) } - rets := returns.NormalizeReturnVector(summaries[sym]) + rets := returnsummary.Normalize(functionfact.ReturnSummaryFromMap(functionFacts, sym)) if len(rets) == 0 { t.Fatalf("missing return summary for %s", name) } diff --git a/compiler/check/tests/errors/strict_type_checks_test.go b/compiler/check/tests/errors/strict_type_checks_test.go index f5165c59..1ce3f2c9 100644 --- a/compiler/check/tests/errors/strict_type_checks_test.go +++ b/compiler/check/tests/errors/strict_type_checks_test.go @@ -42,6 +42,26 @@ local y = x.field wantCode: diag.ErrTypeMismatch, contains: "cannot index type number", }, + { + name: "truthiness probe on primitive still rejects indexing", + code: ` +local x: number = 42 +if x.field then + return +end +`, + wantCode: diag.ErrTypeMismatch, + contains: "cannot index type number", + }, + { + name: "missing closed record field still rejects value read", + code: ` +local p: {name: string} = {name = "a"} +local missing = p.missing +`, + wantCode: diag.ErrNoField, + contains: "missing", + }, { name: "type name shadowed by local value does not resolve Type:is", code: ` diff --git a/compiler/check/tests/flow/fixpoint_unification_test.go b/compiler/check/tests/flow/fixpoint_unification_test.go index 5b0cb346..d30b452d 100644 --- a/compiler/check/tests/flow/fixpoint_unification_test.go +++ b/compiler/check/tests/flow/fixpoint_unification_test.go @@ -108,10 +108,10 @@ local result: string = tbl:process(42) // Literal signatures channel removed in canonical query architecture. } -// TestFixpointUnification_ParamHintPropagation verifies that parameter hints +// TestFixpointUnification_ParameterEvidencePropagation verifies that parameter evidence // from call sites propagate across iterations. In a chain A -> B -> C, where -// A calls B with a number and B calls C, param hints should stabilize. -func TestFixpointUnification_ParamHintPropagation(t *testing.T) { +// A calls B with a number and B calls C, parameter evidence should stabilize. +func TestFixpointUnification_ParameterEvidencePropagation(t *testing.T) { source := ` local function c(x) return x + 1 @@ -140,8 +140,8 @@ local result: number = a() parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent) - if len(summaries) == 0 { + functionFacts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts + if len(functionFacts) == 0 { t.Error("expected non-empty return summaries for the call chain") } @@ -151,14 +151,14 @@ local result: number = a() t.Fatal("missing root graph") } - for sym, rt := range summaries { + for sym, fact := range functionFacts { name := graph.NameOf(sym) if name == "a" || name == "b" || name == "c" { - if len(rt) == 0 { + if len(fact.Summary) == 0 { t.Errorf("empty return summary for %q", name) continue } - if typ.TypeEquals(rt[0], typ.Unknown) { + if typ.TypeEquals(fact.Summary[0], typ.Unknown) { t.Errorf("return type for %q is unknown, expected number", name) } } @@ -217,7 +217,7 @@ end // Verify effects exist and A's effect has Terminates == true. foundA := false - for sym, eff := range sess.Store.InterprocPrev.Refinements { + for sym, eff := range sess.RefinementsForExport() { if eff == nil { continue } @@ -267,7 +267,7 @@ end } foundA := false - for sym, eff := range sess.Store.InterprocPrev.Refinements { + for sym, eff := range sess.RefinementsForExport() { if eff == nil { continue } @@ -307,7 +307,7 @@ end } foundA := false - for sym, eff := range sess.Store.InterprocPrev.Refinements { + for sym, eff := range sess.RefinementsForExport() { if eff == nil { continue } @@ -351,7 +351,7 @@ end } foundA := false - for sym, eff := range sess.Store.InterprocPrev.Refinements { + for sym, eff := range sess.RefinementsForExport() { if eff == nil { continue } @@ -404,12 +404,12 @@ func TestFixpointUnification_EffectRowLabels(t *testing.T) { } } -// TestFixpointUnification_ParamHintNestedPropagation verifies that parameter -// hints propagate correctly through nested function calls within function bodies. -// This is a regression test for the early break bug where PropagateParamHintsFromCallGraph +// TestFixpointUnification_ParameterEvidenceNestedPropagation verifies that parameter +// evidence propagate correctly through nested function calls within function bodies. +// This is a regression test for the early break bug where PropagateParameterEvidence // would fail to resolve callee symbols from identifiers when CalleeSymbol was zero. -func TestFixpointUnification_ParamHintNestedPropagation(t *testing.T) { - // d calls c, c calls b, b has parameter x. Hints should flow d->c->b. +func TestFixpointUnification_ParameterEvidenceNestedPropagation(t *testing.T) { + // d calls c, c calls b, b has parameter x. Evidence should flow d->c->b. // The key is that inner calls (c calling b) need identifier resolution. source := ` local function b(x) @@ -446,28 +446,31 @@ local result: number = d() checkedFunctions := make(map[string]bool) parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent) - for sym, rt := range summaries { + functionFacts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts + for sym, fact := range functionFacts { name := graph.NameOf(sym) if name == "b" || name == "c" || name == "d" { checkedFunctions[name] = true - if len(rt) == 0 { + if len(fact.Summary) == 0 { t.Errorf("empty return summary for %q", name) continue } - if typ.TypeEquals(rt[0], typ.Unknown) { - t.Errorf("return type for %q is unknown, expected number (hints didn't propagate)", name) + if typ.TypeEquals(fact.Summary[0], typ.Unknown) { + t.Errorf("return type for %q is unknown, expected number (evidence didn't propagate)", name) } } } - // Verify that param hints were propagated to inner functions. - paramHintsFound := false - if hints := sess.Store.GetParamHintsSnapshot(sess.RootResult.Graph, parent); len(hints) > 0 { - paramHintsFound = true + // Verify that parameter evidence was propagated to inner functions. + parameterEvidenceFound := false + for _, fact := range functionFacts { + if len(fact.Params) > 0 { + parameterEvidenceFound = true + break + } } - if !paramHintsFound { - t.Log("no param hints found in ParamHintsPrev (propagation may have converged)") + if !parameterEvidenceFound { + t.Log("no parameter evidence found in canonical function facts (propagation may have converged)") } } diff --git a/compiler/check/tests/flow/guards_test.go b/compiler/check/tests/flow/guards_test.go index ec6fdbf0..ed2c9608 100644 --- a/compiler/check/tests/flow/guards_test.go +++ b/compiler/check/tests/flow/guards_test.go @@ -197,6 +197,35 @@ func TestGuards_FieldTruthyNarrowsUnion(t *testing.T) { WantError: false, Stdlib: true, }, + { + Name: "closed record missing field is nil in truthiness probe", + Code: ` + function f(edge: {target_node_id: string?}): boolean + if edge.target_node_id or edge.is_workflow_terminal then + return true + end + if not edge.is_workflow_terminal then + return false + end + return true + end + `, + WantError: false, + Stdlib: true, + }, + { + Name: "closed record missing field nil comparison is existence probe", + Code: ` + function f(edge: {target_node_id: string?}): boolean + if edge.is_workflow_terminal == nil then + return false + end + return true + end + `, + WantError: false, + Stdlib: true, + }, } testutil.RunCases(t, tests) } diff --git a/compiler/check/tests/flow/preflow_convergence_test.go b/compiler/check/tests/flow/preflow_convergence_test.go index 2851381e..0d0f6a86 100644 --- a/compiler/check/tests/flow/preflow_convergence_test.go +++ b/compiler/check/tests/flow/preflow_convergence_test.go @@ -214,11 +214,7 @@ local c: number = b } } -// TestPreflowConvergence_WideningSoundness tests that when an SCC doesn't converge, -// ALL members are widened to unknown, not just missing entries. -func TestPreflowConvergence_WideningSoundness(t *testing.T) { - // This test verifies that partial types don't leak through when widening occurs. - // The key property is that if widening triggers, all affected symbols get unknown. +func TestPreflowConvergence_RecursiveRecordCycleConverges(t *testing.T) { source := ` local a = {x = 1} local b = {y = a} @@ -236,10 +232,7 @@ local n: number = a.x } } -// TestPreflowConvergence_WideningReported tests that widening events are recorded. -func TestPreflowConvergence_WideningReported(t *testing.T) { - // Create a case that triggers widening by exceeding max iterations. - // Deeply recursive mutual dependencies that don't stabilize quickly. +func TestPreflowConvergence_RecursiveFunctionCycleConverges(t *testing.T) { source := ` local a, b, c, d, e @@ -251,66 +244,10 @@ e = function() return a() end ` result := testutil.Check(source, testutil.WithStdlib()) - - // Access widening events from flow inputs - if result.Session == nil || result.Session.RootResult == nil { - t.Fatal("expected session with root result") - } - - inputs := result.Session.RootResult.FlowInputs - if inputs == nil { - t.Fatal("expected flow inputs") - } - - // Even if no widening occurs in this simple case, verify the field exists - // and the API works. A true non-converging case is hard to construct - // without artificial iteration limits. - t.Logf("widening events count: %d", len(inputs.WideningEvents)) -} - -// TestPreflowConvergence_WideningDiagnosticEmitted tests that widening diagnostics -// are emitted when preflow inference doesn't converge. -func TestPreflowConvergence_WideningDiagnosticEmitted(t *testing.T) { - // This test verifies the diagnostic plumbing works. - // Note: Most real code converges within the iteration limit, - // so widening diagnostics are rare in practice. - source := ` -local a, b, c, d, e - -a = function() return b() end -b = function() return c() end -c = function() return d() end -d = function() return e() end -e = function() return a() end -` - - result := testutil.Check(source, testutil.WithStdlib()) - - if result.Session == nil || result.Session.RootResult == nil { - t.Fatal("expected session with root result") - } - - // Count widening diagnostics (if any) - wideningDiagCount := 0 - for _, d := range result.Session.Diagnostics { - if d.Severity == diag.SeverityWarning { - if len(d.Message) > 0 && (contains(d.Message, "widened to unknown") || contains(d.Message, "type inference did not converge")) { - wideningDiagCount++ - t.Logf("Widening diagnostic: %s", d.Message) - } - } - } - - // Log whether widening occurred - inputs := result.Session.RootResult.FlowInputs - if inputs != nil { - t.Logf("widening events: %d, widening diagnostics: %d", len(inputs.WideningEvents), wideningDiagCount) - - // If widening events occurred, diagnostics should be emitted - if len(inputs.WideningEvents) > 0 && wideningDiagCount == 0 { - t.Error("widening events occurred but no diagnostics were emitted") - } + if result.HasError() { + t.Fatalf("expected no errors, got: %v", testutil.ErrorMessages(result.Diagnostics)) } + assertNoConvergenceWarnings(t, result.Diagnostics) } // TestPreflowConvergence_MapEntryFallbackCounters_NoWarnings reproduces @@ -354,15 +291,27 @@ mark_passed("suite:a") } for _, d := range result.Diagnostics { - if d.Severity != diag.SeverityWarning { - continue + if isConvergenceWarning(d) { + t.Fatalf("unexpected convergence warning: %q", d.Message) } - if contains(d.Message, "type inference did not converge") || d.Message == "inter-function fixpoint did not converge" { + } +} + +func assertNoConvergenceWarnings(t *testing.T, diags []diag.Diagnostic) { + t.Helper() + for _, d := range diags { + if isConvergenceWarning(d) { t.Fatalf("unexpected convergence warning: %q", d.Message) } } } +func isConvergenceWarning(d diag.Diagnostic) bool { + return d.Severity == diag.SeverityWarning && + (contains(d.Message, "type inference did not converge") || + contains(d.Message, "inter-function fixpoint did not converge")) +} + // contains is a simple substring check helper. func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || diff --git a/compiler/check/tests/inference/closure_return_infer_test.go b/compiler/check/tests/inference/closure_return_infer_test.go index 3a3149f1..e2127c0d 100644 --- a/compiler/check/tests/inference/closure_return_infer_test.go +++ b/compiler/check/tests/inference/closure_return_infer_test.go @@ -801,10 +801,10 @@ end var summary []typ.Type parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - if summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent); summaries != nil { - for sym, rt := range summaries { + if facts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts; facts != nil { + for sym, fact := range facts { if sess.RootResult.Graph.NameOf(sym) == "get_db" { - summary = rt + summary = fact.Summary break } } @@ -873,10 +873,10 @@ end var summary []typ.Type parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - if summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent); summaries != nil { - for sym, rt := range summaries { + if facts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts; facts != nil { + for sym, fact := range facts { if sess.RootResult.Graph.NameOf(sym) == "get_db" { - summary = rt + summary = fact.Summary break } } @@ -931,10 +931,10 @@ local y: string = b parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - if summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent); summaries != nil { - for sym, rt := range summaries { + if facts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts; facts != nil { + for sym, fact := range facts { name := sess.RootResult.Graph.NameOf(sym) - for i, slot := range rt { + for i, slot := range fact.Summary { if slot == nil { t.Errorf("nil slot at index %d in return summary for %q", i, name) } @@ -965,9 +965,9 @@ end found := 0 parentHash := sess.Store.GraphParentHashOf(sess.RootResult.Graph.ID()) parent := sess.Store.Parents()[parentHash] - if summaries := sess.Store.GetReturnSummariesSnapshot(sess.RootResult.Graph, parent); summaries != nil { - for sym, rt := range summaries { - if len(rt) == 0 { + if facts := sess.Store.GetInterprocFacts(sess.RootResult.Graph, parent).FunctionFacts; facts != nil { + for sym, fact := range facts { + if len(fact.Summary) == 0 { name := "" if sess.RootResult.Graph != nil { name = sess.RootResult.Graph.NameOf(sym) diff --git a/compiler/check/tests/inference/param_hints_and_returns_test.go b/compiler/check/tests/inference/parameter_evidence_and_returns_test.go similarity index 100% rename from compiler/check/tests/inference/param_hints_and_returns_test.go rename to compiler/check/tests/inference/parameter_evidence_and_returns_test.go diff --git a/compiler/check/tests/modules/manifest_test.go b/compiler/check/tests/modules/manifest_test.go index 20ecfa28..3610df6f 100644 --- a/compiler/check/tests/modules/manifest_test.go +++ b/compiler/check/tests/modules/manifest_test.go @@ -3,10 +3,14 @@ package modules import ( "testing" + "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/tests/testutil" "github.com/wippyai/go-lua/types/constraint" "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" ) // TestManifest_BasicExport tests basic manifest export types. @@ -85,9 +89,9 @@ func TestManifest_LocalRequireInFunction(t *testing.T) { } } -// TestManifest_SoftAnnotationParamHints ensures soft annotations like {any} -// are overridden by call-site param hints. -func TestManifest_SoftAnnotationParamHints(t *testing.T) { +// TestManifest_SoftAnnotationParameterEvidence ensures soft annotations like {any} +// are overridden by call-site parameter evidence. +func TestManifest_SoftAnnotationParameterEvidence(t *testing.T) { registryManifest := io.NewManifest("registry") entryType := typ.NewRecord().Field("id", typ.String).Build() findFn := typ.Func().Param("query", typ.Any).Returns(typ.NewArray(entryType)).Build() @@ -115,7 +119,7 @@ func TestManifest_SoftAnnotationParamHints(t *testing.T) { for _, d := range result.Errors { t.Logf("error: %s", d.Message) } - t.Errorf("expected no errors with soft annotation param hints") + t.Errorf("expected no errors with soft annotation parameter evidence") } } @@ -182,6 +186,50 @@ func TestManifest_SoftLocalAnnotations(t *testing.T) { } t.Errorf("expected no errors with soft local annotations") } + + if result.Session == nil || result.Session.Store == nil || result.Session.RootResult == nil || result.Session.RootResult.Graph == nil { + t.Fatal("missing session data") + } + root := result.Session.RootResult.Graph + parentHash := result.Session.Store.GraphParentHashOf(root.ID()) + parent := result.Session.Store.Parents()[parentHash] + functionFacts := result.Session.Store.GetInterprocFacts(root, parent).FunctionFacts + + groupSym := localFunctionSymbolByName(t, root, functionFacts, "group_by_suite") + runSuiteSym := localFunctionSymbolByName(t, root, functionFacts, "run_suite") + entryArray := typ.NewArray(entryType) + suiteMap := typ.NewMap(typ.String, entryArray) + + groupFact := functionFacts[groupSym] + if len(groupFact.Summary) != 2 || !typ.TypeEquals(groupFact.Summary[0], suiteMap) || !typ.TypeEquals(groupFact.Summary[1], entryArray) { + t.Fatalf("expected group_by_suite summary (%v, %v), got %v", suiteMap, entryArray, groupFact.Summary) + } + if len(groupFact.Narrow) != 2 || !typ.TypeEquals(groupFact.Narrow[0], suiteMap) || !typ.TypeEquals(groupFact.Narrow[1], entryArray) { + t.Fatalf("expected group_by_suite narrow summary (%v, %v), got %v", suiteMap, entryArray, groupFact.Narrow) + } + groupFn := unwrap.Function(groupFact.Type) + if groupFn == nil || len(groupFn.Returns) != 2 || !typ.TypeEquals(groupFn.Returns[0], suiteMap) || !typ.TypeEquals(groupFn.Returns[1], entryArray) { + t.Fatalf("expected group_by_suite function returns (%v, %v), got %v", suiteMap, entryArray, groupFact.Type) + } + runSuiteType := functionfact.TypeFromMap(functionFacts, runSuiteSym) + runSuiteFn := unwrap.Function(runSuiteType) + if runSuiteFn == nil || len(runSuiteFn.Params) < 2 || !typ.TypeEquals(runSuiteFn.Params[1].Type, entryArray) { + t.Fatalf("expected run_suite tests param to refine to %v, got %v", entryArray, runSuiteType) + } + if evidence := functionfact.ParameterEvidenceFromMap(functionFacts, runSuiteSym); len(evidence) < 2 || !typ.TypeEquals(evidence[1], entryArray) { + t.Fatalf("expected run_suite parameter evidence %v, got %v", entryArray, evidence) + } +} + +func localFunctionSymbolByName(t *testing.T, graph *cfg.Graph, facts api.FunctionFacts, name string) cfg.SymbolID { + t.Helper() + for sym := range facts { + if graph.NameOf(sym) == name { + return sym + } + } + t.Fatalf("missing function fact for %s", name) + return 0 } // TestManifest_InterfaceExport tests manifest with interface types. diff --git a/compiler/check/tests/narrowing/narrow_synth_guard_test.go b/compiler/check/tests/narrowing/narrow_synth_guard_test.go index 4c0179c1..7bc4c56d 100644 --- a/compiler/check/tests/narrowing/narrow_synth_guard_test.go +++ b/compiler/check/tests/narrowing/narrow_synth_guard_test.go @@ -5,6 +5,7 @@ import ( "github.com/wippyai/go-lua/compiler/ast" "github.com/wippyai/go-lua/compiler/cfg" + "github.com/wippyai/go-lua/compiler/check/api" "github.com/wippyai/go-lua/compiler/check/hooks" "github.com/wippyai/go-lua/compiler/check/scope" ) @@ -18,7 +19,7 @@ func TestHooksRequireNarrowSynth_CallHook(t *testing.T) { } graph := cfg.Build(fn) - diags := hooks.CheckCalls(graph, nil, nil, nil, "test.lua") + diags := hooks.CheckCalls(graph, api.FlowEvidence{}, nil, nil, nil, nil, "test.lua") if len(diags) != 0 { t.Errorf("call hook should return empty diagnostics when NarrowSynth is nil, got %d", len(diags)) @@ -35,7 +36,7 @@ func TestHooksRequireNarrowSynth_ReturnHook(t *testing.T) { graph := cfg.Build(fn) baseScope := scope.New() - diags := hooks.CheckReturns(fn, graph, map[cfg.Point]*scope.State{}, baseScope, nil, nil, "test.lua") + diags := hooks.CheckReturns(fn, graph, api.FlowEvidence{}, map[cfg.Point]*scope.State{}, baseScope, nil, nil, "test.lua") if len(diags) != 0 { t.Errorf("return hook should return empty diagnostics when declared synth is nil, got %d", len(diags)) @@ -48,7 +49,7 @@ func TestHooksRequireNarrowSynth_FieldHook(t *testing.T) { } graph := cfg.Build(fn) - diags := hooks.CheckFields(graph, nil, nil, "test.lua") + diags := hooks.CheckFields(graph, api.FlowEvidence{}, nil, nil, nil, "test.lua") if len(diags) != 0 { t.Errorf("field hook should return empty diagnostics when NarrowSynth is nil, got %d", len(diags)) @@ -61,7 +62,7 @@ func TestHooksRequireNarrowSynth_AssignHook(t *testing.T) { } graph := cfg.Build(fn) - diags := hooks.CheckAssignments(graph, map[cfg.Point]*scope.State{}, nil, nil, "test.lua") + diags := hooks.CheckAssignments(graph, api.FlowEvidence{}, map[cfg.Point]*scope.State{}, nil, nil, "test.lua") if len(diags) != 0 { t.Errorf("assign hook should return empty diagnostics when NarrowSynth is nil, got %d", len(diags)) diff --git a/compiler/check/tests/regression/advanced_type_system_stress_test.go b/compiler/check/tests/regression/advanced_type_system_stress_test.go new file mode 100644 index 00000000..c2c62b28 --- /dev/null +++ b/compiler/check/tests/regression/advanced_type_system_stress_test.go @@ -0,0 +1,453 @@ +package regression + +import ( + "strings" + "testing" + + "github.com/wippyai/go-lua/compiler/check/tests/testutil" +) + +func TestAdvancedTypeSystem_DiscriminatedEventPipelineWithDynamicDecode(t *testing.T) { + source := ` +type MessageEvent = {kind: "message", id: string, text: string, tags: {string}?} +type ToolEvent = {kind: "tool", id: string, name: string, arguments: {[string]: any}} +type ErrorEvent = {kind: "error", id: string, error: {code: string, message: string}} +type Event = MessageEvent | ToolEvent | ErrorEvent + +local function require_string(value, fallback: string): string + if type(value) == "string" then + return value + end + return fallback +end + +local function string_array(value): {string}? + if type(value) ~= "table" then + return nil + end + local out: {string} = {} + for _, item in ipairs(value) do + if type(item) == "string" then + table.insert(out, item) + end + end + return out +end + +local function decode_event(raw: any): (Event?, string?) + if type(raw) ~= "table" then + return nil, "event must be a table" + end + + if raw.kind == "message" then + return { + kind = "message", + id = require_string(raw.id, ""), + text = require_string(raw.text, ""), + tags = string_array(raw.tags), + }, nil + end + + if raw.kind == "tool" then + return { + kind = "tool", + id = require_string(raw.id, ""), + name = require_string(raw.name, ""), + arguments = type(raw.arguments) == "table" and (raw.arguments :: {[string]: any}) or {}, + }, nil + end + + if raw.kind == "error" then + return { + kind = "error", + id = require_string(raw.id, ""), + error = { + code = require_string(raw.code, "unknown"), + message = require_string(raw.message, "failed"), + }, + }, nil + end + + return nil, "unknown event" +end + +local function render_event(event: Event): string + if event.kind == "message" then + return event.id .. ":" .. event.text + end + if event.kind == "tool" then + return event.id .. ":" .. event.name + end + return event.id .. ":" .. event.error.code .. ":" .. event.error.message +end + +local function render_all(raw_events: {any}): ({string}, {string}) + local rendered: {string} = {} + local errors: {string} = {} + for _, raw in ipairs(raw_events) do + local event, err = decode_event(raw) + if event then + table.insert(rendered, render_event(event)) + else + table.insert(errors, err or "unknown") + end + end + return rendered, errors +end + +local rendered, errors = render_all({ + { kind = "message", id = "m1", text = "hello" }, + { kind = "tool", id = "t1", name = "search", arguments = { query = "lua" } }, + { kind = "error", id = "e1", code = "E", message = "boom" }, +}) + +return rendered[1] or errors[1] or "" +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_ResultPipelineKeepsMultiReturnCorrelation(t *testing.T) { + source := ` +type Err = {kind: string, message: string} +type User = {id: string, name: string, roles: {string}} +type Session = {id: string, user: User, expires_at: number} + +local users: {[string]: User} = { + ["u1"] = { id = "u1", name = "Ada", roles = ({ "admin" } :: {string}) }, +} + +local function find_user(id: string): (User?, Err?) + local user = users[id] + if not user then + return nil, { kind = "not_found", message = id } + end + return user, nil +end + +local function create_session(user: User, now: number): (Session?, Err?) + if #user.roles == 0 then + return nil, { kind = "forbidden", message = user.id } + end + return { id = user.id .. ":" .. tostring(now), user = user, expires_at = now + 3600 }, nil +end + +local function with_user(id: string, now: number, fn: (User, number) -> (Session?, Err?)): (Session?, Err?) + local user, err = find_user(id) + if err then + return nil, err + end + return fn(user, now) +end + +local session, err = with_user("u1", 10, create_session) +if err then + return err.message +end +return session.user.name .. ":" .. tostring(session.expires_at) +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_FluentMetatableBuilderPreservesStateAcrossMethods(t *testing.T) { + source := ` +type Request = { + method: string, + path: string, + headers: {[string]: string}, + query: {[string]: string}, + timeout: number, +} + +local Builder = {} +Builder.__index = Builder + +function Builder.new() + return setmetatable({ + method = "GET", + path = "/", + headers = {} :: {[string]: string}, + query = {} :: {[string]: string}, + timeout = 30, + }, Builder) +end + +function Builder.with_method(self: Request, method: string): Request + self.method = method + return self +end + +function Builder.with_header(self: Request, key: string, value: string): Request + self.headers[key] = value + return self +end + +function Builder.with_query(self: Request, key: string, value: string?): Request + if value then + self.query[key] = value + end + return self +end + +function Builder.with_timeout(self: Request, timeout: number?): Request + self.timeout = timeout or self.timeout + return self +end + +function Builder.build(self: Request): Request + return { + method = self.method, + path = self.path, + headers = self.headers, + query = self.query, + timeout = self.timeout, + } +end + +local req = Builder.build( + Builder.with_timeout( + Builder.with_query( + Builder.with_header( + Builder.with_method(Builder.new() :: Request, "POST"), + "Accept", + "application/json" + ), + "q", + "lua" + ), + nil + ) +) + +return req.method .. ":" .. req.headers.Accept .. ":" .. tostring(req.timeout) +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_ModuleBoundaryPreservesTaggedResultAndCallbacks(t *testing.T) { + repoModule := testutil.CheckAndExport(` +local repo = {} + +type Row = {id: string, payload: string, metadata: {[string]: any}?} +type Found = {ok: true, row: Row} +type Missing = {ok: false, error: {kind: "missing", message: string}} +type Result = Found | Missing +type Mapper = (Row) -> string + +local rows = { + ["a"] = { id = "a", payload = "hello", metadata = { source = "test" } }, +} + +function repo.get(id: string): Result + local row = rows[id] + if row then + return { ok = true, row = row } + end + return { ok = false, error = { kind = "missing", message = id } } +end + +function repo.map(id: string, mapper: Mapper): (string?, string?) + local result = repo.get(id) + if result.ok then + return mapper(result.row), nil + end + return nil, result.error.message +end + +return repo +`, "repo", testutil.WithStdlib()) + if repoModule.HasError() { + t.Fatalf("repo module errors: %v", testutil.ErrorMessages(repoModule.Errors)) + } + + source := ` +local repo = require("repo") + +local value, err = repo.map("a", function(row) + local source = row.metadata and row.metadata.source or "none" + return row.id .. ":" .. row.payload .. ":" .. tostring(source) +end) + +if err then + return err +end +return value +` + result := testutil.Check(source, testutil.WithStdlib(), testutil.WithModule("repo", repoModule)) + if result.HasError() { + t.Fatalf("expected module boundary to preserve tagged result and callback parameter shape, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestAdvancedTypeSystem_GenericResultCombinatorsPreserveDiscriminantsAndPayloads(t *testing.T) { + source := ` +type Failure = {code: string, message: string} +type Result = {ok: true, value: T} | {ok: false, error: Failure} +type Envelope = {id: string, attrs: {[string]: string}, nested: {attempts: number}} +type View = {label: string, attempts: number} + +local function ok(value: T): Result + return { ok = true, value = value } +end + +local function fail(code: string, message: string): Result + return { ok = false, error = { code = code, message = message } } +end + +local function map(result: Result, fn: (T) -> U): Result + if result.ok then + return ok(fn(result.value)) + end + return { ok = false, error = result.error } +end + +local function and_then(result: Result, fn: (T) -> Result): Result + if result.ok then + return fn(result.value) + end + return { ok = false, error = result.error } +end + +local function decode(raw: any): Result + if type(raw) ~= "table" then + return fail("shape", "not a table") + end + if type(raw.id) ~= "string" then + return fail("shape", "missing id") + end + return ok({ + id = raw.id, + attrs = type(raw.attrs) == "table" and (raw.attrs :: {[string]: string}) or {}, + nested = { attempts = type(raw.attempts) == "number" and raw.attempts or 0 }, + }) +end + +local view = and_then(decode({ + id = "evt", + attrs = { source = "test" }, + attempts = 2, +}), function(env: Envelope): Result + return map(ok(env), function(inner: Envelope): View + return { + label = inner.id .. ":" .. inner.attrs.source, + attempts = inner.nested.attempts + 1, + } + end) +end) + +if view.ok then + local label: string = view.value.label + local attempts: number = view.value.attempts + return label .. ":" .. tostring(attempts) +end +return view.error.code .. ":" .. view.error.message +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_ExpressionLocalTypeGuardRefinesExpectedTableField(t *testing.T) { + source := ` +type Box = {attempts: number} + +local function wrap(value: Box): Box + return value +end + +local function decode(raw: any): Box + if type(raw) ~= "table" then + return { attempts = 0 } + end + return wrap({ + attempts = type(raw.attempts) == "number" and raw.attempts or 0, + }) +end + +local box = decode({ attempts = 2 }) +local attempts: number = box.attempts +return tostring(attempts) +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_NestedConfigBuilderKeepsPreciseMapAndArrayShapes(t *testing.T) { + source := ` +type Plugin = {id: string, enabled: boolean, config: {[string]: any}} +type Pipeline = {name: string, plugins: {Plugin}, env: {[string]: string}} + +local function add_plugin(pipeline: Pipeline, plugin: Plugin): Pipeline + table.insert(pipeline.plugins, plugin) + return pipeline +end + +local function enable_defaults(pipeline: Pipeline, defaults: {[string]: string}?): Pipeline + for key, value in pairs(defaults or {}) do + pipeline.env[key] = value + end + return add_plugin(pipeline, { + id = "logger", + enabled = true, + config = { level = pipeline.env.LOG_LEVEL or "info" }, + }) +end + +local pipeline = enable_defaults({ + name = "deploy", + plugins = {}, + env = { LOG_LEVEL = "debug" }, +}, { REGION = "local" }) + +local first = pipeline.plugins[1] +if not first then + return pipeline.env.REGION +end + +return first.id .. ":" .. tostring(first.config.level) .. ":" .. pipeline.env.REGION +` + assertNoAdvancedStressErrors(t, source) +} + +func TestAdvancedTypeSystem_SoundnessRejectsTruthyStringFallbackToNumber(t *testing.T) { + source := ` +local function expect_number(value: number) + return value + 1 +end + +local options: {timeout: string?} = { timeout = "30s" } +local timeout = options.timeout or 30 +return expect_number(timeout) +` + assertAdvancedStressErrorContains(t, source, "expected number") +} + +func TestAdvancedTypeSystem_SoundnessRejectsMetadataFieldAfterTruthyString(t *testing.T) { + source := ` +local meta: string | {content_type: string} = "" +local artifact = { meta = meta } + +if artifact.meta then + local content_type: string = artifact.meta.content_type + return content_type +end +return "missing" +` + assertAdvancedStressErrorContains(t, source, "cannot assign") +} + +func assertNoAdvancedStressErrors(t *testing.T, source string) { + t.Helper() + result := testutil.Check(source, testutil.WithStdlib()) + if result.HasError() { + t.Fatalf("expected advanced type-system stress case to type-check, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func assertAdvancedStressErrorContains(t *testing.T, source, want string) { + t.Helper() + result := testutil.Check(source, testutil.WithStdlib()) + if !result.HasError() { + t.Fatalf("expected diagnostic containing %q, got no errors", want) + } + messages := strings.Join(testutil.ErrorMessages(result.Diagnostics), " | ") + if !strings.Contains(messages, want) { + t.Fatalf("expected diagnostic containing %q, got: %s", want, messages) + } +} diff --git a/compiler/check/tests/regression/assert_false_discriminant_narrowing_test.go b/compiler/check/tests/regression/assert_false_discriminant_narrowing_test.go index b8a63ef2..41828abe 100644 --- a/compiler/check/tests/regression/assert_false_discriminant_narrowing_test.go +++ b/compiler/check/tests/regression/assert_false_discriminant_narrowing_test.go @@ -1,9 +1,12 @@ package regression import ( + "strings" "testing" "github.com/wippyai/go-lua/compiler/check/tests/testutil" + "github.com/wippyai/go-lua/types/typ" + "github.com/wippyai/go-lua/types/typ/unwrap" ) // Reproduces llm test pattern: @@ -49,3 +52,675 @@ contains(response.error_message, "Model is required") t.Fatal("expected no errors for assert-based discriminant narrowing") } } + +func TestRegression_DefaultedAnyFieldDoesNotSilentlyAdoptFallbackType(t *testing.T) { + source := ` +local info = nil :: any +local error_message = info.message or "fallback" + +local function needs_string(value: string) + return value +end + +needs_string(error_message) +` + result := testutil.Check(source, testutil.WithStdlib()) + if !result.HasError() { + t.Fatal("expected defaulted any field to remain dynamic, not become string") + } + found := false + for _, msg := range testutil.ErrorMessages(result.Diagnostics) { + if strings.Contains(msg, "expected string, got any") { + found = true + break + } + } + if !found { + t.Fatalf("expected any-to-string diagnostic, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_ImportedAssertFalseDiscriminantNarrowing(t *testing.T) { + testMod := testutil.CheckAndExport(` +local test = {} + +function test.is_false(val: any, msg: string?) + if val ~= false then + error(msg or "expected false") + end +end + +function test.contains(str: any, substr: string, msg: string?): string + if type(str) ~= "string" or not string.find(str, substr, 1, true) then + error(msg or "expected contains") + end + return str +end + +return test +`, "test_mod", testutil.WithStdlib()) + if testMod.HasError() { + t.Fatalf("unexpected test module errors: %v", testutil.ErrorMessages(testMod.Errors)) + } + + containsField := unwrap.Record(testMod.Manifest.Export).GetField("contains") + if containsField == nil { + t.Fatal("expected exported contains function") + } + containsFn := unwrap.Function(containsField.Type) + if containsFn == nil || len(containsFn.Params) == 0 || !typ.TypeEquals(containsFn.Params[0].Type, typ.Any) { + t.Fatalf("contains first param = %v, want any", containsField.Type) + } + if summary, ok := testMod.Manifest.LookupSummary("contains"); ok && summary != nil && len(summary.Params) > 0 { + if !typ.TypeEquals(summary.Params[0], typ.Any) { + t.Fatalf("contains summary first param = %v, want any", summary.Params[0]) + } + } + + producer := testutil.CheckAndExport(` +local M = {} + +function M.handler() + if true then + return { + success = false, + error = "invalid_request", + error_message = "Model is required" + } + end + return { + success = true, + result = { content = "ok" } + } +end + +return M +`, "producer", testutil.WithStdlib()) + if producer.HasError() { + t.Fatalf("unexpected producer errors: %v", testutil.ErrorMessages(producer.Errors)) + } + + source := ` +local tests = require("test_mod") +local producer = require("producer") + +local response = producer.handler() +tests.is_false(response.success) +tests.contains(response.error_message, "Model is required") +` + result := testutil.Check(source, + testutil.WithStdlib(), + testutil.WithModule("test_mod", testMod), + testutil.WithModule("producer", producer), + ) + if result.HasError() { + t.Fatalf("expected imported assert false to narrow discriminant, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_ImportedDiscriminantThroughMultivalueHelper(t *testing.T) { + testMod := testutil.CheckAndExport(` +local test = {} + +function test.is_false(val: any, msg: string?) + if val ~= false then + error(msg or "expected false") + end +end + +function test.contains(str: string, substr: string, msg: string?): string + if type(str) ~= "string" or not string.find(str, substr, 1, true) then + error(msg or "expected contains") + end + return str +end + +return test +`, "test_mod", testutil.WithStdlib()) + if testMod.HasError() { + t.Fatalf("unexpected test module errors: %v", testutil.ErrorMessages(testMod.Errors)) + } + + mapperMod := testutil.CheckAndExport(` +local mapper = {} + +local function map_error_type(_status_code, message) + if message then + local _lower = message:lower() + end + return "invalid_request" +end + +function mapper.map_error_response(info) + local error_message = info.message or "fallback" + local error_type = map_error_type(info.status_code, error_message) + return { + success = false, + error = error_type, + error_message = error_message, + metadata = {} + }, { message = error_message } +end + +function mapper.map_success_response(_response) + return { + success = true, + result = { content = "ok" }, + metadata = {} + } +end + +return mapper +`, "mapper_mod", testutil.WithStdlib()) + if mapperMod.HasError() { + t.Fatalf("unexpected mapper errors: %v", testutil.ErrorMessages(mapperMod.Errors)) + } + + generateMod := testutil.CheckAndExport(` +local mapper = require("mapper_mod") + +local generate = { + _mapper = mapper, +} + +function generate.handler(args) + if args.bad then + return generate._mapper.map_error_response({ + message = "bad request", + status_code = 400, + }) + end + if args.remote_bad then + local response = args.response + return generate._mapper.map_error_response(response) + end + return generate._mapper.map_success_response({}) +end + +return generate +`, "generate_mod", testutil.WithStdlib(), testutil.WithModule("mapper_mod", mapperMod)) + if generateMod.HasError() { + t.Fatalf("unexpected generate errors: %v", testutil.ErrorMessages(generateMod.Errors)) + } + + source := ` +local tests = require("test_mod") +local generate = require("generate_mod") + +local response = generate.handler({ bad = true }) +tests.is_false(response.success) +tests.contains(response.error_message, "bad request") +` + result := testutil.Check(source, + testutil.WithStdlib(), + testutil.WithModule("test_mod", testMod), + testutil.WithModule("generate_mod", generateMod), + ) + if result.HasError() { + t.Fatalf("expected imported multivalue helper result to narrow by success=false, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_BDDCallbackLocalImportedDiscriminant(t *testing.T) { + testMod := testutil.CheckAndExport(` +local test = { _cases = {} } + +function test.is_false(val: any, msg: string?) + if val ~= false then + error(msg or "expected false") + end +end + +function test.is_true(val: any, msg: string?) + if val ~= true then + error(msg or "expected true") + end +end + +function test.contains(str: string, substr: string, msg: string?): string + if type(str) ~= "string" or not string.find(str, substr, 1, true) then + error(msg or "expected contains") + end + return str +end + +function test.describe(_name: string, fn: fun()) + fn() +end + +function test.it(_name: string, fn: fun()) + table.insert(test._cases, fn) +end + +function test.run_cases(define_cases_fn: fun()) + return function() + _G.describe = test.describe + _G.it = test.it + define_cases_fn() + _G.describe = nil + _G.it = nil + end +end + +return test +`, "test_mod", testutil.WithStdlib()) + if testMod.HasError() { + t.Fatalf("unexpected test module errors: %v", testutil.ErrorMessages(testMod.Errors)) + } + + mapperMod := testutil.CheckAndExport(` +local mapper = {} + +local function map_error_type(_status_code, message) + if message then + local _lower = message:lower() + end + return "invalid_request" +end + +function mapper.map_error_response(info) + local error_message = info.message or "fallback" + local error_type = map_error_type(info.status_code, error_message) + return { + success = false, + error = error_type, + error_message = error_message, + metadata = {} + }, { message = error_message } +end + +function mapper.map_success_response(_response) + return { + success = true, + result = { content = "ok" }, + metadata = {} + } +end + +return mapper +`, "mapper_mod", testutil.WithStdlib()) + if mapperMod.HasError() { + t.Fatalf("unexpected mapper errors: %v", testutil.ErrorMessages(mapperMod.Errors)) + } + + generateMod := testutil.CheckAndExport(` +local mapper = require("mapper_mod") + +local generate = { + _mapper = mapper, +} + +function generate.handler(args) + if args.bad then + return generate._mapper.map_error_response({ + message = "bad request", + status_code = 400, + }) + end + if args.remote_bad then + local response = args.response + return generate._mapper.map_error_response(response) + end + return generate._mapper.map_success_response({}) +end + +return generate +`, "generate_mod", testutil.WithStdlib(), testutil.WithModule("mapper_mod", mapperMod)) + if generateMod.HasError() { + t.Fatalf("unexpected generate errors: %v", testutil.ErrorMessages(generateMod.Errors)) + } + + source := ` +local tests = require("test_mod") +local generate = require("generate_mod") + +local function define_tests() + describe("generate", function() + it("error response", function() + generate._mapper = { + map_error_response = function(info) + return { + success = false, + error = "invalid_request", + error_message = info.message, + metadata = {} + } + end, + map_success_response = function() + return { + success = true, + result = { content = "ok" }, + metadata = {} + } + end, + } + + local response = generate.handler({ bad = true }) + tests.is_false(response.success) + tests.contains(response.error_message, "bad request") + end) + + it("success response", function() + generate._mapper = { + map_error_response = function(info) + return { + success = false, + error = "invalid_request", + error_message = info.message, + metadata = {} + } + end, + map_success_response = function() + return { + success = true, + result = { content = "ok" }, + metadata = {} + } + end, + } + + local response = generate.handler({}) + tests.is_true(response.success) + end) + end) +end + +return tests.run_cases(define_tests) +` + result := testutil.Check(source, + testutil.WithStdlib(), + testutil.WithModule("test_mod", testMod), + testutil.WithModule("generate_mod", generateMod), + ) + if result.HasError() { + t.Fatalf("expected BDD callback-local imported discriminant to narrow, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_ImportedHandlerUsesVisibleMapperOverrideContract(t *testing.T) { + testMod := testutil.CheckAndExport(` +local test = { _cases = {} } + +function test.is_false(val: any, msg: string?) + if val ~= false then + error(msg or "expected false") + end +end + +function test.contains(str: string, substr: string, msg: string?): string + if type(str) ~= "string" or not string.find(str, substr, 1, true) then + error(msg or "expected contains") + end + return str +end + +function test.describe(_name: string, fn: fun()) + fn() +end + +function test.it(_name: string, fn: fun()) + table.insert(test._cases, fn) +end + +function test.run_cases(define_cases_fn: fun()) + return function() + _G.describe = test.describe + _G.it = test.it + define_cases_fn() + _G.describe = nil + _G.it = nil + end +end + +return test +`, "test_mod", testutil.WithStdlib()) + if testMod.HasError() { + t.Fatalf("unexpected test module errors: %v", testutil.ErrorMessages(testMod.Errors)) + } + + mapperMod := testutil.CheckAndExport(` +local mapper = {} + +function mapper.map_error_response(error_info) + local error_message = error_info.message or "Google API error" + return { + success = false, + error = "server_error", + error_message = error_message, + metadata = {} + } +end + +return mapper +`, "mapper_mod", testutil.WithStdlib()) + if mapperMod.HasError() { + t.Fatalf("unexpected mapper errors: %v", testutil.ErrorMessages(mapperMod.Errors)) + } + + contractMod := testutil.CheckAndExport(` +local contract = {} + +function contract.get(_id) + return nil, "not found" +end + +return contract +`, "contract_mod", testutil.WithStdlib()) + if contractMod.HasError() { + t.Fatalf("unexpected contract errors: %v", testutil.ErrorMessages(contractMod.Errors)) + } + + generateMod := testutil.CheckAndExport(` +local mapper = require("mapper_mod") +local contract = require("contract_mod") + +local generate = { + _mapper = mapper, + _contract = contract, +} + +function generate.handler(args) + if not args.model then + return generate._mapper.map_error_response({ + message = "Model is required", + status_code = 400, + }) + end + + local _, err = generate._contract.get("client") + if err then + return generate._mapper.map_error_response({ + message = "Failed to get client contract: " .. tostring(err), + status_code = 500, + }) + end + + return { success = true } +end + +return generate +`, "generate_mod", testutil.WithStdlib(), + testutil.WithModule("mapper_mod", mapperMod), + testutil.WithModule("contract_mod", contractMod)) + if generateMod.HasError() { + t.Fatalf("unexpected generate errors: %v", testutil.ErrorMessages(generateMod.Errors)) + } + + source := ` +local tests = require("test_mod") +local generate = require("generate_mod") + +local function define_tests() + describe("generate", function() + it("contract failure", function() + generate._mapper = { + map_error_response = function(error_info) + return { + success = false, + error = "server_error", + error_message = error_info.message, + metadata = {} + } + end + } + + generate._contract = { + get = function(_contract_id) + return nil, "Contract not found" + end + } + + local response = generate.handler({ + model = "gemini-1.5-pro", + messages = { + { role = "user", content = {{ type = "text", text = "Test" }} } + } + }) + + tests.is_false(response.success) + tests.contains(response.error_message, "Failed to get client contract") + end) + end) +end + +return tests.run_cases(define_tests) +` + result := testutil.Check(source, + testutil.WithStdlib(), + testutil.WithModule("test_mod", testMod), + testutil.WithModule("generate_mod", generateMod), + ) + if result.HasError() { + t.Fatalf("expected visible mapper override contract to prove error_message, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_ErrorMapperInfersDefaultedMessageField(t *testing.T) { + mapperMod := testutil.CheckAndExport(` +local output = { + ERROR_TYPE = { + SERVER_ERROR = "server_error", + }, + to_structured_error = function(_response) + return nil + end, +} + +local mapper = {} + +local function map_error_type(_status_code, message) + if message then + local lower_msg = message:lower() + if lower_msg:match("timeout") then + return "timeout" + end + end + return output.ERROR_TYPE.SERVER_ERROR +end + +function mapper.map_error_response(google_error) + if not google_error then + local response = { + success = false, + error = output.ERROR_TYPE.SERVER_ERROR, + error_message = "Unknown Google error", + metadata = {} + } + return response, output.to_structured_error(response) + end + + local error_message = google_error.message or "Google API error" + local error_type = map_error_type(google_error.status_code, error_message) + + local response = { + success = false, + error = error_type, + error_message = error_message, + metadata = google_error.metadata or {} + } + return response, output.to_structured_error(response) +end + +return mapper +`, "mapper_mod", testutil.WithStdlib()) + if mapperMod.HasError() { + t.Fatalf("unexpected mapper errors: %v", testutil.ErrorMessages(mapperMod.Errors)) + } + + field := unwrap.Record(mapperMod.Manifest.Export).GetField("map_error_response") + if field == nil { + t.Fatal("expected exported map_error_response") + } + fn := unwrap.Function(field.Type) + if fn == nil || len(fn.Returns) == 0 { + t.Fatalf("expected function return, got %v", field.Type) + } + rec := unwrap.Record(fn.Returns[0]) + if rec == nil { + t.Fatalf("expected record return, got %v", fn.Returns[0]) + } + errMsg := rec.GetField("error_message") + if errMsg == nil || !typ.TypeEquals(errMsg.Type, typ.String) { + t.Fatalf("error_message = %v, want string in %v", errMsg, fn.Returns[0]) + } +} + +func TestRegression_PartialRecordParameterEvidenceBecomeOptionalFields(t *testing.T) { + source := ` +local mapper = {} + +function mapper.map_tokens(usage) + if not usage then + return nil + end + return { + prompt_tokens = usage.promptTokenCount or 0, + completion_tokens = usage.candidatesTokenCount or 0, + total_tokens = usage.totalTokenCount or 0, + thinking_tokens = usage.thoughtsTokenCount + } +end + +mapper.map_tokens({ promptTokenCount = 10 }) +mapper.map_tokens({ candidatesTokenCount = 20 }) +mapper.map_tokens({ totalTokenCount = 30 }) +mapper.map_tokens({ thoughtsTokenCount = 40 }) +` + result := testutil.Check(source, testutil.WithStdlib()) + if result.HasError() { + t.Fatalf("expected partial record parameter observations to form optional fields, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + +func TestRegression_NestedPartialRecordParameterEvidenceBecomeOptionalFields(t *testing.T) { + source := ` +local mapper = {} + +function mapper.map_tokens(usage) + if not usage then + return nil + end + return { + prompt_tokens = usage.promptTokenCount or 0, + completion_tokens = usage.candidatesTokenCount or 0, + total_tokens = usage.totalTokenCount or 0, + thinking_tokens = usage.thoughtsTokenCount + } +end + +function mapper.map_success_response(response) + return { + tokens = mapper.map_tokens(response.usageMetadata) + } +end + +mapper.map_success_response({ usageMetadata = { promptTokenCount = 10 } }) +mapper.map_success_response({ usageMetadata = { candidatesTokenCount = 20 } }) +mapper.map_success_response({ usageMetadata = { totalTokenCount = 30 } }) +mapper.map_success_response({ usageMetadata = { thoughtsTokenCount = 40 } }) +` + result := testutil.Check(source, testutil.WithStdlib()) + if result.HasError() { + t.Fatalf("expected partial record parameter observations to form optional fields, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} diff --git a/compiler/check/tests/regression/channel_select_helper_return_narrowing_test.go b/compiler/check/tests/regression/channel_select_helper_return_narrowing_test.go index 56c3a1af..1fa0f8e2 100644 --- a/compiler/check/tests/regression/channel_select_helper_return_narrowing_test.go +++ b/compiler/check/tests/regression/channel_select_helper_return_narrowing_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/wippyai/go-lua/compiler/check/tests/testutil" + "github.com/wippyai/go-lua/types/contract" + "github.com/wippyai/go-lua/types/effect" "github.com/wippyai/go-lua/types/io" "github.com/wippyai/go-lua/types/narrow" "github.com/wippyai/go-lua/types/typ" @@ -23,7 +25,7 @@ func TestChannelSelectHelperReturnNarrowing(t *testing.T) { {Name: "unix", Type: typ.Func().Param("self", typ.Self).Returns(typ.Integer).Build()}, }) - chManifest := testutil.ChannelManifest() + chManifest := channelManifestWithReturnEffect() channelGen, ok := chManifest.LookupType("Channel") if !ok { t.Fatal("missing channel.Channel generic") @@ -85,18 +87,18 @@ end root := sess.RootResult.Graph parentHash := sess.Store.GraphParentHashOf(root.ID()) parent := sess.Store.Parents()[parentHash] - funcTypes := sess.Store.GetLocalFuncTypesSnapshot(root, parent) + functionFacts := sess.Store.GetInterprocFacts(root, parent).FunctionFacts var helperFn *typ.Function - for sym, tpe := range funcTypes { + for sym, fact := range functionFacts { if root.NameOf(sym) != "wait_for_exit" { continue } - helperFn = unwrap.Function(tpe) + helperFn = unwrap.Function(fact.Type) break } if helperFn == nil || len(helperFn.Returns) == 0 { - t.Fatalf("missing wait_for_exit function type in local func snapshot: %v", funcTypes) + t.Fatalf("missing wait_for_exit function type in FunctionFacts: %v", functionFacts) } nonNil := narrow.RemoveNil(helperFn.Returns[0]) @@ -105,6 +107,147 @@ end } } +func channelManifestWithReturnEffect() *io.Manifest { + m := io.NewManifest("channel") + + selectCaseType := typ.NewInterface("channel.SelectCase", nil) + selectCaseChannel := typ.NewTypeParam("C", nil) + selectCaseValue := typ.NewTypeParam("T", nil) + selectCaseGeneric := typ.NewGeneric("channel.SelectCase", []*typ.TypeParam{selectCaseChannel, selectCaseValue}, selectCaseType) + + channelElem := typ.NewTypeParam("T", nil) + channelType := typ.NewInterface("channel.Channel", []typ.Method{ + { + Name: "case_receive", + Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.Instantiate(selectCaseGeneric, typ.Self, channelElem)). + Build(), + }, + }) + channelGeneric := typ.NewGeneric("channel.Channel", []*typ.TypeParam{channelElem}, channelType) + + selectResultType := typ.NewRecord(). + Field("channel", typ.Any). + Field("value", typ.Unknown). + Field("ok", typ.Boolean). + OptField("default", typ.Boolean). + Build() + + m.DefineType("Channel", channelGeneric) + m.DefineType("SelectCase", selectCaseGeneric) + m.DefineType("SelectResult", selectResultType) + + selectFunc := typ.Func(). + Param("cases", typ.Any). + OptParam("default", typ.Boolean). + Returns(selectResultType). + Spec(contract.NewSpec().WithEffects(effect.Return{ + ReturnIndex: 0, + Transform: effect.SelectResultOfCases{ + Cases: effect.ParamRef{Index: 0}, + Default: effect.ParamRef{Index: 1}, + }, + })). + Build() + + m.SetExport(typ.NewInterface("channel", []typ.Method{ + {Name: "select", Type: selectFunc}, + })) + return m +} + +// Regression guard for temporal wait helpers that test event.from before +// event.kind. The timeout branch return must exclude the time channel before +// field diagnostics validate the loop body condition. +func TestChannelSelectHelperReturnNarrowingAllowsEventFromFirstCondition(t *testing.T) { + eventRecordType := typ.NewRecord(). + Field("kind", typ.String). + OptField("from", typ.String). + OptField("result", typ.Any). + Build() + eventMethodsType := typ.NewInterface("process.EventMethods", []typ.Method{ + {Name: "payload", Type: typ.Func(). + Param("self", typ.Self). + Returns(typ.NewOptional(typ.Any)). + Build()}, + }) + eventType := typ.NewAlias("process.Event", typ.NewIntersection(eventRecordType, eventMethodsType)) + timeType := typ.NewInterface("time.Time", []typ.Method{ + {Name: "unix", Type: typ.Func().Param("self", typ.Self).Returns(typ.Integer).Build()}, + }) + + chManifest := channelManifestWithReturnEffect() + channelGen, ok := chManifest.LookupType("Channel") + if !ok { + t.Fatal("missing channel.Channel generic") + } + channelGeneric, ok := channelGen.(*typ.Generic) + if !ok { + t.Fatalf("channel.Channel is not generic: %T", channelGen) + } + eventChannelType := typ.Instantiate(channelGeneric, eventType) + timeChannelType := typ.Instantiate(channelGeneric, timeType) + + processManifest := io.NewManifest("process") + processManifest.SetExport(typ.NewIntersection( + typ.NewInterface("process", []typ.Method{ + {Name: "events", Type: typ.Func().Returns(eventChannelType).Build()}, + }), + typ.NewRecord(). + Field("event", typ.NewRecord().Field("EXIT", typ.String).Build()). + Build(), + )) + + timeManifest := io.NewManifest("time") + timeManifest.SetExport(typ.NewInterface("time", []typ.Method{ + {Name: "after", Type: typ.Func().Param("duration", typ.String).Returns(timeChannelType).Build()}, + })) + + source := ` +local time = require("time") + +local function wait_for_exit(events_ch, pid, timeout) + local deadline = time.after(timeout or "10s") + while true do + local result = channel.select { + events_ch:case_receive(), + deadline:case_receive(), + } + if result.channel == deadline then + return nil, "timeout waiting for exit" + end + local event = result.value + local event_kind: string = event.kind + local event_from: string? = event.from + if event.from == pid and event.kind == process.event.EXIT then + return event, nil + end + end +end + +local events_ch = process.events() +local event, err = wait_for_exit(events_ch, "pid", "10s") +if err ~= nil then + return false +end +if event == nil then + return false +end +return event.kind +` + + result := testutil.Check(source, + testutil.WithStdlib(), + testutil.WithManifest("channel", chManifest), + testutil.WithManifest("process", processManifest), + testutil.WithManifest("time", timeManifest), + ) + if result.HasError() { + t.Fatalf("expected no errors for event.from-first select narrowing, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } +} + // Regression guard for temporal workflow pattern: // helper loops over channel.select(Event|Time), accumulates exits[event.from] = event, // returns the table, then caller indexes entries and accesses sender_exit.result.value. diff --git a/compiler/check/tests/regression/contract_open_dynamic_return_test.go b/compiler/check/tests/regression/contract_open_dynamic_return_test.go index 4017e6b5..401fc399 100644 --- a/compiler/check/tests/regression/contract_open_dynamic_return_test.go +++ b/compiler/check/tests/regression/contract_open_dynamic_return_test.go @@ -3,6 +3,7 @@ package regression import ( "testing" + "github.com/wippyai/go-lua/compiler/check/domain/functionfact" "github.com/wippyai/go-lua/compiler/check/tests/testutil" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/io" @@ -83,15 +84,16 @@ end root := result.Session.RootResult.Graph parentHash := result.Session.Store.GraphParentHashOf(root.ID()) parent := result.Session.Store.Parents()[parentHash] - funcTypes := result.Session.Store.GetLocalFuncTypesSnapshot(root, parent) + functionFacts := result.Session.Store.GetInterprocFacts(root, parent).FunctionFacts sym, ok := root.SymbolAt(root.Exit(), "get_tracker") if !ok || sym == 0 { t.Fatal("missing symbol get_tracker") } - fn := unwrap.Function(funcTypes[sym]) + functionType := functionfact.TypeFromMap(functionFacts, sym) + fn := unwrap.Function(functionType) if fn == nil || len(fn.Returns) == 0 || fn.Returns[0] == nil { - t.Fatalf("expected get_tracker function return type, got %v", funcTypes[sym]) + t.Fatalf("expected get_tracker function return type, got %v", functionType) } if fn.Returns[0].Kind() == kind.Nil { t.Fatalf("expected get_tracker return not to collapse to nil, got %v", fn.Returns[0]) diff --git a/compiler/check/tests/regression/exhaustiveness_warning_test.go b/compiler/check/tests/regression/exhaustiveness_warning_test.go new file mode 100644 index 00000000..f48bee8c --- /dev/null +++ b/compiler/check/tests/regression/exhaustiveness_warning_test.go @@ -0,0 +1,244 @@ +package regression + +import ( + "strings" + "testing" + + "github.com/wippyai/go-lua/compiler/check/tests/testutil" + "github.com/wippyai/go-lua/types/diag" +) + +func TestExhaustivenessWarning_DiscriminatedUnionMissingVariant(t *testing.T) { + source := ` +type Message = {kind: "message", text: string} +type Tool = {kind: "tool", name: string} +type Timeout = {kind: "timeout", at: number} +type Event = Message | Tool | Timeout + +local function render(event: Event): string + if event.kind == "message" then + return event.text + elseif event.kind == "tool" then + return event.name + end + return "unknown" +end + +return render({kind = "message", text = "hi"}) +` + + result := testutil.Check(source) + if result.HasError() { + t.Fatalf("expected no errors, got: %v", testutil.ErrorMessages(result.Diagnostics)) + } + assertNonExhaustiveWarning(t, result.Diagnostics, "event.kind", `"timeout"`) +} + +func TestExhaustivenessWarning_ChannelSelectMissingCase(t *testing.T) { + source := ` +type Event = {kind: string} +type Stop = {reason: string} +type Time = {sec: number, nsec: number} + +local function handle(events_ch: Channel, stop_ch: Channel, timeout_ch: Channel