From 6875e871974522e15198532716539aa020c5760a Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:40:28 +0200 Subject: [PATCH 1/3] feat(runtime): resume helper class effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../spec.md | 318 ++++++++++++ docs/kern-5-release-train.md | 22 + .../deferred-expression-preflight.ts | 26 +- .../internal-effect-machine-class-frame.ts | 4 + .../internal-effect-machine-class-graph.ts | 2 +- ...internal-effect-machine-class-preflight.ts | 3 + ...rnal-effect-machine-class-value-runtime.ts | 111 ++++- .../internal-effect-machine-class-value.ts | 62 ++- ...ffect-machine-helper-argument-preflight.ts | 30 ++ .../internal-effect-machine-helper-class.ts | 56 ++- ...internal-effect-machine-helper-contract.ts | 6 + .../internal-effect-machine-helper-graph.ts | 39 +- ...nternal-effect-machine-helper-preflight.ts | 2 + .../internal-effect-machine-helper-runtime.ts | 96 +++- ...internal-effect-machine-structure-state.ts | 29 ++ .../internal-effect-machine-structure.ts | 15 +- .../internal-effect-machine-types.ts | 1 + .../ir/semantics/internal-effect-machine.ts | 4 +- .../runner-capability-class-frame.test.ts | 6 +- ...ffect-machine-class-helper-effects.test.ts | 463 ++++++++++++++++++ ...hine-class-helper-reverse-boundary.test.ts | 11 +- ...ffect-machine-class-helper-reverse.test.ts | 22 +- scripts/check-source-runner-convergence.mjs | 9 +- ...source-runner-class-helper-convergence.mjs | 101 +++- ...e-runner-class-helper-convergence.test.mjs | 95 +++- .../source-runner-convergence-manifest.json | 10 +- scripts/source-runner-convergence.test.mjs | 4 + 27 files changed, 1434 insertions(+), 113 deletions(-) create mode 100644 .Codex/specs/kern-5-r2-m3-31b2c2-resumable-helper-class-effects/spec.md create mode 100644 packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts create mode 100644 packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts create mode 100644 packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts diff --git a/.Codex/specs/kern-5-r2-m3-31b2c2-resumable-helper-class-effects/spec.md b/.Codex/specs/kern-5-r2-m3-31b2c2-resumable-helper-class-effects/spec.md new file mode 100644 index 000000000..b790713cf --- /dev/null +++ b/.Codex/specs/kern-5-r2-m3-31b2c2-resumable-helper-class-effects/spec.md @@ -0,0 +1,318 @@ +# KERN 5 R2 M3.31b2c2 — Resumable Helper-to-Class Effects + +**Status:** DONE +**Date:** 2026-07-17 +**Confidence:** 0.99 + +## Executive Summary + +M3.31b2c2 lets a same-root helper construct or call an owned class whose +selected constructor, method, or getter performs effects. The canonical source +machine must suspend inside that reached class frame, resume exactly once, and +return the helper's declared portable scalar without replaying providers or +leaking helper-local/class-private state. + +The boundary stays narrow. Direct `capability` and `print` nodes in helpers +remain outside the pure-helper language. Existing pure helpers keep their +synchronous trampoline and bounded memoization. Only helpers proven by the +frozen same-root graph to compose with classes enter the new generator frame. +Imported/cross-module class or helper identity remains M3.31c. + +## Current State and Root Cause + +- **VERIFIED:** helper calls are prepared synchronously and executed with one + `.next()`; any yielded provider request is rejected as `produced side + effects`. Evidence: + `packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts`. +- **VERIFIED:** helper results are memoized by helper name, portable arguments, + and integer provenance; the cache is bounded at 1024 entries and pure nested + helpers use a 512-frame trampoline. Evidence: + `internal-effect-machine-helper-runtime.ts`. +- **VERIFIED:** direct helper bodies exclude `capability`, `lambda`, `print`, + and `try`. This remains the direct-helper contract. Evidence: + `packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts`. +- **VERIFIED:** reverse helper-to-class analysis already proves exact + helper-local construction, selected constructor/method/getter reachability, + scalar returns, private receiver containment, and same-root class identity, + but explicitly rejects reached class `capability` and `print` nodes. + Evidence: + `packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts`. +- **VERIFIED:** class constructor, method, and getter evaluators are generators + over the shared machine state and body runner; they already preserve provider + continuation, class snapshot, call stack, and iteration budget. Evidence: + `packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts`. +- **VERIFIED:** class-owned `let`, `print`, and `return` scalar slots classify + suspending expressions and route them through the generator evaluator. + Evidence: `internal-effect-machine-class-leaf.ts` and + `internal-effect-machine-class-value-runtime.ts`. +- **VERIFIED:** scalar helper calls currently classify as pure when their + arguments and declared return contract are portable; that forces the sync + evaluator even when the helper reaches an effectful class. Evidence: + `internal-effect-machine-class-value.ts`. +- **VERIFIED:** whole-class preflight traverses the frozen class registry before + provider dispatch, and capability planning already follows helper-local class + construction and selected members. The current unsupported result is caused + by machine admission rejecting the helper/class graph, not by missing planner + reachability. Evidence: `internal-effect-machine-class-preflight.ts`, + `source-runner-admission.ts`, and `runner-capability-plan.ts`. +- **VERIFIED:** `brainstorm-1784263683238-80zv1i-m3-31b2c2-resumable-helper-class` + completed 3/3 with `claude,codex,agy`. Its winning recommendation requires a + transitive resumable-helper closure and authored-order nested resumable + arguments while retaining event-aware body caching. +- **VERIFIED:** live `origin/main` is `d11fb900`; PR #536 is open and contains + the three stacked M3.31b2 commits through `081a902e`. M3.31b2c2 therefore + stays on the existing branch until the final pre-push refresh. + +## Contract + +| Behavior | Contract | Tag | +| --- | --- | --- | +| Direct helpers | Direct helper `capability`, `print`, `lambda`, and `try` remain rejected | VERIFIED | +| Resumable selection | Frozen same-root helpers that compose with classes, plus every helper transitively calling one, enter the generator helper path | VERIFIED | +| Pure helpers | Helpers without class composition keep the existing sync trampoline, recursion limit, cache key, and diagnostics | VERIFIED | +| Class effects | Reached same-root constructor/method/getter `capability` and `print` may suspend or emit trace events | VERIFIED | +| Result | The resumable helper must complete with its declared portable scalar return | VERIFIED | +| Arguments | Portable scalar/record/array arguments and nested pure-helper arguments remain supported; private receivers are forbidden | VERIFIED | +| Nested resumable arguments | Resumable helper arguments evaluate left-to-right before the outer helper body/cache lookup | VERIFIED | +| Trace | Propagate only observable `stdout`, `stderr`, and `capability` events across the helper boundary; helper-local assign/iteration trace stays private | VERIFIED | +| Cache | Cache a completed helper body only when that body produced no observable events; argument effects always run before outer cache lookup | VERIFIED | +| Replay | A provider request, result, failure, or print is observed once; resumption continues the same generator frame | VERIFIED | +| Preflight | Unsupported graph, slot, arity, result, receiver, or module identity rejects before any provider call | VERIFIED | +| Snapshot | Helper/class bodies, members, lineage, metadata, and registries are frozen before first suspension | VERIFIED | +| Concurrency | Overlapping runs isolate generator, state, cache, receiver, bindings, budget, seed, time, and provider results | VERIFIED | +| Planner | Reached class effects become executable when runtime admission owns the frame; no planner-only suppression is allowed | VERIFIED | +| Modules | Imported, re-exported, aliased, ambiguous, or cross-module identity remains M3.31c | VERIFIED | +| ABI | No public runtime, handler, capability, helper, or class ABI changes | VERIFIED | + +## Selected Design + +### 1. Graph-owned resumable helper identity + +Extend helper/class composition analysis to return both the existing scalar +return-node proof and whether the helper uses an admitted class. Build a helper +call graph from the frozen snapshots, seed helpers that compose with a class, +then propagate resumability to every calling helper with a monotone worklist. +Store the closure as a frozen `resumableHelperNames` set on the per-run internal +machine state. Helper classification consults this set; ordinary helpers with +pure arguments remain on the current synchronous path. + +This avoids routing every helper through generators and preserves the mature +pure-helper recursion/cache path. + +### 2. Generator helper frame + +Add a generator entry point beside the synchronous helper evaluator. Its +argument evaluator handles scalar, array, and record shapes recursively and +enters nested resumable scalar helper calls in left-to-right authored order. It +then reuses the existing arity/depth checks, integer provenance, frozen helper +registry, environment construction, state binding, and cache key. + +Argument effects occur before cache lookup and are always returned to the +caller. On a body-cache miss it `yield*` delegates the helper body to the +existing canonical body runner. It validates normal completion and the declared +portable scalar result, filters body-internal trace, conditionally remembers an +observable-event-free body completion, and returns the argument events plus +body events/value to the class scalar evaluator. + +### 3. Existing class scalar continuation + +The class scalar classifier marks a helper call suspending when the callee is in +the resumable closure or any argument is suspending. The runtime recognizes +that helper call before attempting method dispatch and enters the generator +helper frame. Recursive expression owners already concatenate events for +binary, conditional, unary, template, type assertion, and non-null expressions, +so helper effects remain in authored order inside those slots. + +### 4. Admission relaxation, not language relaxation + +Rename the reverse analyzer's purity terminology to reached-frame terminology +and remove only its rejection of effects inside the selected class frames. All +existing exact receiver, member, construction, scalar shape, graph snapshot, +and module checks remain. Whole-class preflight stays the authority for effect +shape and before-provider rejection. + +## Alternatives + +### A. Route every helper body through the generator + +Rejected. Only calls that are themselves resumable or have resumable arguments +need the generator entry point. Pure-helper bodies retain the proven trampoline, +512-depth recursion, and memoization. + +### B. Cache effectful helper results after first completion + +Rejected. Identical calls must execute observable providers and prints each +time. Memoizing them changes language semantics. + +### C. Disable the helper cache for all class-composing helpers + +Possible but not selected. Pure helper-to-class composition is already safely +memoized. Observable-event-aware caching preserves that behavior while +preventing effect elision. + +### D. Put continuation logic in the compatibility runtime + +Rejected. The release goal is canonical machine ownership; compatibility is +not an acceptable executor for this newly admitted path. + +## Blast Radius + +| File | Action | Reason | +| --- | --- | --- | +| `internal-effect-machine-helper-class.ts` | modify | classify admitted class composition and allow reached class effects | +| `internal-effect-machine-helper-graph.ts` | modify | compute transitive resumable helper names with scalar-return proof | +| `internal-effect-machine-helper-contract.ts` | modify | query frozen resumable helper identity | +| `internal-effect-machine-helper-runtime.ts` plus focused module if needed | modify/add | prepare from evaluated values and add generator body execution below 500 lines | +| `internal-effect-machine-class-value.ts` | modify | classify only resumable helper calls as suspending | +| `internal-effect-machine-class-value-runtime.ts` | modify | enter generator helper frame | +| `internal-effect-machine-types.ts` | modify | hold frozen per-run resumable helper names | +| `internal-effect-machine.ts` | modify | install graph result before structure preflight/execution | +| focused runtime/admission/snapshot tests | add | RED/GREEN continuation and containment oracles | +| planner/convergence tests and scripts | extend | bind executable disposition and mutation coverage | +| manifest/release train/spec | update | close M3.31b2c2 and leave M3.31c exact | + +No new logic goes into the already oversized capability planner. All new or +substantially expanded handwritten files remain below 500 lines. + +## Acceptance Criteria + +- [x] RED-at-base proves a root call to a helper whose helper-local class method + invokes a capability is compatibility-selected/rejected by machine-only. +- [x] A reached constructor, method, and getter can each suspend and resume once + through the helper boundary in sync and async source runners. +- [x] Provider and trace order matches authored constructor/method/getter order; + no effect is replayed after resume. +- [x] `print` inside a reached class member is observable and prevents caching. +- [x] Two identical calls to an effectful helper invoke providers twice; two + identical calls to a pure class-composing helper retain safe memoization. +- [x] Helper-local assigns, iteration events, class receiver identity, and + instance fields do not escape the helper boundary. +- [x] Existing scalar, array, record, integer-provenance, and nested pure-helper + arguments retain behavior; nested resumable arguments run left-to-right + exactly once before the outer body/cache lookup. +- [x] A wrapper helper that transitively calls a class-composing helper is + marked resumable even when it has no direct class syntax. +- [x] Existing pure recursive helper depth 512, loop budget, cache bound, + concurrency, seed/time, and diagnostics remain unchanged. +- [x] Unsupported direct helper effects, private receiver transport, instance + returns/reassignment, optional members, non-scalar class results, wrong + arity, and unsupported nesting reject before provider dispatch. +- [x] Invalid class nodes or completion paths reached only through a helper are + found during whole-graph preflight before an earlier root provider runs. +- [x] Mutation after an earlier suspension cannot change helper/class body, + member selection, lineage, arity, return metadata, or provider identity. +- [x] Overlapping async runs isolate continuations, helper cache, class state, + arguments, budget, seed, time, and failures. +- [x] A provider rejection escapes once, runs generator cleanup, and never + retries through compatibility. +- [x] Planner marks reached same-root helper/class effects executable while + retaining imported/cross-module and unsupported-slot findings. +- [x] Convergence kills removal of resumable identity, yield/resume, event + filtering, no-effect cache, preflight, snapshot, and planner ownership. +- [x] M3.31b2c2 becomes current ownership in the manifest; the sole remaining + parent class-state follow-up is M3.31c module identity. +- [x] Focused gates, typecheck, lint, full `pnpm fitness:kern-5`, and terminal + `agon review -e claude,codex,agy` pass with every verified blocker fixed. + +## RED Oracle Matrix + +1. Helper-local class method performs `capability`, then returns a scalar. +2. Effectful constructor feeds a pure method; provider runs before method. +3. Pure constructor plus effectful getter returns through a compound/template + scalar expression in exact trace order. +4. Identical effectful helper calls execute twice; identical pure composition + remains eligible for cache reuse. +5. Nested pure and resumable helper arguments into another helper remain + supported in left-to-right effect order; disabling the transitive closure or + argument generator must fail the oracle. +6. Direct helper capability remains rejected with provider count zero. +7. Unsupported reached member after an earlier root capability keeps provider + count zero, proving global preflight. +8. Async suspension followed by mutation of helper/class registries and member + bodies still returns from the frozen snapshot. +9. Two overlapping async runs return their own values and never share events or + class fields. +10. Capability planner reports executable for the admitted path and unsupported + for the same imported path. + +The oracle must not turn green by disabling helper memoization globally, +re-running a generator from the beginning, exposing private trace/state, +broadening direct helper effects, flattening module identity, suppressing +planner findings without runtime ownership, or falling back to compatibility. + +## Out of Scope + +- Direct effect nodes in helper bodies. +- Composite results from a class method/getter or a class-composing helper in a + class scalar slot. +- Class instances crossing helper parameters, results, or external bindings. +- Setters, static members, streams, async-language syntax, or transactional + rollback of already observed external effects. +- Imported/re-exported/aliased/cross-module helper or class identity (M3.31c). +- Public ABI or compatibility-runtime promotion. + +## Adversarial Decision + +The full-roster challenge accepted observable-event-aware body caching because +helper-local class instances cannot escape and the cache is per run. The cache +lookup occurs only after arguments execute, so argument effects are never +elided. Any body `stdout`, `stderr`, or `capability` event prevents storage. +Wrapper helpers are included through a fixed-point call-graph closure; direct +syntax-only marking is forbidden. + +## Deploy Order + +The user reports PR #536 merged. M3.31b2c2 was developed on its former stacked +branch while the merge was pending. Immediately before the one allowed push: +fetch origin, verify that the exact prior tree is now on main, cut a fresh +M3.31b2c2 branch from live `origin/main`, and replay only this slice. Run a +scoped post-rebase gate, push once with `--no-verify`, and hand over the printed +PR link. If the remote has not yet converged, preserve the stack and rebase it +onto live `origin/main` instead. + +## Verification Receipt + +- `pnpm fitness:kern-5`: PASS. The wall included lint, build, workspace tests, + 432/432 cross-target fixtures, 233 native contracts, 48/48 checker fixtures, + 39/39 validator verdicts, and 40 application fixtures on three legs plus + whole-app boot. +- Required browser budget: PASS at 152 modules, 1,535,195 raw bytes, 328,258 + gzip bytes, 64 ms cold import/execute, and 127 ms median browser + import/execute. The earlier runner-smoke sample also passed at 71 ms cold and + 123 ms median. +- Source-runner convergence: PASS with all 43 mutations killed, including the + resumable-helper closure, generator yield, event-aware caching, nested + composite arguments, resumable array execution, admission/body preflight + closure, frozen registry, and ownership checks. +- Focused helper/class effect oracles: PASS for method, constructor, getter, + wrapper transitivity, nested authored-order arguments, effect-aware cache, + print visibility, pure cache retention, direct-effect rejection, frozen + snapshots, concurrency isolation, and planner admission. +- Terminal `agon review -e claude,codex,agy`: PASS 3/3 with zero verified and + zero needs-check findings + (`review-1784269895309-ubvwed-m3-31b2c2-resumable-helper-class`). Its one + 0.55-confidence speculative classifier concern is disproved because scalar + helper classification returns `suspending` before the fallback branch, as + exercised by the passing nested `decorate(readRemote())` oracle. The + `super.` note describes an explicitly rejected existing boundary; + the remaining Set-allocation and recursive-traversal notes are + non-behavioral nits. + +The initial implementation review +`review-1784266077644-j5lb17-m3-31b2c2-resumable-helper-class` returned 3/3. +One claimed missing `append` binding was disproved by the source. The verified +composite-argument finding exposed three coupled gaps: admission preflight did +not bind the resumable helper closure, helper-body preflight discarded machine +state, and the runtime did not recursively resume array/record descendants. +RED array/record oracles reproduced them; admission state ownership, recursive +argument preflight/runtime evaluation, and three convergence kills close the +boundary. The remaining claimed nested-helper-return gap was disproved because +scalar classification already marks the outer helper suspending. + +## Corrections Log + +| Original Claim | Reality | Impact | +| --- | --- | --- | +| Direct class composition alone determines resumability | Wrapper helpers transitively inherit resumability | Add fixed-point call-graph closure | +| Nested resumable arguments may stay fail-closed | They are required for authored-order composition | Add recursive generator argument evaluation | +| Adversarial challenge pending | 3/3 challenge completed | Spec approved for implementation | diff --git a/docs/kern-5-release-train.md b/docs/kern-5-release-train.md index fa32ac156..bab61e95c 100644 --- a/docs/kern-5-release-train.md +++ b/docs/kern-5-release-train.md @@ -755,6 +755,28 @@ trusted-publishing/provenance configuration is inspected. (`review-1784262755790-138qtd-m3-31b2c1-pre-super-final`). Resumable helper-to-class effects remain M3.31b2c2 and imported/cross-module identity remains M3.31c. + - [x] M3.31b2c2 resumable helper-to-class effects: a same-root helper and any + transitive wrapper may now enter an owned class constructor, method, or + getter that performs a capability or print effect. Nested resumable helper + arguments execute left-to-right exactly once before the outer cache lookup; + observable body events bypass memoization, while pure helper/class bodies + retain the bounded cache. Only stdout, stderr, and capability events cross + the helper boundary, so helper-local assignment/iteration trace and private + class identity remain contained. Frozen helper/class registries preserve + suspended-run and overlapping-run isolation, and the planner now reports + the admitted same-root path executable. Direct helper effects remain + rejected, and imported/re-exported/cross-module identity remains the sole + M3.31c follow-up. The final `pnpm fitness:kern-5` wall passed with 432/432 + cross-target fixtures, 109/109 class fixtures, 233 native cases, 48/48 + checker fixtures, 39/39 validator verdicts, and 40 application fixtures on + three legs plus whole-app boot. The required browser wall passed at 152 + modules / 1,535,195 raw / 328,258 gzip bytes / 64 ms cold / 127 ms median, + and all 43 convergence mutations were killed. The terminal + `claude,codex,agy` review completed 3/3 with zero verified and zero + needs-check findings; its one speculative classifier concern was disproved + by scalar-first classification and the nested-helper oracle, while three + non-behavioral allocation/traversal/explicit-boundary nits were retained + (`review-1784269895309-ubvwed-m3-31b2c2-resumable-helper-class`). 1. Correct the support matrix and make `fitness:kern-5` the planned aggregate, without pretending missing commands already exist. diff --git a/packages/core/src/ir/semantics/deferred-expression-preflight.ts b/packages/core/src/ir/semantics/deferred-expression-preflight.ts index e7fd5967a..8fbe3d1f1 100644 --- a/packages/core/src/ir/semantics/deferred-expression-preflight.ts +++ b/packages/core/src/ir/semantics/deferred-expression-preflight.ts @@ -9,8 +9,12 @@ import { internalMachineClassGetterForRead, internalMachineClassMethodForCall, } from './internal-effect-machine-class-graph.js'; -import { classifyInternalMachineClassScalarValue } from './internal-effect-machine-class-value.js'; +import { + classifyInternalMachineClassHelperArgument, + classifyInternalMachineClassScalarValue, +} from './internal-effect-machine-class-value.js'; import { assertInternalMachineDoNamespaceAvailable, parseInternalMachineDo } from './internal-effect-machine-do.js'; +import { assertDeferredInternalMachineHelperArgument } from './internal-effect-machine-helper-argument-preflight.js'; import { internalMachineHelperCallInValue } from './internal-effect-machine-helper-graph.js'; import { evalDecimalExpression, isDecimalExpression, isDecimalValueExpression } from './portable-decimal-evaluator.js'; import { evalPortableValue } from './portable-machine-evaluator.js'; @@ -353,8 +357,26 @@ function assertDeferredCall( ); } for (const argument of node.args) { - if (argument.kind === 'arrayLit' || argument.kind === 'objectLit') { + const helperDisposition = classifyInternalMachineClassHelperArgument(argument, env); + if (helperDisposition === 'unsupported') { + throw new Error('portable machine: helper argument is outside the resumable domain'); + } + if (helperDisposition === 'pure' && (argument.kind === 'arrayLit' || argument.kind === 'objectLit')) { assertPortableMachineLetShape(argument, env); + } else if (argument.kind === 'arrayLit') { + assertDeferredInternalMachineHelperArgument( + argument, + env, + deferredBindings, + assertDeferredMachineScalarPreflight, + ); + } else if (argument.kind === 'objectLit') { + assertDeferredInternalMachineHelperArgument( + argument, + env, + deferredBindings, + assertDeferredMachineScalarPreflight, + ); } else if (expressionRequiresDeferredMachinePreflight(argument, env, deferredBindings)) { assertDeferredMachineScalarPreflight(argument, env, deferredBindings); } else { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts index 000161bde..25a7d7d64 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts @@ -176,6 +176,10 @@ export function* evaluateInternalMachineClassGetterFrame( if (!receiver || !bodyRunner) throw new Error('machine class: getter receiver is unavailable'); const registry = internalMachineClassRegistryForEnv(env); + const label = `${resolved.cls.name}.${resolved.getter.name}`; + if ((env.runnerCallStack ?? []).includes(label)) { + throw new Error(`runner-class: recursive member call "${label}" is unsupported`); + } const getterEnv = makeInternalMachineClassMemberEnv(resolved.cls, resolved.getter, receiver, [], env, registry); const restore = bindInternalEffectMachineState(getterEnv, state); try { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts index 4c432e369..cc2e15cc6 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts @@ -389,7 +389,7 @@ export function internalMachineClassGetterForRead( node: Extract, env: SemanticEnv, ): InternalMachineClassGetterRead | undefined { - if (node.optional || node.object.kind !== 'ident' || node.object.name === 'this') return undefined; + if (node.optional || node.object.kind !== 'ident' || node.object.name === 'super') return undefined; const receiver = internalMachineClassReceiver(node.object.name, env); if (!receiver || Object.hasOwn(receiver.fields, node.property)) return undefined; const registry = internalMachineClassRegistryForEnv(env); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts index 5202e29e4..1d8b93d34 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts @@ -8,6 +8,7 @@ import { import { internalMachineClassConstructorPlan } from './internal-effect-machine-class-construction.js'; import { assertInternalMachineClassGraph, + internalMachineClassGetterForRead, internalMachineClassMethodForCall, } from './internal-effect-machine-class-graph.js'; import { INTERNAL_MACHINE_PREFLIGHT_CLASS_OWNER } from './internal-effect-machine-class-instance.js'; @@ -111,6 +112,7 @@ function assertClassExpression( return; } if (node.kind === 'member') { + if (!node.optional && internalMachineClassGetterForRead(node, env)) return; if (node.optional || node.object.kind !== 'ident' || node.object.name !== 'this' || !fields.has(node.property)) { throw new Error('machine class: member expression references an unavailable field'); } @@ -247,6 +249,7 @@ export function assertInternalMachineClassFramePreflight(env: SemanticEnv, analy classRegistry: registry, helperBodyRunner: activeState?.helperBodyRunner, helperRegistry: activeState?.helperRegistry, + resumableHelperNames: activeState?.resumableHelperNames, remainingIterations: undefined, }; for (const cls of registry.values()) { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts index 310a1f9a3..edabc5cba 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts @@ -7,9 +7,15 @@ import { type InternalMachineClassValueGenerator, } from './internal-effect-machine-class-frame.js'; import { + classifyInternalMachineClassHelperArgument, classifyInternalMachineClassLetValue, classifyInternalMachineClassScalarValue, } from './internal-effect-machine-class-value.js'; +import { isInternalMachineHelperCall } from './internal-effect-machine-helper-graph.js'; +import { + evalInternalMachineHelperArgumentValue, + evalInternalMachineHelperFrame, +} from './internal-effect-machine-helper-runtime.js'; import type { InternalEffectMachineState } from './internal-effect-machine-types.js'; import { coerceToString, @@ -18,11 +24,29 @@ import { evalPlusOperator, } from './portable-core-evaluator.js'; import { evalPortableValue } from './portable-machine-evaluator.js'; -import { assertPortableScalar, type PortableScalar, portableTruthy, sameType } from './portable-scalar-domain.js'; +import { + assertPortableRecordEntry, + assertSingleUseFreshArrayRecordSources, + evalRecordArrayFieldValue, +} from './portable-record-evaluator.js'; +import { + assertPortableScalar, + isIntProvenancedExpr, + isPortableScalar, + isRunnerPortableArrayValue, + type PortableScalar, + portableTruthy, + type RunnerPortableArrayValue, + type RunnerPortableValue, + sameType, +} from './portable-scalar-domain.js'; import type { RunnerClassInstanceValue, SemanticEnv } from './semantic-env.js'; import type { TraceEvent } from './trace.js'; -function evaluated(value: PortableScalar, events: readonly TraceEvent[] = []): InternalMachineClassEvaluatedValue { +function evaluated( + value: T, + events: readonly TraceEvent[] = [], +): InternalMachineClassEvaluatedValue { return { events, value }; } @@ -56,6 +80,82 @@ function evaluateBinaryResult( throw new Error(`portable: unsupported binary op "${op}"`); } +function* evaluateInternalMachineHelperArgument( + node: ValueIR, + env: SemanticEnv, + state: InternalEffectMachineState, +): InternalMachineClassValueGenerator { + const disposition = classifyInternalMachineClassHelperArgument(node, env); + if (disposition === 'pure') { + return evaluated(evalInternalMachineHelperArgumentValue(node, env, evalPortableValue)); + } + if (disposition === 'unsupported') { + throw new Error(`machine helper: argument kind "${node.kind}" is outside the resumable domain`); + } + if ( + node.kind === 'call' && + node.callee.kind === 'ident' && + isInternalMachineHelperCall(node.callee.name, node.args.length, env) + ) { + return yield* evaluateInternalMachineHelperCall(node, env, state); + } + if (node.kind === 'arrayLit') { + const events: TraceEvent[] = []; + const out: Array = []; + for (const item of node.items) { + const value = append(events, yield* evaluateInternalMachineHelperArgument(item, env, state)); + if (!isPortableScalar(value) && !isRunnerPortableArrayValue(value)) { + throw new Error('machine helper: array item is outside the portable domain'); + } + out.push(value); + } + return { events, value: Object.freeze(out) }; + } + if (node.kind === 'objectLit') { + assertSingleUseFreshArrayRecordSources(node, env); + const events: TraceEvent[] = []; + const out: Record = Object.create(null); + for (const rawEntry of node.entries) { + const entry = assertPortableRecordEntry(rawEntry, out); + const entryDisposition = classifyInternalMachineClassHelperArgument(entry.value, env); + const arrayValue = + entryDisposition === 'pure' ? evalRecordArrayFieldValue(entry.value, env, evalPortableValue) : undefined; + if (arrayValue !== undefined) { + out[entry.key] = arrayValue; + continue; + } + const value = append(events, yield* evaluateInternalMachineHelperArgument(entry.value, env, state)); + if (!isPortableScalar(value) && !isRunnerPortableArrayValue(value)) { + throw new Error(`machine helper: record field "${entry.key}" is outside the portable domain`); + } + out[entry.key] = value; + } + return { events, value: Object.freeze(out) }; + } + if (classifyInternalMachineClassScalarValue(node, env) === 'suspending') { + return yield* evaluateInternalMachineClassScalarValue(node, env, state); + } + throw new Error(`machine helper: unsupported resumable argument kind "${node.kind}"`); +} + +function* evaluateInternalMachineHelperCall( + node: Extract, + env: SemanticEnv, + state: InternalEffectMachineState, +): InternalMachineClassValueGenerator { + if (node.callee.kind !== 'ident') throw new Error('machine helper: resumable call target is unavailable'); + const events: TraceEvent[] = []; + const values: RunnerPortableValue[] = []; + const provenance: boolean[] = []; + for (const argument of node.args) { + values.push(append(events, yield* evaluateInternalMachineHelperArgument(argument, env, state))); + provenance.push(isIntProvenancedExpr(argument, env)); + } + const body = yield* evalInternalMachineHelperFrame(node.callee.name, values, provenance, env); + events.push(...body.events); + return { events, value: body.value }; +} + export function* evaluateInternalMachineClassScalarValue( node: ValueIR, env: SemanticEnv, @@ -69,6 +169,13 @@ export function* evaluateInternalMachineClassScalarValue( if (node.kind === 'member') return yield* evaluateInternalMachineClassGetterFrame(node, env, state); if (node.kind === 'call') { + if (node.callee.kind === 'ident' && isInternalMachineHelperCall(node.callee.name, node.args.length, env)) { + const result = yield* evaluateInternalMachineHelperCall(node, env, state); + return { + events: result.events, + value: assertPortableScalar(result.value, `function "${node.callee.name}" return`), + }; + } if (node.callee.kind === 'ident' && node.callee.name === 'String') { const argument = yield* evaluateInternalMachineClassScalarValue(node.args[0], env, state); return evaluated(coerceToString(argument.value), argument.events); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-value.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-value.ts index 0c3f65184..24e42efe1 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-value.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-value.ts @@ -6,6 +6,7 @@ import { } from './internal-effect-machine-class-graph.js'; import { isInternalMachineHelperCall, + isInternalMachineResumableHelperCall, isInternalMachineScalarHelperCall, } from './internal-effect-machine-helper-graph.js'; import { @@ -13,6 +14,7 @@ import { assertPortableMachineReturnShape, assertPortableMachineScalarShape, } from './portable-machine-shape.js'; +import { assertPortableRecordEntry, assertSingleUseFreshArrayRecordSources } from './portable-record-evaluator.js'; import type { SemanticEnv } from './semantic-env.js'; export type InternalMachineClassValueDisposition = 'pure' | 'suspending' | 'unsupported'; @@ -103,7 +105,44 @@ function pureScalarShape(node: ValueIR, env: SemanticEnv): InternalMachineClassV } } -function classifyInternalMachineClassHelperArgument( +function assertResumableArrayArgumentShape(node: Extract, env: SemanticEnv): void { + for (const item of node.items) { + if (item.kind === 'objectLit') { + throw new Error('machine helper: nested records are outside the portable array domain'); + } + const disposition = classifyInternalMachineClassHelperArgument(item, env); + if (disposition === 'unsupported') { + throw new Error('machine helper: array item is outside the resumable argument domain'); + } + if (item.kind === 'arrayLit') assertResumableArrayArgumentShape(item, env); + } +} + +function assertResumableCompositeArgumentShape( + node: Extract, + env: SemanticEnv, +): void { + if (node.kind === 'arrayLit') { + assertResumableArrayArgumentShape(node, env); + return; + } + assertSingleUseFreshArrayRecordSources(node, env); + const keys: Record = Object.create(null) as Record; + for (const rawEntry of node.entries) { + const entry = assertPortableRecordEntry(rawEntry, keys); + keys[entry.key] = null; + if (entry.value.kind === 'objectLit') { + throw new Error('machine helper: nested records are outside the portable record domain'); + } + const disposition = classifyInternalMachineClassHelperArgument(entry.value, env); + if (disposition === 'unsupported') { + throw new Error(`machine helper: record field "${entry.key}" is outside the resumable argument domain`); + } + if (entry.value.kind === 'arrayLit') assertResumableArrayArgumentShape(entry.value, env); + } +} + +export function classifyInternalMachineClassHelperArgument( node: ValueIR, env: SemanticEnv, ): InternalMachineClassValueDisposition { @@ -116,12 +155,22 @@ function classifyInternalMachineClassHelperArgument( ) { if (node.args.some(internalMachineClassValueUsesPrivateReceiver)) return 'unsupported'; const args = combine(node.args.map((argument) => classifyInternalMachineClassHelperArgument(argument, env))); - return args === 'pure' ? 'pure' : 'unsupported'; + if (args === 'unsupported') return args; + return isInternalMachineResumableHelperCall(node.callee.name, node.args.length, env) ? 'unsupported' : args; } if (node.kind !== 'arrayLit' && node.kind !== 'objectLit') return 'unsupported'; + const descendants = + node.kind === 'arrayLit' + ? combine(node.items.map((item) => classifyInternalMachineClassHelperArgument(item, env))) + : combine( + node.entries.map((entry) => + classifyInternalMachineClassHelperArgument('kind' in entry ? entry.argument : entry.value, env), + ), + ); + if (descendants === 'unsupported') return descendants; try { - assertPortableMachineLetShape(node, env); - return 'pure'; + assertResumableCompositeArgumentShape(node, env); + return descendants; } catch { return 'unsupported'; } @@ -141,7 +190,10 @@ function classifyCall( if (node.callee.kind === 'ident' && isInternalMachineScalarHelperCall(node.callee.name, node.args.length, env)) { if (node.args.some(internalMachineClassValueUsesPrivateReceiver)) return 'unsupported'; const args = combine(node.args.map((argument) => classifyInternalMachineClassHelperArgument(argument, env))); - return args === 'pure' ? pureScalarShape(node, env) : 'unsupported'; + if (args === 'unsupported') return args; + return args === 'suspending' || isInternalMachineResumableHelperCall(node.callee.name, node.args.length, env) + ? 'suspending' + : pureScalarShape(node, env); } if (node.callee.kind === 'ident' && isInternalMachineHelperCall(node.callee.name, node.args.length, env)) { return 'unsupported'; diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts new file mode 100644 index 000000000..40ce8da17 --- /dev/null +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts @@ -0,0 +1,30 @@ +import type { ValueIR } from '../../value-ir.js'; +import { classifyInternalMachineClassHelperArgument } from './internal-effect-machine-class-value.js'; +import type { SemanticEnv } from './semantic-env.js'; + +type ScalarPreflight = (node: ValueIR, env: SemanticEnv, deferredBindings: ReadonlySet) => void; + +export function assertDeferredInternalMachineHelperArgument( + node: ValueIR, + env: SemanticEnv, + deferredBindings: ReadonlySet, + assertScalar: ScalarPreflight, +): void { + if (classifyInternalMachineClassHelperArgument(node, env) === 'unsupported') { + throw new Error('portable machine: nested helper argument is outside the resumable domain'); + } + if (node.kind === 'arrayLit') { + for (const item of node.items) { + assertDeferredInternalMachineHelperArgument(item, env, deferredBindings, assertScalar); + } + return; + } + if (node.kind === 'objectLit') { + for (const entry of node.entries) { + if ('kind' in entry) throw new Error('portable machine: nested helper record spreads are unsupported'); + assertDeferredInternalMachineHelperArgument(entry.value, env, deferredBindings, assertScalar); + } + return; + } + assertScalar(node, env, deferredBindings); +} diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts index c801a3665..cb796d876 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts @@ -99,7 +99,7 @@ function memberKey(owner: RunnerClassBinding, member: RunnerClassMemberBinding): return `${owner.name}.${member.name}`; } -function assertPureMember( +function assertReachedMember( owner: RunnerClassBinding, receiver: RunnerClassBinding, member: RunnerClassMemberBinding, @@ -111,13 +111,13 @@ function assertPureMember( if (stack.has(key)) throw new Error(`machine helper: recursive class member "${key}" is outside this slice`); stack.add(key); try { - assertPureClassNodes(member.body, { owner, receiver }, classes, env, stack); + assertReachedClassNodes(member.body, { owner, receiver }, classes, env, stack); } finally { stack.delete(key); } } -function assertPureClassValue( +function assertReachedClassValue( node: ValueIR, frame: ClassFrame, classes: ReadonlyMap, @@ -129,7 +129,7 @@ function assertPureClassValue( } if (node.kind === 'call' && node.callee.kind === 'ident' && node.callee.name === 'super') { for (const argument of node.args) { - assertPortableMachineScalarShape(assertPureClassValue(argument, frame, classes, env, stack), env); + assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, stack), env); } return classScalarPlaceholder(); } @@ -150,9 +150,9 @@ function assertPureClassValue( throw new Error('machine helper: reached class method is unavailable'); } for (const argument of node.args) { - assertPortableMachineScalarShape(assertPureClassValue(argument, frame, classes, env, stack), env); + assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, stack), env); } - assertPureMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); return classScalarPlaceholder(); } } @@ -162,7 +162,7 @@ function assertPureClassValue( if (node.optional || node.args.length !== helper.params.length) { throw new Error('machine helper: reached helper call is unavailable'); } - const args = node.args.map((argument) => assertPureClassValue(argument, frame, classes, env, stack)); + const args = node.args.map((argument) => assertReachedClassValue(argument, frame, classes, env, stack)); for (const argument of args) assertPortableMachineLetShape(argument, env); return { ...node, args }; } @@ -182,17 +182,17 @@ function assertPureClassValue( } const resolved = internalMachineClassMemberFor(start, node.property, 'getter', classes); if (!resolved) throw new Error('machine helper: reached class field or getter is unavailable'); - assertPureMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); return classScalarPlaceholder(); } } if (node.kind === 'ident' && (node.name === 'this' || node.name === 'super')) { throw new Error('machine helper: private receiver cannot cross a class member boundary'); } - return mapValue(node, (child) => assertPureClassValue(child, frame, classes, env, stack)); + return mapValue(node, (child) => assertReachedClassValue(child, frame, classes, env, stack)); } -function assertPureClassNodes( +function assertReachedClassNodes( nodes: readonly IRNode[], frame: ClassFrame, classes: ReadonlyMap, @@ -200,24 +200,21 @@ function assertPureClassNodes( stack: Set, ): void { for (const node of nodes) { - if (node.type === 'capability' || node.type === 'print') { - throw new Error(`machine helper: class node type "${node.type}" produces effects`); - } for (const source of expressionSources(node)) { - assertPureClassValue(parseExpression(source), frame, classes, env, stack); + assertReachedClassValue(parseExpression(source), frame, classes, env, stack); } - if (node.children) assertPureClassNodes(node.children, frame, classes, env, stack); + if (node.children) assertReachedClassNodes(node.children, frame, classes, env, stack); } } -function assertPureConstruction( +function assertReachedConstruction( cls: RunnerClassBinding, classes: ReadonlyMap, env: SemanticEnv, stack: Set, ): void { for (const layer of internalMachineClassLineageBaseFirst(cls, classes)) { - if (layer.constructor) assertPureMember(layer, cls, layer.constructor, classes, env, stack); + if (layer.constructor) assertReachedMember(layer, cls, layer.constructor, classes, env, stack); } } @@ -251,7 +248,7 @@ function assertHelperValue( assertHelperValue(argument, bindings, classes, env, stack); assertPortableMachineScalarShape(portableHelperScalarShape(argument, bindings), env); } - assertPureMember(resolved.cls, receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, stack); return; } } @@ -262,7 +259,7 @@ function assertHelperValue( if (internalMachineClassVisibleFields(receiver, classes).has(node.property)) return; const resolved = internalMachineClassMemberFor(receiver, node.property, 'getter', classes); if (!resolved) throw new Error('machine helper: reached class field or getter is unavailable'); - assertPureMember(resolved.cls, receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, stack); return; } } @@ -306,6 +303,7 @@ function assertHelperNodes( env: SemanticEnv, stack: Set, classScalarReturns: Set, + composition: { composesClass: boolean }, ): void { const bindings = new Map(inherited); for (const node of nodes) { @@ -316,11 +314,12 @@ function assertHelperNodes( node.type === 'let' && typeof node.props?.value === 'string' ? parseExpression(node.props.value) : undefined; const constructed = letValue ? classForNew(letValue, classes) : undefined; if (constructed && letValue?.kind === 'new' && letValue.argument.kind === 'call') { + composition.composesClass = true; for (const argument of letValue.argument.args) { assertHelperValue(argument, bindings, classes, env, stack); assertPortableMachineScalarShape(portableHelperScalarShape(argument, bindings), env); } - assertPureConstruction(constructed, classes, env, stack); + assertReachedConstruction(constructed, classes, env, stack); const name = node.props?.name; if (typeof name !== 'string' || name === '') throw new Error('machine helper: class let requires a binding'); bindings.set(name, constructed); @@ -331,6 +330,7 @@ function assertHelperNodes( if (node.type === 'return' && typeof node.props?.value === 'string') { const value = parseExpression(node.props.value); if (usesHelperLocalClassScalar(value, bindings)) { + composition.composesClass = true; const isScalarHelperCall = (name: string, arity: number): boolean => { const helper = env.runnerFunctions?.get(name); return helper?.params.length === arity && isPortableScalarHelperReturnContract(helper.returns); @@ -347,16 +347,24 @@ function assertHelperNodes( } } } - if (node.children) assertHelperNodes(node.children, bindings, classes, env, stack, classScalarReturns); + if (node.children) { + assertHelperNodes(node.children, bindings, classes, env, stack, classScalarReturns, composition); + } } } +export interface InternalMachineHelperClassComposition { + readonly classScalarReturns: ReadonlySet; + readonly composesClass: boolean; +} + export function assertInternalMachineHelperClassComposition( nodes: readonly IRNode[], classes: ReadonlyMap, env: SemanticEnv, -): ReadonlySet { +): InternalMachineHelperClassComposition { const classScalarReturns = new Set(); - assertHelperNodes(nodes, new Map(), classes, env, new Set(), classScalarReturns); - return classScalarReturns; + const composition = { composesClass: false }; + assertHelperNodes(nodes, new Map(), classes, env, new Set(), classScalarReturns, composition); + return { classScalarReturns, composesClass: composition.composesClass }; } diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts index 11ce3275a..6544f471c 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts @@ -25,3 +25,9 @@ export function isInternalMachineScalarHelperCall(name: string, arity: number, e const fn = helperRegistryForEnv(env)?.get(name); return fn !== undefined && fn.params.length === arity && isPortableScalarHelperReturnContract(fn.returns); } + +export function isInternalMachineResumableHelperCall(name: string, arity: number, env: SemanticEnv): boolean { + const state = internalEffectMachineStateForEnv(env); + const fn = state?.helperRegistry?.get(name) ?? env.runnerFunctions?.get(name); + return fn?.params.length === arity && state?.resumableHelperNames?.has(name) === true; +} diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts index 7078371fc..36902128c 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts @@ -5,6 +5,7 @@ import { assertInternalMachineClassGraph } from './internal-effect-machine-class import { assertInternalMachineHelperClassComposition } from './internal-effect-machine-helper-class.js'; import { isInternalMachineHelperCall, + isInternalMachineResumableHelperCall, isInternalMachineScalarHelperCall, isPortableScalarHelperReturnContract, } from './internal-effect-machine-helper-contract.js'; @@ -20,6 +21,23 @@ const PURE_HELPER_EXCLUDED_TYPES = new Set(['capability', 'lambda', 'print', 'tr export interface InternalMachineHelperGraph { readonly functions: ReadonlyMap; readonly requiresIterationBudget: boolean; + readonly resumableHelperNames: ReadonlySet; +} + +function transitiveResumableHelpers( + direct: ReadonlySet, + calls: ReadonlyMap>, +): ReadonlySet { + const resumable = new Set(direct); + for (let changed = true; changed; ) { + changed = false; + for (const [caller, callees] of calls) { + if (resumable.has(caller) || ![...callees].some((callee) => resumable.has(callee))) continue; + resumable.add(caller); + changed = true; + } + } + return resumable; } function snapshotNode(node: IRNode): IRNode { @@ -255,12 +273,16 @@ export function assertInternalMachineHelperGraph( admittedClasses = assertInternalMachineClassGraph(env).classes, ): InternalMachineHelperGraph { const scope = helperScope(env); - if (!scope) return { functions: new Map(), requiresIterationBudget: false }; + if (!scope) { + return { functions: new Map(), requiresIterationBudget: false, resumableHelperNames: new Set() }; + } const pending = new Set(); collectNodeCalls(nodes, scope.functions, pending); collectClassBodyCalls(admittedClasses, scope.functions, pending); const functions = new Map(); const classScalarReturns = new Map>(); + const directResumableHelpers = new Set(); + const helperCalls = new Map>(); let requiresIterationBudget = false; while (pending.size > 0) { const name = pending.values().next().value as string; @@ -271,14 +293,21 @@ export function assertInternalMachineHelperGraph( assertFunctionBinding(name, fn, scope); if (assertPureHelperBody(fn.body)) requiresIterationBudget = true; const snapshot = snapshotFunctionBinding(fn); - classScalarReturns.set(name, assertInternalMachineHelperClassComposition(snapshot.body, admittedClasses, env)); + const composition = assertInternalMachineHelperClassComposition(snapshot.body, admittedClasses, env); + classScalarReturns.set(name, composition.classScalarReturns); + if (composition.composesClass) directResumableHelpers.add(name); functions.set(name, snapshot); const nested = new Set(); - collectNodeCalls(fn.body, scope.functions, nested); + collectNodeCalls(snapshot.body, scope.functions, nested); + helperCalls.set(name, nested); for (const called of nested) if (!functions.has(called)) pending.add(called); } assertScalarHelperContracts(functions, env, classScalarReturns); - return { functions, requiresIterationBudget }; + return { + functions, + requiresIterationBudget, + resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls), + }; } export function internalMachineHelperGraphClaims(nodes: readonly IRNode[], env: SemanticEnv): boolean { @@ -335,4 +364,4 @@ export function internalMachineHelperCallInNode(node: IRNode, env: SemanticEnv): return calls.size > 0; } -export { isInternalMachineHelperCall, isInternalMachineScalarHelperCall }; +export { isInternalMachineHelperCall, isInternalMachineResumableHelperCall, isInternalMachineScalarHelperCall }; diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts index de04170e8..727fa5a02 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts @@ -1,5 +1,6 @@ import type { IRNode } from '../../types.js'; import { assertInternalMachineHelperGraph } from './internal-effect-machine-helper-graph.js'; +import { copyInternalEffectMachineState } from './internal-effect-machine-helper-state.js'; import { makeEnv, type SemanticEnv } from './semantic-env.js'; import type { CompletionKind } from './trace.js'; @@ -27,6 +28,7 @@ export function assertInternalMachineHelperPreflight( seed: env.seed, now: env.now, }); + copyInternalEffectMachineState(env, callEnv); const completions = analyze(fn.body, 0, callEnv, new Set(fn.params), true); if (completions.size !== 1 || !completions.has('return')) { throw new Error(`machine helper: "${fn.name}" must return a portable value on every path`); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts index 6953ca0f1..fc6fc57e1 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts @@ -1,4 +1,8 @@ import type { ValueIR } from '../../value-ir.js'; +import type { + InternalMachineClassEvaluatedValue, + InternalMachineClassValueGenerator, +} from './internal-effect-machine-class-frame.js'; import { bindInternalEffectMachineState, internalEffectMachineStateForEnv, @@ -18,6 +22,7 @@ import { type RunnerPortableValue, } from './portable-scalar-domain.js'; import { getBinding, hasBinding, makeEnv, type SemanticEnv } from './semantic-env.js'; +import type { TraceEvent } from './trace.js'; // Frozen native-runner semantics, shared with the compatibility helper owner. const RUNNER_CALL_DEPTH_LIMIT = 512; @@ -35,7 +40,11 @@ function helperCacheKey( } } -function evaluateArgument(node: ValueIR, env: SemanticEnv, evaluate: EvalPortableValue): RunnerPortableValue { +export function evalInternalMachineHelperArgumentValue( + node: ValueIR, + env: SemanticEnv, + evaluate: EvalPortableValue, +): RunnerPortableValue { if (node.kind === 'ident' && hasBinding(env, node.name)) { return assertRunnerPortableValue(getBinding(env, node.name), `function argument "${node.name}"`); } @@ -65,33 +74,27 @@ interface PreparedHelperCall { readonly values: readonly RunnerPortableValue[]; } -function prepareHelperCall( +function prepareHelperCallFromValues( name: string, - args: readonly ValueIR[], + values: readonly RunnerPortableValue[], + provenance: readonly boolean[], env: SemanticEnv, - evaluate: EvalPortableValue, ): PreparedHelperCall { const state = internalEffectMachineStateForEnv(env); const fn = state?.helperRegistry?.get(name); if (!state || !fn || !state.helperBodyRunner) { throw new Error(`portable machine: function call "${name}" is outside the machine scalar domain`); } - if (args.length !== fn.params.length) { - throw new Error(`portable: function "${name}" expects ${fn.params.length} arguments, got ${args.length}`); + if (values.length !== fn.params.length) { + throw new Error(`portable: function "${name}" expects ${fn.params.length} arguments, got ${values.length}`); } const stack = env.runnerCallStack ?? []; if (stack.length >= RUNNER_CALL_DEPTH_LIMIT) { throw new Error(`portable: runner function call depth exceeded (limit ${RUNNER_CALL_DEPTH_LIMIT})`); } - const values: RunnerPortableValue[] = []; - const provenance: boolean[] = []; const intProvenance = new Set(); for (let index = 0; index < fn.params.length; index += 1) { - const value = evaluateArgument(args[index], env, evaluate); - const isInteger = isIntProvenancedExpr(args[index], env); - values.push(value); - provenance.push(isInteger); - if (isInteger) intProvenance.add(fn.params[index]); + if (provenance[index]) intProvenance.add(fn.params[index]); } return { cache: helperCache(state), @@ -104,6 +107,31 @@ function prepareHelperCall( }; } +function prepareHelperCall( + name: string, + args: readonly ValueIR[], + env: SemanticEnv, + evaluate: EvalPortableValue, +): PreparedHelperCall { + const state = internalEffectMachineStateForEnv(env); + const fn = state?.helperRegistry?.get(name); + if (!state || !fn || !state.helperBodyRunner) { + throw new Error(`portable machine: function call "${name}" is outside the machine scalar domain`); + } + if (args.length !== fn.params.length) { + throw new Error(`portable: function "${name}" expects ${fn.params.length} arguments, got ${args.length}`); + } + const values: RunnerPortableValue[] = []; + const provenance: boolean[] = []; + for (let index = 0; index < fn.params.length; index += 1) { + const value = evalInternalMachineHelperArgumentValue(args[index], env, evaluate); + const isInteger = isIntProvenancedExpr(args[index], env); + values.push(value); + provenance.push(isInteger); + } + return prepareHelperCallFromValues(name, values, provenance, env); +} + function cachedHelperValue(call: PreparedHelperCall): RunnerPortableValue | undefined { if (call.cacheKey === undefined || !call.cache.has(call.cacheKey)) return undefined; return assertRunnerPortableValue(call.cache.get(call.cacheKey), `function "${call.name}" cached return`); @@ -150,6 +178,48 @@ function executePreparedHelper(call: PreparedHelperCall): RunnerPortableValue { } } +function observableHelperEvents(events: readonly TraceEvent[]): readonly TraceEvent[] { + return events.filter((event) => event.op === 'stdout' || event.op === 'stderr' || event.op === 'capability'); +} + +export function* evalInternalMachineHelperFrame( + name: string, + values: readonly RunnerPortableValue[], + provenance: readonly boolean[], + env: SemanticEnv, +): InternalMachineClassValueGenerator { + const call = prepareHelperCallFromValues(name, values, provenance, env); + const cached = cachedHelperValue(call); + if (cached !== undefined) return { events: [], value: cached }; + const fn = call.state.helperRegistry?.get(call.name); + const bodyRunner = call.state.helperBodyRunner; + if (!fn || !bodyRunner) throw new Error(`portable machine: helper "${call.name}" is unavailable`); + const bindings = new Map(fn.params.map((param, index) => [param, call.values[index]])); + const callEnv = makeEnv({ + bindings, + intProvenance: new Set(call.intProvenance), + runnerCallCache: call.cache, + runnerCallStack: [...(call.env.runnerCallStack ?? []), call.name], + runnerClasses: call.env.runnerClasses, + runnerFunctions: new Map(call.state.helperRegistry), + seed: call.env.seed, + now: call.env.now, + }); + const restore = bindInternalEffectMachineState(callEnv, call.state); + try { + const trace = yield* bodyRunner(fn.body, callEnv, call.state); + if (trace.completion.kind !== 'return') { + throw new Error(`portable: function "${call.name}" must return a portable scalar, record, or array`); + } + const value = assertRunnerPortableValue(trace.completion.value, `function "${call.name}" return`); + const events = observableHelperEvents(trace.events); + if (events.length === 0) rememberHelperValue(call, value); + return { events, value } satisfies InternalMachineClassEvaluatedValue; + } finally { + restore(); + } +} + function drivePreparedHelper(initial: PreparedHelperCall): RunnerPortableValue { const state = initial.state; const frames: PreparedHelperCall[] = [initial]; diff --git a/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts b/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts new file mode 100644 index 000000000..5e111beaa --- /dev/null +++ b/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts @@ -0,0 +1,29 @@ +import type { IRNode } from '../../types.js'; +import { assertInternalMachineClassGraph } from './internal-effect-machine-class-graph.js'; +import { assertInternalMachineHelperGraph } from './internal-effect-machine-helper-graph.js'; +import { + bindInternalEffectMachineState, + internalEffectMachineStateForEnv, +} from './internal-effect-machine-helper-state.js'; +import type { SemanticEnv } from './semantic-env.js'; + +export function withInternalEffectMachineStructureState( + nodes: readonly IRNode[], + env: SemanticEnv, + advance: () => T, +): T { + if (internalEffectMachineStateForEnv(env)) return advance(); + const classes = assertInternalMachineClassGraph(env).classes; + const helperGraph = assertInternalMachineHelperGraph(nodes, env, classes); + const restore = bindInternalEffectMachineState(env, { + classRegistry: classes, + helperRegistry: helperGraph.functions, + resumableHelperNames: helperGraph.resumableHelperNames, + remainingIterations: undefined, + }); + try { + return advance(); + } finally { + restore(); + } +} diff --git a/packages/core/src/ir/semantics/internal-effect-machine-structure.ts b/packages/core/src/ir/semantics/internal-effect-machine-structure.ts index c160b46ce..5a4e85186 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-structure.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-structure.ts @@ -35,6 +35,7 @@ import { assertInternalMachineDeferredCaughtExpression, isInternalEffectMachineLeafType, } from './internal-effect-machine-leaf.js'; +import { withInternalEffectMachineStructureState } from './internal-effect-machine-structure-state.js'; import { hasNoBody, InternalEffectMachineError, isUnifiedNodeType } from './internal-effect-machine-types.js'; import { assertLambdaPreflight } from './lambda-preflight.js'; import { evalPortableValue } from './portable-machine-evaluator.js'; @@ -98,7 +99,7 @@ function analyzeBranchFrame( recordEscapingBindingWrites(path.children ?? [], frameWrites); } } - addBindingWrites(unstableBindings, frameWrites); + for (const name of frameWrites) unstableBindings.add(name); return possible; } function analyzeLoop( @@ -472,18 +473,16 @@ function recordNodeBindingWrites(node: IRNode, out: Set): void { } } -function addBindingWrites(target: Set, source: ReadonlySet): void { - for (const name of source) target.add(name); -} - export function assertInternalEffectMachineStructureSupported( nodes: readonly IRNode[], env: SemanticEnv, loopDepth = 0, ): void { - assertInternalEffectMachineHelperStructureSupported(nodes, env); - assertInternalMachineClassFramePreflight(env, analyzeSequence); - assertInternalEffectMachineRootStructureSupported(nodes, env, loopDepth); + withInternalEffectMachineStructureState(nodes, env, () => { + assertInternalEffectMachineHelperStructureSupported(nodes, env); + assertInternalMachineClassFramePreflight(env, analyzeSequence); + assertInternalEffectMachineRootStructureSupported(nodes, env, loopDepth); + }); } export function assertInternalEffectMachineRootStructureSupported( diff --git a/packages/core/src/ir/semantics/internal-effect-machine-types.ts b/packages/core/src/ir/semantics/internal-effect-machine-types.ts index b4e68ec09..e943c918f 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-types.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-types.ts @@ -55,6 +55,7 @@ export interface InternalEffectMachineState { helperCallCache?: Map; helperEvaluationDepth?: number; helperRegistry?: ReadonlyMap; + resumableHelperNames?: ReadonlySet; remainingIterations: number | undefined; } diff --git a/packages/core/src/ir/semantics/internal-effect-machine.ts b/packages/core/src/ir/semantics/internal-effect-machine.ts index ca9dbb4bd..30460c4fd 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine.ts @@ -87,7 +87,9 @@ function* runMachine( state.classRegistry = assertInternalMachineClassGraph(env).classes; assertInternalMachineClassUsage(nodes, env); state.helperBodyRunner = runInternalEffectMachineSequence; - state.helperRegistry = assertInternalMachineHelperGraph(nodes, env, state.classRegistry).functions; + const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + state.helperRegistry = helperGraph.functions; + state.resumableHelperNames = helperGraph.resumableHelperNames; assertInternalEffectMachineStructureSupported(nodes, env); return yield* runInternalEffectMachineSequence(nodes, env, state); } diff --git a/packages/core/tests/runner-capability-class-frame.test.ts b/packages/core/tests/runner-capability-class-frame.test.ts index 374fff840..65fda3ed8 100644 --- a/packages/core/tests/runner-capability-class-frame.test.ts +++ b/packages/core/tests/runner-capability-class-frame.test.ts @@ -161,7 +161,7 @@ describe('M3.31a class-frame capability planning', () => { ]); }); - test('keeps an effectful class reached from a helper unsupported', () => { + test('owns an effectful class reached from a helper', () => { const source = [ 'class name=RemoteWidget', ' method name=read returns=string', @@ -180,9 +180,7 @@ describe('M3.31a class-frame capability planning', () => { const analysis = analyzeKernSourceCapabilities(source, provided); expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual(['llm.complete']); - expect(analysis.unsupportedAsyncExecutions).toEqual([ - expect.objectContaining({ id: 'llm.complete', reason: 'unsupported' }), - ]); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); }); test('does not plan an unused effectful member on a helper-local class', () => { diff --git a/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts b/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts new file mode 100644 index 000000000..0d08faea3 --- /dev/null +++ b/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts @@ -0,0 +1,463 @@ +import { + executeSourceRunnerAsync, + executeSourceRunnerSync, + SOURCE_RUNNER_ENGINE, + selectSourceRunnerEngine, +} from '../src/runtime-envelope/source-runner-engine.js'; +import type { IRNode } from '../src/types.js'; +import { classHelperEnv, helper, member } from './runtime-envelope-effect-machine-class-helper-fixtures.js'; + +function remoteMethodEnv(provider: () => number) { + return classHelperEnv({ + capabilities: { storage: { get: provider } }, + classes: [ + { + constructor: undefined, + fields: [], + getters: new Map(), + methods: new Map([ + [ + 'read', + member('RemoteWidget', 'read', [ + { type: 'capability', props: { name: 'answer', namespace: 'storage', operation: 'get' } }, + { type: 'return', props: { value: 'answer' } }, + ]), + ], + ]), + name: 'RemoteWidget', + }, + ], + helpers: [ + helper( + 'readRemote', + [], + [ + { type: 'let', props: { name: 'item', value: 'new RemoteWidget()' } }, + { type: 'return', props: { value: 'item.read()' } }, + ], + ), + ], + }); +} + +describe('M3.31b2c2 resumable helper-to-class effects', () => { + test('owns a capability reached through a helper-local class method', () => { + let providerCalls = 0; + const env = remoteMethodEnv(() => { + providerCalls += 1; + return 7; + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'readRemote() + 1' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + const trace = executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }); + + expect(trace.completion).toEqual({ kind: 'return', value: 8 }); + expect(trace.events.map((event) => event.op)).toEqual(['capability']); + expect(providerCalls).toBe(1); + }); + + test('resumes an effectful constructor and getter in authored order', () => { + const providerValues = [2, 3]; + const providerCalls: number[] = []; + const env = classHelperEnv({ + capabilities: { + storage: { + get: () => { + const value = providerValues[providerCalls.length]; + providerCalls.push(value); + return value; + }, + }, + }, + classes: [ + { + constructor: member('RemoteWidget', 'constructor', [ + { type: 'capability', props: { name: 'seed', namespace: 'storage', operation: 'get' } }, + { type: 'assign', props: { target: 'this.value', value: 'seed' } }, + ]), + fields: [{ name: 'value' }], + getters: new Map([ + [ + 'total', + member('RemoteWidget', 'total', [ + { type: 'capability', props: { name: 'bonus', namespace: 'storage', operation: 'get' } }, + { type: 'return', props: { value: 'this.value + bonus' } }, + ]), + ], + ]), + methods: new Map(), + name: 'RemoteWidget', + }, + ], + helpers: [ + helper( + 'readRemote', + [], + [ + { type: 'let', props: { name: 'item', value: 'new RemoteWidget()' } }, + { type: 'return', props: { value: 'item.total' } }, + ], + ), + ], + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'readRemote()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + const trace = executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }); + + expect(trace.completion).toEqual({ kind: 'return', value: 5 }); + expect(trace.events.map((event) => event.op)).toEqual(['capability', 'capability']); + expect(providerCalls).toEqual([2, 3]); + }); + + test('propagates resumability through a wrapper and nested helper argument', () => { + let providerCalls = 0; + const env = remoteMethodEnv(() => { + providerCalls += 1; + return 5; + }); + const scope = env.runnerFunctions; + expect(scope).toBeDefined(); + const module = scope?.get('readRemote')?.module; + expect(module).toBeDefined(); + module?.functions.set('decorate', { + body: [{ type: 'return', props: { value: 'value + 10' } }], + module, + name: 'decorate', + params: ['value'], + returns: 'number', + }); + module?.functions.set('wrappedRemote', { + body: [{ type: 'return', props: { value: 'decorate(readRemote())' } }], + module, + name: 'wrappedRemote', + params: [], + returns: 'number', + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'wrappedRemote()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 15, + }); + expect(providerCalls).toBe(1); + }); + + test.each([ + { + bodyValue: 'values[0]', + expression: 'first([readRemote(), 3])', + helperName: 'first', + paramName: 'values', + }, + { + bodyValue: 'record.values[0]', + expression: 'field({ values: [readRemote(), 4] })', + helperName: 'field', + paramName: 'record', + }, + ])('resumes helper descendants inside composite argument: $helperName', ({ + bodyValue, + expression, + helperName, + paramName, + }) => { + let providerCalls = 0; + const env = remoteMethodEnv(() => { + providerCalls += 1; + return 5; + }); + const module = env.runnerFunctions?.get('readRemote')?.module; + expect(module).toBeDefined(); + module?.functions.set(helperName, { + body: [{ type: 'return', props: { value: bodyValue } }], + module, + name: helperName, + params: [paramName], + returns: 'number', + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: expression } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 5, + }); + expect(providerCalls).toBe(1); + }); + + test('never memoizes observable helper/class effects', () => { + let providerCalls = 0; + const env = remoteMethodEnv(() => { + providerCalls += 1; + return providerCalls; + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'readRemote() + readRemote()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 3, + }); + expect(providerCalls).toBe(2); + }); + + test('never memoizes helper-local class prints or leaks private trace events', () => { + const env = classHelperEnv({ + classes: [ + { + constructor: undefined, + fields: [], + getters: new Map(), + methods: new Map([ + [ + 'show', + member('Printer', 'show', [ + { type: 'let', props: { name: 'privateValue', value: '2' } }, + { type: 'print', props: { value: 'privateValue' } }, + { type: 'return', props: { value: 'privateValue' } }, + ]), + ], + ]), + name: 'Printer', + }, + ], + helpers: [ + helper( + 'show', + [], + [ + { type: 'let', props: { name: 'printer', value: 'new Printer()' } }, + { type: 'return', props: { value: 'printer.show()' } }, + ], + ), + ], + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'show() + show()' } }]; + + const trace = executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }); + + expect(trace.completion).toEqual({ kind: 'return', value: 4 }); + expect(trace.events).toEqual([ + { op: 'stdout', text: '2' }, + { op: 'stdout', text: '2' }, + ]); + }); + + test('retains safe memoization for event-free class composition', () => { + const env = classHelperEnv({ + classes: [ + { + constructor: undefined, + fields: [], + getters: new Map(), + methods: new Map([ + [ + 'count', + member('Counter', 'count', [ + { type: 'let', props: { name: 'total', value: '0' } }, + { + type: 'for', + props: { from: '0', name: 'index', to: '1' }, + children: [{ type: 'assign', props: { op: '+=', target: 'total', value: '1' } }], + }, + { type: 'return', props: { value: 'total' } }, + ]), + ], + ]), + name: 'Counter', + }, + ], + helpers: [ + helper( + 'countOnce', + [], + [ + { type: 'let', props: { name: 'counter', value: 'new Counter()' } }, + { type: 'return', props: { value: 'counter.count()' } }, + ], + ), + ], + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'countOnce() + countOnce()' } }]; + const options = { iterationBudget: 1 }; + + expect(selectSourceRunnerEngine(nodes, env, options)).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { ...options, policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 2, + }); + }); + + test('keeps direct helper effects outside the language before provider dispatch', () => { + let providerCalls = 0; + const env = classHelperEnv({ + capabilities: { storage: { get: () => ++providerCalls } }, + classes: [], + helpers: [ + helper( + 'directEffect', + [], + [ + { type: 'capability', props: { name: 'answer', namespace: 'storage', operation: 'get' } }, + { type: 'return', props: { value: 'answer' } }, + ], + ), + ], + }); + const nodes: readonly IRNode[] = [ + { type: 'capability', props: { namespace: 'storage', operation: 'get' } }, + { type: 'return', props: { value: 'directEffect()' } }, + ]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); + expect(() => executeSourceRunnerSync(nodes, env, { policy: 'machine-only' })).toThrow(); + expect(providerCalls).toBe(0); + }); + + test('freezes helper and class bodies across an owned async suspension', async () => { + let entered!: () => void; + let release!: () => void; + let providerCalls = 0; + const providerEntered = new Promise((resolve) => { + entered = resolve; + }); + const providerRelease = new Promise((resolve) => { + release = resolve; + }); + const env = classHelperEnv({ + classes: [ + { + constructor: undefined, + fields: [], + getters: new Map(), + methods: new Map([ + [ + 'read', + member('RemoteWidget', 'read', [ + { type: 'capability', props: { name: 'answer', namespace: 'llm', operation: 'complete' } }, + { type: 'return', props: { value: 'answer + "-old"' } }, + ]), + ], + ]), + name: 'RemoteWidget', + }, + ], + helpers: [ + helper( + 'readRemote', + [], + [ + { type: 'let', props: { name: 'item', value: 'new RemoteWidget()' } }, + { type: 'return', props: { value: 'item.read()' } }, + ], + 'string', + ), + ], + }); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'readRemote()' } }]; + const pending = executeSourceRunnerAsync(nodes, env, { + asyncCapabilities: { + llm: { + complete: async () => { + providerCalls += 1; + entered(); + await providerRelease; + return 'answer'; + }, + }, + }, + policy: 'machine-only', + }); + + await providerEntered; + const methodBody = env.runnerClasses?.get('RemoteWidget')?.methods.get('read')?.body; + if (methodBody?.[1]?.props) methodBody[1].props.value = 'answer + "-new"'; + const helperBody = env.runnerFunctions?.get('readRemote')?.body; + if (helperBody?.[1]?.props) helperBody[1].props.value = '"mutated"'; + release(); + + await expect(pending).resolves.toEqual( + expect.objectContaining({ completion: { kind: 'return', value: 'answer-old' } }), + ); + expect(providerCalls).toBe(1); + }); + + test('isolates overlapping helper/class continuations on one environment', async () => { + const releases = new Map void>(); + const gates = new Map>(); + for (const name of ['a', 'b']) { + gates.set( + name, + new Promise((resolve) => { + releases.set(name, resolve); + }), + ); + } + const env = classHelperEnv({ + classes: [ + { + constructor: undefined, + fields: [], + getters: new Map(), + methods: new Map([ + [ + 'read', + member( + 'RemoteWidget', + 'read', + [ + { + type: 'capability', + props: { + input: '{ prompt: value }', + name: 'answer', + namespace: 'llm', + operation: 'complete', + }, + }, + { type: 'return', props: { value: 'answer' } }, + ], + ['value'], + ), + ], + ]), + name: 'RemoteWidget', + }, + ], + helpers: [ + helper( + 'readRemote', + ['value'], + [ + { type: 'let', props: { name: 'item', value: 'new RemoteWidget()' } }, + { type: 'return', props: { value: 'item.read(value)' } }, + ], + 'string', + ), + ], + }); + const options = { + asyncCapabilities: { + llm: { + complete: async ({ input }: { input: unknown }) => { + const prompt = (input as { prompt: string }).prompt; + await gates.get(prompt); + return prompt; + }, + }, + }, + policy: 'machine-only' as const, + }; + const first = executeSourceRunnerAsync([{ type: 'return', props: { value: 'readRemote("a")' } }], env, options); + const second = executeSourceRunnerAsync([{ type: 'return', props: { value: 'readRemote("b")' } }], env, options); + + releases.get('b')?.(); + releases.get('a')?.(); + + await expect(first).resolves.toEqual(expect.objectContaining({ completion: { kind: 'return', value: 'a' } })); + await expect(second).resolves.toEqual(expect.objectContaining({ completion: { kind: 'return', value: 'b' } })); + }); +}); diff --git a/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts b/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts index 9c91599e5..a6605030b 100644 --- a/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts +++ b/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts @@ -117,7 +117,7 @@ describe('M3.31b2b3 reverse helper/class containment', () => { expect(providerCalls).toBe(0); }); - test('rejects an effectful getter reached through this before provider dispatch', () => { + test('owns an effectful getter reached through this', () => { let providerCalls = 0; const env = classHelperEnv({ capabilities: { storage: { get: () => ++providerCalls } }, @@ -158,9 +158,12 @@ describe('M3.31b2b3 reverse helper/class containment', () => { }); const nodes = providerThen('readWidget()'); - expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); - expect(() => executeSourceRunnerSync(nodes, env, { policy: 'machine-only' })).toThrow(); - expect(providerCalls).toBe(0); + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 2, + }); + expect(providerCalls).toBe(2); }); test('rejects this passed from a class method into a helper', () => { diff --git a/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts b/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts index 5e97333aa..940c3bdb6 100644 --- a/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts +++ b/packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts @@ -106,7 +106,7 @@ describe('M3.31b2b3 pure helper-to-class composition', () => { expect(() => executeSourceRunnerSync(nodes, env, { policy: 'machine-only' })).toThrow(); }); - test('rejects a helper-reached effectful class before an earlier provider', () => { + test('owns a helper-reached effectful class after an earlier provider', () => { let providerCalls = 0; const env = classHelperEnv({ capabilities: { storage: { get: () => ++providerCalls } }, @@ -143,9 +143,12 @@ describe('M3.31b2b3 pure helper-to-class composition', () => { { type: 'return', props: { value: 'readRemote()' } }, ]; - expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); - expect(() => executeSourceRunnerSync(nodes, env, { policy: 'machine-only' })).toThrow(); - expect(providerCalls).toBe(0); + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 2, + }); + expect(providerCalls).toBe(2); }); test('does not reject an unused effectful member on the constructed class', () => { @@ -188,7 +191,7 @@ describe('M3.31b2b3 pure helper-to-class composition', () => { }); }); - test('rejects an indirectly reached effectful member', () => { + test('owns an indirectly reached effectful member', () => { let providerCalls = 0; const env = classHelperEnv({ capabilities: { storage: { get: () => ++providerCalls } }, @@ -226,9 +229,12 @@ describe('M3.31b2b3 pure helper-to-class composition', () => { { type: 'return', props: { value: 'readRemote()' } }, ]; - expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); - expect(() => executeSourceRunnerSync(nodes, env, { policy: 'machine-only' })).toThrow(); - expect(providerCalls).toBe(0); + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 2, + }); + expect(providerCalls).toBe(2); }); test('preserves inherited virtual and super dispatch inside a helper', () => { diff --git a/scripts/check-source-runner-convergence.mjs b/scripts/check-source-runner-convergence.mjs index e7ac39bfc..966839cdb 100644 --- a/scripts/check-source-runner-convergence.mjs +++ b/scripts/check-source-runner-convergence.mjs @@ -98,7 +98,7 @@ function validateManifest(text, errors) { errors.push('manifest top-level schema drifted'); return; } - if (manifest.schemaVersion !== 1 || manifest.milestone !== 'KERN-5-R2-M3.31b2c1') { + if (manifest.schemaVersion !== 1 || manifest.milestone !== 'KERN-5-R2-M3.31b2c2') { errors.push('manifest schemaVersion or milestone is invalid'); } if (!Array.isArray(manifest.owned) || !Array.isArray(manifest.deferred)) { @@ -219,11 +219,8 @@ function validateManifest(text, errors) { } } const classState = manifest.deferred.find((item) => item?.id === 'runner-classes-state'); - if ( - classState?.followUp !== - 'M3.31b2c2-resumable-helper-class-effects-and-M3.31c-module-ownership' - ) { - errors.push('manifest must keep remaining class behavior as the exact M3.31b2c2/M3.31c follow-up'); + if (classState?.followUp !== 'M3.31c-module-ownership') { + errors.push('manifest must keep remaining class behavior as the exact M3.31c follow-up'); } } diff --git a/scripts/source-runner-class-helper-convergence.mjs b/scripts/source-runner-class-helper-convergence.mjs index 9f4cb1bb0..29bf2964f 100644 --- a/scripts/source-runner-class-helper-convergence.mjs +++ b/scripts/source-runner-class-helper-convergence.mjs @@ -1,11 +1,16 @@ export const CLASS_HELPER_FILES = Object.freeze({ classHelperContract: 'packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts', + classHelperEffectsTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts', classHelperPortableTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-portable.test.ts', classHelperReverseGraph: 'packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts', classHelperReverseBoundaryTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts', classHelperReverseTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts', classHelperRuntime: 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + helperArgumentPreflight: + 'packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts', + helperPreflight: 'packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts', + structureState: 'packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts', classHelperSnapshotTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-snapshot.test.ts', classHelperTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper.test.ts', }); @@ -35,6 +40,18 @@ export function validateClassHelperManifest(manifest, errors) { if (manifest.owned.filter((item) => item?.id === 'runner-helper-pure-class-calls').length !== 1) { errors.push('manifest runner-helper-pure-class-calls owner is duplicated'); } + const effects = manifest.owned.find((item) => item?.id === 'runner-helper-resumable-class-effects'); + if ( + effects?.kind !== 'environment' || + effects?.status !== 'unified' || + effects?.evidence !== CLASS_HELPER_FILES.classHelperEffectsTests || + Object.keys(effects).sort().join(',') !== 'evidence,id,kind,status' + ) { + errors.push('manifest must contain exactly one evidenced unified runner-helper-resumable-class-effects owner'); + } + if (manifest.owned.filter((item) => item?.id === 'runner-helper-resumable-class-effects').length !== 1) { + errors.push('manifest runner-helper-resumable-class-effects owner is duplicated'); + } } export function validateClassHelperSlice(contents, errors) { @@ -46,6 +63,9 @@ export function validateClassHelperSlice(contents, errors) { 'structuredClone(fn.returns)', 'assertScalarHelperContracts(functions, env, classScalarReturns)', 'assertPortableMachineScalarShape(parseExpression(value), env, isScalarHelperCall, isPortableHelperCall)', + 'if (composition.composesClass) directResumableHelpers.add(name)', + 'helperCalls.set(name, nested)', + 'resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls)', ]) { if (!contents.classHelperGraph?.includes(required)) { errors.push(`class-body helper reachability owner is missing ${required}`); @@ -54,13 +74,12 @@ export function validateClassHelperSlice(contents, errors) { for (const required of [ 'internalMachineClassLineageBaseFirst', 'internalMachineClassMemberFor', - 'assertPureConstruction', - 'assertPureMember', + 'assertReachedConstruction', + 'assertReachedMember', 'class binding identity cannot be reassigned', 'portableHelperScalarShape(value, bindings)', 'private receiver cannot cross a class member boundary', "if (target.kind === 'ident') return classes.get(target.name)", - "node.type === 'capability' || node.type === 'print'", ]) { if (!contents.classHelperReverseGraph?.includes(required)) { errors.push(`reverse helper-class owner is missing ${required}`); @@ -93,8 +112,19 @@ export function validateClassHelperSlice(contents, errors) { errors.push(`helper scalar return contract owner is missing ${required}`); } } - if (!contents.classHelperRuntime?.includes('runnerFunctions: new Map(call.state.helperRegistry)')) { - errors.push('helper execution must use the snapshotted helper registry for nested call shape'); + const frozenHelperRegistry = 'runnerFunctions: new Map(call.state.helperRegistry)'; + if ((contents.classHelperRuntime?.split(frozenHelperRegistry).length ?? 1) - 1 !== 2) { + errors.push('both helper execution paths must use the snapshotted helper registry for nested call shape'); + } + for (const required of [ + 'export function* evalInternalMachineHelperFrame', + 'const trace = yield* bodyRunner(fn.body, callEnv, call.state)', + "event.op === 'stdout' || event.op === 'stderr' || event.op === 'capability'", + 'if (events.length === 0) rememberHelperValue(call, value)', + ]) { + if (!contents.classHelperRuntime?.includes(required)) { + errors.push(`resumable helper runtime owner is missing ${required}`); + } } for (const required of [ 'isInternalMachineHelperCall(node.callee.name, node.args.length, env)', @@ -107,8 +137,10 @@ export function validateClassHelperSlice(contents, errors) { } } for (const required of [ - "return args === 'pure' ? pureScalarShape(node, env) : 'unsupported'", 'isInternalMachineScalarHelperCall(node.callee.name, node.args.length, env)', + 'isInternalMachineResumableHelperCall(node.callee.name, node.args.length, env)', + "args === 'suspending' || isInternalMachineResumableHelperCall", + 'assertResumableCompositeArgumentShape(node, env)', ]) { if (!contents.classValue?.includes(required)) { errors.push(`class-body helper value owner is missing ${required}`); @@ -123,11 +155,19 @@ export function validateClassHelperSlice(contents, errors) { errors.push('scalar helper proof must preserve portable composite helper arguments'); } const registry = contents.nonRootRuntime?.indexOf( - 'state.helperRegistry = assertInternalMachineHelperGraph(nodes, env, state.classRegistry).functions', + 'const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry)', ); + const resumable = contents.nonRootRuntime?.indexOf('state.resumableHelperNames = helperGraph.resumableHelperNames'); const structure = contents.nonRootRuntime?.indexOf('assertInternalEffectMachineStructureSupported(nodes, env)'); - if (registry === undefined || structure === undefined || registry < 0 || structure < 0 || registry > structure) { - errors.push('class-body helper registry must be frozen before combined structure preflight'); + if ( + registry === undefined || + resumable === undefined || + structure === undefined || + registry < 0 || + resumable < registry || + structure < resumable + ) { + errors.push('class-body helper registry and resumable closure must be frozen before combined structure preflight'); } const classBranch = contents.sourceAdmission?.indexOf('if (internalMachineClassGraphHasClasses(env))'); const helperBranch = contents.sourceAdmission?.indexOf( @@ -160,7 +200,7 @@ export function validateClassHelperSlice(contents, errors) { 'accepts a pure helper-local class scalar as a method argument', 'rejects helper-local class binding %s before provider dispatch', 'rejects helper-local class binding loop shadowing before provider dispatch', - 'rejects an effectful getter reached through this before provider dispatch', + 'owns an effectful getter reached through this', 'rejects this passed from a class method into a helper', 'rejects this passed between class methods', 'rejects a non-scalar %s argument before provider dispatch', @@ -190,9 +230,9 @@ export function validateClassHelperSlice(contents, errors) { 'owns helper-local construction, field/getter reads, and method calls', 'accepts a scalar class method as the direct helper return', 'keeps helper-created instances inside the helper invocation', - 'rejects a helper-reached effectful class before an earlier provider', + 'owns a helper-reached effectful class after an earlier provider', 'does not reject an unused effectful member on the constructed class', - 'rejects an indirectly reached effectful member', + 'owns an indirectly reached effectful member', 'preserves inherited virtual and super dispatch inside a helper', 'rejects helper-local instance transport into a nested helper', 'uses frozen class members after an earlier async suspension', @@ -202,11 +242,46 @@ export function validateClassHelperSlice(contents, errors) { } } for (const oracle of [ - 'keeps an effectful class reached from a helper unsupported', + 'owns an effectful class reached from a helper', 'does not plan an unused effectful member on a helper-local class', ]) { if (!contents.classCapabilityTests?.includes(oracle)) { errors.push(`reverse helper-class planner oracle is missing: ${oracle}`); } } + for (const oracle of [ + 'owns a capability reached through a helper-local class method', + 'resumes an effectful constructor and getter in authored order', + 'propagates resumability through a wrapper and nested helper argument', + 'resumes helper descendants inside composite argument', + 'never memoizes observable helper/class effects', + 'never memoizes helper-local class prints or leaks private trace events', + 'retains safe memoization for event-free class composition', + 'freezes helper and class bodies across an owned async suspension', + 'isolates overlapping helper/class continuations on one environment', + ]) { + if (!contents.classHelperEffectsTests?.includes(oracle)) { + errors.push(`resumable helper-class oracle is missing: ${oracle}`); + } + } + for (const required of [ + 'evaluateInternalMachineHelperCall(node, env, state)', + 'evalInternalMachineHelperFrame(node.callee.name, values, provenance, env)', + 'isIntProvenancedExpr(argument, env)', + "if (node.kind === 'arrayLit')", + 'evaluateInternalMachineHelperArgument(item, env, state)', + ]) { + if (!contents.classValueRuntime?.includes(required)) { + errors.push(`resumable helper argument owner is missing ${required}`); + } + } + if (!contents.structureState?.includes('resumableHelperNames: helperGraph.resumableHelperNames')) { + errors.push('structure preflight must bind the resumable helper closure before admission'); + } + if (!contents.helperPreflight?.includes('copyInternalEffectMachineState(env, callEnv)')) { + errors.push('helper body preflight must inherit the resumable helper closure'); + } + if (!contents.helperArgumentPreflight?.includes('assertDeferredInternalMachineHelperArgument(item')) { + errors.push('deferred helper argument preflight must recurse through composite values'); + } } diff --git a/scripts/source-runner-class-helper-convergence.test.mjs b/scripts/source-runner-class-helper-convergence.test.mjs index 8ca760d24..de495d510 100644 --- a/scripts/source-runner-class-helper-convergence.test.mjs +++ b/scripts/source-runner-class-helper-convergence.test.mjs @@ -33,6 +33,14 @@ test('rejects deletion of the pure helper-class owner', () => { assert.ok(errors.some((error) => error.includes('runner-helper-pure-class-calls owner'))); }); +test('rejects deletion of the resumable helper-class owner', () => { + const file = 'scripts/source-runner-convergence-manifest.json'; + const manifest = JSON.parse(source(file)); + manifest.owned = manifest.owned.filter(({ id }) => id !== 'runner-helper-resumable-class-effects'); + const errors = validate(new Map([[file, JSON.stringify(manifest)]])); + assert.ok(errors.some((error) => error.includes('runner-helper-resumable-class-effects owner'))); +}); + test('rejects deletion of reverse-composition ownership', () => { const errors = validate( replace( @@ -130,15 +138,37 @@ test('rejects deletion of bare helper receiver containment', () => { assert.ok(errors.some((error) => error.includes('recursive boundary diagnostic'))); }); -test('rejects deletion of reached class effect containment', () => { +test('rejects deletion of transitive resumable-helper propagation', () => { const errors = validate( replace( - 'packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts', - "node.type === 'capability' || node.type === 'print'", - 'false', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', + 'resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls)', + 'resumableHelperNames: directResumableHelpers', ), ); - assert.ok(errors.some((error) => error.includes('reverse helper-class owner'))); + assert.ok(errors.some((error) => error.includes('class-body helper reachability owner'))); +}); + +test('rejects caching helper bodies with observable effects', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + 'if (events.length === 0) rememberHelperValue(call, value);', + 'rememberHelperValue(call, value);', + ), + ); + assert.ok(errors.some((error) => error.includes('resumable helper runtime owner'))); +}); + +test('rejects deletion of resumable helper yield ownership', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + 'const trace = yield* bodyRunner(fn.body, callEnv, call.state);', + 'const trace = bodyRunner(fn.body, callEnv, call.state).next().value;', + ), + ); + assert.ok(errors.some((error) => error.includes('resumable helper runtime owner'))); }); test('rejects deletion of the helper binding snapshot', () => { @@ -164,13 +194,11 @@ test('rejects a shallow helper metadata snapshot', () => { }); test('rejects live helper metadata during nested call validation', () => { - const errors = validate( - replace( - 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', - 'runnerFunctions: new Map(call.state.helperRegistry)', - 'runnerFunctions: call.env.runnerFunctions', - ), - ); + const file = 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts'; + const anchor = 'runnerFunctions: new Map(call.state.helperRegistry)'; + const current = source(file); + assert.equal(current.split(anchor).length - 1, 2, `mutation anchor count changed in ${file}`); + const errors = validate(new Map([[file, current.replaceAll(anchor, 'runnerFunctions: call.env.runnerFunctions')]])); assert.ok(errors.some((error) => error.includes('snapshotted helper registry'))); }); @@ -207,6 +235,39 @@ test('rejects deletion of nested composite helper arguments', () => { assert.ok(errors.some((error) => error.includes('helper arguments must recurse'))); }); +test('rejects deletion of resumable array argument execution', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts', + "if (node.kind === 'arrayLit')", + "if (false && node.kind === 'arrayLit')", + ), + ); + assert.ok(errors.some((error) => error.includes('resumable helper argument owner'))); +}); + +test('rejects deletion of resumable closure during admission preflight', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts', + 'resumableHelperNames: helperGraph.resumableHelperNames', + 'resumableHelperNames: new Set()', + ), + ); + assert.ok(errors.some((error) => error.includes('structure preflight must bind'))); +}); + +test('rejects deletion of resumable closure in helper body preflight', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts', + 'copyInternalEffectMachineState(env, callEnv)', + 'void callEnv', + ), + ); + assert.ok(errors.some((error) => error.includes('helper body preflight must inherit'))); +}); + test('rejects helper-only admission before combined class preflight', () => { const errors = validate( replace( @@ -220,10 +281,14 @@ test('rejects helper-only admission before combined class preflight', () => { test('rejects freezing the helper registry after structure preflight', () => { const file = 'packages/core/src/ir/semantics/internal-effect-machine.ts'; - const before = `state.helperRegistry = assertInternalMachineHelperGraph(nodes, env, state.classRegistry).functions; + const before = `const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + state.helperRegistry = helperGraph.functions; + state.resumableHelperNames = helperGraph.resumableHelperNames; assertInternalEffectMachineStructureSupported(nodes, env);`; const after = `assertInternalEffectMachineStructureSupported(nodes, env); - state.helperRegistry = assertInternalMachineHelperGraph(nodes, env, state.classRegistry).functions;`; + const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + state.helperRegistry = helperGraph.functions; + state.resumableHelperNames = helperGraph.resumableHelperNames;`; const errors = validate(replace(file, before, after)); - assert.ok(errors.some((error) => error.includes('registry must be frozen before combined structure preflight'))); + assert.ok(errors.some((error) => error.includes('resumable closure must be frozen before combined structure preflight'))); }); diff --git a/scripts/source-runner-convergence-manifest.json b/scripts/source-runner-convergence-manifest.json index 10e55a9b4..032d55728 100644 --- a/scripts/source-runner-convergence-manifest.json +++ b/scripts/source-runner-convergence-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "milestone": "KERN-5-R2-M3.31b2c1", + "milestone": "KERN-5-R2-M3.31b2c2", "owned": [ { "id": "do", @@ -98,6 +98,12 @@ "status": "unified", "evidence": "packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts" }, + { + "id": "runner-helper-resumable-class-effects", + "kind": "environment", + "status": "unified", + "evidence": "packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts" + }, { "id": "runner-class-pre-super-constructor", "kind": "environment", @@ -110,7 +116,7 @@ "id": "runner-classes-state", "kind": "environment", "status": "legacy", - "followUp": "M3.31b2c2-resumable-helper-class-effects-and-M3.31c-module-ownership" + "followUp": "M3.31c-module-ownership" } ] } diff --git a/scripts/source-runner-convergence.test.mjs b/scripts/source-runner-convergence.test.mjs index 5a449a215..382b26733 100644 --- a/scripts/source-runner-convergence.test.mjs +++ b/scripts/source-runner-convergence.test.mjs @@ -17,9 +17,12 @@ const files = new Map( 'packages/core/src/ir/semantics/internal-effect-machine-class-value.ts', 'packages/core/src/ir/semantics/internal-effect-machine-class-value-runtime.ts', 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts', 'packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts', 'packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts', 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + 'packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts', 'packages/core/src/ir/semantics/internal-effect-machine-class-instance.ts', 'packages/core/src/ir/semantics/internal-effect-machine-leaf.ts', 'packages/core/src/ir/semantics/internal-effect-machine-leaf-result.ts', @@ -62,6 +65,7 @@ const files = new Map( 'packages/core/tests/runtime-envelope-effect-machine-class-virtual-method.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-virtual-method-admission.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-helper.test.ts', + 'packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-helper-portable.test.ts', From 579fec983509dfbdf29c3260d4a5f9a772294756 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:25:41 +0200 Subject: [PATCH 2/3] feat(runtime): own linked module identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../kern-5-r2-m3-31c-module-ownership/spec.md | 362 ++++++++++ docs/kern-5-release-train.md | 24 + package.json | 2 +- ...nternal-effect-machine-class-activation.ts | 18 +- ...ernal-effect-machine-class-construction.ts | 2 + .../internal-effect-machine-class-frame.ts | 41 +- .../internal-effect-machine-class-graph.ts | 117 ++- ...internal-effect-machine-class-preflight.ts | 37 +- .../internal-effect-machine-class-runtime.ts | 4 +- .../internal-effect-machine-helper-class.ts | 79 +- ...internal-effect-machine-helper-contract.ts | 17 +- .../internal-effect-machine-helper-graph.ts | 183 +++-- ...nternal-effect-machine-helper-preflight.ts | 21 +- .../internal-effect-machine-helper-runtime.ts | 59 +- .../internal-effect-machine-module-graph.ts | 144 ++++ ...internal-effect-machine-structure-state.ts | 7 +- .../internal-effect-machine-types.ts | 5 +- .../ir/semantics/internal-effect-machine.ts | 9 +- .../src/ir/semantics/runner-machine-scope.ts | 133 +++- .../src/runner-capability-linked-handlers.ts | 353 +++++++++ packages/core/src/runner-capability-plan.ts | 678 +----------------- ...runner-class-frame-capability-admission.ts | 47 +- packages/core/src/runner-runtime-scope.ts | 85 +++ packages/core/src/runner.ts | 82 +-- .../runner-capability-class-frame.test.ts | 147 +++- ...ffect-machine-class-helper-effects.test.ts | 37 +- ...nvelope-effect-machine-class-state.test.ts | 37 +- ...pe-effect-machine-module-ownership.test.ts | 337 +++++++++ scripts/check-source-runner-convergence.mjs | 10 +- scripts/kern-5-fitness-policy.json | 2 +- ...er-class-constructor-super-convergence.mjs | 4 +- .../source-runner-class-frame-convergence.mjs | 11 +- ...source-runner-class-getter-convergence.mjs | 5 +- ...source-runner-class-helper-convergence.mjs | 24 +- ...e-runner-class-helper-convergence.test.mjs | 26 +- ...-runner-class-super-method-convergence.mjs | 8 +- ...unner-class-virtual-method-convergence.mjs | 11 +- .../source-runner-convergence-manifest.json | 13 +- scripts/source-runner-convergence-utils.mjs | 4 +- scripts/source-runner-convergence.test.mjs | 38 +- ...ce-runner-module-ownership-convergence.mjs | 236 ++++++ ...nner-module-ownership-convergence.test.mjs | 192 +++++ 42 files changed, 2536 insertions(+), 1115 deletions(-) create mode 100644 .Codex/specs/kern-5-r2-m3-31c-module-ownership/spec.md create mode 100644 packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts create mode 100644 packages/core/src/runner-capability-linked-handlers.ts create mode 100644 packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts create mode 100644 scripts/source-runner-module-ownership-convergence.mjs create mode 100644 scripts/source-runner-module-ownership-convergence.test.mjs diff --git a/.Codex/specs/kern-5-r2-m3-31c-module-ownership/spec.md b/.Codex/specs/kern-5-r2-m3-31c-module-ownership/spec.md new file mode 100644 index 000000000..a18c8d1b2 --- /dev/null +++ b/.Codex/specs/kern-5-r2-m3-31c-module-ownership/spec.md @@ -0,0 +1,362 @@ +# KERN 5 R2 M3.31c Module-Owned Helper and Class Identity + +**Status:** COMPLETE +**Date:** 2026-07-17 +**Confidence:** 0.98 +**Parent objective:** close the final `runner-classes-state` follow-up and promote the source runner's class row + +## Executive Summary + +M3.31c makes the canonical source machine own helper and class execution across +already-valid KERN module imports, additive re-exports, and aliases. A reached +binding must execute against its defining module's private helper/class scope, +not a root-flattened registry. Alias spelling is a lookup name only; function, +class, cache, receiver, recursion, and snapshot identity follow the defining +linker binding. + +The implementation will authenticate the complete linker-created module graph, +clone it once per admission while preserving shared binding identity, and switch +every helper/class call frame to the frozen defining-module scope. Preflight and +capability planning will traverse that same module-relative graph. Direct helper +effects remain rejected, public runtime/capability ABIs do not change, and +cross-module inheritance remains unsupported because the compatibility linker +already rejects an imported base before scope wiring. + +## Current State and Root Cause + +- **VERIFIED:** the runtime linker creates one scope per module, tags own + declarations with that scope, and wires imports/re-exports to the exact + defining binding object. It deliberately does not copy imported bindings. + Evidence: `packages/core/src/runner.ts:444-508`. +- **VERIFIED:** only the root scope is currently marked for machine admission. + The ownership fact snapshots the root class map but has no authenticated + module graph or module-relative snapshot. Evidence: + `runner-machine-scope.ts:27-28,112-133` and `runner.ts:511-519`. +- **VERIFIED:** class admission rejects an alias because it requires map key, + canonical class name, and defining scope to equal the selected root scope. + Evidence: `internal-effect-machine-class-graph.ts:129-166`. +- **VERIFIED:** helper admission has the same root/name restriction and scans a + reached helper body against root functions rather than the helper's defining + module. Evidence: `internal-effect-machine-helper-graph.ts:241-310`. +- **VERIFIED:** per-run execution stores flat name-keyed class/helper registries + and a name-keyed resumable set. Evidence: + `internal-effect-machine-types.ts:52-60` and + `internal-effect-machine.ts:76-94`. +- **VERIFIED:** helper cache keys, lookup, and child environments use the call + spelling plus a flattened helper registry. This can collide for equal names + and cannot resolve a defining module's private symbols. Evidence: + `internal-effect-machine-helper-runtime.ts:31-40,77-107,149-207`. +- **VERIFIED:** class constructor/member environments likewise reuse the caller's + functions and a flat class registry. Receiver lookup uses `className` against + the current environment instead of the receiver's defining module. Evidence: + `internal-effect-machine-class-activation.ts:83-126` and + `internal-effect-machine-class-graph.ts:352-436`. +- **VERIFIED:** capability planning explicitly disables machine class ownership + whenever imports exist, and imported class references conservatively mark all + members unsupported. Evidence: + `runner-capability-plan.ts:154-173,485-550`. +- **VERIFIED:** compatibility behavior already covers explicit aliases, + re-exports, transitive imports, an imported helper's private class, and + same-named private helper isolation. Evidence: + `packages/core/tests/runner-source-executor.test.ts:4025-4171,4329-4380,4483-4518`. +- **VERIFIED:** M3.31b2c2 leaves imported/re-exported/cross-module identity as the + sole M3.31c follow-up. Evidence: `docs/kern-5-release-train.md:758-779` and + `scripts/source-runner-convergence-manifest.json`. +- **VERIFIED:** cross-module inheritance is not compatibility behavior: + `collectRunnerClasses` rejects a base absent from the declaring module before + imports are wired. Evidence: `runner-runtime-scope.ts:186-201`. + +## Scope and Claim Ledger + +| Claim | Status | Evidence or oracle | +| --- | --- | --- | +| Imports and re-exports preserve the defining binding object | VERIFIED | `runner.ts:460-503` | +| Alias spelling may differ from canonical binding name | VERIFIED | runtime linker plus existing module fixtures | +| Machine admission can trust only linker-created, unchanged scope graphs | VERIFIED requirement | current private root/class ownership boundary | +| One frozen graph can retain alias references and defining-module identity | PROPOSED | RED mutation, alias, and same-name isolation oracles | +| Helper/class frames must execute with the binding's defining scope | VERIFIED requirement | compatibility behavior and current flat-frame root cause | +| Planner must clear unsupported only for runtime-admitted linked paths | VERIFIED requirement | existing planner/runtime disposition contract | +| Cross-module inheritance is part of M3.31c | REJECTED | compatibility collection rejects imported bases | +| Public handler, capability, continuation, or module-loader ABI must change | REJECTED | private runtime convergence only | + +## Contract Table + +| Surface | M3.31c contract | Failure rule | +| --- | --- | --- | +| Graph owner | exact root plus every linked module scope and exact map entries | forged, replaced, deleted, or added entries fail before provider dispatch | +| Binding identity | aliases and re-exports point to one frozen defining binding | no canonical-name copy or root flattening | +| Function lookup | resolve call spelling in the active frozen module scope | missing/kind-skewed target remains link/admission failure | +| Function frame | body, nested helpers, and private classes use `fn.module` | caller scope never shadows defining-module private names | +| Class lookup | resolve construction spelling in active scope; instance stores canonical class plus defining scope | alias spelling does not become class identity | +| Class frame | constructor, fields, methods, getters, lineage, and helpers use `cls.module` | receiver/module mismatch fails closed | +| Inheritance | same-defining-module inheritance only | imported base remains existing link error | +| Helper cache | partition by frozen function binding identity plus values/provenance | equal names in separate modules never share results | +| Recursion identity | binding/member identity, not display label alone | equal class/member names across modules do not false-positive | +| Resumability | fixed-point closure over frozen function binding identities | name collisions cannot mark the wrong helper resumable | +| Snapshot | graph maps, bodies, metadata, members, lineage, and aliases freeze before first suspension | later source/map/object mutation cannot affect the run | +| Concurrency | graph, cache, receivers, frames, budget, seed, time, and providers stay per run | overlapping runs cannot share private state | +| Preflight | traverse root sequence, then each reached binding in its defining scope | any reached unsupported path rejects before the first provider | +| Planner | exact imported function/class constructor/member reachability follows defining modules | unreachable member capabilities are not executable; admitted reached paths are not unsupported | +| Compatibility | all out-of-domain shapes keep compatibility selection | no post-provider fallback | + +## Selected Design + +### 1. Linker-authenticated module graph + +Extend the private scope owner from one root marker to one graph marker. The +marker receives the root scope plus every linker-built scope after imports are +wired. It snapshots exact function/class maps, map entries, defining-module +references, and owned binding metadata. Every entry must resolve to a binding +whose defining scope belongs to the graph. Own entries use their canonical +name; imported aliases may use a different local key but must reference an +existing defining binding. + +Single-module scope construction uses the same one-scope graph contract. Raw +caller maps and a valid root map mutated after marking remain inadmissible. + +### 2. Identity-preserving frozen graph + +Admission clones all authenticated scopes in two passes: + +1. allocate one empty frozen-view scope per original module; +2. clone each unique function/class binding once, point its `module` at the + cloned defining scope, then populate every local key with that shared clone. + +Bodies, params, returns, fields, constructors, methods, getters, and IR metadata +use existing deep snapshot rules. The result exposes a frozen root scope plus a +private original/snapshot scope index. Alias/re-export keys therefore survive, +while two modules' equal canonical names remain distinct bindings. + +The clone owner is always the original binding's `module`, never the scope whose +alias/re-export entry happened to expose it. For A -> B re-export -> C import, +all three local keys reference one clone whose module is A's cloned scope. + +### 3. Module-relative execution + +Install the frozen graph on `InternalEffectMachineState`. Root execution resolves +against its frozen root. A helper call first resolves the local spelling in the +active scope, then creates its child environment with the resolved binding's +defining `functions` and `classes`. Class construction follows the same rule. +An instance keeps its defining frozen module; later getter/method dispatch uses +that module registry even when the receiver is held by another module. + +Cache partitions key first by function binding identity. Resumable closure uses +function binding identities. Class recursion labels gain a private module/binding +token while public diagnostics retain canonical `Class.member` spelling. + +### 4. Module-aware graph admission and preflight + +Replace root-name worklists with binding-identity worklists carrying the active +module scope. Calls in a helper/class body resolve from the binding's defining +scope. Class composition, scalar-return proof, inheritance, constructor plans, +and selected member preflight receive that scope's frozen registries. The root +sequence is the only root-scope seed; transitive private declarations enter only +through reached bindings. + +Unused declarations may exist in an authenticated snapshot, but an unsupported +unused member must not poison an otherwise admitted path. Direct `capability` or +`print` nodes in helper bodies remain outside the pure-helper language; effects +are still owned only through admitted class frames. + +### 5. Exact linked-module capability planning + +Replace the single `ownsClassFrames` root boolean with linked-graph admission. +The planner builds the same linked runtime scopes from its already-parsed module +graph, then asks source-machine admission for the selected root handler. + +Reachability work items carry defining module plus function/class/member +identity. Imported function calls jump to their defining handler. Imported class +construction queues only its constructor lineage; receiver tracking queues only +the selected getter/method and continues that member body in the defining +module. Re-export and alias resolution reuse the existing capability-module +scope map. A reached handler is `unsupported` unless the complete linked graph is +runtime-admitted; planner-only suppression is forbidden. + +This planner continues to classify requirements and compare them with the +existing host-provided capability options. KERN modules do not register +providers during module initialization, so M3.31c neither introduces a provider +dependency graph nor changes ambient/native provider readiness. + +The already oversized `runner-capability-plan.ts` receives no new traversal +bulk. Module-aware handler traversal is extracted to a focused source file under +500 lines. + +### 6. Final convergence promotion + +Add a dedicated module-ownership convergence owner and mutations for graph +authentication, alias identity, defining-scope switching, binding-keyed cache, +receiver module dispatch, snapshot depth, preflight timing, and planner +disposition. Promote `runner-classes-state` from `legacy` to owned/current, +remove the M3.31c follow-up, and record the release receipt. Reference and +fallback engines must not be invoked for the admitted corpus. + +## Alternatives Considered + +### A. Relax root name/module guards + +Rejected. It admits aliases but still flattens nested private symbols, collides +equal names, and loses receiver identity after suspension. + +### B. Copy imported bindings into the root registry under aliases + +Rejected. Copies make one definition acquire multiple identities, break private +module resolution, and make mutation/cache behavior order-dependent. + +### C. Use module path strings as runtime identity + +Rejected. Paths are loader metadata, invite hardcoded/public coupling, and are +unnecessary because linker-owned scope/binding objects already provide private +identity. + +### D. Snapshot every loaded module but preflight every declaration + +Rejected. Authentication may cover the complete linked graph, but behavioral +admission must remain reachability-based so unused unsupported members do not +become false blockers. + +## Blast Radius + +| Area | Action | Reason | +| --- | --- | --- | +| `runner-machine-scope.ts` plus focused snapshot module | modify/add | authenticate and clone complete module graphs | +| `runner-runtime-scope.ts`, `runner.ts` | modify | mark all scopes and share linked-scope construction | +| class/helper graph and preflight modules | modify/extract | binding-identity, defining-scope traversal | +| helper runtime/types/state | modify | scope-relative lookup, binding cache/resumability identity | +| class graph/frame/activation | modify | alias construction and receiver-defining-module dispatch | +| planner admission and linked handler traversal | modify/extract | exact runtime-owned cross-module disposition | +| focused runtime/planner tests | add | RED/GREEN identity, suspension, containment, precision | +| convergence scripts/manifest/release train | modify | final row promotion and mutation binding | + +No new handwritten source file may exceed 500 lines; already oversized files +must shrink or receive only small wiring changes. + +## Acceptance Criteria + +- [x] RED-at-base proves an imported helper alias and imported class alias select + compatibility (or fail machine-only) before M3.31c. +- [x] Sync and async public runners execute imported and re-exported aliases on + the source machine with zero reference/fallback invocations. +- [x] An imported helper resolves its defining module's private same-named helper + and private class, not root declarations with equal names. +- [x] A root-held imported class receiver dispatches constructor, method, getter, + virtual/super work, and nested helper calls in its defining module. +- [x] Two modules with equal helper/class/member names keep distinct cache, + resumability, receiver, and recursion identity. +- [x] A transitive additive re-export retains the original defining binding and + module-private resolution. +- [x] A reached imported class effect suspends/resumes exactly once; provider and + trace order match compatibility with no replay. +- [x] A reached unsupported private path rejects before an earlier root provider; + an unreachable unsupported member does not poison admission. +- [x] Mutation after selection cannot replace graph maps, aliases, function/class + metadata, bodies, members, lineage, or defining-module identity. +- [x] Overlapping runs isolate snapshots, caches, receivers, continuations, + providers, budget, seed, and time. +- [x] Forged scopes, foreign bindings, map mutation, alias replacement, and + receiver/module skew fail before provider/accessor execution. +- [x] Planner reports only reached imported capabilities executable, clears + unsupported only for the admitted graph, and preserves missing-provider and + async-boundary reporting. +- [x] Import cycles, missing exports, kind mismatch, alias conflict, direct helper + effects, and cross-module inheritance retain current failures. +- [x] Convergence mutations bind every identity boundary; `runner-classes-state` + is current with no remaining M3.31c follow-up. +- [x] Focused gates, typecheck, lint, full `pnpm fitness:kern-5`, and terminal + `agon review -e claude,codex,agy` pass with all verified blockers fixed. + +## RED Oracle Matrix + +1. `main -> imported bar as remoteBar -> private foo`, with a root `foo` of the + same name; output must come from the imported module. +2. `main -> re-exported helper alias -> private class -> effectful getter`; one + async provider call and one result after resume. +3. `main -> new ImportedBox as Alias -> method/getter`, while root also declares + `Box`; receiver dispatch must use imported identity. +4. Root class method calls imported helper; imported helper uses a same-named + private class/member. No false recursion diagnostic. +5. Two imported helpers named `compute` through different aliases receive equal + arguments but produce module-specific cached results. +6. Two-level additive re-export preserves the base module's private helper and + class identity. +7. A provider fires before external metadata mutation; resumed execution uses + the pre-suspension module graph. +8. Two overlapping async runs mutate different exposed metadata and receive + different provider results without cross-run leakage. +9. A reached malformed/unsupported imported member fails before an earlier root + provider; the same member when unreachable does not affect execution. +10. Planner sees one selected imported method capability but not another unused + method's capability; machine/reference dispositions agree. +11. Forged module scopes and post-mark alias/map replacement reject without + calling a provider or getter. +12. Existing link-error parity and direct-helper-effect rejection remain green. + +## Out of Scope + +- Cross-module inheritance or changing class collection/link semantics. +- Class instances crossing helper parameters, helper results, or external + bindings. +- Direct helper `capability`/`print`, setters, statics, streams, or explicit async + KERN member syntax. +- New public module tokens, continuation/frame APIs, handler ABI, capability ABI, + or module-loader ABI. +- Transactional rollback of already-observed external effects. + +## Open Questions + +None. + +## Adversarial Record + +`nero-1784271256785-sab58w` challenged re-export parenting, cyclic module +initialization, JavaScript live bindings, prototype/`instanceof` identity, and +ambient capability providers. The re-export challenge strengthened the explicit +rule that a clone is owned only by the original binding's defining module and is +created once for the whole run. + +The other scenarios are outside the actual KERN contract: module exports are +functions/classes rather than mutable JavaScript namespace bindings; KERN +modules do not execute provider-registration initialization; runner class +instances are private records with no JavaScript prototype/`instanceof` ABI and +cannot cross helper/external boundaries; and capability planning reports host +provider readiness rather than granting capabilities from user-module imports. +No implementation scope was widened for those assumptions. + +## Terminal Verification Record + +The final Node 22 `pnpm fitness:kern-5` wall passed after every review fix: +432/432 cross-target fixtures, 109/109 class fixtures, 233 native cases, +48/48 checker fixtures, 39/39 validator verdicts, and 40 application fixtures +on three legs plus whole-app boot. The required browser wall passed at 153 +modules / 1,534,548 raw / 328,497 gzip bytes / 75 ms cold / 108 ms browser, +and all 68 convergence mutations were killed. + +The review sequence was substantive rather than ceremonial. The first panels +added exact oracles for caller-independent field initialization and binding-keyed +resumability (`review-1784274854035-iz1wpn`, +`review-1784275324468-ctztgz`). The next full-roster review found a real +imported-helper -> private-class -> private-helper reachability gap; a RED +iteration-budget oracle reproduced it, the class composition graph now returns +the exact reached helper bindings, and convergence binds both the owner and +oracle (`review-1784277601118-gduk7h`). + +The terminal full-roster review completed on the final diff +(`review-1784279863324-vvtril`). Its remaining Agy blockers were disproved +against current source: resumable frames already execute `call.fn`; scanning +every non-root class would violate the accepted exact-reachability contract and +is superseded by imported-alias and helper-class binding traversal; constructor +stack labels are not compared for recursion identity, while method/getter +frames use `classFrameIdentity`. Codex reported no findings, and Claude's only +important item was explicitly low-confidence defense-in-depth rather than a +current executable defect. + +## Deploy Order + +Build and verify the complete slice locally with granular signed commits and one +remote push. Immediately before publication, fetch and rebase. If M3.31b2c2 is +then present on `origin/main`, move the validated M3.31c commits to a fresh +`feat/kern-5-r2-m3-31c-module-ownership` branch created from `origin/main`; never +push the merged M3.31b2c2 branch again. If it is still absent, stack M3.31c on the +existing branch and push that same branch once, exactly as instructed. + +No source release is declared until the full local wall, convergence mutations, +and terminal full-roster review pass. diff --git a/docs/kern-5-release-train.md b/docs/kern-5-release-train.md index bab61e95c..d6cc6d723 100644 --- a/docs/kern-5-release-train.md +++ b/docs/kern-5-release-train.md @@ -777,6 +777,30 @@ trusted-publishing/provenance configuration is inspected. by scalar-first classification and the nested-helper oracle, while three non-behavioral allocation/traversal/explicit-boundary nits were retained (`review-1784269895309-ubvwed-m3-31b2c2-resumable-helper-class`). + - [x] M3.31c module-owned helper and class identity: the canonical source + machine now authenticates the complete linker-built module graph, snapshots + every function/class binding once while preserving alias and re-export + identity, and executes reached helpers and class frames in their defining + module scopes. Helper caches, resumability, recursion labels, receiver + dispatch, and metadata snapshots are binding-keyed rather than flattened by + display name. The shared runtime linker now feeds both execution and linked + capability admission; the planner follows exact imported function, + constructor, method, and getter reachability without poisoning an admitted + path with unused private members. `runner-classes-state` is unified/current + and the convergence manifest has no deferred rows. The final Node 22 + `pnpm fitness:kern-5` wall passed with 432/432 cross-target fixtures, + 109/109 class fixtures, 233 native cases, 48/48 checker fixtures, 39/39 + validator verdicts, and 40 application fixtures on three legs plus + whole-app boot. The required browser wall passed at 153 modules / 1,534,548 + raw / 328,497 gzip bytes / 75 ms cold / 108 ms browser, and all 68 + convergence mutations were killed. Reviews added and fixed binding-identity + resumability plus imported-helper -> private-class -> private-helper budget + reachability with RED public oracles + (`review-1784275324468-ctztgz`, `review-1784277601118-gduk7h`). The final + full-roster review completed on the fixed diff; its two Agy blockers were + stale or contradicted the exact-reachability contract, Codex reported no + findings, and Claude reported no high-confidence blocker + (`review-1784279863324-vvtril`). 1. Correct the support matrix and make `fitness:kern-5` the planned aggregate, without pretending missing commands already exist. diff --git a/package.json b/package.json index b91adb2a2..2f60341e3 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "test:runtime-abi": "pnpm --filter @kernlang/core test --testPathPatterns='runtime-handler-public' && node --test ./scripts/runtime-handler-public-import-closure.test.mjs && node ./scripts/check-runtime-envelope.mjs", "test:core-runtime-internalization": "pnpm --filter @kernlang/core --filter kern-lang build && node scripts/check-core-runtime-internalization.mjs && node --test scripts/core-runtime-internalization.test.mjs", "check:source-runner-convergence": "node ./scripts/check-source-runner-convergence.mjs", - "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|class-(state|method|getter|inheritance|frame|constructor-super|pre-super|super-method|virtual-method|helper))' && node --test ./scripts/source-runner-convergence.test.mjs ./scripts/source-runner-class-helper-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs", + "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|module-ownership|class-)' && node --test ./scripts/source-runner*-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs", "build:kern-alpha-receipt": "node ./scripts/kir-v1/alpha-receipt.mjs", "test:infra": "pnpm test:prepush && pnpm test:release-policy && pnpm test:kern-5-fitness && pnpm test:kern-semantic-ownership && pnpm test:kern-ir-eligibility && pnpm test:kern-canonical-value && pnpm test:kern-kir-structural-constitution && pnpm test:kern-kir-structural-codec && pnpm test:kern-kir-module-graph && pnpm test:kern-kir-coverage-closure && pnpm test:kern-kir-evidence && pnpm test:kern-alpha-receipt && pnpm test:kern-runtime-envelope && pnpm test:runtime-abi && pnpm test:core-runtime-internalization && pnpm test:source-runner-convergence", "fitness": "pnpm fitness:kern-5", diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts index cbcba989c..5aa9088ea 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts @@ -47,6 +47,8 @@ export function initializeInternalMachineClassFields( evaluate: EvalPortableValue, ): Record { const fields = Object.create(null) as Record; + // Whole-graph preflight admits only closed field expressions before handler + // locals exist, so caller bindings cannot become class initializer inputs. for (const candidate of internalMachineClassLineageBaseFirst(cls, registry)) { initializeInternalMachineClassLayerFields(candidate, fields, env, evaluate); } @@ -86,16 +88,18 @@ export function makeInternalMachineClassConstructorEnv( values: readonly PortableScalar[], env: SemanticEnv, registry: ReadonlyMap, + stackLabel = `${cls.name}.constructor`, ): SemanticEnv { const params = cls.constructor?.params ?? []; + const scope = cls.module; return makeEnv({ bindings: new Map(params.map((param, index) => [param, values[index]])), capabilities: env.capabilities, capabilityContext: env.capabilityContext, runnerCallCache: env.runnerCallCache, - runnerCallStack: [...(env.runnerCallStack ?? []), `${cls.name}.constructor`], - runnerClasses: new Map(registry), - runnerFunctions: env.runnerFunctions, + runnerCallStack: [...(env.runnerCallStack ?? []), stackLabel], + runnerClasses: scope?.classes ?? new Map(registry), + runnerFunctions: scope?.functions ?? env.runnerFunctions, runnerSuperClass: cls.extendsName, runnerThis: instance, seed: env.seed, @@ -110,15 +114,17 @@ export function makeInternalMachineClassMemberEnv( values: readonly PortableScalar[], env: SemanticEnv, registry: ReadonlyMap, + stackLabel = `${cls.name}.${member.name}`, ): SemanticEnv { + const scope = cls.module; return makeEnv({ bindings: new Map(member.params.map((param, index) => [param, values[index]])), capabilities: env.capabilities, capabilityContext: env.capabilityContext, runnerCallCache: env.runnerCallCache, - runnerCallStack: [...(env.runnerCallStack ?? []), `${cls.name}.${member.name}`], - runnerClasses: new Map(registry), - runnerFunctions: env.runnerFunctions, + runnerCallStack: [...(env.runnerCallStack ?? []), stackLabel], + runnerClasses: scope?.classes ?? new Map(registry), + runnerFunctions: scope?.functions ?? env.runnerFunctions, runnerSuperClass: cls.extendsName, runnerThis: instance, seed: env.seed, diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-construction.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-construction.ts index fad139937..c7f0d87f5 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-construction.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-construction.ts @@ -103,6 +103,8 @@ function effectiveBaseConstructor( cls: RunnerClassBinding, registry: ReadonlyMap, ): RunnerClassBinding | undefined { + // Cross-module inheritance is rejected before constructor planning; every + // admitted candidate therefore resolves through this one defining registry. const seen = new Set(); let current: RunnerClassBinding | undefined = cls.extendsName ? registry.get(cls.extendsName) : undefined; while (current) { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts index 25a7d7d64..aa65203ea 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts @@ -39,6 +39,15 @@ export type InternalMachineClassValueEvaluator = ( state: InternalEffectMachineState, ) => InternalMachineClassValueGenerator; +function classFrameIdentity( + state: InternalEffectMachineState, + cls: NonNullable>, + member: string, +): string { + const identity = state.moduleGraph?.classIdentity.get(cls); + return identity === undefined ? `${cls.name}.${member}` : `module-class:${identity}:${cls.name}.${member}`; +} + function appendEvaluation(target: TraceEvent[], evaluated: InternalMachineClassEvaluatedValue): T { target.push(...evaluated.events); return evaluated.value; @@ -73,6 +82,8 @@ function* evaluateInternalMachineClassConstructorLayer( } } if (cls.extendsName) { + // Class collection and lineage admission require the complete chain to + // share this defining module, so the registry is stable across layers. const base = registry.get(cls.extendsName); if (!base) throw new Error(`machine class: unknown base class "${cls.extendsName}"`); const baseValues = plan.superArguments.map((argument) => @@ -104,8 +115,8 @@ export function* evaluateInternalMachineClassNewFrame( evaluate: InternalMachineClassValueEvaluator, ): InternalMachineClassValueGenerator { const candidate = internalMachineClassForNew(value, env); - const registry = state.classRegistry; - const cls = candidate ? registry?.get(candidate.name) : undefined; + const cls = candidate; + const registry = cls?.module?.classes ?? state.classRegistry; if (!registry || !cls || !state.helperBodyRunner) throw new Error('machine class: construction is unavailable'); const events: TraceEvent[] = []; @@ -139,12 +150,21 @@ export function* evaluateInternalMachineClassMethodFrame( const values: PortableScalar[] = []; for (const argument of node.args) values.push(appendEvaluation(events, yield* evaluate(argument, env, state))); const label = `${resolved.cls.name}.${resolved.method.name}`; - if ((env.runnerCallStack ?? []).includes(label)) { + const frameIdentity = classFrameIdentity(state, resolved.cls, resolved.method.name); + if ((env.runnerCallStack ?? []).includes(frameIdentity)) { // Keep the compatibility runtime's public recursion diagnostic verbatim. throw new Error(`runner-class: recursive member call "${label}" is unsupported`); } const registry = internalMachineClassRegistryForEnv(env); - const methodEnv = makeInternalMachineClassMemberEnv(resolved.cls, resolved.method, receiver, values, env, registry); + const methodEnv = makeInternalMachineClassMemberEnv( + resolved.cls, + resolved.method, + receiver, + values, + env, + registry, + frameIdentity, + ); const restore = bindInternalEffectMachineState(methodEnv, state); try { const trace = yield* bodyRunner(resolved.method.body, methodEnv, state); @@ -177,10 +197,19 @@ export function* evaluateInternalMachineClassGetterFrame( const registry = internalMachineClassRegistryForEnv(env); const label = `${resolved.cls.name}.${resolved.getter.name}`; - if ((env.runnerCallStack ?? []).includes(label)) { + const frameIdentity = classFrameIdentity(state, resolved.cls, resolved.getter.name); + if ((env.runnerCallStack ?? []).includes(frameIdentity)) { throw new Error(`runner-class: recursive member call "${label}" is unsupported`); } - const getterEnv = makeInternalMachineClassMemberEnv(resolved.cls, resolved.getter, receiver, [], env, registry); + const getterEnv = makeInternalMachineClassMemberEnv( + resolved.cls, + resolved.getter, + receiver, + [], + env, + registry, + frameIdentity, + ); const restore = bindInternalEffectMachineState(getterEnv, state); try { const trace = yield* bodyRunner(resolved.getter.body, getterEnv, state); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts index cc2e15cc6..8f81cf71f 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts @@ -8,13 +8,19 @@ import { internalMachineClassVisibleFields, } from './internal-effect-machine-class-lineage.js'; import { internalEffectMachineStateForEnv } from './internal-effect-machine-helper-state.js'; +import { + assertInternalMachineModuleGraph, + type InternalMachineModuleGraph, + internalMachineClassForEnv, + internalMachineModuleScopeForEnv, +} from './internal-effect-machine-module-graph.js'; import { lambdaRequiresIterationBudget } from './lambda-preflight.js'; import { isPortableBindingName } from './portable-scalar-domain.js'; -import { isRunnerMachineClassBinding, runnerMachineRootScope } from './runner-machine-scope.js'; import type { RunnerClassBinding, RunnerClassMemberBinding, RunnerModuleScope, SemanticEnv } from './semantic-env.js'; export interface InternalMachineClassGraph { readonly classes: ReadonlyMap; + readonly moduleGraph: InternalMachineModuleGraph; readonly requiresIterationBudget: boolean; } @@ -36,46 +42,6 @@ function classBodyRequiresIterationBudget(nodes: readonly IRNode[]): boolean { return false; } -function snapshotNode(node: IRNode): IRNode { - return { - type: node.type, - ...(node.loc ? { loc: { ...node.loc } } : {}), - ...(node.props ? { props: { ...node.props } } : {}), - ...(node.__quotedProps ? { __quotedProps: [...node.__quotedProps] } : {}), - ...(node.children ? { children: node.children.map(snapshotNode) } : {}), - }; -} - -function snapshotClassBinding(cls: RunnerClassBinding): RunnerClassBinding { - const snapshotMember = (member: RunnerClassMemberBinding): RunnerClassMemberBinding => ({ - body: member.body.map(snapshotNode), - ...(member.handler ? { handler: snapshotNode(member.handler) } : {}), - name: member.name, - ownerClass: member.ownerClass, - params: [...member.params], - }); - return { - name: cls.name, - ...(cls.extendsName !== undefined ? { extendsName: cls.extendsName } : {}), - fields: cls.fields.map((field) => ({ - name: field.name, - value: field.value, - })), - constructor: cls.constructor - ? { - name: cls.constructor.name, - ownerClass: cls.constructor.ownerClass, - params: [...cls.constructor.params], - body: cls.constructor.body.map(snapshotNode), - ...(cls.constructor.handler ? { handler: snapshotNode(cls.constructor.handler) } : {}), - } - : undefined, - methods: new Map([...cls.methods].map(([name, member]) => [name, snapshotMember(member)])), - getters: new Map([...cls.getters].map(([name, member]) => [name, snapshotMember(member)])), - module: cls.module, - }; -} - function assertGetter(key: string, getter: RunnerClassMemberBinding, cls: RunnerClassBinding): void { if ( getter.name !== key || @@ -127,14 +93,14 @@ function assertConstructor(cls: RunnerClassBinding): void { } function assertClassBinding(key: string, cls: RunnerClassBinding, scope: RunnerModuleScope): void { - if (!isRunnerMachineClassBinding(cls)) { - throw new Error(`machine class: "${key}" is not linker-owned`); - } - if (cls.name !== key || !isPortableBindingName(cls.name)) { + if (!isPortableBindingName(key) || !isPortableBindingName(cls.name)) { throw new Error(`machine class: invalid binding name "${key}"`); } - if (cls.module?.functions !== scope.functions || cls.module.classes !== scope.classes) { - throw new Error(`machine class: "${key}" is not defined in the selected root module`); + if (!cls.module || cls.module.classes.get(cls.name) !== cls) { + throw new Error(`machine class: "${key}" has invalid defining-module identity`); + } + if (scope.classes.get(key) !== cls) { + throw new Error(`machine class: "${key}" is not bound in the selected module`); } if (cls.extendsName !== undefined && !isPortableBindingName(cls.extendsName)) { throw new Error(`machine class: invalid base class name for "${key}"`); @@ -156,17 +122,19 @@ function assertClassBinding(key: string, cls: RunnerClassBinding, scope: RunnerM } export function assertInternalMachineClassGraph(env: SemanticEnv): InternalMachineClassGraph { - const classes = env.runnerClasses; - if (!classes || classes.size === 0) return { classes: new Map(), requiresIterationBudget: false }; - if (!env.runnerFunctions) throw new Error('machine class: root function scope is missing'); - const scope = runnerMachineRootScope(env.runnerFunctions, classes); - if (!scope) throw new Error('machine class: root scope is not linker-owned'); - for (const [key, cls] of classes) assertClassBinding(key, cls, scope); - assertInternalMachineClassInheritance(classes); - const snapshots = new Map([...classes].map(([name, cls]) => [name, snapshotClassBinding(cls)])); + const moduleGraph = assertInternalMachineModuleGraph(env); + const seen = new Set(); + for (const scope of moduleGraph.scopes) { + for (const [key, cls] of scope.classes) { + assertClassBinding(key, cls, scope); + seen.add(cls); + } + assertInternalMachineClassInheritance(scope.classes); + } return { - classes: snapshots, - requiresIterationBudget: [...snapshots.values()].some((cls) => + classes: moduleGraph.root.classes, + moduleGraph, + requiresIterationBudget: [...seen].some((cls) => [...(cls.constructor ? [cls.constructor] : []), ...cls.methods.values(), ...cls.getters.values()].some((member) => classBodyRequiresIterationBudget(member.body), ), @@ -335,7 +303,7 @@ export function internalMachineClassGraphClaims(nodes: readonly IRNode[], env: S export function internalMachineClassGraphHasClasses(env: SemanticEnv): boolean { try { - return assertInternalMachineClassGraph(env).classes.size > 0; + return assertInternalMachineClassGraph(env).moduleGraph.scopes.some((scope) => scope.classes.size > 0); } catch { return false; } @@ -358,19 +326,38 @@ export function internalMachineClassForNew(node: ValueIR, env: SemanticEnv): Run ) { return undefined; } - const admitted = internalEffectMachineStateForEnv(env)?.classRegistry?.get(node.argument.callee.name); + const state = internalEffectMachineStateForEnv(env); + const admitted = state?.moduleGraph + ? internalMachineClassForEnv(state.moduleGraph, env, node.argument.callee.name) + : state?.classRegistry?.get(node.argument.callee.name); if (admitted) return admitted; const cls = env.runnerClasses?.get(node.argument.callee.name); if (!cls) return undefined; try { - return assertInternalMachineClassGraph(env).classes.get(cls.name); + return assertInternalMachineClassGraph(env).classes.get(node.argument.callee.name); } catch { return undefined; } } export function internalMachineClassRegistryForEnv(env: SemanticEnv): ReadonlyMap { - return internalEffectMachineStateForEnv(env)?.classRegistry ?? assertInternalMachineClassGraph(env).classes; + const state = internalEffectMachineStateForEnv(env); + if (state?.moduleGraph) { + const scope = internalMachineModuleScopeForEnv(state.moduleGraph, env); + if (scope) return scope.classes; + } + return state?.classRegistry ?? assertInternalMachineClassGraph(env).classes; +} + +function internalMachineClassRegistryForReceiver( + env: SemanticEnv, + receiver: { readonly module?: RunnerModuleScope }, +): ReadonlyMap { + const state = internalEffectMachineStateForEnv(env); + if (state?.moduleGraph && receiver.module && state.moduleGraph.scopes.includes(receiver.module)) { + return receiver.module.classes; + } + return internalMachineClassRegistryForEnv(env); } export interface InternalMachineClassMethodCall { @@ -392,7 +379,7 @@ export function internalMachineClassGetterForRead( if (node.optional || node.object.kind !== 'ident' || node.object.name === 'super') return undefined; const receiver = internalMachineClassReceiver(node.object.name, env); if (!receiver || Object.hasOwn(receiver.fields, node.property)) return undefined; - const registry = internalMachineClassRegistryForEnv(env); + const registry = internalMachineClassRegistryForReceiver(env, receiver); const cls = registry.get(receiver.className); const resolved = cls ? internalMachineClassMemberFor(cls, node.property, 'getter', registry) : undefined; return resolved ? { cls: resolved.cls, getter: resolved.member, receiverName: node.object.name } : undefined; @@ -415,7 +402,7 @@ export function internalMachineClassMethodForCall( const receiver = internalMachineClassReceiver('this', env); const baseName = env.runnerSuperClass; if (!receiver || !baseName) return undefined; - const registry = internalMachineClassRegistryForEnv(env); + const registry = internalMachineClassRegistryForReceiver(env, receiver); const base = registry.get(baseName); const resolved = base ? internalMachineClassMemberFor(base, node.callee.property, 'method', registry) : undefined; return resolved ? { cls: resolved.cls, method: resolved.member, receiverName: 'this' } : undefined; @@ -423,14 +410,14 @@ export function internalMachineClassMethodForCall( if (node.callee.object.name === 'this') { const receiver = internalMachineClassReceiver('this', env); if (!receiver) return undefined; - const registry = internalMachineClassRegistryForEnv(env); + const registry = internalMachineClassRegistryForReceiver(env, receiver); const cls = registry.get(receiver.className); const resolved = cls ? internalMachineClassMemberFor(cls, node.callee.property, 'method', registry) : undefined; return resolved ? { cls: resolved.cls, method: resolved.member, receiverName: 'this' } : undefined; } const receiver = internalMachineClassReceiver(node.callee.object.name, env); if (!receiver) return undefined; - const registry = internalMachineClassRegistryForEnv(env); + const registry = internalMachineClassRegistryForReceiver(env, receiver); const cls = registry.get(receiver.className); const resolved = cls ? internalMachineClassMemberFor(cls, node.callee.property, 'method', registry) : undefined; return resolved ? { cls: resolved.cls, method: resolved.member, receiverName: node.callee.object.name } : undefined; diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts index 1d8b93d34..26796e227 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts @@ -243,17 +243,28 @@ function markDefiniteConstructorFieldAssignments( } export function assertInternalMachineClassFramePreflight(env: SemanticEnv, analyze: ClassBodyAnalyzer): void { - const registry = assertInternalMachineClassGraph(env).classes; const activeState = internalEffectMachineStateForEnv(env); - const preflightState = { - classRegistry: registry, - helperBodyRunner: activeState?.helperBodyRunner, - helperRegistry: activeState?.helperRegistry, - resumableHelperNames: activeState?.resumableHelperNames, - remainingIterations: undefined, - }; - for (const cls of registry.values()) { - const visibleFields = internalMachineClassVisibleFields(cls, registry); + const classGraph = activeState?.moduleGraph + ? { + classes: activeState.moduleGraph.root.classes, + moduleGraph: activeState.moduleGraph, + requiresIterationBudget: false, + } + : assertInternalMachineClassGraph(env); + const registry = classGraph.classes; + const classes = new Set(classGraph.moduleGraph.scopes.flatMap((scope) => [...scope.classes.values()])); + for (const cls of classes) { + const definingRegistry = cls.module?.classes ?? registry; + const preflightState = { + classRegistry: definingRegistry, + helperBodyRunner: activeState?.helperBodyRunner, + helperRegistry: activeState?.helperRegistry, + moduleGraph: activeState?.moduleGraph ?? classGraph.moduleGraph, + resumableHelpers: activeState?.resumableHelpers, + resumableHelperNames: activeState?.resumableHelperNames, + remainingIterations: undefined, + }; + const visibleFields = internalMachineClassVisibleFields(cls, definingRegistry); const constructorValues = (cls.constructor?.params ?? []).map(() => null); const { constructorEnv, instance } = createInternalMachineClassInstance( cls, @@ -261,12 +272,12 @@ export function assertInternalMachineClassFramePreflight(env: SemanticEnv, analy constructorValues, evalPortableValue, INTERNAL_MACHINE_PREFLIGHT_CLASS_OWNER, - registry, + definingRegistry, ); if (cls.constructor) { const restore = bindInternalEffectMachineState(constructorEnv, preflightState); try { - const plan = internalMachineClassConstructorPlan(cls, registry); + const plan = internalMachineClassConstructorPlan(cls, definingRegistry); const unstableBindings = new Set(cls.constructor.params); assertClassBodyExpressions(plan.preSuper, visibleFields, constructorEnv, false); const preSuperCompletions = analyze(plan.preSuper, 0, constructorEnv, unstableBindings, true); @@ -304,7 +315,7 @@ export function assertInternalMachineClassFramePreflight(env: SemanticEnv, analy instance, member.params.map(() => null), env, - registry, + definingRegistry, ); const restore = bindInternalEffectMachineState(memberEnv, preflightState); try { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-class-runtime.ts b/packages/core/src/ir/semantics/internal-effect-machine-class-runtime.ts index 853621b6e..9e96ab32a 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-class-runtime.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-class-runtime.ts @@ -81,7 +81,7 @@ function classNew( const value = parseExpression(node.props.value); const cls = internalMachineClassForNew(value, env); if (!cls) return undefined; - const registry = internalMachineClassRegistryForEnv(env); + const registry = cls.module?.classes ?? internalMachineClassRegistryForEnv(env); const admitted = registry.get(cls.name); return admitted ? { cls: admitted, registry, value } : undefined; } @@ -96,6 +96,8 @@ export function preflightInternalMachineClassLet( ): boolean { const resolved = classNew(node, env); if (!resolved) return false; + // The graph-level class preflight validates these expressions before this + // handler-local environment is populated; keep the per-let check as defense. const lineage = internalMachineClassLineageBaseFirst(resolved.cls, resolved.registry); const fieldExpressions = lineage.flatMap((candidate) => candidate.fields.flatMap((field) => diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts index cb796d876..a3fe169fa 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts @@ -8,13 +8,23 @@ import { } from './internal-effect-machine-class-lineage.js'; import { isPortableScalarHelperReturnContract } from './internal-effect-machine-helper-contract.js'; import { assertPortableMachineLetShape, assertPortableMachineScalarShape } from './portable-machine-shape.js'; -import type { RunnerClassBinding, RunnerClassMemberBinding, SemanticEnv } from './semantic-env.js'; +import type { + RunnerClassBinding, + RunnerClassMemberBinding, + RunnerFunctionBinding, + SemanticEnv, +} from './semantic-env.js'; interface ClassFrame { readonly owner: RunnerClassBinding; readonly receiver: RunnerClassBinding; } +interface ClassReachability { + readonly helpers: Set; + readonly stack: Set; +} + function expressionSources(node: IRNode): readonly string[] { const out: string[] = []; for (const key of ['cond', 'expr', 'from', 'in', 'input', 'on', 'step', 'to', 'value'] as const) { @@ -105,15 +115,17 @@ function assertReachedMember( member: RunnerClassMemberBinding, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, ): void { const key = memberKey(owner, member); - if (stack.has(key)) throw new Error(`machine helper: recursive class member "${key}" is outside this slice`); - stack.add(key); + if (reachability.stack.has(key)) { + throw new Error(`machine helper: recursive class member "${key}" is outside this slice`); + } + reachability.stack.add(key); try { - assertReachedClassNodes(member.body, { owner, receiver }, classes, env, stack); + assertReachedClassNodes(member.body, { owner, receiver }, classes, env, reachability); } finally { - stack.delete(key); + reachability.stack.delete(key); } } @@ -122,14 +134,14 @@ function assertReachedClassValue( frame: ClassFrame, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, ): ValueIR { if (node.kind === 'new' && classForNew(node, classes)) { throw new Error('machine helper: class-to-class allocation is outside this slice'); } if (node.kind === 'call' && node.callee.kind === 'ident' && node.callee.name === 'super') { for (const argument of node.args) { - assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, stack), env); + assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, reachability), env); } return classScalarPlaceholder(); } @@ -150,9 +162,9 @@ function assertReachedClassValue( throw new Error('machine helper: reached class method is unavailable'); } for (const argument of node.args) { - assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, stack), env); + assertPortableMachineScalarShape(assertReachedClassValue(argument, frame, classes, env, reachability), env); } - assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, reachability); return classScalarPlaceholder(); } } @@ -162,7 +174,8 @@ function assertReachedClassValue( if (node.optional || node.args.length !== helper.params.length) { throw new Error('machine helper: reached helper call is unavailable'); } - const args = node.args.map((argument) => assertReachedClassValue(argument, frame, classes, env, stack)); + reachability.helpers.add(helper); + const args = node.args.map((argument) => assertReachedClassValue(argument, frame, classes, env, reachability)); for (const argument of args) assertPortableMachineLetShape(argument, env); return { ...node, args }; } @@ -182,14 +195,14 @@ function assertReachedClassValue( } const resolved = internalMachineClassMemberFor(start, node.property, 'getter', classes); if (!resolved) throw new Error('machine helper: reached class field or getter is unavailable'); - assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, frame.receiver, resolved.member, classes, env, reachability); return classScalarPlaceholder(); } } if (node.kind === 'ident' && (node.name === 'this' || node.name === 'super')) { throw new Error('machine helper: private receiver cannot cross a class member boundary'); } - return mapValue(node, (child) => assertReachedClassValue(child, frame, classes, env, stack)); + return mapValue(node, (child) => assertReachedClassValue(child, frame, classes, env, reachability)); } function assertReachedClassNodes( @@ -197,13 +210,13 @@ function assertReachedClassNodes( frame: ClassFrame, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, ): void { for (const node of nodes) { for (const source of expressionSources(node)) { - assertReachedClassValue(parseExpression(source), frame, classes, env, stack); + assertReachedClassValue(parseExpression(source), frame, classes, env, reachability); } - if (node.children) assertReachedClassNodes(node.children, frame, classes, env, stack); + if (node.children) assertReachedClassNodes(node.children, frame, classes, env, reachability); } } @@ -211,10 +224,10 @@ function assertReachedConstruction( cls: RunnerClassBinding, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, ): void { for (const layer of internalMachineClassLineageBaseFirst(cls, classes)) { - if (layer.constructor) assertReachedMember(layer, cls, layer.constructor, classes, env, stack); + if (layer.constructor) assertReachedMember(layer, cls, layer.constructor, classes, env, reachability); } } @@ -223,7 +236,7 @@ function assertHelperValue( bindings: ReadonlyMap, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, ): void { if (node.kind === 'ident' && (node.name === 'this' || node.name === 'super')) { throw new Error('machine helper: class use is outside the pure helper domain'); @@ -245,10 +258,10 @@ function assertHelperValue( throw new Error('machine helper: reached class method is unavailable'); } for (const argument of node.args) { - assertHelperValue(argument, bindings, classes, env, stack); + assertHelperValue(argument, bindings, classes, env, reachability); assertPortableMachineScalarShape(portableHelperScalarShape(argument, bindings), env); } - assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, reachability); return; } } @@ -259,11 +272,11 @@ function assertHelperValue( if (internalMachineClassVisibleFields(receiver, classes).has(node.property)) return; const resolved = internalMachineClassMemberFor(receiver, node.property, 'getter', classes); if (!resolved) throw new Error('machine helper: reached class field or getter is unavailable'); - assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, stack); + assertReachedMember(resolved.cls, receiver, resolved.member, classes, env, reachability); return; } } - for (const child of valueChildren(node)) assertHelperValue(child, bindings, classes, env, stack); + for (const child of valueChildren(node)) assertHelperValue(child, bindings, classes, env, reachability); } function usesHelperLocalClassScalar(node: ValueIR, bindings: ReadonlyMap): boolean { @@ -301,7 +314,7 @@ function assertHelperNodes( inherited: ReadonlyMap, classes: ReadonlyMap, env: SemanticEnv, - stack: Set, + reachability: ClassReachability, classScalarReturns: Set, composition: { composesClass: boolean }, ): void { @@ -316,16 +329,16 @@ function assertHelperNodes( if (constructed && letValue?.kind === 'new' && letValue.argument.kind === 'call') { composition.composesClass = true; for (const argument of letValue.argument.args) { - assertHelperValue(argument, bindings, classes, env, stack); + assertHelperValue(argument, bindings, classes, env, reachability); assertPortableMachineScalarShape(portableHelperScalarShape(argument, bindings), env); } - assertReachedConstruction(constructed, classes, env, stack); + assertReachedConstruction(constructed, classes, env, reachability); const name = node.props?.name; if (typeof name !== 'string' || name === '') throw new Error('machine helper: class let requires a binding'); bindings.set(name, constructed); } else { for (const source of expressionSources(node)) { - assertHelperValue(parseExpression(source), bindings, classes, env, stack); + assertHelperValue(parseExpression(source), bindings, classes, env, reachability); } if (node.type === 'return' && typeof node.props?.value === 'string') { const value = parseExpression(node.props.value); @@ -348,7 +361,7 @@ function assertHelperNodes( } } if (node.children) { - assertHelperNodes(node.children, bindings, classes, env, stack, classScalarReturns, composition); + assertHelperNodes(node.children, bindings, classes, env, reachability, classScalarReturns, composition); } } } @@ -356,6 +369,7 @@ function assertHelperNodes( export interface InternalMachineHelperClassComposition { readonly classScalarReturns: ReadonlySet; readonly composesClass: boolean; + readonly reachableFunctions: ReadonlySet; } export function assertInternalMachineHelperClassComposition( @@ -365,6 +379,11 @@ export function assertInternalMachineHelperClassComposition( ): InternalMachineHelperClassComposition { const classScalarReturns = new Set(); const composition = { composesClass: false }; - assertHelperNodes(nodes, new Map(), classes, env, new Set(), classScalarReturns, composition); - return { classScalarReturns, composesClass: composition.composesClass }; + const reachability: ClassReachability = { helpers: new Set(), stack: new Set() }; + assertHelperNodes(nodes, new Map(), classes, env, reachability, classScalarReturns, composition); + return { + classScalarReturns, + composesClass: composition.composesClass, + reachableFunctions: reachability.helpers, + }; } diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts index 6544f471c..cc7df0787 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts @@ -1,10 +1,16 @@ import { internalEffectMachineStateForEnv } from './internal-effect-machine-helper-state.js'; +import { internalMachineFunctionForEnv } from './internal-effect-machine-module-graph.js'; import type { RunnerFunctionBinding, SemanticEnv } from './semantic-env.js'; const PORTABLE_SCALAR_RETURN_TYPES = new Set(['boolean', 'null', 'number', 'string']); function helperRegistryForEnv(env: SemanticEnv): ReadonlyMap | undefined { - return internalEffectMachineStateForEnv(env)?.helperRegistry ?? env.runnerFunctions; + const state = internalEffectMachineStateForEnv(env); + if (state?.moduleGraph) { + const scope = state.moduleGraph.scopeByFunctions.get(env.runnerFunctions ?? state.moduleGraph.root.functions); + if (scope) return scope.functions; + } + return state?.helperRegistry ?? env.runnerFunctions; } export function isPortableScalarHelperReturnContract(returns: unknown): boolean { @@ -28,6 +34,11 @@ export function isInternalMachineScalarHelperCall(name: string, arity: number, e export function isInternalMachineResumableHelperCall(name: string, arity: number, env: SemanticEnv): boolean { const state = internalEffectMachineStateForEnv(env); - const fn = state?.helperRegistry?.get(name) ?? env.runnerFunctions?.get(name); - return fn?.params.length === arity && state?.resumableHelperNames?.has(name) === true; + const fn = state?.moduleGraph + ? internalMachineFunctionForEnv(state.moduleGraph, env, name) + : (state?.helperRegistry?.get(name) ?? env.runnerFunctions?.get(name)); + return ( + fn?.params.length === arity && + (state?.resumableHelpers ? state.resumableHelpers.has(fn) : state?.resumableHelperNames?.has(name) === true) + ); } diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts index 36902128c..f8447f4f8 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts @@ -9,25 +9,28 @@ import { isInternalMachineScalarHelperCall, isPortableScalarHelperReturnContract, } from './internal-effect-machine-helper-contract.js'; +import type { InternalMachineModuleGraph } from './internal-effect-machine-module-graph.js'; import { hasNoBody, isUnifiedNodeType } from './internal-effect-machine-types.js'; import { assertPortableMachineScalarShape } from './portable-machine-shape.js'; import { isPortableBindingName } from './portable-scalar-domain.js'; -import { runnerMachineRootScope } from './runner-machine-scope.js'; -import type { RunnerClassBinding, RunnerFunctionBinding, RunnerModuleScope, SemanticEnv } from './semantic-env.js'; +import type { RunnerClassBinding, RunnerFunctionBinding, SemanticEnv } from './semantic-env.js'; const PURE_HELPER_CONTAINER_TYPES = new Set(['branch', 'each', 'else', 'for', 'if', 'while']); const PURE_HELPER_EXCLUDED_TYPES = new Set(['capability', 'lambda', 'print', 'try']); export interface InternalMachineHelperGraph { readonly functions: ReadonlyMap; + readonly moduleGraph: InternalMachineModuleGraph; + readonly reachableFunctions: ReadonlySet; readonly requiresIterationBudget: boolean; + readonly resumableHelpers: ReadonlySet; readonly resumableHelperNames: ReadonlySet; } function transitiveResumableHelpers( - direct: ReadonlySet, - calls: ReadonlyMap>, -): ReadonlySet { + direct: ReadonlySet, + calls: ReadonlyMap>, +): ReadonlySet { const resumable = new Set(direct); for (let changed = true; changed; ) { changed = false; @@ -40,30 +43,16 @@ function transitiveResumableHelpers( return resumable; } -function snapshotNode(node: IRNode): IRNode { - return { - type: node.type, - ...(node.loc ? { loc: { ...node.loc } } : {}), - ...(node.props ? { props: structuredClone(node.props) } : {}), - ...(node.__quotedProps ? { __quotedProps: [...node.__quotedProps] } : {}), - ...(node.children ? { children: node.children.map(snapshotNode) } : {}), - }; -} - -function snapshotFunctionBinding(fn: RunnerFunctionBinding): RunnerFunctionBinding { - return { - body: fn.body.map(snapshotNode), - ...(fn.handler ? { handler: snapshotNode(fn.handler) } : {}), - module: fn.module, - name: fn.name, - params: [...fn.params], - returns: structuredClone(fn.returns), - }; -} - -function valueCalls(node: ValueIR, names: ReadonlyMap, out: Set): void { +function valueCalls( + node: ValueIR, + names: ReadonlyMap, + out: Set, +): void { if (node.kind === 'call') { - if (node.callee.kind === 'ident' && names.has(node.callee.name)) out.add(node.callee.name); + if (node.callee.kind === 'ident') { + const binding = names.get(node.callee.name); + if (binding) out.add(binding); + } valueCalls(node.callee, names, out); for (const argument of node.args) valueCalls(argument, names, out); return; @@ -131,14 +120,18 @@ function nodeExpressionSources(node: IRNode): readonly string[] { return sources; } -function directNodeCalls(node: IRNode, names: ReadonlyMap, out: Set): void { +function directNodeCalls( + node: IRNode, + names: ReadonlyMap, + out: Set, +): void { for (const source of nodeExpressionSources(node)) valueCalls(parseExpression(source), names, out); } function collectNodeCalls( nodes: readonly IRNode[], names: ReadonlyMap, - out: Set, + out: Set, ): void { for (const node of nodes) { directNodeCalls(node, names, out); @@ -148,10 +141,11 @@ function collectNodeCalls( function collectClassBodyCalls( classes: ReadonlyMap, - names: ReadonlyMap, - out: Set, + out: Set, ): void { - for (const cls of classes.values()) { + for (const cls of new Set(classes.values())) { + const names = cls.module?.functions; + if (!names) throw new Error(`machine helper: class "${cls.name}" has no defining function scope`); if (cls.constructor) collectNodeCalls(cls.constructor.body, names, out); for (const member of cls.methods.values()) collectNodeCalls(member.body, names, out); for (const getter of cls.getters.values()) collectNodeCalls(getter.body, names, out); @@ -211,102 +205,101 @@ function assertScalarHelperReturns( } function assertScalarHelperContracts( - functions: ReadonlyMap, + functions: ReadonlySet, env: SemanticEnv, - classScalarReturns: ReadonlyMap>, + classScalarReturns: ReadonlyMap>, ): void { - const helperFunctions = new Map(functions); - const scalarFunctions = new Map( - [...helperFunctions].filter(([, fn]) => isPortableScalarHelperReturnContract(fn.returns)), - ); - const helperEnv: SemanticEnv = { ...env, runnerFunctions: helperFunctions }; - const isScalarHelperCall = (name: string, arity: number): boolean => { - const fn = scalarFunctions.get(name); - return fn !== undefined && fn.params.length === arity; - }; - const isPortableHelperCall = (name: string, arity: number): boolean => - helperFunctions.get(name)?.params.length === arity; - for (const [name, fn] of scalarFunctions) { + for (const fn of functions) { + if (!isPortableScalarHelperReturnContract(fn.returns)) continue; + const scope = fn.module; + if (!scope) throw new Error(`machine helper: "${fn.name}" has no defining module`); + const helperEnv: SemanticEnv = { ...env, runnerClasses: scope.classes, runnerFunctions: scope.functions }; + const isScalarHelperCall = (name: string, arity: number): boolean => { + const called = scope.functions.get(name); + return ( + called !== undefined && called.params.length === arity && isPortableScalarHelperReturnContract(called.returns) + ); + }; + const isPortableHelperCall = (name: string, arity: number): boolean => + scope.functions.get(name)?.params.length === arity; assertScalarHelperReturns( fn.body, helperEnv, - name, + fn.name, isScalarHelperCall, isPortableHelperCall, - classScalarReturns.get(name) ?? new Set(), + classScalarReturns.get(fn) ?? new Set(), ); } } -function assertFunctionBinding(key: string, fn: RunnerFunctionBinding, scope: RunnerModuleScope): void { - if (fn.name !== key || !isPortableBindingName(fn.name)) { - throw new Error(`machine helper: invalid binding name "${key}"`); - } - if (fn.module?.functions !== scope.functions || fn.module.classes !== scope.classes) { - throw new Error(`machine helper: "${key}" is not defined in the selected root module`); +function assertFunctionBinding(fn: RunnerFunctionBinding): void { + if (!isPortableBindingName(fn.name)) throw new Error(`machine helper: invalid binding name "${fn.name}"`); + if (!fn.module || fn.module.functions.get(fn.name) !== fn) { + throw new Error(`machine helper: "${fn.name}" has invalid defining-module identity`); } if (!Array.isArray(fn.params) || !Array.isArray(fn.body)) { - throw new Error(`machine helper: "${key}" has invalid params or body`); + throw new Error(`machine helper: "${fn.name}" has invalid params or body`); } const params = new Set(); for (const param of fn.params) { if (!isPortableBindingName(param) || params.has(param)) { - throw new Error(`machine helper: "${key}" has invalid or duplicate parameters`); + throw new Error(`machine helper: "${fn.name}" has invalid or duplicate parameters`); } params.add(param); } if (fn.returns === undefined || fn.returns === '' || fn.returns === 'void') { - throw new Error(`machine helper: "${key}" must declare a non-void return`); + throw new Error(`machine helper: "${fn.name}" must declare a non-void return`); } } -function helperScope(env: SemanticEnv): RunnerModuleScope | undefined { - if (!env.runnerFunctions || env.runnerFunctions.size === 0) return undefined; - const scope = runnerMachineRootScope(env.runnerFunctions, env.runnerClasses); - if (!scope) throw new Error('machine helper: root scope is not linker-owned'); - return scope; -} - export function assertInternalMachineHelperGraph( nodes: readonly IRNode[], env: SemanticEnv, - admittedClasses = assertInternalMachineClassGraph(env).classes, + admittedClassGraph = assertInternalMachineClassGraph(env), ): InternalMachineHelperGraph { - const scope = helperScope(env); - if (!scope) { - return { functions: new Map(), requiresIterationBudget: false, resumableHelperNames: new Set() }; - } - const pending = new Set(); + const moduleGraph = admittedClassGraph.moduleGraph; + const scope = moduleGraph.root; + const pending = new Set(); collectNodeCalls(nodes, scope.functions, pending); - collectClassBodyCalls(admittedClasses, scope.functions, pending); - const functions = new Map(); - const classScalarReturns = new Map>(); - const directResumableHelpers = new Set(); - const helperCalls = new Map>(); + collectClassBodyCalls(scope.classes, pending); + const functions = new Set(); + const classScalarReturns = new Map>(); + const directResumableHelpers = new Set(); + const helperCalls = new Map>(); let requiresIterationBudget = false; while (pending.size > 0) { - const name = pending.values().next().value as string; - pending.delete(name); - if (functions.has(name)) continue; - const fn = scope.functions.get(name); - if (!fn) throw new Error(`machine helper: unknown function "${name}"`); - assertFunctionBinding(name, fn, scope); + const fn = pending.values().next().value as RunnerFunctionBinding; + pending.delete(fn); + if (functions.has(fn)) continue; + assertFunctionBinding(fn); if (assertPureHelperBody(fn.body)) requiresIterationBudget = true; - const snapshot = snapshotFunctionBinding(fn); - const composition = assertInternalMachineHelperClassComposition(snapshot.body, admittedClasses, env); - classScalarReturns.set(name, composition.classScalarReturns); - if (composition.composesClass) directResumableHelpers.add(name); - functions.set(name, snapshot); - const nested = new Set(); - collectNodeCalls(snapshot.body, scope.functions, nested); - helperCalls.set(name, nested); + const defining = fn.module; + if (!defining) throw new Error(`machine helper: "${fn.name}" has no defining module`); + const helperEnv: SemanticEnv = { + ...env, + runnerClasses: defining.classes, + runnerFunctions: defining.functions, + }; + const composition = assertInternalMachineHelperClassComposition(fn.body, defining.classes, helperEnv); + classScalarReturns.set(fn, composition.classScalarReturns); + if (composition.composesClass) directResumableHelpers.add(fn); + functions.add(fn); + const nested = new Set(); + collectNodeCalls(fn.body, defining.functions, nested); + for (const called of composition.reachableFunctions) nested.add(called); + helperCalls.set(fn, nested); for (const called of nested) if (!functions.has(called)) pending.add(called); } assertScalarHelperContracts(functions, env, classScalarReturns); + const resumableHelpers = transitiveResumableHelpers(directResumableHelpers, helperCalls); return { - functions, + functions: scope.functions, + moduleGraph, + reachableFunctions: functions, requiresIterationBudget, - resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls), + resumableHelpers, + resumableHelperNames: new Set([...resumableHelpers].map((fn) => fn.name)), }; } @@ -329,7 +322,7 @@ export function internalMachineHelperGraphRequiresIterationBudget(nodes: readonl export function internalMachineHelperGraphHasReachableFunctions(nodes: readonly IRNode[], env: SemanticEnv): boolean { try { - return assertInternalMachineHelperGraph(nodes, env).functions.size > 0; + return assertInternalMachineHelperGraph(nodes, env).reachableFunctions.size > 0; } catch { return false; } @@ -338,7 +331,7 @@ export function internalMachineHelperGraphHasReachableFunctions(nodes: readonly export function internalMachineHelperCallInValue(node: ValueIR, env: SemanticEnv): boolean { const functions = env.runnerFunctions; if (!functions || functions.size === 0) return false; - const calls = new Set(); + const calls = new Set(); valueCalls(node, functions, calls); return calls.size > 0; } @@ -355,7 +348,7 @@ export function internalMachineHelperCallInRaw(raw: unknown, env: SemanticEnv): export function internalMachineHelperCallInNode(node: IRNode, env: SemanticEnv): boolean { const functions = env.runnerFunctions; if (!functions || functions.size === 0) return false; - const calls = new Set(); + const calls = new Set(); try { directNodeCalls(node, functions, calls); } catch { diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts index 727fa5a02..26d4d75e0 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts @@ -1,6 +1,9 @@ import type { IRNode } from '../../types.js'; import { assertInternalMachineHelperGraph } from './internal-effect-machine-helper-graph.js'; -import { copyInternalEffectMachineState } from './internal-effect-machine-helper-state.js'; +import { + copyInternalEffectMachineState, + internalEffectMachineStateForEnv, +} from './internal-effect-machine-helper-state.js'; import { makeEnv, type SemanticEnv } from './semantic-env.js'; import type { CompletionKind } from './trace.js'; @@ -17,14 +20,22 @@ export function assertInternalMachineHelperPreflight( env: SemanticEnv, analyze: HelperBodyAnalyzer, ): void { - const graph = assertInternalMachineHelperGraph(nodes, env); - for (const fn of graph.functions.values()) { + const activeState = internalEffectMachineStateForEnv(env); + const moduleGraph = activeState?.moduleGraph; + const graph = assertInternalMachineHelperGraph( + nodes, + env, + moduleGraph ? { classes: moduleGraph.root.classes, moduleGraph, requiresIterationBudget: false } : undefined, + ); + for (const fn of graph.reachableFunctions) { + const scope = fn.module; + if (!scope) throw new Error(`machine helper: "${fn.name}" has no defining module`); const callEnv = makeEnv({ bindings: new Map(fn.params.map((name) => [name, null])), runnerCallCache: new Map(), runnerCallStack: [fn.name], - runnerClasses: env.runnerClasses, - runnerFunctions: env.runnerFunctions, + runnerClasses: scope.classes, + runnerFunctions: scope.functions, seed: env.seed, now: env.now, }); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts b/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts index fc6fc57e1..a70f2e2bd 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts @@ -7,6 +7,7 @@ import { bindInternalEffectMachineState, internalEffectMachineStateForEnv, } from './internal-effect-machine-helper-state.js'; +import { internalMachineFunctionForEnv } from './internal-effect-machine-module-graph.js'; import { InternalEffectMachineHelperPending, type InternalEffectMachineState, @@ -21,20 +22,16 @@ import { type PortableScalar, type RunnerPortableValue, } from './portable-scalar-domain.js'; -import { getBinding, hasBinding, makeEnv, type SemanticEnv } from './semantic-env.js'; +import { getBinding, hasBinding, makeEnv, type RunnerFunctionBinding, type SemanticEnv } from './semantic-env.js'; import type { TraceEvent } from './trace.js'; // Frozen native-runner semantics, shared with the compatibility helper owner. const RUNNER_CALL_DEPTH_LIMIT = 512; const RUNNER_CALL_CACHE_LIMIT = 1024; -function helperCacheKey( - name: string, - values: readonly RunnerPortableValue[], - provenance: readonly boolean[], -): string | undefined { +function helperCacheKey(values: readonly RunnerPortableValue[], provenance: readonly boolean[]): string | undefined { try { - return `machine-helper:${JSON.stringify([name, values.map((value, index) => [value, provenance[index]])])}`; + return JSON.stringify(values.map((value, index) => [value, provenance[index]])); } catch { return undefined; } @@ -60,14 +57,21 @@ export function evalInternalMachineHelperArgumentValue( return evaluate(node, env); } -function helperCache(state: InternalEffectMachineState): Map { - return (state.helperCallCache ??= new Map()); +function helperCache(state: InternalEffectMachineState, fn: RunnerFunctionBinding): Map { + const caches = (state.helperCallCache ??= new Map()); + let cache = caches.get(fn); + if (!cache) { + cache = new Map(); + caches.set(fn, cache); + } + return cache; } interface PreparedHelperCall { readonly cache: Map; readonly cacheKey: string | undefined; readonly env: SemanticEnv; + readonly fn: RunnerFunctionBinding; readonly intProvenance: ReadonlySet; readonly name: string; readonly state: InternalEffectMachineState; @@ -81,7 +85,9 @@ function prepareHelperCallFromValues( env: SemanticEnv, ): PreparedHelperCall { const state = internalEffectMachineStateForEnv(env); - const fn = state?.helperRegistry?.get(name); + const fn = state?.moduleGraph + ? internalMachineFunctionForEnv(state.moduleGraph, env, name) + : state?.helperRegistry?.get(name); if (!state || !fn || !state.helperBodyRunner) { throw new Error(`portable machine: function call "${name}" is outside the machine scalar domain`); } @@ -97,9 +103,10 @@ function prepareHelperCallFromValues( if (provenance[index]) intProvenance.add(fn.params[index]); } return { - cache: helperCache(state), - cacheKey: helperCacheKey(name, values, provenance), + cache: helperCache(state, fn), + cacheKey: helperCacheKey(values, provenance), env, + fn, intProvenance, name, state, @@ -114,7 +121,9 @@ function prepareHelperCall( evaluate: EvalPortableValue, ): PreparedHelperCall { const state = internalEffectMachineStateForEnv(env); - const fn = state?.helperRegistry?.get(name); + const fn = state?.moduleGraph + ? internalMachineFunctionForEnv(state.moduleGraph, env, name) + : state?.helperRegistry?.get(name); if (!state || !fn || !state.helperBodyRunner) { throw new Error(`portable machine: function call "${name}" is outside the machine scalar domain`); } @@ -147,17 +156,21 @@ function rememberHelperValue(call: PreparedHelperCall, value: RunnerPortableValu } function executePreparedHelper(call: PreparedHelperCall): RunnerPortableValue { - const fn = call.state.helperRegistry?.get(call.name); + const fn = call.fn; const bodyRunner = call.state.helperBodyRunner; if (!fn || !bodyRunner) throw new Error(`portable machine: helper "${call.name}" is unavailable`); const bindings = new Map(fn.params.map((param, index) => [param, call.values[index]])); + const scope = fn.module; + if (!scope) throw new Error(`portable machine: helper "${call.name}" has no defining module`); + const identity = call.state.moduleGraph?.functionIdentity.get(fn); + const stackLabel = identity === undefined ? call.name : `module-function:${identity}:${fn.name}`; const callEnv = makeEnv({ bindings, intProvenance: new Set(call.intProvenance), runnerCallCache: call.cache, - runnerCallStack: [...(call.env.runnerCallStack ?? []), call.name], - runnerClasses: call.env.runnerClasses, - runnerFunctions: new Map(call.state.helperRegistry), + runnerCallStack: [...(call.env.runnerCallStack ?? []), stackLabel], + runnerClasses: scope.classes, + runnerFunctions: scope.functions, seed: call.env.seed, now: call.env.now, }); @@ -191,17 +204,21 @@ export function* evalInternalMachineHelperFrame( const call = prepareHelperCallFromValues(name, values, provenance, env); const cached = cachedHelperValue(call); if (cached !== undefined) return { events: [], value: cached }; - const fn = call.state.helperRegistry?.get(call.name); + const fn = call.fn; const bodyRunner = call.state.helperBodyRunner; if (!fn || !bodyRunner) throw new Error(`portable machine: helper "${call.name}" is unavailable`); const bindings = new Map(fn.params.map((param, index) => [param, call.values[index]])); + const scope = fn.module; + if (!scope) throw new Error(`portable machine: helper "${call.name}" has no defining module`); + const identity = call.state.moduleGraph?.functionIdentity.get(fn); + const stackLabel = identity === undefined ? call.name : `module-function:${identity}:${fn.name}`; const callEnv = makeEnv({ bindings, intProvenance: new Set(call.intProvenance), runnerCallCache: call.cache, - runnerCallStack: [...(call.env.runnerCallStack ?? []), call.name], - runnerClasses: call.env.runnerClasses, - runnerFunctions: new Map(call.state.helperRegistry), + runnerCallStack: [...(call.env.runnerCallStack ?? []), stackLabel], + runnerClasses: scope.classes, + runnerFunctions: scope.functions, seed: call.env.seed, now: call.env.now, }); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts b/packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts new file mode 100644 index 000000000..c8ed21cb2 --- /dev/null +++ b/packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts @@ -0,0 +1,144 @@ +import type { IRNode } from '../../types.js'; +import { isRunnerMachineClassBinding, runnerMachineScopeGraph } from './runner-machine-scope.js'; +import type { + RunnerClassBinding, + RunnerClassMemberBinding, + RunnerFunctionBinding, + RunnerModuleScope, + SemanticEnv, +} from './semantic-env.js'; + +export interface InternalMachineModuleGraph { + readonly classIdentity: ReadonlyMap; + readonly functionIdentity: ReadonlyMap; + readonly root: RunnerModuleScope; + readonly scopeByFunctions: ReadonlyMap; + readonly scopes: readonly RunnerModuleScope[]; +} + +function snapshotNode(node: IRNode): IRNode { + return { + type: node.type, + ...(node.loc ? { loc: { ...node.loc } } : {}), + ...(node.props ? { props: structuredClone(node.props) } : {}), + ...(node.__quotedProps ? { __quotedProps: [...node.__quotedProps] } : {}), + ...(node.children ? { children: node.children.map(snapshotNode) } : {}), + }; +} + +function snapshotMember(member: RunnerClassMemberBinding): RunnerClassMemberBinding { + return { + body: member.body.map(snapshotNode), + ...(member.handler ? { handler: snapshotNode(member.handler) } : {}), + name: member.name, + ownerClass: member.ownerClass, + params: [...member.params], + }; +} + +export function assertInternalMachineModuleGraph(env: SemanticEnv): InternalMachineModuleGraph { + const functions = env.runnerFunctions; + if (!functions) { + if (env.runnerClasses && env.runnerClasses.size > 0) { + throw new Error('machine module: root function scope is missing'); + } + const root: RunnerModuleScope = { classes: new Map(), functions: new Map() }; + return { + classIdentity: new Map(), + functionIdentity: new Map(), + root, + scopeByFunctions: new Map([[root.functions, root]]), + scopes: [root], + }; + } + const owned = runnerMachineScopeGraph(functions, env.runnerClasses); + if (!owned) throw new Error('machine module: root scope graph is not linker-owned'); + + const scopeClones = new Map(); + for (const scope of owned.scopes) scopeClones.set(scope, { classes: new Map(), functions: new Map() }); + + const functionClones = new Map(); + const classClones = new Map(); + const cloneFunction = (binding: RunnerFunctionBinding): RunnerFunctionBinding => { + const existing = functionClones.get(binding); + if (existing) return existing; + const module = binding.module ? scopeClones.get(binding.module) : undefined; + if (!module) throw new Error(`machine module: function "${binding.name}" has a foreign defining scope`); + const clone: RunnerFunctionBinding = { + body: binding.body.map(snapshotNode), + ...(binding.handler ? { handler: snapshotNode(binding.handler) } : {}), + module, + name: binding.name, + params: [...binding.params], + returns: structuredClone(binding.returns), + }; + functionClones.set(binding, clone); + return clone; + }; + const cloneClass = (binding: RunnerClassBinding): RunnerClassBinding => { + const existing = classClones.get(binding); + if (existing) return existing; + if (!isRunnerMachineClassBinding(binding)) { + throw new Error('machine module: class binding is not linker-owned'); + } + const module = binding.module ? scopeClones.get(binding.module) : undefined; + if (!module) throw new Error(`machine module: class "${binding.name}" has a foreign defining scope`); + const clone: RunnerClassBinding = { + name: binding.name, + ...(binding.extendsName !== undefined ? { extendsName: binding.extendsName } : {}), + fields: binding.fields.map((field) => ({ name: field.name, value: field.value })), + constructor: binding.constructor ? snapshotMember(binding.constructor) : undefined, + methods: new Map([...binding.methods].map(([name, member]) => [name, snapshotMember(member)])), + getters: new Map([...binding.getters].map(([name, member]) => [name, snapshotMember(member)])), + module, + }; + classClones.set(binding, clone); + return clone; + }; + + for (const original of owned.scopes) { + const clone = scopeClones.get(original); + if (!clone) throw new Error('machine module: scope snapshot allocation failed'); + for (const [name, binding] of original.functions) clone.functions.set(name, cloneFunction(binding)); + for (const [name, binding] of original.classes) clone.classes.set(name, cloneClass(binding)); + } + + const root = scopeClones.get(owned.root); + if (!root) throw new Error('machine module: root scope snapshot is missing'); + const scopes = [...scopeClones.values()]; + const scopeByFunctions = new Map(); + for (const [original, clone] of scopeClones) { + scopeByFunctions.set(original.functions, clone); + scopeByFunctions.set(clone.functions, clone); + } + return { + classIdentity: new Map([...classClones.values()].map((binding, index) => [binding, index])), + functionIdentity: new Map([...functionClones.values()].map((binding, index) => [binding, index])), + root, + scopeByFunctions, + scopes, + }; +} + +export function internalMachineModuleScopeForEnv( + graph: InternalMachineModuleGraph, + env: SemanticEnv, +): RunnerModuleScope | undefined { + return env.runnerFunctions ? graph.scopeByFunctions.get(env.runnerFunctions) : undefined; +} + +export function internalMachineFunctionForEnv( + graph: InternalMachineModuleGraph, + env: SemanticEnv, + name: string, +): RunnerFunctionBinding | undefined { + return internalMachineModuleScopeForEnv(graph, env)?.functions.get(name); +} + +export function internalMachineClassForEnv( + graph: InternalMachineModuleGraph, + env: SemanticEnv, + name: string, +): RunnerClassBinding | undefined { + return internalMachineModuleScopeForEnv(graph, env)?.classes.get(name); +} diff --git a/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts b/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts index 5e111beaa..037cf450b 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-structure-state.ts @@ -13,11 +13,14 @@ export function withInternalEffectMachineStructureState( advance: () => T, ): T { if (internalEffectMachineStateForEnv(env)) return advance(); - const classes = assertInternalMachineClassGraph(env).classes; - const helperGraph = assertInternalMachineHelperGraph(nodes, env, classes); + const classGraph = assertInternalMachineClassGraph(env); + const classes = classGraph.classes; + const helperGraph = assertInternalMachineHelperGraph(nodes, env, classGraph); const restore = bindInternalEffectMachineState(env, { classRegistry: classes, helperRegistry: helperGraph.functions, + moduleGraph: classGraph.moduleGraph, + resumableHelpers: helperGraph.resumableHelpers, resumableHelperNames: helperGraph.resumableHelperNames, remainingIterations: undefined, }); diff --git a/packages/core/src/ir/semantics/internal-effect-machine-types.ts b/packages/core/src/ir/semantics/internal-effect-machine-types.ts index e943c918f..43c3f0bc0 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine-types.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine-types.ts @@ -1,6 +1,7 @@ import type { KernRunnerAsyncCapabilities, RuntimeCapabilityValue } from '../../runner-capabilities.js'; import type { IRNode } from '../../types.js'; import type { PreparedInternalCapabilityEffect } from './capability-runtime.js'; +import type { InternalMachineModuleGraph } from './internal-effect-machine-module-graph.js'; import type { RunnerClassBinding, RunnerFunctionBinding, SemanticEnv } from './semantic-env.js'; import type { Trace } from './trace.js'; @@ -52,9 +53,11 @@ export interface InternalCapabilityEffectRequest { export interface InternalEffectMachineState { classRegistry?: ReadonlyMap; helperBodyRunner?: InternalEffectMachineChildSequenceRunner; - helperCallCache?: Map; + helperCallCache?: Map>; helperEvaluationDepth?: number; helperRegistry?: ReadonlyMap; + moduleGraph?: InternalMachineModuleGraph; + resumableHelpers?: ReadonlySet; resumableHelperNames?: ReadonlySet; remainingIterations: number | undefined; } diff --git a/packages/core/src/ir/semantics/internal-effect-machine.ts b/packages/core/src/ir/semantics/internal-effect-machine.ts index 30460c4fd..794720b17 100644 --- a/packages/core/src/ir/semantics/internal-effect-machine.ts +++ b/packages/core/src/ir/semantics/internal-effect-machine.ts @@ -84,11 +84,14 @@ function* runMachine( nodes[0] ?? { type: '__block' }, ); } - state.classRegistry = assertInternalMachineClassGraph(env).classes; - assertInternalMachineClassUsage(nodes, env); + const classGraph = assertInternalMachineClassGraph(env); + state.classRegistry = classGraph.classes; + state.moduleGraph = classGraph.moduleGraph; + assertInternalMachineClassUsage(nodes, env, classGraph.classes); state.helperBodyRunner = runInternalEffectMachineSequence; - const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + const helperGraph = assertInternalMachineHelperGraph(nodes, env, classGraph); state.helperRegistry = helperGraph.functions; + state.resumableHelpers = helperGraph.resumableHelpers; state.resumableHelperNames = helperGraph.resumableHelperNames; assertInternalEffectMachineStructureSupported(nodes, env); return yield* runInternalEffectMachineSequence(nodes, env, state); diff --git a/packages/core/src/ir/semantics/runner-machine-scope.ts b/packages/core/src/ir/semantics/runner-machine-scope.ts index 552ac6852..6a7e13950 100644 --- a/packages/core/src/ir/semantics/runner-machine-scope.ts +++ b/packages/core/src/ir/semantics/runner-machine-scope.ts @@ -1,8 +1,16 @@ -import type { RunnerClassBinding, RunnerModuleScope } from './semantic-env.js'; +import type { RunnerClassBinding, RunnerFunctionBinding, RunnerModuleScope } from './semantic-env.js'; interface RootScopeOwnership { readonly classes: RunnerModuleScope['classes']; + readonly functionMetadata: ReadonlyMap; + readonly root: RunnerModuleScope; + readonly scopes: readonly ScopeOwnership[]; +} + +interface ScopeOwnership { readonly classEntries: ReadonlyMap; + readonly functionEntries: ReadonlyMap; + readonly scope: RunnerModuleScope; } interface DataDescriptorSnapshot { @@ -27,7 +35,10 @@ interface ClassBindingOwnership { const rootScopes = new WeakMap(); const linkerOwnedClassBindings = new WeakMap(); -function snapshotOwnedMetadata(binding: RunnerClassBinding): readonly ObjectDescriptorSnapshot[] { +function snapshotOwnedObjectGraph( + root: object, + skipNested: (object: object, key: PropertyKey) => boolean, +): readonly ObjectDescriptorSnapshot[] { const snapshots: ObjectDescriptorSnapshot[] = []; const seen = new WeakSet(); const visit = (object: object): void => { @@ -47,15 +58,38 @@ function snapshotOwnedMetadata(binding: RunnerClassBinding): readonly ObjectDesc writable: descriptor.writable ?? false, }); const nested = descriptor.value; - const skipsScopeGraph = object === binding && (key === 'module' || key === 'methods' || key === 'getters'); - if (!skipsScopeGraph && typeof nested === 'object' && nested !== null) visit(nested); + if (!skipNested(object, key) && typeof nested === 'object' && nested !== null) visit(nested); } snapshots.push({ descriptors, object, prototype: Object.getPrototypeOf(object) }); }; - visit(binding); + visit(root); + return snapshots; +} + +function snapshotOwnedMetadata(binding: RunnerClassBinding): readonly ObjectDescriptorSnapshot[] { + const snapshots = [ + ...snapshotOwnedObjectGraph( + binding, + (object, key) => object === binding && (key === 'module' || key === 'methods' || key === 'getters'), + ), + ]; + const seen = new Set(snapshots.map((snapshot) => snapshot.object)); for (const member of binding.methods.values()) visit(member); for (const member of binding.getters.values()) visit(member); return snapshots; + + function visit(member: object): void { + for (const snapshot of snapshotOwnedObjectGraph(member, () => false)) { + if (!seen.has(snapshot.object)) { + seen.add(snapshot.object); + snapshots.push(snapshot); + } + } + } +} + +function snapshotOwnedFunctionMetadata(binding: RunnerFunctionBinding): readonly ObjectDescriptorSnapshot[] { + return snapshotOwnedObjectGraph(binding, (object, key) => object === binding && key === 'module'); } function mapMatchesSnapshot(current: ReadonlyMap, expected: ReadonlyMap): boolean { @@ -109,9 +143,73 @@ export function isRunnerMachineClassBinding(binding: RunnerClassBinding): boolea ); } +function scopeGraph(root: RunnerModuleScope): readonly RunnerModuleScope[] { + const scopes: RunnerModuleScope[] = []; + const seen = new Set(); + const pending = [root]; + while (pending.length > 0) { + const scope = pending.pop(); + if (!scope || seen.has(scope)) continue; + seen.add(scope); + scopes.push(scope); + for (const binding of [...scope.functions.values(), ...scope.classes.values()]) { + if (binding.module && !seen.has(binding.module)) pending.push(binding.module); + } + } + return scopes; +} + +function scopeOwnershipMatches(owned: ScopeOwnership, graphScopes: ReadonlySet): boolean { + const { scope } = owned; + if ( + !mapMatchesSnapshot(scope.functions, owned.functionEntries) || + !mapMatchesSnapshot(scope.classes, owned.classEntries) + ) { + return false; + } + for (const binding of scope.functions.values()) { + const defining = binding.module; + if (!defining || !graphScopes.has(defining)) return false; + if (defining.functions.get(binding.name) !== binding) return false; + } + for (const binding of scope.classes.values()) { + if (!isRunnerMachineClassBinding(binding)) return false; + const defining = binding.module; + if (!defining || !graphScopes.has(defining)) return false; + if (defining.classes.get(binding.name) !== binding) return false; + } + return true; +} + +function rootScopeOwnership( + functions: RunnerModuleScope['functions'], + classes: RunnerModuleScope['classes'] | undefined, +): RootScopeOwnership | undefined { + const owned = rootScopes.get(functions); + if (!owned || (classes !== undefined && classes !== owned.classes)) return undefined; + const graphScopes = new Set(owned.scopes.map((entry) => entry.scope)); + for (const [binding, metadata] of owned.functionMetadata) { + if (!metadataMatchesSnapshot(metadata)) return undefined; + if (!graphScopes.has(binding.module as RunnerModuleScope)) return undefined; + } + if (!owned.scopes.every((entry) => scopeOwnershipMatches(entry, graphScopes))) return undefined; + return owned; +} + /** Mark the linker-created root scope as eligible for private machine admission. */ export function markRunnerMachineRootScope(scope: RunnerModuleScope): void { - rootScopes.set(scope.functions, { classes: scope.classes, classEntries: new Map(scope.classes) }); + const scopes = scopeGraph(scope); + const functions = new Set(scopes.flatMap((candidate) => [...candidate.functions.values()])); + rootScopes.set(scope.functions, { + classes: scope.classes, + functionMetadata: new Map([...functions].map((binding) => [binding, snapshotOwnedFunctionMetadata(binding)])), + root: scope, + scopes: scopes.map((candidate) => ({ + classEntries: new Map(candidate.classes), + functionEntries: new Map(candidate.functions), + scope: candidate, + })), + }); } /** Raw caller-supplied function maps never satisfy this private ownership fact. */ @@ -124,11 +222,20 @@ export function runnerMachineRootScope( functions: RunnerModuleScope['functions'], classes: RunnerModuleScope['classes'] | undefined, ): RunnerModuleScope | undefined { - const owned = rootScopes.get(functions); - if (!owned || (classes !== undefined && classes !== owned.classes)) return undefined; - if (owned.classes.size !== owned.classEntries.size) return undefined; - for (const [name, binding] of owned.classEntries) { - if (owned.classes.get(name) !== binding) return undefined; - } - return { classes: owned.classes, functions }; + const owned = rootScopeOwnership(functions, classes); + return owned ? owned.root : undefined; +} + +export interface RunnerMachineScopeGraph { + readonly root: RunnerModuleScope; + readonly scopes: readonly RunnerModuleScope[]; +} + +/** Resolve the exact unchanged linker-created scope graph behind a root view. */ +export function runnerMachineScopeGraph( + functions: RunnerModuleScope['functions'], + classes: RunnerModuleScope['classes'] | undefined, +): RunnerMachineScopeGraph | undefined { + const owned = rootScopeOwnership(functions, classes); + return owned ? { root: owned.root, scopes: owned.scopes.map((entry) => entry.scope) } : undefined; } diff --git a/packages/core/src/runner-capability-linked-handlers.ts b/packages/core/src/runner-capability-linked-handlers.ts new file mode 100644 index 000000000..f750cfb23 --- /dev/null +++ b/packages/core/src/runner-capability-linked-handlers.ts @@ -0,0 +1,353 @@ +import type { RunnerClassBinding, RunnerFunctionBinding, RunnerModuleScope } from './ir/semantics/semantic-env.js'; +import { parseExpression } from './parser-expression.js'; +import type { IRNode } from './types.js'; +import type { ValueIR } from './value-ir.js'; + +const AMBIGUOUS_CLASS = Symbol('ambiguous runner class'); +const NON_CLASS = Symbol('non-class runner value'); +const FIELD_HANDLER_CACHE = new WeakMap(); +type ClassBinding = RunnerClassBinding | typeof AMBIGUOUS_CLASS | typeof NON_CLASS; +type ExpressionMode = 'scalar' | 'let' | 'cap'; + +interface HandlerWork { + readonly handler: IRNode; + readonly scope: RunnerModuleScope; + readonly ownerClass?: RunnerClassBinding; + readonly receiverClass?: RunnerClassBinding; + readonly unsupported: boolean; +} + +export interface LinkedExecutableHandlers { + readonly handlers: ReadonlySet; + readonly unsupported: ReadonlySet; +} + +/** Trace exact function and class binding identities through the linked runtime scopes. */ +export function linkedExecutableKernHandlers( + entryHandler: IRNode | undefined, + rootScope: RunnerModuleScope | undefined, + ownsClassFrames: boolean, +): LinkedExecutableHandlers { + const handlers = new Set(); + const unsupported = new Set(); + if (!entryHandler || !rootScope) return { handlers, unsupported }; + + const queue: HandlerWork[] = [{ handler: entryHandler, scope: rootScope, unsupported: false }]; + const visited = new Set(); + const identities = new WeakMap(); + let nextIdentity = 0; + const identity = (value: object | undefined): number => { + if (!value) return 0; + const existing = identities.get(value); + if (existing !== undefined) return existing; + nextIdentity += 1; + identities.set(value, nextIdentity); + return nextIdentity; + }; + + const enqueue = (work: HandlerWork): void => { + queue.push(work); + }; + + while (queue.length > 0) { + const work = queue.pop(); + if (!work) continue; + const key = [ + identity(work.handler), + identity(work.scope), + identity(work.ownerClass), + identity(work.receiverClass), + work.unsupported ? 1 : 0, + ].join(':'); + if (visited.has(key)) continue; + visited.add(key); + handlers.add(work.handler); + if (work.unsupported) unsupported.add(work.handler); + scanHandler(work, ownsClassFrames, enqueue); + } + return { handlers, unsupported }; +} + +function scanHandler(work: HandlerWork, ownsClassFrames: boolean, enqueue: (work: HandlerWork) => void): void { + const classBindings = new Map(); + for (const node of walkNodes({ type: '__block', children: [...(work.handler.children ?? [])] })) { + recordClassBinding(node, work.scope, classBindings); + for (const expression of handlerExpressions(node)) { + scanExpression(expression.node, expression.mode, work, classBindings, ownsClassFrames, enqueue); + } + } +} + +function recordClassBinding(node: IRNode, scope: RunnerModuleScope, classBindings: Map): void { + if (node.type !== 'let') return; + const name = typeof node.props?.name === 'string' ? node.props.name : ''; + const raw = typeof node.props?.value === 'string' ? node.props.value : ''; + if (!name || !raw) return; + try { + const value = parseExpression(raw); + if (value.kind === 'new' && value.argument.kind === 'call' && value.argument.callee.kind === 'ident') { + const cls = scope.classes.get(value.argument.callee.name); + if (cls) mergeClassBinding(classBindings, name, cls); + } else if (value.kind === 'call' && value.callee.kind === 'ident' && scope.functions.has(value.callee.name)) { + mergeClassBinding(classBindings, name, AMBIGUOUS_CLASS); + } else if (value.kind === 'objectLit' || value.kind === 'arrayLit') { + mergeClassBinding(classBindings, name, NON_CLASS); + } + } catch { + // Parser and runtime diagnostics own malformed expressions. + } +} + +function mergeClassBinding(bindings: Map, name: string, value: ClassBinding): void { + const existing = bindings.get(name); + if (!existing) bindings.set(name, value); + else if (existing !== value) bindings.set(name, AMBIGUOUS_CLASS); +} + +function scanExpression( + node: ValueIR, + mode: ExpressionMode, + work: HandlerWork, + classBindings: ReadonlyMap, + ownsClassFrames: boolean, + enqueue: (work: HandlerWork) => void, +): void { + if (node.kind === 'call' && node.callee.kind === 'ident') { + const helper = work.scope.functions.get(node.callee.name); + if (helper) enqueueFunction(helper, work.unsupported, work.scope, enqueue); + } + if (node.kind === 'new' && node.argument.kind === 'call' && node.argument.callee.kind === 'ident') { + const cls = work.scope.classes.get(node.argument.callee.name); + if (cls) enqueueConstruction(cls, work.unsupported || !ownsClassFrames, enqueue); + } + if (node.kind === 'call' && node.callee.kind === 'member') { + enqueueMember(node.callee.object, node.callee.property, 'method', work, classBindings, ownsClassFrames, enqueue); + } else if (node.kind === 'member') { + enqueueMember(node.object, node.property, 'getter', work, classBindings, ownsClassFrames, enqueue); + } + for (const child of valueChildren(node, mode, work.scope)) { + scanExpression(child.node, child.mode, work, classBindings, ownsClassFrames, enqueue); + } +} + +function enqueueFunction( + binding: RunnerFunctionBinding, + unsupported: boolean, + fallbackScope: RunnerModuleScope, + enqueue: (work: HandlerWork) => void, +): void { + if (!binding.handler) return; + enqueue({ handler: binding.handler, scope: binding.module ?? fallbackScope, unsupported }); +} + +function enqueueConstruction( + receiver: RunnerClassBinding, + unsupported: boolean, + enqueue: (work: HandlerWork) => void, +): void { + for (const owner of classAncestry(receiver)) { + const scope = owner.module ?? receiver.module; + if (!scope) continue; + for (const field of owner.fields) { + if (typeof field.value !== 'string' || field.value.trim() === '') continue; + let fieldHandler = FIELD_HANDLER_CACHE.get(field); + if (!fieldHandler) { + fieldHandler = { + type: 'handler', + props: { lang: 'kern' }, + children: [{ type: 'do', props: { value: field.value } }], + }; + FIELD_HANDLER_CACHE.set(field, fieldHandler); + } + enqueue({ handler: fieldHandler, scope, ownerClass: owner, receiverClass: receiver, unsupported }); + } + if (owner.constructor?.handler) { + enqueue({ + handler: owner.constructor.handler, + scope, + ownerClass: owner, + receiverClass: receiver, + unsupported, + }); + } + } +} + +function enqueueMember( + object: ValueIR, + name: string, + kind: 'getter' | 'method', + work: HandlerWork, + classBindings: ReadonlyMap, + ownsClassFrames: boolean, + enqueue: (work: HandlerWork) => void, +): void { + const resolved = classForObject(object, work, classBindings); + if (resolved === NON_CLASS) return; + if (resolved === AMBIGUOUS_CLASS || !resolved) { + for (const cls of uniqueClasses(work.scope)) { + enqueueResolvedMember(cls, cls, name, kind, true, enqueue); + } + return; + } + const { startClass, receiverClass } = resolved; + enqueueResolvedMember(startClass, receiverClass, name, kind, work.unsupported || !ownsClassFrames, enqueue); +} + +function classForObject( + object: ValueIR, + work: HandlerWork, + classBindings: ReadonlyMap, +): + | { readonly startClass: RunnerClassBinding; readonly receiverClass: RunnerClassBinding } + | typeof AMBIGUOUS_CLASS + | typeof NON_CLASS + | undefined { + if (object.kind === 'ident' && object.name === 'this' && work.receiverClass) { + return { startClass: work.receiverClass, receiverClass: work.receiverClass }; + } + if (object.kind === 'ident' && object.name === 'super' && work.ownerClass && work.receiverClass) { + const parent = parentClass(work.ownerClass); + return parent ? { startClass: parent, receiverClass: work.receiverClass } : undefined; + } + if (object.kind === 'ident') { + const cls = classBindings.get(object.name); + return cls === AMBIGUOUS_CLASS || cls === NON_CLASS || !cls ? cls : { startClass: cls, receiverClass: cls }; + } + if (object.kind === 'new' && object.argument.kind === 'call' && object.argument.callee.kind === 'ident') { + const cls = work.scope.classes.get(object.argument.callee.name); + return cls ? { startClass: cls, receiverClass: cls } : undefined; + } + return AMBIGUOUS_CLASS; +} + +function enqueueResolvedMember( + startClass: RunnerClassBinding, + receiverClass: RunnerClassBinding, + name: string, + kind: 'getter' | 'method', + unsupported: boolean, + enqueue: (work: HandlerWork) => void, +): void { + for (const owner of classAncestry(startClass)) { + const member = kind === 'method' ? owner.methods.get(name) : owner.getters.get(name); + if (!member?.handler) continue; + const scope = owner.module ?? startClass.module; + if (!scope) return; + enqueue({ handler: member.handler, scope, ownerClass: owner, receiverClass, unsupported }); + return; + } +} + +function parentClass(cls: RunnerClassBinding): RunnerClassBinding | undefined { + return cls.extendsName ? cls.module?.classes.get(cls.extendsName) : undefined; +} + +function classAncestry(cls: RunnerClassBinding): RunnerClassBinding[] { + const out: RunnerClassBinding[] = []; + const seen = new Set(); + for ( + let current: RunnerClassBinding | undefined = cls; + current && !seen.has(current); + current = parentClass(current) + ) { + seen.add(current); + out.push(current); + } + return out; +} + +function uniqueClasses(scope: RunnerModuleScope): Set { + return new Set(scope.classes.values()); +} + +interface HandlerExpression { + readonly node: ValueIR; + readonly mode: ExpressionMode; +} + +function handlerExpressions(node: IRNode): HandlerExpression[] { + const props = node.props ?? {}; + const out: HandlerExpression[] = []; + const add = (raw: unknown, mode: ExpressionMode): void => { + if (typeof raw !== 'string' || raw.trim() === '') return; + try { + out.push({ node: parseExpression(raw), mode }); + } catch { + // Parser and runtime diagnostics own malformed expressions. + } + }; + if (node.type === 'let') add(props.value, 'let'); + else if (node.type === 'capability') add(props.input, 'cap'); + else if (node.type === 'do') add(props.value, 'scalar'); + else if (node.type === 'assign' || node.type === 'print' || node.type === 'return') add(props.value, 'scalar'); + else if (node.type === 'if' || node.type === 'while') add(props.cond, 'scalar'); + else if (node.type === 'fmt' && typeof props.template === 'string') add(`\`${props.template}\``, 'scalar'); + return out; +} + +function valueChildren(node: ValueIR, mode: ExpressionMode, scope: RunnerModuleScope): HandlerExpression[] { + const child = (value: ValueIR, childMode: ExpressionMode = 'scalar'): HandlerExpression => ({ + node: value, + mode: childMode, + }); + switch (node.kind) { + case 'unary': + case 'spread': + case 'await': + case 'propagate': + return [child(node.argument)]; + case 'binary': + return [child(node.left), child(node.right)]; + case 'conditional': + return [child(node.test), child(node.consequent), child(node.alternate)]; + case 'member': + return [child(node.object)]; + case 'index': + return []; + case 'call': + if (node.callee.kind === 'member') return [child(node.callee.object), ...node.args.map((arg) => child(arg))]; + if ( + node.callee.kind === 'ident' && + (scope.functions.has(node.callee.name) || node.callee.name === 'String' || node.callee.name === 'super') + ) { + return node.args.map((arg) => child(arg)); + } + return []; + case 'new': + return node.argument.kind === 'call' ? node.argument.args.map((arg) => child(arg)) : []; + case 'typeAssert': + case 'nonNull': + return [child(node.expression)]; + case 'tmplLit': + return node.expressions.map((expression) => child(expression)); + case 'arrayLit': + if (mode === 'cap') + return node.items.filter((item): item is ValueIR => Boolean(item)).map((item) => child(item, 'cap')); + if (mode === 'let') { + return node.items + .filter((item): item is ValueIR => Boolean(item)) + .map((item) => child(item, item.kind === 'arrayLit' ? 'let' : 'scalar')); + } + return []; + case 'objectLit': + if (mode === 'cap') return node.entries.flatMap((entry) => ('kind' in entry ? [] : [child(entry.value, mode)])); + if (mode === 'let') { + return node.entries.flatMap((entry) => ('kind' in entry ? [] : [child(entry.value)])); + } + return []; + default: + return []; + } +} + +function* walkNodes(root: IRNode): Generator { + const pending = [root]; + while (pending.length > 0) { + const node = pending.pop(); + if (!node) continue; + yield node; + const children = node.children ?? []; + for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]); + } +} diff --git a/packages/core/src/runner-capability-plan.ts b/packages/core/src/runner-capability-plan.ts index 58fafcc82..490285c7e 100644 --- a/packages/core/src/runner-capability-plan.ts +++ b/packages/core/src/runner-capability-plan.ts @@ -1,27 +1,23 @@ import { parseDocumentWithDiagnostics } from './parser.js'; import type { ParseOptions } from './parser-core.js'; -import { parseExpression } from './parser-expression.js'; import { ASYNC_CAPABILITY_IDS, CAPABILITY_DESCRIPTORS, type CapabilityDescriptor, type CapabilityId, } from './runner-capability-catalog.js'; -import { - RUNNER_CAPABILITY_AMBIGUOUS_CLASS, - recordRunnerCapabilityClassBinding, - resolveRunnerCapabilityClassCall, - runnerCapabilityClassAncestry, - runnerCapabilityClassCallKey, -} from './runner-capability-class-dispatch.js'; +import { linkedExecutableKernHandlers } from './runner-capability-linked-handlers.js'; import { collectExecutableRequirements, collectUnsupportedAsyncExecutionsAcrossModules, } from './runner-capability-requirement-reachability.js'; -import { ownsSingleModuleClassFrames } from './runner-class-frame-capability-admission.js'; +import { + type LinkedClassFrameAdmission, + linkedClassFrameAdmission, +} from './runner-class-frame-capability-admission.js'; import { moduleLinkErrors, ownExplicitExportKinds, type RunnerModuleExportRecord } from './runner-module-link.js'; +import { collectRunnerClasses, collectRunnerFunctions } from './runner-runtime-scope.js'; import type { IRNode, ParseDiagnostic } from './types.js'; -import type { ValueIR } from './value-ir.js'; export type { AsyncCapabilityId, @@ -32,7 +28,6 @@ export type { CapabilitySyncBoundary, } from './runner-capability-catalog.js'; export { CAPABILITY_DESCRIPTORS } from './runner-capability-catalog.js'; - export interface CapabilityRequirement { readonly id: CapabilityId; readonly namespace: string; @@ -156,21 +151,17 @@ export function analyzeKernSourceCapabilities( // imported helper called from main must count exactly as if it lived in the // root file (finding: preflight readiness parity across module boundaries). const moduleRoots = graph.roots.map((module) => module.root); - const unsupportedHandlers = new Set(); const rootModule = graph.roots.find((module) => module.path === rootPath); - const ownsClassFrames = Boolean( - rootModule && - graph.roots.length === 1 && - rootModule.imports.length === 0 && - ownsSingleModuleClassFrames(rootModule.root, entryHandlerName, options.iterationBudget), - ); - const executableHandlers = crossModuleExecutableHandlers( - graph, - rootPath, - entryHandlerName, - unsupportedHandlers, - ownsClassFrames, + const admission = rootModule + ? capabilityLinkedAdmission(graph, rootModule, entryHandlerName, options.iterationBudget) + : { ownsClassFrames: false }; + const executable = linkedExecutableKernHandlers( + admission.entryHandler, + admission.rootScope, + admission.ownsClassFrames, ); + const executableHandlers = executable.handlers; + const unsupportedHandlers = executable.unsupported; const executableRequirements = collectExecutableRequirements(moduleRoots, executableHandlers, requirements); const executableAsyncPlannedCapabilities = collectExecutableRequirements( moduleRoots, @@ -272,7 +263,7 @@ function parseCapabilityGraph( name: string, seen: Set, ): (RunnerModuleExportRecord & { readonly path: string }) | undefined { - const key = `${path}${name}`; + const key = `${path}\u0000${name}`; if (seen.has(key)) return undefined; seen.add(key); const module = modules.get(path); @@ -432,630 +423,27 @@ function topLevelNodes(root: IRNode): readonly IRNode[] { return root.type === 'document' ? (root.children ?? []) : []; } -export const ASYNC_SOURCE_UNSUPPORTED_CONTAINER_TYPES: ReadonlySet = new Set(); - -interface CapabilityModuleScope { - readonly path: string; - readonly root: IRNode; - readonly importedFn: Map; - readonly importedClass: Map; -} - -function buildCapabilityModuleScopes(graph: { - readonly roots: readonly CapabilityGraphModule[]; -}): Map { - const byPath = new Map(graph.roots.map((module) => [module.path, module])); - const scopes = new Map(); - - function resolveExport( - path: string, - name: string, - seen: Set, - ): (RunnerModuleExportRecord & { readonly path: string }) | undefined { - const key = `${path}${name}`; - if (seen.has(key)) return undefined; - seen.add(key); - const module = byPath.get(path); - if (!module) return undefined; - const own = module.ownExports.get(name); - if (own) return { ...own, path }; - const reexport = module.imports.find((imported) => imported.exportOnly && imported.localName === name); - if (reexport && reexport.targetPath !== '') return resolveExport(reexport.targetPath, reexport.importedName, seen); - return undefined; - } - - for (const module of graph.roots) { - const importedFn = new Map(); - const importedClass = new Map(); - for (const imported of module.imports) { - // export=true imports are additive (locally bound AND re-exported), so - // they resolve into the local scope exactly like plain imports. - if (imported.targetPath === '') continue; - const resolved = resolveExport(imported.targetPath, imported.importedName, new Set()); - if (!resolved) continue; - if (resolved.kind === 'fn') - importedFn.set(imported.localName, { path: resolved.path, name: resolved.sourceName }); - else importedClass.set(imported.localName, { path: resolved.path, name: resolved.sourceName }); - } - scopes.set(module.path, { path: module.path, root: module.root, importedFn, importedClass }); - } - return scopes; -} - -/** - * Executable handler nodes reachable from the entry handler ACROSS the linked - * module graph. Within each module the existing single-file reachability walk is - * reused unchanged; imported function calls jump to the defining module and the - * walk continues there. Imported classes that are referenced are treated - * fail-closed (all members marked executable + unsupported), since class-member - * capabilities can never execute at runtime. - */ -function crossModuleExecutableHandlers( +function capabilityLinkedAdmission( graph: { readonly roots: readonly CapabilityGraphModule[] }, - rootPath: string, - entryHandlerName: string, - unsupported: Set, - rootOwnsClassFrames: boolean, -): ReadonlySet { - const scopes = buildCapabilityModuleScopes(graph); - const out = new Set(); - const visited = new Set(); - const queue: { path: string; entry: string }[] = [{ path: rootPath, entry: entryHandlerName }]; - while (queue.length > 0) { - const item = queue.pop(); - if (!item) continue; - const key = `${item.path}${item.entry}`; - if (visited.has(key)) continue; - visited.add(key); - const scope = scopes.get(item.path); - if (!scope) continue; - const localUnsupported = new Set(); - const localHandlers = findExecutableKernHandlers( - scope.root, - item.entry, - localUnsupported, - scope.path === rootPath && rootOwnsClassFrames, - ); - for (const handler of localHandlers) out.add(handler); - for (const handler of localUnsupported) unsupported.add(handler); - for (const handler of localHandlers) { - for (const name of calledSymbolNames(handler)) { - const importedFn = scope.importedFn.get(name); - if (importedFn) queue.push({ path: importedFn.path, entry: importedFn.name }); - const importedClass = scope.importedClass.get(name); - if (importedClass) enqueueImportedClassMembers(importedClass, scopes, out, unsupported); - } - } - } - return out; -} - -function enqueueImportedClassMembers( - ref: { readonly path: string; readonly name: string }, - scopes: ReadonlyMap, - out: Set, - unsupported: Set, -): void { - const scope = scopes.get(ref.path); - if (!scope) return; - for (const node of topLevelNodes(scope.root)) { - if (node.type !== 'class' || node.props?.name !== ref.name) continue; - for (const member of node.children ?? []) { - if (member.type !== 'method' && member.type !== 'getter' && member.type !== 'constructor') continue; - const handler = previewHelperHandler(member); - if (handler) { - out.add(handler); - unsupported.add(handler); - } - } - } -} - -function calledSymbolNames(handler: IRNode): Set { - const names = new Set(); - for (const node of walkNodes(handler)) { - for (const expr of supportedHelperCallExpressions(node)) { - collectCalledSymbolNames(expr.node, names); - } - } - return names; -} - -function collectCalledSymbolNames(node: ValueIR, names: Set): void { - if (node.kind === 'call' && node.callee.kind === 'ident') names.add(node.callee.name); - if (node.kind === 'new' && node.argument.kind === 'call' && node.argument.callee.kind === 'ident') { - names.add(node.argument.callee.name); - } - for (const child of allValueChildren(node)) collectCalledSymbolNames(child, names); -} - -function allValueChildren(node: ValueIR): readonly ValueIR[] { - switch (node.kind) { - case 'unary': - case 'spread': - case 'await': - case 'new': - case 'propagate': - return [node.argument]; - case 'binary': - return [node.left, node.right]; - case 'conditional': - return [node.test, node.consequent, node.alternate]; - case 'member': - return [node.object]; - case 'index': - return [node.object, node.index]; - case 'call': - return [node.callee, ...node.args]; - case 'typeAssert': - case 'nonNull': - return [node.expression]; - case 'tmplLit': - return node.expressions; - case 'arrayLit': - return node.items.filter((item): item is ValueIR => Boolean(item)); - case 'objectLit': - return node.entries.map((entry) => ('kind' in entry ? entry.argument : entry.value)); - default: - return []; - } -} - -function findExecutableKernHandlers( - root: IRNode, - entryHandlerName: string, - unsupported = new Set(), - ownsClassFrames = false, -): ReadonlySet { - const out = new Set(); - const helpers = new Map(); - const classMethods = new Map(); - const classMethodsByName = new Map(); - const classConstructors = new Map(); - const classExtends = new Map(); - const classFieldInitializers = new Map(); - let entryHandler: IRNode | undefined; - - for (const node of root.children ?? []) { - if (node.type === 'fn') { - const name = typeof node.props?.name === 'string' ? node.props.name : ''; - const handler = previewHelperHandler(node); - if (!handler) continue; - if (name === entryHandlerName) entryHandler = handler; - else if (isPreviewHelperFunction(node, name)) helpers.set(name, handler); - continue; - } - if (node.type === 'class') { - const className = typeof node.props?.name === 'string' ? node.props.name : ''; - const extendsName = typeof node.props?.extends === 'string' ? node.props.extends : ''; - if (className && extendsName) classExtends.set(className, extendsName); - for (const member of node.children ?? []) { - if (member.type === 'field' && className && typeof member.props?.value === 'string' && member.props.value) { - try { - const existing = classFieldInitializers.get(className); - const parsed = parseExpression(member.props.value); - if (existing) existing.push(parsed); - else classFieldInitializers.set(className, [parsed]); - } catch { - // Parser/runtime diagnostics own malformed expressions. - } - continue; - } - if (member.type === 'constructor') { - const handler = previewHelperHandler(member); - if (className && handler) classConstructors.set(className, handler); - continue; - } - if (member.type !== 'method' && member.type !== 'getter') continue; - const name = typeof member.props?.name === 'string' ? member.props.name : ''; - const handler = previewHelperHandler(member); - if (!name || !handler) continue; - if (className) { - const key = `${className}.${name}`; - const existing = classMethods.get(key); - if (existing) existing.push(handler); - else classMethods.set(key, [handler]); - } - const existingByName = classMethodsByName.get(name); - if (existingByName) existingByName.push(handler); - else classMethodsByName.set(name, [handler]); - } - } - } - if (!entryHandler) return out; - - out.add(entryHandler); - const queued = calledExecutableHandlers( - entryHandler.children ?? [], - helpers, - classMethods, - classMethodsByName, - classConstructors, - classExtends, - classFieldInitializers, - ownsClassFrames, - ); - const visited = new Set(); - while (queued.length > 0) { - const item = queued.pop(); - if (!item || visited.has(item.key)) continue; - visited.add(item.key); - const handler = item.handler; - if (!handler) continue; - out.add(handler); - if (item.u) unsupported.add(handler); - queued.push( - ...calledExecutableHandlers( - handler.children ?? [], - helpers, - classMethods, - classMethodsByName, - classConstructors, - classExtends, - classFieldInitializers, - ownsClassFrames, - item.ownerClass, - item.receiverClass, - ).map((ref) => (item.u ? { ...ref, u: true } : ref)), - ); - } - return out; -} - -interface ExecutableHandlerRef { - readonly key: string; - readonly handler: IRNode; - readonly ownerClass?: string; - readonly receiverClass?: string; - readonly u?: boolean; -} - -function calledExecutableHandlers( - nodes: readonly IRNode[], - helpers: ReadonlyMap, - classMethods: ReadonlyMap, - classMethodsByName: ReadonlyMap, - classConstructors: ReadonlyMap, - classExtends: ReadonlyMap, - classFieldInitializers: ReadonlyMap, - ownsClassFrames: boolean, - ownerClass?: string, - receiverClass?: string, -): ExecutableHandlerRef[] { - const helperNames = new Set(); - const methodKeys = new Set(); - const ambiguousMethodNames = new Set(); - const constructorNames = new Set(); - const unsupportedHelperNames = new Set(); - const classBindings = new Map(); - const superClassName = ownerClass ? classExtends.get(ownerClass) : undefined; - for (const node of walkNodes({ type: '__block', children: [...nodes] })) { - recordRunnerCapabilityClassBinding(node, classBindings, helpers); - for (const expr of supportedHelperCallExpressions(node)) { - collectHelperCalls( - expr.node, - helpers, - helperNames, - methodKeys, - ambiguousMethodNames, - constructorNames, - classBindings, - expr.mode, - superClassName, - receiverClass, - ); - } - } - const expandedConstructorNames = new Set(); - for (let changed = true; changed; ) { - changed = false; - for (const name of [...constructorNames]) { - if (expandedConstructorNames.has(name)) continue; - expandedConstructorNames.add(name); - for (const className of runnerCapabilityClassAncestry(name, classExtends)) { - for (const initializer of classFieldInitializers.get(className) ?? []) { - collectHelperCalls( - initializer, - helpers, - unsupportedHelperNames, - methodKeys, - ambiguousMethodNames, - constructorNames, - classBindings, - 'scalar', - undefined, - undefined, - ); - } - } - changed = true; - } - } - const out: ExecutableHandlerRef[] = []; - for (const name of helperNames) { - const handler = helpers.get(name); - if (handler) out.push({ key: `fn:${name}`, handler }); - } - for (const name of unsupportedHelperNames) { - const handler = helpers.get(name); - if (handler) out.push({ key: `fn:${name}`, handler, u: true }); - } - for (const key of methodKeys) { - const resolved = resolveRunnerCapabilityClassCall(key, classMethods, classExtends); - for (const [index, handler] of (resolved?.values ?? []).entries()) { - out.push({ - key: `method:${key}:${index}`, - handler, - ownerClass: resolved?.ownerClass, - receiverClass: resolved?.receiverClass, - u: !ownsClassFrames, - }); - } - } - for (const name of ambiguousMethodNames) { - for (const [index, handler] of (classMethodsByName.get(name) ?? []).entries()) { - out.push({ key: `method:${name}:${index}`, handler, u: true }); - } - } - for (const name of constructorNames) { - for (const className of runnerCapabilityClassAncestry(name, classExtends)) { - const handler = classConstructors.get(className); - if (handler) { - out.push({ - key: `constructor:${name}:${className}`, - handler, - ownerClass: className, - receiverClass: name, - u: !ownsClassFrames, - }); - } - } + rootModule: CapabilityGraphModule, + entryName: string, + iterationBudget: number | undefined, +): LinkedClassFrameAdmission { + try { + const records = graph.roots.map((module) => ({ + classes: collectRunnerClasses(module.root), + exports: module.ownExports, + functions: collectRunnerFunctions(module.root), + imports: module.imports, + path: module.path, + })); + return linkedClassFrameAdmission(records, rootModule.path, rootModule.root, entryName, iterationBudget); + } catch { + return { ownsClassFrames: false }; } - return out; -} - -type HelperCallExpressionMode = 'scalar' | 'let' | 'cap'; - -interface HelperCallExpression { - readonly node: ValueIR; - readonly mode: HelperCallExpressionMode; -} - -function supportedHelperCallExpressions(node: IRNode): HelperCallExpression[] { - const props = node.props ?? {}; - const out: HelperCallExpression[] = []; - function add(raw: unknown, mode: HelperCallExpressionMode): void { - if (typeof raw !== 'string' || raw.trim() === '') return; - try { - out.push({ node: parseExpression(raw), mode }); - } catch { - // Ignore malformed expressions here; parser/runtime diagnostics own them. - } - } - if (node.type === 'let') { - add(props.value, 'let'); - } else if (node.type === 'capability') { - add(props.input, 'cap'); - } else if (node.type === 'do') { - add(props.value, 'scalar'); - } else if ( - node.type === 'assign' || - node.type === 'print' || - node.type === 'return' || - node.type === 'if' || - node.type === 'while' - ) { - add(node.type === 'if' || node.type === 'while' ? props.cond : props.value, 'scalar'); - } else if (node.type === 'fmt' && typeof props.template === 'string') { - try { - out.push({ node: parseExpression(`\`${props.template}\``), mode: 'scalar' }); - } catch { - // Ignore malformed templates here; parser/runtime diagnostics own them. - } - } - return out; -} - -function collectHelperCalls( - node: ValueIR, - helpers: ReadonlyMap, - helperNames: Set, - methodKeys: Set, - ambiguousMethodNames: Set, - constructorNames: Set, - classBindings: ReadonlyMap, - mode: HelperCallExpressionMode, - superClassName?: string, - receiverClass?: string, -): void { - if (node.kind === 'call' && node.callee.kind === 'ident' && helpers.has(node.callee.name)) { - helperNames.add(node.callee.name); - } - if (node.kind === 'call' && node.callee.kind === 'member') { - if (node.callee.object.kind === 'ident' && node.callee.object.name === 'super' && superClassName) { - methodKeys.add( - runnerCapabilityClassCallKey(superClassName, receiverClass ?? superClassName, node.callee.property), - ); - } else if (node.callee.object.kind === 'ident' && node.callee.object.name === 'this' && receiverClass) { - methodKeys.add(runnerCapabilityClassCallKey(receiverClass, receiverClass, node.callee.property)); - } else if (node.callee.object.kind === 'ident' && classBindings.has(node.callee.object.name)) { - const className = classBindings.get(node.callee.object.name); - if (className === RUNNER_CAPABILITY_AMBIGUOUS_CLASS) { - ambiguousMethodNames.add(node.callee.property); - } else if (className) { - methodKeys.add(runnerCapabilityClassCallKey(className, className, node.callee.property)); - } - } else { - ambiguousMethodNames.add(node.callee.property); - } - } - if (node.kind === 'new' && node.argument.kind === 'call' && node.argument.callee.kind === 'ident') { - constructorNames.add(node.argument.callee.name); - } - if (node.kind === 'member') { - if (node.object.kind === 'ident' && classBindings.has(node.object.name)) { - const className = classBindings.get(node.object.name); - if (className === RUNNER_CAPABILITY_AMBIGUOUS_CLASS) { - ambiguousMethodNames.add(node.property); - } else if (className) { - methodKeys.add(runnerCapabilityClassCallKey(className, className, node.property)); - } - } else if ( - node.object.kind === 'new' && - node.object.argument.kind === 'call' && - node.object.argument.callee.kind === 'ident' - ) { - methodKeys.add( - runnerCapabilityClassCallKey(node.object.argument.callee.name, node.object.argument.callee.name, node.property), - ); - } - } - for (const child of valueChildren(node, helpers, mode)) { - collectHelperCalls( - child.node, - helpers, - helperNames, - methodKeys, - ambiguousMethodNames, - constructorNames, - classBindings, - child.mode, - superClassName, - receiverClass, - ); - } -} - -function valueChildren( - node: ValueIR, - helpers: ReadonlyMap, - mode: HelperCallExpressionMode, -): readonly HelperCallExpression[] { - switch (node.kind) { - case 'unary': - return [{ node: node.argument, mode: 'scalar' }]; - case 'binary': - return [ - { node: node.left, mode: 'scalar' }, - { node: node.right, mode: 'scalar' }, - ]; - case 'conditional': - return [ - { node: node.test, mode: 'scalar' }, - { node: node.consequent, mode: 'scalar' }, - { node: node.alternate, mode: 'scalar' }, - ]; - case 'member': - return [{ node: node.object, mode: 'scalar' }]; - case 'index': - return []; - case 'call': - if ( - node.callee.kind === 'ident' && - (helpers.has(node.callee.name) || node.callee.name === 'String' || node.callee.name === 'super') - ) { - return node.args.map((arg) => ({ node: arg, mode: 'scalar' })); - } - if (node.callee.kind === 'member') { - return [ - { node: node.callee.object, mode: 'scalar' }, - ...node.args.map((arg): HelperCallExpression => ({ node: arg, mode: 'scalar' })), - ]; - } - return []; - case 'new': - return node.argument.kind === 'call' ? node.argument.args.map((arg) => ({ node: arg, mode: 'scalar' })) : []; - case 'typeAssert': - case 'nonNull': - return [{ node: node.expression, mode: 'scalar' }]; - case 'tmplLit': - return node.expressions.map((expression) => ({ node: expression, mode: 'scalar' })); - case 'arrayLit': - if (mode === 'cap') { - return node.items.filter((item): item is ValueIR => Boolean(item)).map((item) => ({ node: item, mode: 'cap' })); - } - if (mode === 'let') { - return node.items - .filter((item): item is ValueIR => Boolean(item)) - .map((item) => ({ node: item, mode: item.kind === 'arrayLit' ? 'let' : 'scalar' })); - } - return []; - case 'objectLit': - if (mode === 'cap') { - return node.entries.flatMap((entry) => ('kind' in entry ? [] : [{ node: entry.value, mode }])); - } - if (mode === 'let') { - return node.entries.flatMap((entry) => - 'kind' in entry ? [] : [{ node: entry.value, mode: 'scalar' as const }], - ); - } - return []; - default: - return []; - } -} - -function isPreviewHelperFunction(fn: IRNode, name: string): boolean { - return ( - isPortableHelperName(name) && - !isTrueProp(fn.props?.async) && - !isTrueProp(fn.props?.stream) && - fn.props?.returns !== undefined && - fn.props.returns !== '' && - fn.props.returns !== 'void' && - previewHelperParamsAreSupported(fn) - ); -} - -function previewHelperHandler(fn: IRNode): IRNode | undefined { - const handlers = (fn.children ?? []).filter((child) => child.type === 'handler' && child.props?.lang === 'kern'); - return handlers.length === 1 ? handlers[0] : undefined; -} - -function isTrueProp(value: unknown): boolean { - return value === true || value === 'true'; -} - -function isPortableHelperName(value: unknown): value is string { - return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); -} - -function previewHelperParamsAreSupported(fn: IRNode): boolean { - const paramChildren = (fn.children ?? []).filter((child) => child.type === 'param'); - const legacyParams = typeof fn.props?.params === 'string' ? fn.props.params.trim() : ''; - if (paramChildren.length > 0 && legacyParams !== '') return false; - const names = - paramChildren.length > 0 ? previewParamChildNames(paramChildren) : previewLegacyParamNames(legacyParams); - if (!names) return false; - return new Set(names).size === names.length; -} - -function previewParamChildNames(paramChildren: readonly IRNode[]): string[] | undefined { - const names: string[] = []; - for (const param of paramChildren) { - const name = param.props?.name; - if (!isPortableHelperName(name)) return undefined; - if ((param.children ?? []).length > 0) return undefined; - if (param.props?.value !== undefined || param.props?.default !== undefined) return undefined; - if (isTrueProp(param.props?.optional) || isTrueProp(param.props?.variadic)) return undefined; - names.push(name); - } - return names; -} - -function previewLegacyParamNames(params: string): string[] | undefined { - if (params === '') return []; - const names: string[] = []; - for (const part of params.split(',')) { - const trimmed = part.trim(); - if (trimmed === '' || trimmed.includes('=') || trimmed.startsWith('...') || trimmed.includes('?')) return undefined; - const match = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:\[\])?)?$/.exec(trimmed); - if (!match) return undefined; - names.push(match[1]); - } - return names; } +export const ASYNC_SOURCE_UNSUPPORTED_CONTAINER_TYPES: ReadonlySet = new Set(); function* walkNodes(root: IRNode): Generator { const stack: IRNode[] = [root]; while (stack.length > 0) { diff --git a/packages/core/src/runner-class-frame-capability-admission.ts b/packages/core/src/runner-class-frame-capability-admission.ts index 339bd8434..e3e01deaf 100644 --- a/packages/core/src/runner-class-frame-capability-admission.ts +++ b/packages/core/src/runner-class-frame-capability-admission.ts @@ -1,6 +1,12 @@ +import { markRunnerMachineRootScope } from './ir/semantics/runner-machine-scope.js'; +import type { RunnerModuleScope } from './ir/semantics/semantic-env.js'; import { makeEnv } from './ir/semantics/semantic-env.js'; import { sourceRunnerMachineAdmission } from './ir/semantics/source-runner-admission.js'; -import { buildSingleModuleRunnerRootScope } from './runner-runtime-scope.js'; +import { + buildRunnerModuleScopes, + buildSingleModuleRunnerRootScope, + type RunnerLinkedScopeRecord, +} from './runner-runtime-scope.js'; import type { IRNode } from './types.js'; function entryHandler(root: IRNode, name: string): IRNode | undefined { @@ -11,6 +17,45 @@ function entryHandler(root: IRNode, name: string): IRNode | undefined { return handlers.length === 1 ? handlers[0] : undefined; } +export interface LinkedClassFrameAdmission { + readonly entryHandler?: IRNode; + readonly ownsClassFrames: boolean; + readonly rootScope?: RunnerModuleScope; +} + +export function linkedClassFrameAdmission( + records: readonly RunnerLinkedScopeRecord[], + rootPath: string, + root: IRNode, + entryName: string, + iterationBudget: number | undefined, +): LinkedClassFrameAdmission { + const handler = entryHandler(root, entryName); + if (!handler) return { ownsClassFrames: false }; + let rootScope: RunnerModuleScope | undefined; + try { + const scopes = buildRunnerModuleScopes(records); + rootScope = scopes.get(rootPath); + if (!rootScope) return { entryHandler: handler, ownsClassFrames: false }; + markRunnerMachineRootScope(rootScope); + const env = makeEnv({ + runnerFunctions: rootScope.functions, + runnerClasses: rootScope.classes, + runnerCallStack: [], + runnerCallCache: new Map(), + }); + let ownsClassFrames = false; + try { + ownsClassFrames = sourceRunnerMachineAdmission(handler.children ?? [], env, iterationBudget); + } catch { + // Structural diagnostics remain owned by execution preflight; planner reachability still uses the linked scope. + } + return { entryHandler: handler, ownsClassFrames, rootScope }; + } catch { + return { entryHandler: handler, ownsClassFrames: false, rootScope }; + } +} + export function ownsSingleModuleClassFrames( root: IRNode, entryName: string, diff --git a/packages/core/src/runner-runtime-scope.ts b/packages/core/src/runner-runtime-scope.ts index c6cd29dde..bb30b31a0 100644 --- a/packages/core/src/runner-runtime-scope.ts +++ b/packages/core/src/runner-runtime-scope.ts @@ -8,6 +8,7 @@ import type { RunnerModuleScope, } from './ir/semantics/semantic-env.js'; import { KernRunnerError } from './runner-error.js'; +import { moduleLinkErrors } from './runner-module-link.js'; import type { IRNode } from './types.js'; function topLevelNodes(root: IRNode): readonly IRNode[] { @@ -213,6 +214,90 @@ export function validateRunnerCallableNames( } } +export interface RunnerLinkedScopeExport { + readonly kind: 'class' | 'fn'; + readonly sourceName: string; +} + +export interface RunnerLinkedScopeImport { + readonly exportOnly: boolean; + readonly importedName: string; + readonly localName: string; + readonly targetPath: string; +} + +export interface RunnerLinkedScopeRecord { + readonly classes: ReadonlyMap; + readonly exports: ReadonlyMap; + readonly functions: ReadonlyMap; + readonly imports: readonly RunnerLinkedScopeImport[]; + readonly path: string; +} + +/** Build one identity-preserving runtime scope per already-validated linked module. */ +export function buildRunnerModuleScopes(records: readonly RunnerLinkedScopeRecord[]): Map { + const byPath = new Map(records.map((record) => [record.path, record])); + const scopes = new Map(); + for (const record of records) { + const scope: RunnerModuleScope = { functions: new Map(), classes: new Map() }; + for (const [name, binding] of record.functions) scope.functions.set(name, { ...binding, module: scope }); + for (const [name, binding] of record.classes) { + const scopedBinding = { ...binding, module: scope }; + markRunnerMachineClassBinding(scopedBinding); + scope.classes.set(name, scopedBinding); + } + scopes.set(record.path, scope); + } + + const resolveExport = ( + path: string, + name: string, + seen: Set, + ): { readonly kind: 'class' | 'fn'; readonly binding: RunnerClassBinding | RunnerFunctionBinding } | undefined => { + const key = `${path}\u0000${name}`; + if (seen.has(key)) return undefined; + seen.add(key); + const record = byPath.get(path); + const scope = scopes.get(path); + if (!record || !scope) return undefined; + // Additive re-exports are import edges, so follow them before consulting + // own exports. Own exports always name declarations allocated in pass one; + // they never depend on another module's pass-two import wiring. + const reexport = record.imports.find((imported) => imported.exportOnly && imported.localName === name); + if (reexport) return resolveExport(reexport.targetPath, reexport.importedName, seen); + const exported = record.exports.get(name); + if (exported?.kind === 'fn') { + const binding = scope.functions.get(exported.sourceName); + if (binding) return { binding, kind: 'fn' }; + } else if (exported?.kind === 'class') { + const binding = scope.classes.get(exported.sourceName); + if (binding) return { binding, kind: 'class' }; + } + return undefined; + }; + + for (const record of records) { + const scope = scopes.get(record.path); + if (!scope) continue; + for (const imported of record.imports) { + const resolved = resolveExport(imported.targetPath, imported.importedName, new Set()); + if (!resolved) { + throw new KernRunnerError( + moduleLinkErrors.doesNotExport(imported.targetPath, imported.importedName, record.path), + ); + } + if (scope.functions.has(imported.localName) || scope.classes.has(imported.localName)) { + throw new KernRunnerError(moduleLinkErrors.aliasConflicts(imported.localName, record.path)); + } + if (resolved.kind === 'fn') scope.functions.set(imported.localName, resolved.binding as RunnerFunctionBinding); + else scope.classes.set(imported.localName, resolved.binding as RunnerClassBinding); + } + validateRunnerCallableNames(scope.functions, scope.classes); + assertRunnerClassAcyclic(scope.classes); + } + return scopes; +} + export function buildSingleModuleRunnerRootScope(root: IRNode): RunnerModuleScope { const rawFunctions = collectRunnerFunctions(root); const rawClasses = collectRunnerClasses(root); diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 5541949b8..74aeae605 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -11,7 +11,7 @@ import { InternalEffectMachineError } from './ir/semantics/internal-effect-machi import { isPortableBindingName } from './ir/semantics/portable-scalar.js'; import { ReferenceRunnerError, referenceRunSequence } from './ir/semantics/reference-runner.js'; import { registerAllContracts } from './ir/semantics/register-all.js'; -import { markRunnerMachineClassBinding, markRunnerMachineRootScope } from './ir/semantics/runner-machine-scope.js'; +import { markRunnerMachineRootScope } from './ir/semantics/runner-machine-scope.js'; import { parseDocumentWithDiagnostics } from './parser.js'; import type { ParseOptions } from './parser-core.js'; import { parseExpression } from './parser-expression.js'; @@ -31,7 +31,7 @@ import { import { KernRunnerError } from './runner-error.js'; import { moduleLinkErrors } from './runner-module-link.js'; import { - assertRunnerClassAcyclic, + buildRunnerModuleScopes, buildSingleModuleRunnerRootScope, collectRunnerClasses, collectRunnerFunctions, @@ -430,84 +430,6 @@ function linkedRoot(records: readonly LinkedModuleRecord[], options: ExecuteKern return root; } -/** - * Build a private {@link RunnerModuleScope} for every linked module. Modules are - * singletons: each scope holds the module's OWN functions/classes (each tagged - * with the scope so their bodies resolve against it) plus references to the - * bindings it imports — the SAME binding object from the defining module's - * scope, never a copy flattened into this scope. So an imported helper resolves - * its own module's private helpers/classes, transitive imports chain correctly, - * and a name defined here does not shadow the imported module's same-named - * private symbol. `linkRunnerModules` has already validated the import graph, so - * this pass only wires references and re-checks callable-name conflicts. - */ -function buildRunnerModuleScopes(records: readonly LinkedModuleRecord[]): Map { - const byPath = new Map(records.map((record) => [record.path, record])); - const scopes = new Map(); - - // Pass 1: seed each scope with its own declarations, tagged with the scope. - for (const record of records) { - const scope: RunnerModuleScope = { functions: new Map(), classes: new Map() }; - for (const [name, binding] of record.functions) scope.functions.set(name, { ...binding, module: scope }); - for (const [name, binding] of record.classes) { - const scopedBinding = { ...binding, module: scope }; - markRunnerMachineClassBinding(scopedBinding); - scope.classes.set(name, scopedBinding); - } - scopes.set(record.path, scope); - } - - // Resolve an export (own or re-exported) to the DEFINING module's tagged binding. - const resolveExport = ( - path: string, - name: string, - seen: Set, - ): { readonly kind: 'fn' | 'class'; readonly binding: RunnerFunctionBinding | RunnerClassBinding } | undefined => { - const key = `${path} ${name}`; - if (seen.has(key)) return undefined; - seen.add(key); - const record = byPath.get(path); - const scope = scopes.get(path); - if (!record || !scope) return undefined; - const reexport = record.imports.find((imported) => imported.exportOnly && imported.localName === name); - if (reexport) return resolveExport(reexport.targetPath, reexport.importedName, seen); - const exported = record.exports.get(name); - if (exported?.kind === 'fn') { - const binding = scope.functions.get(exported.sourceName); - if (binding) return { kind: 'fn', binding }; - } else if (exported?.kind === 'class') { - const binding = scope.classes.get(exported.sourceName); - if (binding) return { kind: 'class', binding }; - } - return undefined; - }; - - // Pass 2: wire each module's imports as references into its scope. Imports - // with export=true are additive (local binding AND re-export), so they are - // wired locally exactly like plain imports. - for (const record of records) { - const scope = scopes.get(record.path); - if (!scope) continue; - for (const imported of record.imports) { - const resolved = resolveExport(imported.targetPath, imported.importedName, new Set()); - if (!resolved) { - throw new KernRunnerError( - moduleLinkErrors.doesNotExport(imported.targetPath, imported.importedName, record.path), - ); - } - if (scope.functions.has(imported.localName) || scope.classes.has(imported.localName)) { - throw new KernRunnerError(moduleLinkErrors.aliasConflicts(imported.localName, record.path)); - } - if (resolved.kind === 'fn') scope.functions.set(imported.localName, resolved.binding as RunnerFunctionBinding); - else scope.classes.set(imported.localName, resolved.binding as RunnerClassBinding); - } - validateRunnerCallableNames(scope.functions, scope.classes); - assertRunnerClassAcyclic(scope.classes); - } - - return scopes; -} - function linkedRootScope(records: readonly LinkedModuleRecord[], rootRecord: LinkedModuleRecord): RunnerModuleScope { if (records.length === 1 && rootRecord.imports.length === 0) { return buildSingleModuleRunnerRootScope(rootRecord.root); diff --git a/packages/core/tests/runner-capability-class-frame.test.ts b/packages/core/tests/runner-capability-class-frame.test.ts index 65fda3ed8..dcdef5026 100644 --- a/packages/core/tests/runner-capability-class-frame.test.ts +++ b/packages/core/tests/runner-capability-class-frame.test.ts @@ -89,7 +89,27 @@ describe('M3.31a class-frame capability planning', () => { ).toEqual([]); }); - test('keeps an imported class frame unsupported even when its capability is reachable', () => { + test('preserves root capability reachability when machine structure admission rejects', () => { + const source = [ + 'fn name=main returns=void', + ' handler lang="kern"', + ' try', + ' capability namespace=llm operation=complete name=body input="{ prompt: \\"body\\" }"', + ' finally', + ' capability namespace=llm operation=complete name=cleanup input="{ prompt: \\"cleanup\\" }"', + ' return value="1"', + ].join('\n'); + + const analysis = analyzeKernSourceCapabilities(source, provided); + + expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual([ + 'llm.complete', + 'llm.complete', + ]); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); + }); + + test('owns an imported class frame when its capability is reachable', () => { const remoteSource = [ 'class name=RemoteLabel export=true', ' method name=read returns=string', @@ -117,13 +137,12 @@ describe('M3.31a class-frame capability planning', () => { }, }); + expect(analysis.parseDiagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual(['llm.complete']); - expect(analysis.unsupportedAsyncExecutions).toEqual([ - expect.objectContaining({ id: 'llm.complete', reason: 'unsupported' }), - ]); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); }); - test('keeps an imported helper called from a local class frame unsupported', () => { + test('owns an imported helper called from a local class frame', () => { const remoteSource = [ 'fn name=decorate export=true returns=string', ' param name=value type=string', @@ -156,8 +175,124 @@ describe('M3.31a class-frame capability planning', () => { }); expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual(['llm.complete']); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); + }); + + test('follows an imported helper into its defining module private class', () => { + const remoteSource = [ + 'class name=PrivateLabel', + ' method name=read returns=string', + ' handler lang="kern"', + ' capability namespace=llm operation=complete name=answer input="{ prompt: \\"private\\" }"', + ' return value="answer"', + 'fn name=readRemote export=true returns=string', + ' handler lang="kern"', + ' let name=label value="new PrivateLabel()"', + ' return value="label.read()"', + ].join('\n'); + const rootSource = [ + 'use path="./remote"', + ' from name=readRemote kind=fn as=readPrivate', + 'fn name=main returns=void', + ' handler lang="kern"', + ' print value="readPrivate()"', + ].join('\n'); + const analysis = analyzeKernSourceCapabilities(rootSource, { + ...provided, + sourcePath: '/app/main.kern', + moduleLoader: { + resolve: (specifier) => (specifier === './remote' ? '/app/remote.kern' : null), + readSource: (path) => { + if (path !== '/app/remote.kern') throw new Error(`unexpected module: ${path}`); + return remoteSource; + }, + }, + }); + + expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual(['llm.complete']); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); + }); + + test('keeps class identity through an aliased additive re-export', () => { + const modules = new Map([ + [ + '/app/leaf.kern', + [ + 'class name=RemoteLabel export=true', + ' method name=read returns=string', + ' handler lang="kern"', + ' capability namespace=llm operation=complete name=answer input="{ prompt: \\"leaf\\" }"', + ' return value="answer"', + ].join('\n'), + ], + [ + '/app/bridge.kern', + ['use path="./leaf"', ' from name=RemoteLabel kind=class as=Forwarded export=true'].join('\n'), + ], + ]); + const rootSource = [ + 'use path="./bridge"', + ' from name=Forwarded kind=class as=ImportedLabel', + 'fn name=main returns=void', + ' handler lang="kern"', + ' let name=label value="new ImportedLabel()"', + ' print value="label.read()"', + ].join('\n'); + const analysis = analyzeKernSourceCapabilities(rootSource, { + ...provided, + sourcePath: '/app/main.kern', + moduleLoader: { + resolve: (specifier) => { + if (specifier === './bridge') return '/app/bridge.kern'; + if (specifier === './leaf') return '/app/leaf.kern'; + return null; + }, + readSource: (path) => { + const source = modules.get(path); + if (!source) throw new Error(`unexpected module: ${path}`); + return source; + }, + }, + }); + + expect(analysis.executableAsyncPlannedCapabilities.map((requirement) => requirement.id)).toEqual(['llm.complete']); + expect(analysis.unsupportedAsyncExecutions).toEqual([]); + }); + + test('does not plan an unused effectful member on an imported class', () => { + const remoteSource = [ + 'class name=RemoteLabel export=true', + ' method name=local returns=string', + ' handler lang="kern"', + ' return value="\\"local\\""', + ' method name=remote returns=string', + ' handler lang="kern"', + ' capability namespace=llm operation=complete name=answer input="{ prompt: \\"remote\\" }"', + ' return value="answer"', + ].join('\n'); + const rootSource = [ + 'use path="./remote"', + ' from name=RemoteLabel kind=class as=ImportedLabel', + 'fn name=main returns=void', + ' handler lang="kern"', + ' let name=label value="new ImportedLabel()"', + ' print value="label.local()"', + ].join('\n'); + const analysis = analyzeKernSourceCapabilities(rootSource, { + ...provided, + sourcePath: '/app/main.kern', + moduleLoader: { + resolve: (specifier) => (specifier === './remote' ? '/app/remote.kern' : null), + readSource: (path) => { + if (path !== '/app/remote.kern') throw new Error(`unexpected module: ${path}`); + return remoteSource; + }, + }, + }); + + expect(analysis.executableAsyncPlannedCapabilities).toEqual([]); expect(analysis.unsupportedAsyncExecutions).toEqual([ - expect.objectContaining({ id: 'llm.complete', reason: 'unsupported' }), + expect.objectContaining({ id: 'llm.complete', reason: 'outside-main' }), ]); }); diff --git a/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts b/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts index 0d08faea3..e35e7269d 100644 --- a/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts +++ b/packages/core/tests/runtime-envelope-effect-machine-class-helper-effects.test.ts @@ -7,7 +7,7 @@ import { import type { IRNode } from '../src/types.js'; import { classHelperEnv, helper, member } from './runtime-envelope-effect-machine-class-helper-fixtures.js'; -function remoteMethodEnv(provider: () => number) { +function remoteMethodEnv(provider: () => number, additionalHelpers: readonly ReturnType[] = []) { return classHelperEnv({ capabilities: { storage: { get: provider } }, classes: [ @@ -36,6 +36,7 @@ function remoteMethodEnv(provider: () => number) { { type: 'return', props: { value: 'item.read()' } }, ], ), + ...additionalHelpers, ], }); } @@ -116,25 +117,10 @@ describe('M3.31b2c2 resumable helper-to-class effects', () => { const env = remoteMethodEnv(() => { providerCalls += 1; return 5; - }); - const scope = env.runnerFunctions; - expect(scope).toBeDefined(); - const module = scope?.get('readRemote')?.module; - expect(module).toBeDefined(); - module?.functions.set('decorate', { - body: [{ type: 'return', props: { value: 'value + 10' } }], - module, - name: 'decorate', - params: ['value'], - returns: 'number', - }); - module?.functions.set('wrappedRemote', { - body: [{ type: 'return', props: { value: 'decorate(readRemote())' } }], - module, - name: 'wrappedRemote', - params: [], - returns: 'number', - }); + }, [ + helper('decorate', ['value'], [{ type: 'return', props: { value: 'value + 10' } }]), + helper('wrappedRemote', [], [{ type: 'return', props: { value: 'decorate(readRemote())' } }]), + ]); const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'wrappedRemote()' } }]; expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); @@ -168,16 +154,7 @@ describe('M3.31b2c2 resumable helper-to-class effects', () => { const env = remoteMethodEnv(() => { providerCalls += 1; return 5; - }); - const module = env.runnerFunctions?.get('readRemote')?.module; - expect(module).toBeDefined(); - module?.functions.set(helperName, { - body: [{ type: 'return', props: { value: bodyValue } }], - module, - name: helperName, - params: [paramName], - returns: 'number', - }); + }, [helper(helperName, [paramName], [{ type: 'return', props: { value: bodyValue } }])]); const nodes: readonly IRNode[] = [{ type: 'return', props: { value: expression } }]; expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); diff --git a/packages/core/tests/runtime-envelope-effect-machine-class-state.test.ts b/packages/core/tests/runtime-envelope-effect-machine-class-state.test.ts index 779d95cc3..a00aab344 100644 --- a/packages/core/tests/runtime-envelope-effect-machine-class-state.test.ts +++ b/packages/core/tests/runtime-envelope-effect-machine-class-state.test.ts @@ -4,6 +4,7 @@ import { makeEnv, type RunnerClassBinding, type RunnerClassMemberBinding, + type RunnerFunctionBinding, type RunnerModuleScope, type SemanticEnv, } from '../src/ir/semantics/semantic-env.js'; @@ -19,6 +20,7 @@ import type { IRNode } from '../src/types.js'; function stateClassEnv( overrides: Partial = {}, classOverrides: Partial = {}, + helpers: readonly Omit[] = [], ): SemanticEnv { const functions: RunnerModuleScope['functions'] = new Map(); const classes: RunnerModuleScope['classes'] = new Map(); @@ -40,6 +42,7 @@ function stateClassEnv( }; markRunnerMachineClassBinding(box); classes.set('Box', box); + for (const helper of helpers) functions.set(helper.name, { ...helper, module: scope }); markRunnerMachineRootScope(scope); return makeEnv({ ...overrides, @@ -91,15 +94,14 @@ describe('M3.26 same-root state-only class ownership', () => { }); test('selects machine when the linked root function map contains the entry function', () => { - const env = stateClassEnv(); - const scope = env.runnerClasses?.get('Box')?.module; - scope?.functions.set('main', { - body: stateProgram, - module: scope, - name: 'main', - params: [], - returns: 'void', - }); + const env = stateClassEnv({}, {}, [ + { + body: stateProgram, + name: 'main', + params: [], + returns: 'void', + }, + ]); expect(selectSourceRunnerEngine(stateProgram, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); }); @@ -413,15 +415,14 @@ describe('M3.26 same-root state-only class ownership', () => { ]; expect(selectSourceRunnerEngine(undeclared, stateClassEnv(), {})).toBe(SOURCE_RUNNER_ENGINE.legacy); - const env = stateClassEnv(); - const scope = env.runnerClasses?.get('Box')?.module; - scope?.functions.set('identity', { - body: [{ type: 'return', props: { value: 'value' } }], - module: scope, - name: 'identity', - params: ['value'], - returns: 'number', - }); + const env = stateClassEnv({}, {}, [ + { + body: [{ type: 'return', props: { value: 'value' } }], + name: 'identity', + params: ['value'], + returns: 'number', + }, + ]); expect(selectSourceRunnerEngine([{ type: 'print', props: { value: 'identity(1)' } }], env, {})).toBe( SOURCE_RUNNER_ENGINE.machine, ); diff --git a/packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts b/packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts new file mode 100644 index 000000000..332cc1f92 --- /dev/null +++ b/packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts @@ -0,0 +1,337 @@ +import { isInternalMachineResumableHelperCall } from '../src/ir/semantics/internal-effect-machine-helper-contract.js'; +import { bindInternalEffectMachineState } from '../src/ir/semantics/internal-effect-machine-helper-state.js'; +import { markRunnerMachineClassBinding, markRunnerMachineRootScope } from '../src/ir/semantics/runner-machine-scope.js'; +import { + makeEnv, + type RunnerClassBinding, + type RunnerClassMemberBinding, + type RunnerFunctionBinding, + type RunnerModuleScope, +} from '../src/ir/semantics/semantic-env.js'; +import { + executeSourceRunnerAsync, + executeSourceRunnerSync, + SOURCE_RUNNER_ENGINE, + selectSourceRunnerEngine, +} from '../src/runtime-envelope/source-runner-engine.js'; +import type { IRNode } from '../src/types.js'; + +function moduleScope(): RunnerModuleScope { + return { classes: new Map(), functions: new Map() }; +} + +function addHelper( + scope: RunnerModuleScope, + name: string, + body: readonly IRNode[], + params: readonly string[] = [], + returns: unknown = 'number', +): RunnerFunctionBinding { + const binding: RunnerFunctionBinding = { body, module: scope, name, params, returns }; + scope.functions.set(name, binding); + return binding; +} + +function classMember( + ownerClass: string, + name: string, + body: readonly IRNode[], + params: readonly string[] = [], +): RunnerClassMemberBinding { + return { body, name, ownerClass, params }; +} + +function addClass( + scope: RunnerModuleScope, + name: string, + options: { + readonly constructor?: RunnerClassMemberBinding; + readonly fields?: RunnerClassBinding['fields']; + readonly getters?: ReadonlyMap; + readonly methods?: ReadonlyMap; + } = {}, +): RunnerClassBinding { + const binding: RunnerClassBinding = { + constructor: Object.hasOwn(options, 'constructor') ? options.constructor : undefined, + fields: options.fields ?? [], + getters: new Map(options.getters), + methods: new Map(options.methods), + module: scope, + name, + }; + markRunnerMachineClassBinding(binding); + scope.classes.set(name, binding); + return binding; +} + +function linkedEnv(root: RunnerModuleScope, capabilities?: Parameters[0]['capabilities']) { + markRunnerMachineRootScope(root); + return makeEnv({ + capabilities, + runnerCallCache: new Map(), + runnerCallStack: [], + runnerClasses: root.classes, + runnerFunctions: root.functions, + }); +} + +describe('M3.31c module-owned helper and class identity', () => { + test('executes an imported helper alias in its defining private helper scope', () => { + const remote = moduleScope(); + addHelper(remote, 'value', [{ type: 'return', props: { value: '40' } }]); + const remoteRead = addHelper(remote, 'read', [{ type: 'return', props: { value: 'value() + 2' } }]); + const root = moduleScope(); + addHelper(root, 'value', [{ type: 'return', props: { value: '1' } }]); + root.functions.set('remoteRead', remoteRead); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'remoteRead() + value()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 43, + }); + }); + + test('requires a budget for a private helper reached through an imported helper class', () => { + const remote = moduleScope(); + addHelper( + remote, + 'spin', + [ + { type: 'let', props: { name: 'value', value: '0' } }, + { + type: 'while', + props: { cond: 'value < limit' }, + children: [{ type: 'assign', props: { target: 'value', value: 'value + 1' } }], + }, + { type: 'return', props: { value: 'value' } }, + ], + ['limit'], + ); + const read = classMember('PrivateBox', 'read', [{ type: 'return', props: { value: 'spin(1)' } }]); + addClass(remote, 'PrivateBox', { methods: new Map([['read', read]]) }); + const remoteRead = addHelper(remote, 'readRemote', [ + { type: 'let', props: { name: 'box', value: 'new PrivateBox()' } }, + { type: 'return', props: { value: 'box.read()' } }, + ]); + const root = moduleScope(); + root.functions.set('remoteRead', remoteRead); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'remoteRead()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); + expect(selectSourceRunnerEngine(nodes, env, { iterationBudget: 1 })).toBe(SOURCE_RUNNER_ENGINE.machine); + }); + + test('partitions equal private helper names by defining binding identity', () => { + const left = moduleScope(); + addHelper(left, 'compute', [{ type: 'return', props: { value: '1' } }], ['value']); + const leftRead = addHelper(left, 'read', [{ type: 'return', props: { value: 'compute(0)' } }]); + const right = moduleScope(); + addHelper(right, 'compute', [{ type: 'return', props: { value: '2' } }], ['value']); + const rightRead = addHelper(right, 'read', [{ type: 'return', props: { value: 'compute(0)' } }]); + const root = moduleScope(); + root.functions.set('leftRead', leftRead); + root.functions.set('rightRead', rightRead); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'leftRead() + rightRead()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 3, + }); + }); + + test('prefers resumable helper binding identity over an equal display name', () => { + const pureModule = moduleScope(); + addHelper(pureModule, 'h', [{ type: 'return', props: { value: '1' } }]); + const effectModule = moduleScope(); + const resumable = addHelper(effectModule, 'h', [{ type: 'return', props: { value: '2' } }]); + const env = makeEnv({ runnerFunctions: pureModule.functions }); + const restore = bindInternalEffectMachineState(env, { + helperRegistry: pureModule.functions, + remainingIterations: undefined, + resumableHelperNames: new Set(['h']), + resumableHelpers: new Set([resumable]), + }); + + try { + expect(isInternalMachineResumableHelperCall('h', 0, env)).toBe(false); + } finally { + restore(); + } + }); + + test('does not classify an equal-name pure helper as resumable by display name', async () => { + const effectModule = moduleScope(); + const effectfulRead = classMember('EffectBox', 'read', [ + { type: 'capability', props: { input: '"remote"', name: 'answer', namespace: 'llm', operation: 'complete' } }, + { type: 'return', props: { value: 'answer' } }, + ]); + addClass(effectModule, 'EffectBox', { methods: new Map([['read', effectfulRead]]) }); + const resumable = addHelper( + effectModule, + 'h', + [ + { type: 'let', props: { name: 'box', value: 'new EffectBox()' } }, + { type: 'return', props: { value: 'box.read() + String(value)' } }, + ], + ['value'], + 'string', + ); + + const pureModule = moduleScope(); + const pure = addHelper(pureModule, 'h', [{ type: 'return', props: { value: '1' } }]); + const root = moduleScope(); + root.functions.set('aH', resumable); + root.functions.set('h', pure); + const combine = classMember('RootBox', 'combine', [{ type: 'return', props: { value: 'aH(h())' } }]); + addClass(root, 'RootBox', { methods: new Map([['combine', combine]]) }); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [ + { type: 'let', props: { name: 'box', value: 'new RootBox()' } }, + { type: 'return', props: { value: 'box.combine()' } }, + ]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + await expect( + executeSourceRunnerAsync(nodes, env, { + asyncCapabilities: { llm: { complete: async () => 'owned:' } }, + policy: 'machine-only', + }), + ).resolves.toEqual(expect.objectContaining({ completion: { kind: 'return', value: 'owned:1' } })); + }); + + test('suspends in an imported class alias and dispatches through its defining module', async () => { + const remote = moduleScope(); + addHelper(remote, 'suffix', [{ type: 'return', props: { value: '"remote"' } }], [], 'string'); + const read = classMember('Box', 'read', [ + { type: 'capability', props: { input: '"remote"', name: 'answer', namespace: 'llm', operation: 'complete' } }, + { type: 'return', props: { value: 'answer + ":" + suffix()' } }, + ]); + const remoteBox = addClass(remote, 'Box', { methods: new Map([['read', read]]) }); + const root = moduleScope(); + addHelper(root, 'suffix', [{ type: 'return', props: { value: '"root"' } }], [], 'string'); + root.classes.set('RemoteBox', remoteBox); + const env = linkedEnv(root); + let providerCalls = 0; + const nodes: readonly IRNode[] = [ + { type: 'let', props: { name: 'box', value: 'new RemoteBox()' } }, + { type: 'return', props: { value: 'box.read()' } }, + ]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + await expect( + executeSourceRunnerAsync(nodes, env, { + asyncCapabilities: { + llm: { + complete: async () => { + providerCalls += 1; + return 'owned'; + }, + }, + }, + policy: 'machine-only', + }), + ).resolves.toEqual(expect.objectContaining({ completion: { kind: 'return', value: 'owned:remote' } })); + expect(providerCalls).toBe(1); + }); + + test('does not resolve an imported class field initializer from the caller module', () => { + const remote = moduleScope(); + const remoteBox = addClass(remote, 'Box', { fields: [{ name: 'value', value: 'secret' }] }); + const root = moduleScope(); + root.classes.set('RemoteBox', remoteBox); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [ + { type: 'let', props: { name: 'secret', value: '99' } }, + { type: 'let', props: { name: 'box', value: 'new RemoteBox()' } }, + { type: 'return', props: { value: 'box.value' } }, + ]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); + }); + + test('does not confuse equal class member labels across defining modules', () => { + const remote = moduleScope(); + const remoteReadMember = classMember('Box', 'read', [{ type: 'return', props: { value: '2' } }]); + addClass(remote, 'Box', { methods: new Map([['read', remoteReadMember]]) }); + const remoteRead = addHelper(remote, 'remoteRead', [ + { type: 'let', props: { name: 'box', value: 'new Box()' } }, + { type: 'return', props: { value: 'box.read()' } }, + ]); + + const root = moduleScope(); + root.functions.set('remoteRead', remoteRead); + const rootReadMember = classMember('Box', 'read', [{ type: 'return', props: { value: 'remoteRead() + 1' } }]); + addClass(root, 'Box', { methods: new Map([['read', rootReadMember]]) }); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [ + { type: 'let', props: { name: 'box', value: 'new Box()' } }, + { type: 'return', props: { value: 'box.read()' } }, + ]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.machine); + expect(executeSourceRunnerSync(nodes, env, { policy: 'machine-only' }).completion).toEqual({ + kind: 'return', + value: 3, + }); + }); + + test('rejects a helper inserted after the linker ownership mark', () => { + const root = moduleScope(); + addHelper(root, 'safe', [{ type: 'return', props: { value: '1' } }]); + const env = linkedEnv(root); + addHelper(root, 'forged', [{ type: 'return', props: { value: '2' } }]); + const nodes: readonly IRNode[] = [{ type: 'return', props: { value: 'forged()' } }]; + + expect(selectSourceRunnerEngine(nodes, env, {})).toBe(SOURCE_RUNNER_ENGINE.legacy); + }); + + test('snapshots an imported private helper across async suspension', async () => { + const remote = moduleScope(); + const privateValue = addHelper(remote, 'privateValue', [{ type: 'return', props: { value: '41' } }]); + const read = classMember('Box', 'read', [ + { type: 'capability', props: { input: '"remote"', name: 'answer', namespace: 'llm', operation: 'complete' } }, + { type: 'return', props: { value: 'privateValue() + 1' } }, + ]); + const remoteBox = addClass(remote, 'Box', { methods: new Map([['read', read]]) }); + const root = moduleScope(); + root.classes.set('RemoteBox', remoteBox); + const env = linkedEnv(root); + const nodes: readonly IRNode[] = [ + { type: 'let', props: { name: 'box', value: 'new RemoteBox()' } }, + { type: 'return', props: { value: 'box.read()' } }, + ]; + + await expect( + executeSourceRunnerAsync(nodes, env, { + asyncCapabilities: { + llm: { + complete: async () => { + (privateValue.body[0].props as Record).value = '0'; + return 'ignored'; + }, + }, + }, + policy: 'machine-only', + }), + ).resolves.toEqual(expect.objectContaining({ completion: { kind: 'return', value: 42 } })); + }); + + test('rejects an imported alias replaced after linker ownership', () => { + const remote = moduleScope(); + const read = addHelper(remote, 'read', [{ type: 'return', props: { value: '1' } }]); + const root = moduleScope(); + root.functions.set('remoteRead', read); + const env = linkedEnv(root); + const replacementScope = moduleScope(); + root.functions.set('remoteRead', addHelper(replacementScope, 'read', [{ type: 'return', props: { value: '2' } }])); + + expect(selectSourceRunnerEngine([{ type: 'return', props: { value: 'remoteRead()' } }], env, {})).toBe( + SOURCE_RUNNER_ENGINE.legacy, + ); + }); +}); diff --git a/scripts/check-source-runner-convergence.mjs b/scripts/check-source-runner-convergence.mjs index 966839cdb..9b850d6ea 100644 --- a/scripts/check-source-runner-convergence.mjs +++ b/scripts/check-source-runner-convergence.mjs @@ -6,6 +6,7 @@ import * as constructorSuper from './source-runner-class-constructor-super-conve import * as frames from './source-runner-class-frame-convergence.mjs'; import * as classHelper from './source-runner-class-helper-convergence.mjs'; import * as inheritance from './source-runner-class-inheritance-convergence.mjs'; +import * as moduleOwnership from './source-runner-module-ownership-convergence.mjs'; import * as preSuper from './source-runner-class-pre-super-convergence.mjs'; import * as superMethod from './source-runner-class-super-method-convergence.mjs'; import * as virtualMethod from './source-runner-class-virtual-method-convergence.mjs'; @@ -18,6 +19,7 @@ const FILES = Object.freeze({ ...frames.CLASS_FRAME_FILES, ...classHelper.CLASS_HELPER_FILES, ...inheritance.CLASS_INHERITANCE_FILES, + ...moduleOwnership.MODULE_OWNERSHIP_FILES, ...preSuper.CLASS_PRE_SUPER_FILES, ...superMethod.CLASS_SUPER_METHOD_FILES, ...virtualMethod.CLASS_VIRTUAL_METHOD_FILES, @@ -98,7 +100,7 @@ function validateManifest(text, errors) { errors.push('manifest top-level schema drifted'); return; } - if (manifest.schemaVersion !== 1 || manifest.milestone !== 'KERN-5-R2-M3.31b2c2') { + if (manifest.schemaVersion !== 1 || manifest.milestone !== 'KERN-5-R2-M3.31c') { errors.push('manifest schemaVersion or milestone is invalid'); } if (!Array.isArray(manifest.owned) || !Array.isArray(manifest.deferred)) { @@ -206,6 +208,7 @@ function validateManifest(text, errors) { superMethod.validateClassSuperMethodManifest(manifest, errors); virtualMethod.validateClassVirtualMethodManifest(manifest, errors); classHelper.validateClassHelperManifest(manifest, errors); + moduleOwnership.validateModuleOwnershipManifest(manifest, errors); preSuper.validateClassPreSuperManifest(manifest, errors); const deferredIds = manifest.deferred.map((item) => item?.id); if (new Set(deferredIds).size !== deferredIds.length) errors.push('manifest deferred ids must be unique'); @@ -218,10 +221,6 @@ function validateManifest(text, errors) { errors.push(`manifest blocker ${id} has invalid kind, status, or follow-up`); } } - const classState = manifest.deferred.find((item) => item?.id === 'runner-classes-state'); - if (classState?.followUp !== 'M3.31c-module-ownership') { - errors.push('manifest must keep remaining class behavior as the exact M3.31c follow-up'); - } } function validateClassStateSlice( @@ -497,6 +496,7 @@ export function validateSourceRunnerConvergence(readText) { superMethod.validateClassSuperMethodSlice(contents, errors); virtualMethod.validateClassVirtualMethodSlice(contents, errors); classHelper.validateClassHelperSlice(contents, errors); + moduleOwnership.validateModuleOwnershipSlice(contents, errors); preSuper.validateClassPreSuperSlice(contents, errors); if (contents.disposition) validateDisposition(contents.disposition, errors); return errors; diff --git a/scripts/kern-5-fitness-policy.json b/scripts/kern-5-fitness-policy.json index 485024076..dd4942abd 100644 --- a/scripts/kern-5-fitness-policy.json +++ b/scripts/kern-5-fitness-policy.json @@ -20,7 +20,7 @@ "test:kern-5-fitness": "node --test scripts/kern-5-fitness.test.mjs", "test:core-runtime-internalization": "pnpm --filter @kernlang/core --filter kern-lang build && node scripts/check-core-runtime-internalization.mjs && node --test scripts/core-runtime-internalization.test.mjs", "check:source-runner-convergence": "node ./scripts/check-source-runner-convergence.mjs", - "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|class-(state|method|getter|inheritance|frame|constructor-super|pre-super|super-method|virtual-method|helper))' && node --test ./scripts/source-runner-convergence.test.mjs ./scripts/source-runner-class-helper-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs" + "test:source-runner-convergence": "pnpm --filter @kernlang/core test --testPathPatterns='runner-capability-(class-frame|plan)|source-runner-engine|runtime-envelope-effect-machine-(do|each|expression-v1|helper|lambda|non-root|module-ownership|class-)' && node --test ./scripts/source-runner*-convergence.test.mjs && node ./scripts/check-source-runner-convergence.mjs" }, "gates": [ { diff --git a/scripts/source-runner-class-constructor-super-convergence.mjs b/scripts/source-runner-class-constructor-super-convergence.mjs index e11b645f8..cbfc5a851 100644 --- a/scripts/source-runner-class-constructor-super-convergence.mjs +++ b/scripts/source-runner-class-constructor-super-convergence.mjs @@ -55,7 +55,7 @@ export function validateClassConstructorSuperSlice(contents, errors) { } for (const required of [ 'function* evaluateInternalMachineClassConstructorLayer', - 'const registry = state.classRegistry', + 'const registry = cls?.module?.classes ?? state.classRegistry', 'state.helperBodyRunner', 'yield* evaluateInternalMachineClassConstructorLayer(base, instance, baseValues', 'initializeInternalMachineClassLayerFields(cls, instance.fields', @@ -72,7 +72,7 @@ export function validateClassConstructorSuperSlice(contents, errors) { if (!contents.classRuntime?.includes(required)) errors.push(`constructor-super preflight state is missing ${required}`); } for (const required of [ - 'internalMachineClassConstructorPlan(cls, registry)', + 'internalMachineClassConstructorPlan(cls, definingRegistry)', 'for (const argument of plan.superArguments)', 'assertClassBodyExpressions(plan.preSuper, visibleFields, constructorEnv, false)', 'assertClassBodyExpressions(plan.postSuper, visibleFields, constructorEnv)', diff --git a/scripts/source-runner-class-frame-convergence.mjs b/scripts/source-runner-class-frame-convergence.mjs index 552f5852e..2167ed59a 100644 --- a/scripts/source-runner-class-frame-convergence.mjs +++ b/scripts/source-runner-class-frame-convergence.mjs @@ -2,6 +2,7 @@ export const CLASS_FRAME_FILES = Object.freeze({ classActivation: 'packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts', classCapabilityAdmission: 'packages/core/src/runner-class-frame-capability-admission.ts', classCapabilityPlanner: 'packages/core/src/runner-capability-plan.ts', + classCapabilityLinked: 'packages/core/src/runner-capability-linked-handlers.ts', classCapabilityReachability: 'packages/core/src/runner-capability-requirement-reachability.ts', classCapabilityTests: 'packages/core/tests/runner-capability-class-frame.test.ts', classFrame: 'packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts', @@ -48,18 +49,22 @@ export function validateClassFrameSlice(contents, errors) { } } for (const required of [ - 'buildSingleModuleRunnerRootScope', - 'return sourceRunnerMachineAdmission(handler.children ?? [], env, iterationBudget);', + 'buildRunnerModuleScopes', + 'markRunnerMachineRootScope(rootScope)', + 'ownsClassFrames = sourceRunnerMachineAdmission(handler.children ?? [], env, iterationBudget)', ]) { if (!contents.classCapabilityAdmission?.includes(required)) { errors.push(`class-frame capability admission is missing ${required}`); } } - for (const required of ['ownsSingleModuleClassFrames', 'ownsClassFrames', 'u: !ownsClassFrames']) { + for (const required of ['linkedClassFrameAdmission', 'linkedExecutableKernHandlers', 'admission.ownsClassFrames']) { if (!contents.classCapabilityPlanner?.includes(required)) { errors.push(`class-frame capability planner is missing ${required}`); } } + if (!contents.classCapabilityLinked?.includes('work.unsupported || !ownsClassFrames')) { + errors.push('class-frame capability planner does not preserve unsupported disposition'); + } for (const required of ['buildSingleModuleRunnerRootScope', 'markRunnerMachineRootScope']) { if (!contents.runnerScope?.includes(required)) errors.push(`runner root scope extraction is missing ${required}`); } diff --git a/scripts/source-runner-class-getter-convergence.mjs b/scripts/source-runner-class-getter-convergence.mjs index 5cd16cf57..6ee5a9806 100644 --- a/scripts/source-runner-class-getter-convergence.mjs +++ b/scripts/source-runner-class-getter-convergence.mjs @@ -21,9 +21,12 @@ export function validateClassGetterSlice(contents, errors) { const runtime = [contents.classRuntime, contents.classActivation, contents.classFrame, contents.classFramePreflight] .filter(Boolean) .join('\n'); - for (const required of ['assertGetter', 'internalMachineClassGetterForRead', 'getters: new Map', 'assertInternalMachineClassInheritance']) { + for (const required of ['assertGetter', 'internalMachineClassGetterForRead', 'assertInternalMachineClassInheritance']) { if (!contents.classGraph.includes(required)) errors.push(`machine class getter graph is missing ${required}`); } + if (!contents.moduleGraph?.includes('getters: new Map')) { + errors.push('machine class getter snapshot is missing getters: new Map'); + } for (const required of [ 'evalInternalMachineClassMember', 'resolved.getter', diff --git a/scripts/source-runner-class-helper-convergence.mjs b/scripts/source-runner-class-helper-convergence.mjs index 29bf2964f..8c0cb3b8b 100644 --- a/scripts/source-runner-class-helper-convergence.mjs +++ b/scripts/source-runner-class-helper-convergence.mjs @@ -7,6 +7,7 @@ export const CLASS_HELPER_FILES = Object.freeze({ 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse-boundary.test.ts', classHelperReverseTests: 'packages/core/tests/runtime-envelope-effect-machine-class-helper-reverse.test.ts', classHelperRuntime: 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + moduleGraph: 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', helperArgumentPreflight: 'packages/core/src/ir/semantics/internal-effect-machine-helper-argument-preflight.ts', helperPreflight: 'packages/core/src/ir/semantics/internal-effect-machine-helper-preflight.ts', @@ -56,21 +57,22 @@ export function validateClassHelperManifest(manifest, errors) { export function validateClassHelperSlice(contents, errors) { for (const required of [ - 'collectClassBodyCalls(admittedClasses, scope.functions, pending)', - 'assertInternalMachineHelperClassComposition(snapshot.body, admittedClasses, env)', - 'snapshotFunctionBinding(fn)', - 'structuredClone(node.props)', - 'structuredClone(fn.returns)', + 'collectClassBodyCalls(scope.classes, pending)', + 'assertInternalMachineHelperClassComposition(fn.body, defining.classes, helperEnv)', + 'ReadonlySet', 'assertScalarHelperContracts(functions, env, classScalarReturns)', 'assertPortableMachineScalarShape(parseExpression(value), env, isScalarHelperCall, isPortableHelperCall)', - 'if (composition.composesClass) directResumableHelpers.add(name)', - 'helperCalls.set(name, nested)', - 'resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls)', + 'if (composition.composesClass) directResumableHelpers.add(fn)', + 'helperCalls.set(fn, nested)', + 'const resumableHelpers = transitiveResumableHelpers(directResumableHelpers, helperCalls)', ]) { if (!contents.classHelperGraph?.includes(required)) { errors.push(`class-body helper reachability owner is missing ${required}`); } } + for (const required of ['props: structuredClone(node.props)', 'returns: structuredClone(binding.returns)']) { + if (!contents.moduleGraph?.includes(required)) errors.push(`class-body helper snapshot owner is missing ${required}`); + } for (const required of [ 'internalMachineClassLineageBaseFirst', 'internalMachineClassMemberFor', @@ -112,7 +114,7 @@ export function validateClassHelperSlice(contents, errors) { errors.push(`helper scalar return contract owner is missing ${required}`); } } - const frozenHelperRegistry = 'runnerFunctions: new Map(call.state.helperRegistry)'; + const frozenHelperRegistry = 'runnerFunctions: scope.functions'; if ((contents.classHelperRuntime?.split(frozenHelperRegistry).length ?? 1) - 1 !== 2) { errors.push('both helper execution paths must use the snapshotted helper registry for nested call shape'); } @@ -155,9 +157,9 @@ export function validateClassHelperSlice(contents, errors) { errors.push('scalar helper proof must preserve portable composite helper arguments'); } const registry = contents.nonRootRuntime?.indexOf( - 'const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry)', + 'const helperGraph = assertInternalMachineHelperGraph(nodes, env, classGraph)', ); - const resumable = contents.nonRootRuntime?.indexOf('state.resumableHelperNames = helperGraph.resumableHelperNames'); + const resumable = contents.nonRootRuntime?.indexOf('state.resumableHelpers = helperGraph.resumableHelpers'); const structure = contents.nonRootRuntime?.indexOf('assertInternalEffectMachineStructureSupported(nodes, env)'); if ( registry === undefined || diff --git a/scripts/source-runner-class-helper-convergence.test.mjs b/scripts/source-runner-class-helper-convergence.test.mjs index de495d510..4f9fdd4db 100644 --- a/scripts/source-runner-class-helper-convergence.test.mjs +++ b/scripts/source-runner-class-helper-convergence.test.mjs @@ -45,7 +45,7 @@ test('rejects deletion of reverse-composition ownership', () => { const errors = validate( replace( 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', - 'assertInternalMachineHelperClassComposition(snapshot.body, admittedClasses, env)', + 'assertInternalMachineHelperClassComposition(fn.body, defining.classes, helperEnv)', 'new Set()', ), ); @@ -142,8 +142,8 @@ test('rejects deletion of transitive resumable-helper propagation', () => { const errors = validate( replace( 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', - 'resumableHelperNames: transitiveResumableHelpers(directResumableHelpers, helperCalls)', - 'resumableHelperNames: directResumableHelpers', + 'const resumableHelpers = transitiveResumableHelpers(directResumableHelpers, helperCalls);', + 'const resumableHelpers = directResumableHelpers;', ), ); assert.ok(errors.some((error) => error.includes('class-body helper reachability owner'))); @@ -174,28 +174,28 @@ test('rejects deletion of resumable helper yield ownership', () => { test('rejects deletion of the helper binding snapshot', () => { const errors = validate( replace( - 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', - 'const snapshot = snapshotFunctionBinding(fn);', - 'const snapshot = fn;', + 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', + 'const functionClones = new Map();', + 'const functionClones = new WeakMap();', ), ); - assert.ok(errors.some((error) => error.includes('class-body helper reachability owner'))); + assert.ok(errors.some((error) => error.includes('identity-preserving module snapshot'))); }); test('rejects a shallow helper metadata snapshot', () => { const errors = validate( replace( - 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', + 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', 'props: structuredClone(node.props)', 'props: { ...node.props }', ), ); - assert.ok(errors.some((error) => error.includes('class-body helper reachability owner'))); + assert.ok(errors.some((error) => error.includes('class-body helper snapshot owner'))); }); test('rejects live helper metadata during nested call validation', () => { const file = 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts'; - const anchor = 'runnerFunctions: new Map(call.state.helperRegistry)'; + const anchor = 'runnerFunctions: scope.functions'; const current = source(file); assert.equal(current.split(anchor).length - 1, 2, `mutation anchor count changed in ${file}`); const errors = validate(new Map([[file, current.replaceAll(anchor, 'runnerFunctions: call.env.runnerFunctions')]])); @@ -281,13 +281,15 @@ test('rejects helper-only admission before combined class preflight', () => { test('rejects freezing the helper registry after structure preflight', () => { const file = 'packages/core/src/ir/semantics/internal-effect-machine.ts'; - const before = `const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + const before = `const helperGraph = assertInternalMachineHelperGraph(nodes, env, classGraph); state.helperRegistry = helperGraph.functions; + state.resumableHelpers = helperGraph.resumableHelpers; state.resumableHelperNames = helperGraph.resumableHelperNames; assertInternalEffectMachineStructureSupported(nodes, env);`; const after = `assertInternalEffectMachineStructureSupported(nodes, env); - const helperGraph = assertInternalMachineHelperGraph(nodes, env, state.classRegistry); + const helperGraph = assertInternalMachineHelperGraph(nodes, env, classGraph); state.helperRegistry = helperGraph.functions; + state.resumableHelpers = helperGraph.resumableHelpers; state.resumableHelperNames = helperGraph.resumableHelperNames;`; const errors = validate(replace(file, before, after)); assert.ok(errors.some((error) => error.includes('resumable closure must be frozen before combined structure preflight'))); diff --git a/scripts/source-runner-class-super-method-convergence.mjs b/scripts/source-runner-class-super-method-convergence.mjs index 22a3c6e81..8010154a4 100644 --- a/scripts/source-runner-class-super-method-convergence.mjs +++ b/scripts/source-runner-class-super-method-convergence.mjs @@ -6,7 +6,7 @@ export const CLASS_SUPER_METHOD_FILES = Object.freeze({ 'packages/core/tests/runtime-envelope-effect-machine-class-super-method-admission.test.ts', classSuperMethodTests: 'packages/core/tests/runtime-envelope-effect-machine-class-super-method.test.ts', runnerCapabilityClassDispatch: 'packages/core/src/runner-capability-class-dispatch.ts', - runnerCapabilityPlan: 'packages/core/src/runner-capability-plan.ts', + runnerCapabilityPlan: 'packages/core/src/runner-capability-linked-handlers.ts', runnerCapabilityPlanTests: 'packages/core/tests/runner-capability-plan.test.ts', }); @@ -55,9 +55,9 @@ export function validateClassSuperMethodSlice(contents, errors) { } } for (const required of [ - 'item.ownerClass', - "node.callee.object.name === 'super' && superClassName", - 'resolveRunnerCapabilityClassCall', + "object.name === 'super' && work.ownerClass && work.receiverClass", + 'const parent = parentClass(work.ownerClass)', + 'return parent ? { startClass: parent, receiverClass: work.receiverClass } : undefined', ]) { if (!contents.runnerCapabilityPlan?.includes(required)) errors.push(`super-method capability plan is missing ${required}`); } diff --git a/scripts/source-runner-class-virtual-method-convergence.mjs b/scripts/source-runner-class-virtual-method-convergence.mjs index 2f36ef1d9..7ef7a13b6 100644 --- a/scripts/source-runner-class-virtual-method-convergence.mjs +++ b/scripts/source-runner-class-virtual-method-convergence.mjs @@ -6,7 +6,7 @@ export const CLASS_VIRTUAL_METHOD_FILES = Object.freeze({ 'packages/core/tests/runtime-envelope-effect-machine-class-virtual-method-admission.test.ts', classVirtualMethodTests: 'packages/core/tests/runtime-envelope-effect-machine-class-virtual-method.test.ts', runnerCapabilityClassDispatch: 'packages/core/src/runner-capability-class-dispatch.ts', - runnerCapabilityPlan: 'packages/core/src/runner-capability-plan.ts', + runnerCapabilityPlan: 'packages/core/src/runner-capability-linked-handlers.ts', virtualRunnerCapabilityPlanTests: 'packages/core/tests/runner-capability-plan-virtual-method.test.ts', }); @@ -57,11 +57,10 @@ export function validateClassVirtualMethodSlice(contents, errors) { } } for (const required of [ - 'item.receiverClass', - "node.callee.object.name === 'this' && receiverClass", - 'runnerCapabilityClassCallKey(receiverClass, receiverClass', - 'resolveRunnerCapabilityClassCall', - 'key: `constructor:${name}:${className}`', + "object.name === 'this' && work.receiverClass", + 'return { startClass: work.receiverClass, receiverClass: work.receiverClass }', + 'enqueue({ handler: member.handler, scope, ownerClass: owner, receiverClass, unsupported })', + 'if (cls) enqueueConstruction(cls, work.unsupported || !ownsClassFrames, enqueue)', ]) { if (!contents.runnerCapabilityPlan?.includes(required)) { errors.push(`virtual-method capability plan is missing ${required}`); diff --git a/scripts/source-runner-convergence-manifest.json b/scripts/source-runner-convergence-manifest.json index 032d55728..4d4ba21ee 100644 --- a/scripts/source-runner-convergence-manifest.json +++ b/scripts/source-runner-convergence-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "milestone": "KERN-5-R2-M3.31b2c2", + "milestone": "KERN-5-R2-M3.31c", "owned": [ { "id": "do", @@ -109,14 +109,13 @@ "kind": "environment", "status": "unified", "evidence": "packages/core/tests/runtime-envelope-effect-machine-class-pre-super.test.ts" - } - ], - "deferred": [ + }, { "id": "runner-classes-state", "kind": "environment", - "status": "legacy", - "followUp": "M3.31c-module-ownership" + "status": "unified", + "evidence": "packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts" } - ] + ], + "deferred": [] } diff --git a/scripts/source-runner-convergence-utils.mjs b/scripts/source-runner-convergence-utils.mjs index f73d23245..25bed9c3c 100644 --- a/scripts/source-runner-convergence-utils.mjs +++ b/scripts/source-runner-convergence-utils.mjs @@ -1,6 +1,4 @@ -export const REQUIRED_DEFERRED = Object.freeze({ - 'runner-classes-state': ['environment', 'legacy'], -}); +export const REQUIRED_DEFERRED = Object.freeze({}); export function exactKeys(value, keys) { return ( diff --git a/scripts/source-runner-convergence.test.mjs b/scripts/source-runner-convergence.test.mjs index 382b26733..b3659efe4 100644 --- a/scripts/source-runner-convergence.test.mjs +++ b/scripts/source-runner-convergence.test.mjs @@ -26,6 +26,7 @@ const files = new Map( 'packages/core/src/ir/semantics/internal-effect-machine-class-instance.ts', 'packages/core/src/ir/semantics/internal-effect-machine-leaf.ts', 'packages/core/src/ir/semantics/internal-effect-machine-leaf-result.ts', + 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', 'packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts', 'packages/core/src/ir/semantics/internal-effect-machine-class-runtime.ts', 'packages/core/src/ir/semantics/portable-reference-body.ts', @@ -42,6 +43,7 @@ const files = new Map( 'packages/core/src/runtime-envelope/source-runner-engine.ts', 'packages/core/src/runner-capability-plan.ts', 'packages/core/src/runner-capability-class-dispatch.ts', + 'packages/core/src/runner-capability-linked-handlers.ts', 'packages/core/src/runner-capability-requirement-reachability.ts', 'packages/core/src/runner-class-frame-capability-admission.ts', 'packages/core/src/runner-error.ts', @@ -71,6 +73,7 @@ const files = new Map( 'packages/core/tests/runtime-envelope-effect-machine-class-helper-portable.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-class-helper-snapshot.test.ts', 'packages/core/tests/runtime-envelope-effect-machine-non-root.test.ts', + 'packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts', 'scripts/source-runner-convergence-manifest.json', ].map((file) => [file, fs.readFileSync(path.join(root, file), 'utf8')]), ); @@ -140,13 +143,13 @@ test('rejects post-construction metadata replacement', () => { } }); -test('rejects blocker deletion and owned-node regressions', () => { - const blockerErrors = validate((mutated) => { +test('rejects final class-state owner deletion and owned-node regressions', () => { + const classStateErrors = validate((mutated) => { const manifest = JSON.parse(mutated.get('scripts/source-runner-convergence-manifest.json')); - manifest.deferred = manifest.deferred.filter(({ id }) => id !== 'runner-classes-state'); + manifest.owned = manifest.owned.filter(({ id }) => id !== 'runner-classes-state'); mutated.set('scripts/source-runner-convergence-manifest.json', JSON.stringify(manifest)); }); - assert.ok(blockerErrors.some((error) => error.includes('audited blocker set'))); + assert.ok(classStateErrors.some((error) => error.includes('runner-classes-state owner'))); const budgetErrors = validate((mutated) => { const manifest = JSON.parse(mutated.get('scripts/source-runner-convergence-manifest.json')); @@ -288,8 +291,8 @@ test('rejects state-only class ownership regressions', () => { replace( mutated, 'packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts', + 'const registry = cls?.module?.classes ?? state.classRegistry', 'const registry = state.classRegistry', - 'const registry = undefined', ), ); assert.ok(ownershipErrors.some((error) => error.includes('constructor-super frame'))); @@ -298,8 +301,8 @@ test('rejects state-only class ownership regressions', () => { replace( mutated, 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', - 'collectClassBodyCalls(admittedClasses, scope.functions, pending);', - 'void admittedClasses;', + 'collectClassBodyCalls(scope.classes, pending);', + 'void scope;', ), ); assert.ok(mixingErrors.some((error) => error.includes('class-body helper reachability'))); @@ -485,8 +488,8 @@ test('rejects resumable class-frame ownership regressions', () => { replace( mutated, 'packages/core/src/runner-class-frame-capability-admission.ts', - 'return sourceRunnerMachineAdmission(handler.children ?? [], env, iterationBudget);', - 'return false;', + 'ownsClassFrames = sourceRunnerMachineAdmission(handler.children ?? [], env, iterationBudget);', + 'ownsClassFrames = false;', ), ); assert.ok(capabilityAdmissionErrors.some((error) => error.includes('capability admission'))); @@ -691,8 +694,8 @@ test('rejects super-method dispatch ownership regressions', () => { const plannerErrors = validate((mutated) => replace( mutated, - 'packages/core/src/runner-capability-plan.ts', - "node.callee.object.name === 'super' && superClassName", + 'packages/core/src/runner-capability-linked-handlers.ts', + "object.name === 'super' && work.ownerClass && work.receiverClass", 'false', ), ); @@ -728,16 +731,21 @@ test('rejects virtual this-method dispatch ownership regressions', () => { assert.ok(graphErrors.some((error) => error.includes('virtual-method graph owner'))); const plannerErrors = validate((mutated) => - replace(mutated, 'packages/core/src/runner-capability-plan.ts', 'item.receiverClass,', 'undefined,'), + replace( + mutated, + 'packages/core/src/runner-capability-linked-handlers.ts', + "object.name === 'this' && work.receiverClass", + "object.name === 'removed-this' && work.receiverClass", + ), ); assert.ok(plannerErrors.some((error) => error.includes('virtual-method capability plan'))); const constructorKeyErrors = validate((mutated) => replace( mutated, - 'packages/core/src/runner-capability-plan.ts', - 'key: `constructor:${name}:${className}`', - 'key: `constructor:${className}`', + 'packages/core/src/runner-capability-linked-handlers.ts', + 'if (cls) enqueueConstruction(cls, work.unsupported || !ownsClassFrames, enqueue);', + 'if (cls) void ownsClassFrames;', ), ); assert.ok(constructorKeyErrors.some((error) => error.includes('virtual-method capability plan'))); diff --git a/scripts/source-runner-module-ownership-convergence.mjs b/scripts/source-runner-module-ownership-convergence.mjs new file mode 100644 index 000000000..0f9ae2abf --- /dev/null +++ b/scripts/source-runner-module-ownership-convergence.mjs @@ -0,0 +1,236 @@ +export const MODULE_OWNERSHIP_FILES = Object.freeze({ + moduleClassActivation: 'packages/core/src/ir/semantics/internal-effect-machine-class-activation.ts', + moduleClassFrame: 'packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts', + moduleClassGraph: 'packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts', + moduleClassPreflight: 'packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts', + moduleGraph: 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', + moduleHelperClass: 'packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts', + moduleHelperContract: 'packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts', + moduleHelperGraph: 'packages/core/src/ir/semantics/internal-effect-machine-helper-graph.ts', + moduleHelperRuntime: 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + modulePlanner: 'packages/core/src/runner-capability-linked-handlers.ts', + modulePlannerAdmission: 'packages/core/src/runner-class-frame-capability-admission.ts', + modulePlannerTests: 'packages/core/tests/runner-capability-class-frame.test.ts', + moduleRuntimeScope: 'packages/core/src/runner-runtime-scope.ts', + moduleRuntimeTests: 'packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts', + moduleScopeOwner: 'packages/core/src/ir/semantics/runner-machine-scope.ts', + moduleState: 'packages/core/src/ir/semantics/internal-effect-machine-types.ts', + moduleSourceRunner: 'packages/core/src/runner.ts', +}); + +function requireAll(text, requirements, label, errors) { + for (const required of requirements) { + if (!text?.includes(required)) errors.push(`${label} is missing ${required}`); + } +} + +export function validateModuleOwnershipManifest(manifest, errors) { + const owner = manifest.owned.find((item) => item?.id === 'runner-classes-state'); + if ( + owner?.kind !== 'environment' || + owner?.status !== 'unified' || + owner?.evidence !== MODULE_OWNERSHIP_FILES.moduleRuntimeTests || + Object.keys(owner).sort().join(',') !== 'evidence,id,kind,status' + ) { + errors.push('manifest must contain exactly one evidenced unified runner-classes-state owner'); + } + if (manifest.owned.filter((item) => item?.id === 'runner-classes-state').length !== 1) { + errors.push('manifest runner-classes-state owner is duplicated'); + } + if (manifest.deferred.length !== 0) errors.push('M3.31c manifest must have no deferred convergence rows'); +} + +export function validateModuleOwnershipSlice(contents, errors) { + const combined = Object.keys(MODULE_OWNERSHIP_FILES) + .map((key) => contents[key] ?? '') + .join('\n'); + for (const forbidden of ['portable-reference-body', 'portable-reference-evaluator', 'async-reference-runner']) { + if (combined.includes(forbidden)) errors.push(`module ownership imports forbidden compatibility owner ${forbidden}`); + } + + requireAll( + contents.moduleScopeOwner, + [ + 'function scopeGraph(root: RunnerModuleScope)', + 'function scopeOwnershipMatches', + 'functionMetadata: new Map', + 'classEntries: new Map(candidate.classes)', + 'functionEntries: new Map(candidate.functions)', + 'metadataMatchesSnapshot', + 'export function runnerMachineScopeGraph', + ], + 'module graph ownership boundary', + errors, + ); + requireAll( + contents.moduleGraph, + [ + 'runnerMachineScopeGraph(functions, env.runnerClasses)', + 'const scopeClones = new Map()', + 'const functionClones = new Map()', + 'const classClones = new Map()', + 'body: binding.body.map(snapshotNode)', + 'props: structuredClone(node.props)', + 'scopeByFunctions.set(original.functions, clone)', + 'scopeByFunctions.set(clone.functions, clone)', + 'functionIdentity: new Map', + 'classIdentity: new Map', + ], + 'identity-preserving module snapshot', + errors, + ); + requireAll( + contents.moduleHelperContract, + [ + 'state?.resumableHelpers', + '? state.resumableHelpers.has(fn)', + ': state?.resumableHelperNames?.has(name) === true', + ], + 'binding-identity resumable helper classification', + errors, + ); + requireAll( + contents.moduleHelperClass, + [ + 'interface ClassReachability', + 'readonly helpers: Set', + 'reachability.helpers.add(helper)', + 'readonly reachableFunctions: ReadonlySet', + 'reachableFunctions: reachability.helpers', + ], + 'class-reached helper binding graph', + errors, + ); + requireAll( + contents.moduleHelperGraph, + [ + 'ReadonlySet', + 'fn.module.functions.get(fn.name) !== fn', + 'const helperCalls = new Map>', + 'moduleGraph?: InternalMachineModuleGraph', + 'resumableHelpers?: ReadonlySet', + ], + 'module-owned runtime state', + errors, + ); + requireAll( + contents.moduleClassActivation, + ['const scope = cls.module', 'runnerClasses: scope?.classes', 'runnerFunctions: scope?.functions'], + 'defining-module class activation', + errors, + ); + requireAll( + contents.moduleClassFrame, + [ + 'moduleGraph?.classIdentity.get(cls)', + 'const registry = cls?.module?.classes ?? state.classRegistry', + 'const frameIdentity = classFrameIdentity', + ], + 'binding-identity class frame', + errors, + ); + requireAll( + contents.moduleClassGraph, + [ + 'for (const scope of moduleGraph.scopes)', + 'receiver.module.classes', + 'moduleGraph.scopes.some((scope) => scope.classes.size > 0)', + ], + 'module-relative class graph', + errors, + ); + requireAll( + contents.moduleClassPreflight, + [ + 'classGraph.moduleGraph.scopes.flatMap', + 'const definingRegistry = cls.module?.classes ?? registry', + 'internalMachineClassConstructorPlan(cls, definingRegistry)', + ], + 'whole-graph class preflight', + errors, + ); + requireAll( + contents.moduleRuntimeScope, + ['export function buildRunnerModuleScopes', 'resolveExport', 'scope.functions.set(imported.localName, resolved.binding'], + 'shared linked runtime scope builder', + errors, + ); + requireAll( + contents.moduleSourceRunner, + ['buildRunnerModuleScopes(records)', 'markRunnerMachineRootScope(rootScope)'], + 'source runner linked scope installation', + errors, + ); + requireAll( + contents.modulePlannerAdmission, + ['export function linkedClassFrameAdmission', 'buildRunnerModuleScopes(records)', 'markRunnerMachineRootScope(rootScope)'], + 'linked capability admission', + errors, + ); + requireAll( + contents.modulePlanner, + [ + 'export function linkedExecutableKernHandlers', + "const NON_CLASS = Symbol('non-class runner value');", + 'binding.module ?? fallbackScope', + 'enqueueConstruction', + 'enqueueResolvedMember', + 'owner.module ?? startClass.module', + ], + 'exact linked capability reachability', + errors, + ); + if ((contents.modulePlanner?.split('enqueueResolvedMember').length ?? 1) - 1 !== 3) { + errors.push('exact linked capability reachability must preserve selected-member dispatch'); + } + + for (const oracle of [ + 'executes an imported helper alias in its defining private helper scope', + 'requires a budget for a private helper reached through an imported helper class', + 'partitions equal private helper names by defining binding identity', + 'prefers resumable helper binding identity over an equal display name', + 'does not classify an equal-name pure helper as resumable by display name', + 'suspends in an imported class alias and dispatches through its defining module', + 'does not resolve an imported class field initializer from the caller module', + 'does not confuse equal class member labels across defining modules', + 'rejects a helper inserted after the linker ownership mark', + 'snapshots an imported private helper across async suspension', + 'rejects an imported alias replaced after linker ownership', + ]) { + if (!contents.moduleRuntimeTests?.includes(oracle)) errors.push(`module runtime oracle is missing: ${oracle}`); + } + for (const oracle of [ + 'owns an imported class frame when its capability is reachable', + 'follows an imported helper into its defining module private class', + 'keeps class identity through an aliased additive re-export', + 'does not plan an unused effectful member on an imported class', + ]) { + if (!contents.modulePlannerTests?.includes(oracle)) errors.push(`module planner oracle is missing: ${oracle}`); + } +} diff --git a/scripts/source-runner-module-ownership-convergence.test.mjs b/scripts/source-runner-module-ownership-convergence.test.mjs new file mode 100644 index 000000000..19958ecee --- /dev/null +++ b/scripts/source-runner-module-ownership-convergence.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { validateSourceRunnerConvergence } from './check-source-runner-convergence.mjs'; + +function validate(overrides = new Map()) { + return validateSourceRunnerConvergence((file) => overrides.get(file) ?? fs.readFileSync(file, 'utf8')); +} + +function replace(file, before, after) { + const source = fs.readFileSync(file, 'utf8'); + assert.ok(source.includes(before), `mutation anchor missing in ${file}`); + return new Map([[file, source.replace(before, after)]]); +} + +test('rejects deletion of final runner class-state ownership', () => { + const file = 'scripts/source-runner-convergence-manifest.json'; + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + manifest.owned = manifest.owned.filter(({ id }) => id !== 'runner-classes-state'); + assert.ok(validate(new Map([[file, JSON.stringify(manifest)]])).some((error) => error.includes('runner-classes-state'))); +}); + +test('rejects a restored deferred module-ownership row', () => { + const file = 'scripts/source-runner-convergence-manifest.json'; + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + manifest.deferred.push({ + id: 'runner-classes-state', + kind: 'environment', + status: 'legacy', + followUp: 'M3.31c-module-ownership', + }); + assert.ok(validate(new Map([[file, JSON.stringify(manifest)]])).some((error) => error.includes('no deferred'))); +}); + +for (const [name, file, before, after, expected] of [ + [ + 'scope graph authentication', + 'packages/core/src/ir/semantics/runner-machine-scope.ts', + 'function scopeOwnershipMatches', + 'function scopeOwnershipWasRemoved', + 'module graph ownership boundary', + ], + [ + 'function metadata snapshot', + 'packages/core/src/ir/semantics/runner-machine-scope.ts', + 'functionMetadata: new Map(', + 'functionMetadata: new WeakMap(', + 'module graph ownership boundary', + ], + [ + 'identity-preserving function clones', + 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', + 'const functionClones = new Map();', + 'const functionClones = new WeakMap();', + 'identity-preserving module snapshot', + ], + [ + 'original-to-snapshot scope index', + 'packages/core/src/ir/semantics/internal-effect-machine-module-graph.ts', + 'scopeByFunctions.set(original.functions, clone);', + 'void original;', + 'identity-preserving module snapshot', + ], + [ + 'binding-keyed helper cache', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + 'function helperCache(state: InternalEffectMachineState, fn: RunnerFunctionBinding)', + 'function helperCache(state: InternalEffectMachineState, fn: string)', + 'defining-module helper runtime', + ], + [ + 'resumable helper binding precedence', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-contract.ts', + '? state.resumableHelpers.has(fn)', + '? state.resumableHelperNames?.has(name) === true', + 'binding-identity resumable helper classification', + ], + [ + 'defining helper function scope', + 'packages/core/src/ir/semantics/internal-effect-machine-helper-runtime.ts', + 'runnerFunctions: scope.functions,', + 'runnerFunctions: call.env.runnerFunctions,', + 'defining-module helper runtime', + ], + [ + 'binding-identity recursion labels', + 'packages/core/src/ir/semantics/internal-effect-machine-class-frame.ts', + 'moduleGraph?.classIdentity.get(cls)', + 'undefined', + 'binding-identity class frame', + ], + [ + 'receiver defining-module dispatch', + 'packages/core/src/ir/semantics/internal-effect-machine-class-graph.ts', + 'return receiver.module.classes;', + 'return internalMachineClassRegistryForEnv(env);', + 'module-relative class graph', + ], + [ + 'whole-graph class preflight', + 'packages/core/src/ir/semantics/internal-effect-machine-class-preflight.ts', + 'classGraph.moduleGraph.scopes.flatMap', + '[classGraph.moduleGraph.root].flatMap', + 'whole-graph class preflight', + ], + [ + 'shared runtime linker', + 'packages/core/src/runner-runtime-scope.ts', + 'export function buildRunnerModuleScopes', + 'function buildRunnerModuleScopes', + 'shared linked runtime scope builder', + ], + [ + 'linked planner admission', + 'packages/core/src/runner-class-frame-capability-admission.ts', + 'export function linkedClassFrameAdmission', + 'function linkedClassFrameAdmission', + 'linked capability admission', + ], + [ + 'exact imported member reachability', + 'packages/core/src/runner-capability-linked-handlers.ts', + 'enqueueResolvedMember(', + 'enqueueEveryMember(', + 'exact linked capability reachability', + ], + [ + 'ordinary-record containment', + 'packages/core/src/runner-capability-linked-handlers.ts', + "const NON_CLASS = Symbol('non-class runner value');", + "const NON_CLASS = Symbol.for('non-class runner value');", + 'exact linked capability reachability', + ], +]) { + test(`rejects deletion of ${name}`, () => { + const errors = validate(replace(file, before, after)); + assert.ok(errors.some((error) => error.includes(expected))); + }); +} + +test('rejects deletion of the imported helper private-class planner oracle', () => { + const errors = validate( + replace( + 'packages/core/tests/runner-capability-class-frame.test.ts', + 'follows an imported helper into its defining module private class', + 'private class planner coverage removed', + ), + ); + assert.ok(errors.some((error) => error.includes('module planner oracle'))); +}); + +test('rejects deletion of class-reached helper binding ownership', () => { + const errors = validate( + replace( + 'packages/core/src/ir/semantics/internal-effect-machine-helper-class.ts', + 'reachability.helpers.add(helper)', + 'void helper', + ), + ); + assert.ok(errors.some((error) => error.includes('class-reached helper binding graph'))); +}); + +test('rejects deletion of the defining-scope runtime oracle', () => { + const errors = validate( + replace( + 'packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts', + 'executes an imported helper alias in its defining private helper scope', + 'defining scope coverage removed', + ), + ); + assert.ok(errors.some((error) => error.includes('module runtime oracle'))); +}); + +for (const oracle of [ + 'requires a budget for a private helper reached through an imported helper class', + 'prefers resumable helper binding identity over an equal display name', + 'does not classify an equal-name pure helper as resumable by display name', + 'does not resolve an imported class field initializer from the caller module', + 'snapshots an imported private helper across async suspension', + 'rejects an imported alias replaced after linker ownership', +]) { + test(`rejects deletion of the ${oracle} oracle`, () => { + const errors = validate( + replace( + 'packages/core/tests/runtime-envelope-effect-machine-module-ownership.test.ts', + oracle, + 'module ownership coverage removed', + ), + ); + assert.ok(errors.some((error) => error.includes('module runtime oracle'))); + }); +} From 04604a2b1402bed1ce98ebad974f3afce4fc3630 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:49:07 +0200 Subject: [PATCH 3/3] feat(kir): bridge runtime handler types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../kern-5-r1-5e1-handler-type-kir/spec.md | 303 ++++++++++++++++++ docs/kern-5-release-train.md | 11 + packages/core/src/kir-structural/canonical.ts | 16 +- .../src/kir-structural/catalog.generated.ts | 14 +- .../core/src/kir-structural/handler-type.ts | 68 ++++ .../core/src/kir-structural/module-types.ts | 2 +- packages/core/src/kir-structural/node.ts | 66 +++- packages/core/src/kir-structural/types.ts | 9 +- packages/core/src/portable-handler-type.ts | 32 ++ packages/core/src/runtime-handler-contract.ts | 20 +- packages/core/tests/kir-module-graph.test.ts | 32 +- packages/core/tests/kir-structural.test.ts | 236 +++++++++++++- scripts/check-kir-coverage-closure.mjs | 38 ++- scripts/check-kir-structural-codec.mjs | 2 +- scripts/kir-structural/constitution.json | 14 +- scripts/kir-structural/constitution.mjs | 11 +- scripts/kir-structural/constitution.test.mjs | 24 ++ scripts/kir-v1/alpha-receipt-policy.json | 2 + scripts/kir-v1/coverage-witness-ledger.json | 38 +-- scripts/kir-v1/eligibility.json | 8 +- scripts/kir-v1/validate-coverage-ledger.mjs | 7 +- scripts/kir-v1/validate-eligibility.mjs | 2 +- 22 files changed, 870 insertions(+), 85 deletions(-) create mode 100644 .Codex/specs/kern-5-r1-5e1-handler-type-kir/spec.md create mode 100644 packages/core/src/kir-structural/handler-type.ts create mode 100644 packages/core/src/portable-handler-type.ts diff --git a/.Codex/specs/kern-5-r1-5e1-handler-type-kir/spec.md b/.Codex/specs/kern-5-r1-5e1-handler-type-kir/spec.md new file mode 100644 index 000000000..8b65989d6 --- /dev/null +++ b/.Codex/specs/kern-5-r1-5e1-handler-type-kir/spec.md @@ -0,0 +1,303 @@ +# KERN 5 R1.5e.1 — Runtime-Handler Type KIR Bridge + +**Status:** COMPLETE — FULL FITNESS AND TERMINAL REVIEW PASS +**Date:** 2026-07-17 +**Confidence:** 0.99 +**Parent objective:** close the exact KIR blocker before M4 canonicalizer/frontend work + +## Executive Summary + +M3.31c empties the source-runner convergence ledger, but M4 still cannot start +against a truthful typed KIR contract. The live structural codec deliberately +publishes an empty type catalog and rejects every `typeAnnotation`, including +the `string`, `number`, `boolean`, `void`, and one-dimensional list annotations +already admitted by `kern.runtime.handler.v1`. A direct probe of a typed `fn` +fails at `fn.returns` with `excluded-host-type` before an artifact exists. +**VERIFIED:** `packages/core/src/kir-structural/types.ts:3-12,61-69`, +`packages/core/src/kir-structural/node.ts:144-154`, and the 2026-07-17 probe +`encodeModuleKir([{ fn returns=string }]) -> StructuralKirError at +$.root.props.returns`. + +R1.5e.1 introduces one shared, host-independent type owner for the exact public +runtime-handler domain, lowers those annotations into canonical structural KIR, +and bumps every affected internal wire identifier. It does not serialize raw +type text, admit arbitrary TypeScript-shaped types, freeze public KIR v1, or +start the formatter. Its purpose is to make the already-shipped typed handler +language representable before M4 consumes the seam. + +## Current State / Root Cause + +- **VERIFIED:** the structural artifact advertises + `kern.type-admission.r1.5c.2-empty`, requires `admittedKinds: []`, and contains + no `lowered-type` disposition. Evidence: + `packages/core/src/kir-structural/types.ts:3-12,61-69` and + `packages/core/src/kir-structural/canonical.ts:29-48,74-91`. +- **VERIFIED:** all `typeAnnotation` values currently hit the generic + `excluded-*` failure before encoding. The reader likewise has no type-value + validator. Evidence: `packages/core/src/kir-structural/node.ts:144-154,275-294`. +- **VERIFIED:** the public runtime handler already admits exactly scalar + `string | number | boolean`, one-dimensional arrays of those scalars, and + return-only `void`. Evidence: + `packages/core/src/runtime-handler-contract.ts:14-35`. +- **VERIFIED:** that type logic is private duplication inside the handler + contract, so KIR cannot reuse it without extracting a neutral owner. + Evidence: `packages/core/src/runtime-handler-contract.ts:19-27`. +- **VERIFIED:** the source constitution contains 95 `typeAnnotation` property + contracts, all currently `excluded-host-type`; only three are schema-required. + Evidence: command + `node -e "...constitution.properties.filter(schemaKind === typeAnnotation)..."` + on 2026-07-17 reported `count 95 required 3 optional 92`. +- **VERIFIED:** 150 committed `.kern` files parse without parser errors and + contain 577 type-annotation occurrences across 109 distinct spellings. The + exact runtime-handler subset is common, but the corpus also contains unions, + records, generics, tuples, function types, indexed access, type predicates, + and host-oriented types. Evidence: the source-bound parser census run on + 2026-07-17. +- **VERIFIED:** the release fitness policy still calls versioned KIR v1 + `not-shipped`, while the runtime handler ABI is already an `internal-oracle`. + Evidence: `scripts/kern-5-fitness-policy.json:407-416` and the + `versioned-kir-v1` row immediately above it. +- **VERIFIED:** M3.31c marks the convergence manifest at + `KERN-5-R2-M3.31c`, promotes `runner-classes-state`, and leaves no deferred + rows. This closes runtime compatibility migration, not KIR type identity. + Evidence: `scripts/source-runner-convergence-manifest.json` and + `docs/kern-5-release-train.md:780-803`. + +## What Already Works + +- R1.5b canonical values already provide strict UTF-8 bytes, bounded recursive + values, canonical record ordering, and unknown-field rejection. Reuse this + encoding unchanged. +- R1.5c.2 structural nodes already sort properties, preserve child order, and + validate expression/import-path payloads. Add a type branch; do not build a + second codec. +- R1.5c.3 module artifacts already revalidate every embedded root and recompute + graph metadata. Retain that mechanism and change the format identity because + the admitted payload domain changes. +- M3.17 already owns public handler argument/result validation. Extract its + type parser; do not invent a wider KIR type domain than the executable ABI. +- R1.5c.4 already binds all 95 type rows to executable rejection witnesses. + Promote only the exact structured handler rows and retain explicit rejection + witnesses for the other type locations; do not discard the coverage ledger. + +## Contract (Verified and Proposed) + +> Verified against the current branch on 2026-07-17. `PROPOSED` rows become +> build oracles only after the required full-roster brainstorm resolves them. + +| Field / Behavior | Contract | Evidence | Tag | +|---|---|---|---| +| Runtime scalar syntax | exact trimmed `string`, `number`, `boolean` | `runtime-handler-contract.ts:19-26` | VERIFIED | +| Runtime list syntax | exactly one `[]` suffix over a runtime scalar | `runtime-handler-contract.ts:23-26` | VERIFIED | +| Runtime void syntax | exact `void`, admitted only for handler returns by the runtime consumer | `runtime-handler-contract.ts:19-26` | VERIFIED | +| Neutral semantic kinds | `text`, `integer`, `boolean`, `list`, `void` | matches `KernRuntimeHandlerAdmittedType` | PROPOSED | +| Type KIR scalar | canonical record `{ kind }` | R1.5b record encoding | VERIFIED design, brainstorm `brainstorm-1784280922754-r1rhbq` | +| Type KIR list | canonical record `{ element, kind: "list" }`, where `element` is one scalar kind | runtime domain is one-dimensional | VERIFIED design, brainstorm | +| Shared parser | neutral registry-independent owner; retain `KernRuntimeHandlerAdmittedType` as compatibility alias | removes current duplicated authority | VERIFIED design, brainstorm | +| Structural version | new internal format; old c.2 bytes reject as unsupported | semantic payload domain changes | VERIFIED design, brainstorm | +| Module version | new internal format without a duplicate type catalog; roots receive exact structural validation | embedded root domain changes | VERIFIED design, brainstorm | +| Type catalog | structural envelope only, with exact ordered kinds and a new non-empty catalog format | module duplication adds no proof | VERIFIED design, brainstorm | +| Unsupported type syntax | stable `invalid-type` error/path before artifact return | no raw host type transport | VERIFIED design, brainstorm | +| Public KIR v1 | remains false and unexported | fitness policy currently says not shipped | VERIFIED guard | + +## Selected Design + +### 1. One neutral portable handler-type owner + +Extract the annotation parser from `runtime-handler-contract.ts` into a focused +browser-safe module with no parser, TypeScript compiler, KIR, runner, registry, +or Node dependency. It returns the existing semantic shape: + +```text +boolean | integer | text | void | list +``` + +The parser accepts surrounding whitespace exactly as the current runtime +handler does. It rejects empty annotations, case changes, aliases, nested +lists, `void[]`, unions, records, generics, optional markers, defaults, and all +other syntax. The neutral owner takes an explicit `parameter | return` +position: `void` is admitted only for returns and scalar/list types are admitted +for both. KIR and runtime admission call that same contextual owner. + +### 2. Canonical type value inside structural properties + +Add `lowered-type` to the structural disposition vocabulary only for the exact +runtime-handler contexts: `fn.returns` and `param.type` where that `param` is a +direct child of the same `fn`. Projection maps those annotations to canonical +records; validation accepts only those exact canonical shapes, positions, and +catalog kinds. All other `typeAnnotation` locations retain +`excluded-host-type`. + +A typed KIR handler must use structured `param` children. A non-empty legacy +`fn params="name:type"` remains a raw string and therefore rejects from this +profile instead of leaking type syntax into KIR. Untyped empty `params` may be +omitted/canonicalized by existing parser behavior. Parent context must be +verified during both projection and decode so a detached or reparented `param` +cannot smuggle a lowered type into a non-handler node. + +### 3. Honest wire versioning + +Change the structural artifact and type-catalog formats because previously +invalid inputs now encode. Change the module artifact format because its +embedded root payload domain changes. The type catalog stays in the structural +envelope; module decoding obtains its proof by exact root validation and does +not embed a second catalog. Old bytes must fail at the format field; they are +not silently reinterpreted. Constitution, coverage-ledger, eligibility, and +receipt hashes update through their existing source-bound checks. + +The proof label remains `ALPHA-NO-GO`, `versioned-kir-v1` remains +`not-shipped`, and no package export is added. + +### 4. Coverage migration without self-proof + +Regenerate the source-bound constitution after changing the explicit +disposition policy. Migrate only `fn.returns` and context-qualified +`param.type` evidence to `lowered-type`; keep the other 93 schema-location rows +explicitly excluded. Add independent hostile fixtures for every rejected +grammar family, legacy raw `fn.params`, wrong-parent parameters, and canonical- +payload tampering. The checked-in ledger remains independent evidence; the +runtime implementation may not generate expected canonical values for its own +assertions. + +### 5. Exact M4 handoff + +After this slice, M4.1 may build a KERN-authored canonicalizer over typed handler +KIR. M4 still cannot claim the whole committed source corpus: arbitrary types, +opaque host expressions/blocks, full frontend ownership, comments, and trivia +remain explicit later work. The release train records that corrected boundary. + +## Alternatives Considered + +### A. Admit the exact runtime-handler type domain (selected) + +Confidence 0.91 after full-roster brainstorm. It closes a real cross-contract +gap, has an executable consumer, and keeps the wire independent of TypeScript. + +### B. Implement all 109 observed type spellings now + +Confidence 0.61. Rejected for this slice. The corpus requires unions, records, +intersections, tuples, generics, function types, indexed access, predicates, +assertions, and host utility types. Freezing them before the KERN frontend and +compiler define semantic ownership would canonize TypeScript syntax by census. + +### C. Store canonicalized raw type text + +Confidence 0.18. Rejected. Whitespace normalization does not define type +semantics, and downstream KERN code would still need a host parser. + +### D. Start M4 against the empty type catalog + +Confidence 0.10. Rejected by the live RED probe: a typed handler cannot enter +the module artifact at all. + +## Blast Radius + +| File / Area | Action | Reason | +|---|---|---| +| neutral portable handler-type module | add | single registry-independent type owner | +| `runtime-handler-contract.ts` | modify | consume the shared type owner without ABI widening | +| `kir-structural/types.ts` and focused type module | modify/add | non-empty catalog, exact KIR type shape, stable errors | +| structural node/canonical codecs | modify | project/validate lowered type values and bumped formats | +| module KIR types/canonical codec | modify | reject old embedded-root wire identity | +| constitution generator/catalog | modify/regenerate | source-bind only exact handler type contexts to `lowered-type` | +| coverage ledger/validator/tests | modify | positive handler-context and retained exclusion evidence | +| runtime-handler and KIR focused tests | modify | parity, mutation, version, and fail-closed oracles | +| fitness policy/release train/spec | modify | truthful internal status and exact next boundary | + +No handwritten source file may exceed 500 lines. Generated catalogs, ledgers, +and lockfiles remain exempt. + +## Acceptance Criteria + +- [x] A RED-at-base test proves `fn returns=string` and a structured + `param type="string[]"` cannot currently enter structural/module KIR for + the intended empty-catalog reason. +- [x] One neutral parser owns the exact handler type domain; runtime-handler + signature admission has byte-for-byte unchanged accept/reject behavior. +- [x] Structural KIR round-trips structured handler `string`, `number`, + `boolean`, `void`, + `string[]`, `number[]`, and `boolean[]` as exact semantic canonical values, + never annotation text. +- [x] Non-empty legacy `fn.params`, typed `param` outside a direct `fn`, and + every non-handler type-annotation property remain fail-closed before + artifact return. +- [x] Unknown names, case variants, `void[]`, nested arrays, unions, records, + tuples, generics, optionals/defaults, function types, indexed access, and + predicates reject with stable code and property path before artifact + return. +- [x] Reader mutation tests reject wrong record keys/order, wrong scalar/list + kinds, list-of-void, unknown catalog kinds, missing/extra catalog fields, + and old structural/module format identities. +- [x] The complete 95-row type-annotation constitution and coverage ledger stay + source-bound: exact handler rows have positive populated fixtures, all + other rows retain explicit rejection, and omitted-state behavior plus + hostile-type witnesses remain independent. +- [x] Runtime-handler public ABI tests, structural codec tests, module graph + tests, coverage closure, eligibility, Alpha receipt, and source-runner + convergence all pass together. +- [x] `versioned-kir-v1`, public KIR export, Alpha acceptance, formatter, + frontend, compiler, interpreter, and semantic cutover remain unclaimed. +- [x] Focused gates, `pnpm lint`, `pnpm check:kern-5-contract`, full + `pnpm fitness:kern-5`, `git diff --check`, and terminal + `agon review -e claude,codex,agy` pass with every verified blocker fixed. + +## RED Oracle Matrix + +1. Typed `fn` with structured scalar/list params and scalar/list/void returns: + current branch rejects; implementation round-trips semantic type records. +2. Same source through runtime-handler signature admission and KIR projection: + accepted spellings map to identical semantic kinds. +3. `Custom`, `String`, `string[][]`, `void[]`, `string | null`, + `Record`, `(x:string)=>void`, and `value is string` all + reject in KIR; runtime handler remains equally conservative. +4. Tamper an encoded scalar to `{kind:"number"}` rather than `integer`, reorder + list fields, add a field, or use list element `void`: decode rejects. +5. Replace the new structural/module/type-catalog version with each predecessor: + decode rejects as unsupported before returning an artifact. +6. Take an old module/root containing raw type text, change only its outer + format to the new identifier, and prove exact root validation still rejects. +7. Use `fn params="value:string"`, reparent a typed `param` below a non-`fn`, or + place a valid `string` annotation on `field.type`; all remain rejected. +8. Remove the shared parser call from either consumer or restore a private + annotation regex: convergence/mutation evidence fails. +9. Alter a type coverage row to raw text, auto-derived expected output, or an + omitted witness: the coverage gate fails. + +## Out of Scope + +- General named types, unions, intersections, records, tuples, generics, + functions, indexed access, predicates, assertions, or host utility types. +- Normalizing legacy `params="name:type"` into structured parameter children; + this slice rejects it from typed KIR instead. +- Opaque host expressions/blocks or changes to their explicit rejection. +- Public KIR v1 export/freeze, reader compatibility promise, or package API. +- KERN canonicalizer/parser/compiler/interpreter implementation. +- Comment/trivia preservation or user-facing `kern format` behavior. +- Runtime-handler ABI widening, capability ABI changes, or semantic cutover. + +## Open Questions + +None. Full-roster brainstorm `brainstorm-1784280922754-r1rhbq` resolved both +pre-build questions: the module envelope does not duplicate the structural type +catalog, and the semantic owner is neutral while +`KernRuntimeHandlerAdmittedType` remains a compatibility alias. + +## Deploy Order + +Build and gate the complete slice on the current stacked branch because fresh +`origin/main` still ends at `377a12ef` and does not contain the pushed M3.31c +commit `579fec98`. Immediately before publication, run `git fetch origin` and +rebase first. If `579fec98` has landed by then, create a fresh +`feat/kern-5-r1-5e1-handler-type-kir` from `origin/main` and transplant only +this slice. Otherwise stack and push once on the existing branch. Never push an +old merged branch. + +## Corrections Log + +| Original Claim | Reality | Impact | +|---|---|---| +| M3.31c completion means M4.1 is immediately buildable. | Runtime convergence is complete, but typed handlers still cannot enter the empty-catalog KIR. | Insert R1.5e.1 before M4. | +| The next slice should be the KERN canonicalizer. | A live `returns=string` module-KIR probe fails before artifact creation. | Do not canonize an Alpha-only incomplete seam. | +| The whole source type corpus should be frozen now. | 109 observed spellings include host-specific and unresolved semantic families. | Admit only the executable runtime-handler domain first. | +| All 95 `typeAnnotation` schema locations should share the new grammar. | That would admit valid-looking types outside the executable handler contract and erase explicit exclusions. | Lower only direct structured `fn` params and `fn.returns`; retain the other exclusions. | +| The module artifact should repeat the type catalog. | Its roots are already revalidated and the module format is versioned. | Keep the catalog structural-only and add a stale-root-with-new-envelope kill test. | diff --git a/docs/kern-5-release-train.md b/docs/kern-5-release-train.md index d6cc6d723..ee9c2e76d 100644 --- a/docs/kern-5-release-train.md +++ b/docs/kern-5-release-train.md @@ -200,6 +200,14 @@ trusted-publishing/provenance configuration is inspected. gate: policy-bound hashes/oracles/exclusions, dirty-tree and symlink kill tests, full fitness wall, and final 3/3 Agon review passed on 2026-07-12. The SHA-named ignored receipt is generated only after commit. + - [x] R1.5e.1 runtime-handler type KIR bridge: one neutral exact handler-type + owner now feeds both runtime admission and canonical semantic KIR records + for `fn.returns` plus direct structured `param.type`. The other 93 + type-annotation locations remain explicitly excluded; structural, module, + catalog, constitution, and coverage identities are versioned together. + The Node 22 full fitness wall and terminal `claude,codex,agy` review passed + on 2026-07-17 with zero verified, needs-check, or speculative findings + (`ALPHA-NO-GO`; no public KIR v1 or formatter claim). - [ ] R2 M3 runtime and handler ownership. - [x] M3.1 internal transactional runtime envelope: default-off sync/async normalization with closed portable values, structural diagnostics, failure @@ -827,6 +835,9 @@ trusted-publishing/provenance configuration is inspected. 9. R1.5d: version diagnostic/location evidence separately, require stable non-empty diagnostics and expression spans, then generate the clean-SHA Alpha manifest. Unknown versions and fields fail before effects. +10. R1.5e.1: bridge the exact executable runtime-handler type domain into + semantic structural/module KIR before M4 consumes the seam. Keep the other + 93 type-annotation locations excluded and preserve `ALPHA-NO-GO`. The original broad R1.4-before-ownership order was withdrawn after the R1.3 probe. The selected projection covers seven of 302 source node kinds, while the diff --git a/packages/core/src/kir-structural/canonical.ts b/packages/core/src/kir-structural/canonical.ts index d530a57f0..c0cfb72f7 100644 --- a/packages/core/src/kir-structural/canonical.ts +++ b/packages/core/src/kir-structural/canonical.ts @@ -1,5 +1,6 @@ import { decodeCanonicalValue, encodeCanonicalValue } from '../canonical-value/canonical.js'; import { type CanonicalValue, CanonicalValueDecodeError, type CanonicalValueLimits } from '../canonical-value/types.js'; +import { PORTABLE_HANDLER_TYPE_KINDS } from '../portable-handler-type.js'; import type { IRNode } from '../types.js'; import { STRUCTURAL_KIR_CONSTITUTION_FORMAT, STRUCTURAL_KIR_PROOF_LABEL } from './catalog.generated.js'; import { projectStructuralNode, validateStructuralNode } from './node.js'; @@ -39,7 +40,10 @@ function artifactValue(root: StructuralKirNode): CanonicalValue { value: { tag: 'record', value: [ - { key: 'admittedKinds', value: { tag: 'list', value: [] } }, + { + key: 'admittedKinds', + value: { tag: 'list', value: PORTABLE_HANDLER_TYPE_KINDS.map((kind) => ({ tag: 'text', value: kind })) }, + }, { key: 'format', value: { tag: 'text', value: STRUCTURAL_KIR_TYPE_CATALOG_FORMAT } }, ], }, @@ -78,15 +82,19 @@ function validateArtifact(value: CanonicalValue): StructuralKirArtifact { exactText(field(artifact, 'proofLabel'), STRUCTURAL_KIR_PROOF_LABEL, '$.proofLabel'); const typeCatalog = exact(field(artifact, 'typeCatalog'), ['admittedKinds', 'format'], '$.typeCatalog'); const admittedKinds = field(typeCatalog, 'admittedKinds'); - if (admittedKinds.tag !== 'list' || admittedKinds.value.length !== 0) { - fail('invalid-artifact', '$.typeCatalog.admittedKinds', 'type admission catalog must remain empty'); + if ( + admittedKinds.tag !== 'list' || + admittedKinds.value.length !== PORTABLE_HANDLER_TYPE_KINDS.length || + admittedKinds.value.some((kind, index) => kind.tag !== 'text' || kind.value !== PORTABLE_HANDLER_TYPE_KINDS[index]) + ) { + fail('invalid-artifact', '$.typeCatalog.admittedKinds', 'expected exact portable handler type catalog'); } exactText(field(typeCatalog, 'format'), STRUCTURAL_KIR_TYPE_CATALOG_FORMAT, '$.typeCatalog.format'); return { format: STRUCTURAL_KIR_ARTIFACT_FORMAT, constitution: STRUCTURAL_KIR_CONSTITUTION_FORMAT, proofLabel: STRUCTURAL_KIR_PROOF_LABEL, - typeCatalog: { format: STRUCTURAL_KIR_TYPE_CATALOG_FORMAT, admittedKinds: [] }, + typeCatalog: { format: STRUCTURAL_KIR_TYPE_CATALOG_FORMAT, admittedKinds: PORTABLE_HANDLER_TYPE_KINDS }, root: validateStructuralNode(field(artifact, 'root')), }; } diff --git a/packages/core/src/kir-structural/catalog.generated.ts b/packages/core/src/kir-structural/catalog.generated.ts index 542d34fa9..93902f145 100644 --- a/packages/core/src/kir-structural/catalog.generated.ts +++ b/packages/core/src/kir-structural/catalog.generated.ts @@ -1,7 +1,7 @@ // Generated by scripts/kir-structural/generate-constitution.mjs. Do not edit. import type { StructuralNodeContract } from './types.js'; -export const STRUCTURAL_KIR_CONSTITUTION_FORMAT = 'kern.kir.structural.alpha.1' as const; +export const STRUCTURAL_KIR_CONSTITUTION_FORMAT = 'kern.kir.structural.r1.5e.1' as const; export const STRUCTURAL_KIR_PROOF_LABEL = 'ALPHA-NO-GO' as const; // biome-ignore format: generated catalog is byte-bound to the source constitution export const STRUCTURAL_KIR_NODE_CATALOG = new Map( @@ -3666,15 +3666,15 @@ export const STRUCTURAL_KIR_NODE_CATALOG = new Map( "schemaKind": "string", "required": false, "values": null, - "disposition": "included-value", - "reasonId": "portable-text" + "disposition": "excluded-host-type", + "reasonId": "structured-handler-parameters-required" }, "returns": { "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required" + "disposition": "lowered-type", + "reasonId": "portable-handler-return-type" }, "stream": { "schemaKind": "boolean", @@ -10708,8 +10708,8 @@ export const STRUCTURAL_KIR_NODE_CATALOG = new Map( "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required" + "disposition": "lowered-type", + "reasonId": "portable-handler-parameter-type" }, "value": { "schemaKind": "expression", diff --git a/packages/core/src/kir-structural/handler-type.ts b/packages/core/src/kir-structural/handler-type.ts new file mode 100644 index 000000000..89aa80535 --- /dev/null +++ b/packages/core/src/kir-structural/handler-type.ts @@ -0,0 +1,68 @@ +import type { CanonicalValue } from '../canonical-value/types.js'; +import { + type PortableHandlerScalarKind, + type PortableHandlerType, + type PortableHandlerTypePosition, + parsePortableHandlerType, +} from '../portable-handler-type.js'; +import { StructuralKirError } from './types.js'; + +const SCALAR_KINDS: readonly PortableHandlerScalarKind[] = ['boolean', 'integer', 'text']; + +function fail(path: string, message: string): never { + throw new StructuralKirError('invalid-type', path, message); +} + +function text(value: CanonicalValue, path: string): string { + if (value.tag !== 'text') fail(path, 'expected semantic type text'); + return value.value; +} + +function scalar(value: string): PortableHandlerScalarKind | null { + return SCALAR_KINDS.includes(value as PortableHandlerScalarKind) ? (value as PortableHandlerScalarKind) : null; +} + +export function projectHandlerType( + source: unknown, + position: PortableHandlerTypePosition, + path: string, +): CanonicalValue { + if (typeof source !== 'string') fail(path, 'expected handler type annotation text'); + const type = parsePortableHandlerType(source, position); + if (type === null) fail(path, 'type is outside the portable runtime-handler domain'); + if (type.kind === 'list') { + return { + tag: 'record', + value: [ + { key: 'element', value: { tag: 'text', value: type.element } }, + { key: 'kind', value: { tag: 'text', value: 'list' } }, + ], + }; + } + return { + tag: 'record', + value: [{ key: 'kind', value: { tag: 'text', value: type.kind } }], + }; +} + +export function validateHandlerType( + value: CanonicalValue, + position: PortableHandlerTypePosition, + path: string, +): PortableHandlerType { + if (value.tag !== 'record') fail(path, 'expected semantic type record'); + if (value.value.length === 1 && value.value[0]?.key === 'kind') { + const kind = text(value.value[0].value, `${path}.kind`); + const scalarKind = scalar(kind); + if (scalarKind !== null) return { kind: scalarKind }; + if (kind === 'void' && position === 'return') return { kind: 'void' }; + fail(`${path}.kind`, 'scalar type kind is outside the handler position domain'); + } + if (value.value.length === 2 && value.value[0]?.key === 'element' && value.value[1]?.key === 'kind') { + const element = scalar(text(value.value[0].value, `${path}.element`)); + if (element === null) fail(`${path}.element`, 'list element kind is outside the portable handler domain'); + if (text(value.value[1].value, `${path}.kind`) !== 'list') fail(`${path}.kind`, 'expected list type kind'); + return { element, kind: 'list' }; + } + fail(path, 'expected exact semantic handler type fields'); +} diff --git a/packages/core/src/kir-structural/module-types.ts b/packages/core/src/kir-structural/module-types.ts index d47561071..435e8a37f 100644 --- a/packages/core/src/kir-structural/module-types.ts +++ b/packages/core/src/kir-structural/module-types.ts @@ -2,7 +2,7 @@ import type { CanonicalValueLimits } from '../canonical-value/types.js'; import type { IRNode } from '../types.js'; import type { StructuralKirNode } from './types.js'; -export const MODULE_KIR_ARTIFACT_FORMAT = 'kern.kir.modules.r1.5c.3-alpha' as const; +export const MODULE_KIR_ARTIFACT_FORMAT = 'kern.kir.modules.r1.5e.1-alpha' as const; export const MODULE_KIR_SYMBOL_CATALOG_FORMAT = 'kern.symbol-admission.r1.5c.3' as const; export const MODULE_KIR_SYMBOL_KINDS = ['class', 'fn'] as const; export const MODULE_KIR_ROOT_KINDS = ['class', 'fn', 'from', 'module', 'use'] as const; diff --git a/packages/core/src/kir-structural/node.ts b/packages/core/src/kir-structural/node.ts index e6247cf3e..c32a624a0 100644 --- a/packages/core/src/kir-structural/node.ts +++ b/packages/core/src/kir-structural/node.ts @@ -5,9 +5,11 @@ import { type CanonicalValueLimits, } from '../canonical-value/types.js'; import { compareCodePoints, validateCanonicalValueLimits } from '../canonical-value/validate.js'; +import type { PortableHandlerTypePosition } from '../portable-handler-type.js'; import type { IRNode } from '../types.js'; import { STRUCTURAL_KIR_NODE_CATALOG } from './catalog.generated.js'; import { projectExpressionText, validateExpressionValue } from './expression.js'; +import { projectHandlerType, validateHandlerType } from './handler-type.js'; import { StructuralKirError, type StructuralKirNode, @@ -141,7 +143,24 @@ function includedValue(value: unknown, contract: StructuralPropertyContract, pat fail('invalid-property', path, `unsupported included schema kind ${contract.schemaKind}`); } -function projectProperty(value: unknown, contract: StructuralPropertyContract, path: string): CanonicalValue { +function handlerTypePosition( + kind: string, + name: string, + parentKind: string | undefined, +): PortableHandlerTypePosition | undefined { + if (kind === 'fn' && name === 'returns') return 'return'; + if (kind === 'param' && name === 'type' && parentKind === 'fn') return 'parameter'; + return undefined; +} + +function projectProperty( + value: unknown, + contract: StructuralPropertyContract, + path: string, + kind: string, + name: string, + parentKind: string | undefined, +): CanonicalValue { if (contract.disposition.startsWith('excluded-')) { fail('excluded-host-payload', path, `${contract.disposition}: ${contract.reasonId}`); } @@ -151,9 +170,18 @@ function projectProperty(value: unknown, contract: StructuralPropertyContract, p if (contract.disposition === 'included-value') return includedValue(value, contract, path); if (contract.disposition === 'lowered-import-path') return { tag: 'text', value: normalizeImportPath(value, path) }; if (contract.disposition === 'lowered-expression') return projectExpressionText(expressionSource(value, path), path); + if (contract.disposition === 'lowered-type') { + const position = handlerTypePosition(kind, name, parentKind); + if (position === undefined) fail('excluded-host-payload', path, 'type is outside a structured handler signature'); + return projectHandlerType(value, position, path); + } fail('invalid-property', path, `unknown property disposition ${String(contract.disposition)}`); } +function canonicalizesToOmission(kind: string, name: string, value: unknown): boolean { + return kind === 'fn' && name === 'params' && typeof value === 'string' && value.trim() === ''; +} + function nodeContract(kind: string, path: string): StructuralNodeContract { const contract = STRUCTURAL_KIR_NODE_CATALOG.get(kind); if (contract === undefined || contract.schemaStatus !== 'bound' || contract.disposition !== 'structural-candidate') { @@ -185,7 +213,13 @@ interface ProjectState { readonly limits: CanonicalValueLimits; } -function projectNode(input: unknown, path: string, depth: number, state: ProjectState): StructuralKirNode { +function projectNode( + input: unknown, + path: string, + depth: number, + state: ProjectState, + parentKind?: string, +): StructuralKirNode { if (depth > state.limits.maxDepth) { throw new CanonicalValueDecodeError( 'limit-depth', @@ -213,14 +247,15 @@ function projectNode(input: unknown, path: string, depth: number, state: Project for (const name of Object.keys(rawProperties).sort(compareCodePoints)) { const propertyContract = contract.properties[name]; if (propertyContract === undefined) fail('unknown-property', `${path}.props.${name}`, `unknown ${kind} property`); + if (canonicalizesToOmission(kind, name, rawProperties[name])) continue; properties.push({ key: name, - value: projectProperty(rawProperties[name], propertyContract, `${path}.props.${name}`), + value: projectProperty(rawProperties[name], propertyContract, `${path}.props.${name}`, kind, name, parentKind), }); } const rawChildren = node.children === undefined ? [] : array(node.children, `${path}.children`); const children = rawChildren.map((child, index) => - projectNode(child, `${path}.children[${index}]`, depth + 1, state), + projectNode(child, `${path}.children[${index}]`, depth + 1, state, kind), ); if (contract.allowedChildren !== null) { children.forEach((child, index) => { @@ -266,7 +301,14 @@ function canonicalText(value: CanonicalValue, path: string): string { return value.value; } -function validateProperty(value: CanonicalValue, contract: StructuralPropertyContract, path: string): void { +function validateProperty( + value: CanonicalValue, + contract: StructuralPropertyContract, + path: string, + kind: string, + name: string, + parentKind: string | undefined, +): void { if (contract.disposition.startsWith('excluded-')) fail('excluded-host-payload', path, `${contract.disposition} is forbidden`); if (contract.disposition === 'lowered-expression') { @@ -278,6 +320,12 @@ function validateProperty(value: CanonicalValue, contract: StructuralPropertyCon if (normalizeImportPath(source, path) !== source) fail('invalid-import-path', path, 'import path is not canonical'); return; } + if (contract.disposition === 'lowered-type') { + const position = handlerTypePosition(kind, name, parentKind); + if (position === undefined) fail('excluded-host-payload', path, 'type is outside a structured handler signature'); + validateHandlerType(value, position, path); + return; + } if (contract.schemaKind === 'boolean') { if (value.tag !== 'bool') fail('invalid-property', path, 'expected boolean'); return; @@ -293,7 +341,7 @@ function validateProperty(value: CanonicalValue, contract: StructuralPropertyCon fail('invalid-property', path, 'value is outside enum'); } -export function validateStructuralNode(value: CanonicalValue, path = '$.root'): StructuralKirNode { +export function validateStructuralNode(value: CanonicalValue, path = '$.root', parentKind?: string): StructuralKirNode { const node = canonicalRecord(value, ['children', 'kind', 'properties'], path); const kind = canonicalText(canonicalField(node, 'kind'), `${path}.kind`); const contract = nodeContract(kind, `${path}.kind`); @@ -305,11 +353,13 @@ export function validateStructuralNode(value: CanonicalValue, path = '$.root'): const propertyContract = contract.properties[entry.key]; if (propertyContract === undefined) fail('unknown-property', `${path}.properties.${entry.key}`, `unknown ${kind} property`); - validateProperty(entry.value, propertyContract, `${path}.properties.${entry.key}`); + validateProperty(entry.value, propertyContract, `${path}.properties.${entry.key}`, kind, entry.key, parentKind); }); const childValue = canonicalField(node, 'children'); if (childValue.tag !== 'list') fail('invalid-artifact', `${path}.children`, 'expected child list'); - const children = childValue.value.map((child, index) => validateStructuralNode(child, `${path}.children[${index}]`)); + const children = childValue.value.map((child, index) => + validateStructuralNode(child, `${path}.children[${index}]`, kind), + ); if (contract.allowedChildren !== null) children.forEach((child, index) => { if (!contract.allowedChildren?.includes(child.kind)) diff --git a/packages/core/src/kir-structural/types.ts b/packages/core/src/kir-structural/types.ts index 14356b9e5..3c4913b96 100644 --- a/packages/core/src/kir-structural/types.ts +++ b/packages/core/src/kir-structural/types.ts @@ -1,12 +1,14 @@ import type { CanonicalRecordEntry, CanonicalValue } from '../canonical-value/types.js'; +import type { PortableHandlerTypeKind } from '../portable-handler-type.js'; -export const STRUCTURAL_KIR_ARTIFACT_FORMAT = 'kern.kir.structural.r1.5c.2-alpha' as const; -export const STRUCTURAL_KIR_TYPE_CATALOG_FORMAT = 'kern.type-admission.r1.5c.2-empty' as const; +export const STRUCTURAL_KIR_ARTIFACT_FORMAT = 'kern.kir.structural.r1.5e.1-alpha' as const; +export const STRUCTURAL_KIR_TYPE_CATALOG_FORMAT = 'kern.type-admission.r1.5e.1-handler' as const; export type StructuralPropertyDisposition = | 'included-value' | 'lowered-import-path' | 'lowered-expression' + | 'lowered-type' | 'excluded-host-expression' | 'excluded-host-type' | 'excluded-raw-block'; @@ -44,6 +46,7 @@ export type StructuralKirErrorCode = | 'invalid-child' | 'unknown-expression-kind' | 'invalid-expression' + | 'invalid-type' | 'invalid-import-path'; export class StructuralKirError extends TypeError { @@ -64,7 +67,7 @@ export interface StructuralKirArtifact { readonly proofLabel: 'ALPHA-NO-GO'; readonly typeCatalog: { readonly format: typeof STRUCTURAL_KIR_TYPE_CATALOG_FORMAT; - readonly admittedKinds: readonly []; + readonly admittedKinds: readonly PortableHandlerTypeKind[]; }; readonly root: StructuralKirNode; } diff --git a/packages/core/src/portable-handler-type.ts b/packages/core/src/portable-handler-type.ts new file mode 100644 index 000000000..a3d0b3091 --- /dev/null +++ b/packages/core/src/portable-handler-type.ts @@ -0,0 +1,32 @@ +export const PORTABLE_HANDLER_TYPE_KINDS = Object.freeze(['boolean', 'integer', 'list', 'text', 'void'] as const); + +export type PortableHandlerScalarKind = 'boolean' | 'integer' | 'text'; +export type PortableHandlerTypeKind = (typeof PORTABLE_HANDLER_TYPE_KINDS)[number]; +export type PortableHandlerTypePosition = 'parameter' | 'return'; + +export type PortableHandlerType = + | { readonly kind: PortableHandlerScalarKind } + | { readonly element: PortableHandlerScalarKind; readonly kind: 'list' } + | { readonly kind: 'void' }; + +function scalarKind(annotation: string): PortableHandlerScalarKind | null { + if (annotation === 'boolean') return 'boolean'; + if (annotation === 'number') return 'integer'; + if (annotation === 'string') return 'text'; + return null; +} + +export function parsePortableHandlerType( + annotation: string | undefined, + position: PortableHandlerTypePosition, +): PortableHandlerType | null { + if (annotation === undefined) return null; + const value = annotation.trim(); + if (position === 'return' && value === 'void') return { kind: 'void' }; + if (value.endsWith('[]')) { + const element = scalarKind(value.slice(0, -2)); + return element === null ? null : { element, kind: 'list' }; + } + const kind = scalarKind(value); + return kind === null ? null : { kind }; +} diff --git a/packages/core/src/runtime-handler-contract.ts b/packages/core/src/runtime-handler-contract.ts index d4ad0c2a0..06717c25d 100644 --- a/packages/core/src/runtime-handler-contract.ts +++ b/packages/core/src/runtime-handler-contract.ts @@ -1,4 +1,5 @@ import { isPortableBindingName } from './ir/semantics/portable-scalar-domain.js'; +import { type PortableHandlerType, parsePortableHandlerType } from './portable-handler-type.js'; import type { IRNode } from './types.js'; export interface KernRuntimeHandlerParameter { @@ -11,26 +12,13 @@ export interface KernRuntimeHandlerSignature { readonly returns?: string; } -export type KernRuntimeHandlerAdmittedType = - | { readonly kind: 'boolean' | 'integer' | 'text' } - | { readonly element: 'boolean' | 'integer' | 'text'; readonly kind: 'list' } - | { readonly kind: 'void' }; - -function admittedAnnotation(annotation: string | undefined, returns: boolean): KernRuntimeHandlerAdmittedType | null { - if (annotation === undefined) return null; - const value = annotation.trim(); - if (returns && value === 'void') return { kind: 'void' }; - const scalar = value.endsWith('[]') ? value.slice(0, -2) : value; - const kind = scalar === 'string' ? 'text' : scalar === 'number' ? 'integer' : scalar === 'boolean' ? 'boolean' : null; - if (kind === null) return null; - return value.endsWith('[]') ? { element: kind, kind: 'list' } : { kind }; -} +export type KernRuntimeHandlerAdmittedType = PortableHandlerType; export function admitKernRuntimeHandlerSignature( signature: KernRuntimeHandlerSignature, ): readonly KernRuntimeHandlerAdmittedType[] | null { - const parameters = signature.parameters.map(({ annotation }) => admittedAnnotation(annotation, false)); - const returns = admittedAnnotation(signature.returns, true); + const parameters = signature.parameters.map(({ annotation }) => parsePortableHandlerType(annotation, 'parameter')); + const returns = parsePortableHandlerType(signature.returns, 'return'); if (parameters.some((type) => type === null) || returns === null) return null; return [...(parameters as KernRuntimeHandlerAdmittedType[]), returns]; } diff --git a/packages/core/tests/kir-module-graph.test.ts b/packages/core/tests/kir-module-graph.test.ts index 1da142adb..04b244694 100644 --- a/packages/core/tests/kir-module-graph.test.ts +++ b/packages/core/tests/kir-module-graph.test.ts @@ -89,7 +89,7 @@ describe('internal structural KIR module graph', () => { test('round-trips fn/class aliases and re-exports under the explicit catalog', () => { const bytes = encodeModuleKir(moduleFixture(), limits); const artifact = decodeModuleKir(bytes, limits); - expect(artifact.format).toBe('kern.kir.modules.r1.5c.3-alpha'); + expect(artifact.format).toBe('kern.kir.modules.r1.5e.1-alpha'); expect(artifact.proofLabel).toBe('ALPHA-NO-GO'); expect(artifact.diagnostics).toEqual([]); expect(artifact.symbolCatalog.admittedKinds).toEqual(['class', 'fn']); @@ -262,6 +262,36 @@ describe('internal structural KIR module graph', () => { expectStructuralCode(() => decodeModuleKir(encodeCanonicalValue(rootDrift, limits), limits), 'invalid-artifact'); }); + test('reader rejects raw type text hidden beneath the current module envelope', () => { + const value = structuredClone( + decodeCanonicalValue( + encodeModuleKir( + [{ id: 'main.kern', roots: [{ type: 'fn', props: { name: 'main', returns: 'string' } }] }], + limits, + ), + limits, + ), + ); + const modules = recordField(value, 'modules'); + const module = listItem(modules, 0); + const rootsValue = recordField(module, 'roots'); + const root = listItem(rootsValue, 0); + const properties = recordField(root, 'properties'); + if (properties.tag !== 'record') throw new Error('expected root properties'); + const returns = properties.value.find((entry) => entry.key === 'returns'); + if (!returns) throw new Error('expected return type'); + returns.value = { tag: 'text', value: 'string' }; + expectStructuralCode(() => decodeModuleKir(encodeCanonicalValue(value, limits), limits), 'invalid-type'); + }); + + test('reader rejects the predecessor module envelope identity', () => { + const value = structuredClone(decodeCanonicalValue(encodeModuleKir(moduleFixture(), limits), limits)); + const format = recordField(value, 'format'); + if (format.tag !== 'text') throw new Error('expected module format'); + format.value = 'kern.kir.modules.r1.5c.3-alpha'; + expectModuleCode(() => decodeModuleKir(encodeCanonicalValue(value, limits), limits), 'unsupported-module-version'); + }); + test('forbids the non-catalog document container instead of synthesizing source structure', () => { expectStructuralCode( () => encodeModuleKir([{ id: 'main.kern', roots: [{ type: 'document', children: [] }] }], limits), diff --git a/packages/core/tests/kir-structural.test.ts b/packages/core/tests/kir-structural.test.ts index d5606e2ce..5101ae185 100644 --- a/packages/core/tests/kir-structural.test.ts +++ b/packages/core/tests/kir-structural.test.ts @@ -77,10 +77,10 @@ describe('internal structural KIR writer and bounded reader', () => { expect(encodeStructuralKir(reordered, limits)).toEqual(bytes); const artifact = decodeStructuralKir(bytes, limits); expect(encodeCanonicalValue(decodeCanonicalValue(bytes, limits), limits)).toEqual(bytes); - expect(artifact.format).toBe('kern.kir.structural.r1.5c.2-alpha'); - expect(artifact.constitution).toBe('kern.kir.structural.alpha.1'); + expect(artifact.format).toBe('kern.kir.structural.r1.5e.1-alpha'); + expect(artifact.constitution).toBe('kern.kir.structural.r1.5e.1'); expect(artifact.proofLabel).toBe('ALPHA-NO-GO'); - expect(artifact.typeCatalog.admittedKinds).toEqual([]); + expect(artifact.typeCatalog.admittedKinds).toEqual(['boolean', 'integer', 'list', 'text', 'void']); expect(artifact.root.properties.map((entry) => entry.key)).toEqual(['export', 'name']); expect(artifact.root.children.map((child) => child.kind)).toEqual(['let', 'return']); }); @@ -121,6 +121,143 @@ describe('internal structural KIR writer and bounded reader', () => { ] as const)('rejects expression outside the closed catalog: %s', (source, code) => expectStructuralCode(() => encodeStructuralKir(letNode(source), limits), code)); + test('lowers exact structured runtime-handler types into semantic KIR', () => { + const artifact = decodeStructuralKir( + encodeStructuralKir( + { + type: 'fn', + props: { name: 'answer', returns: 'string[]' }, + children: [{ type: 'param', props: { name: 'question', type: 'string' } }], + }, + limits, + ), + limits, + ); + const returns = artifact.root.properties.find((entry) => entry.key === 'returns')?.value; + const parameterType = artifact.root.children[0]?.properties.find((entry) => entry.key === 'type')?.value; + expect(returns).toEqual({ + tag: 'record', + value: [ + { key: 'element', value: { tag: 'text', value: 'text' } }, + { key: 'kind', value: { tag: 'text', value: 'list' } }, + ], + }); + expect(parameterType).toEqual({ + tag: 'record', + value: [{ key: 'kind', value: { tag: 'text', value: 'text' } }], + }); + }); + + test.each([ + ['string', 'text'], + ['number', 'integer'], + ['boolean', 'boolean'], + ])('lowers scalar handler type %s to %s in parameter and return positions', (annotation, kind) => { + const artifact = decodeStructuralKir( + encodeStructuralKir( + { + type: 'fn', + props: { name: 'f', returns: annotation }, + children: [{ type: 'param', props: { name: 'value', type: annotation } }], + }, + limits, + ), + limits, + ); + const expected = { tag: 'record', value: [{ key: 'kind', value: { tag: 'text', value: kind } }] }; + expect(artifact.root.properties.find((entry) => entry.key === 'returns')?.value).toEqual(expected); + expect(artifact.root.children[0]?.properties.find((entry) => entry.key === 'type')?.value).toEqual(expected); + }); + + test.each([ + ['string[]', 'text'], + ['number[]', 'integer'], + ['boolean[]', 'boolean'], + ])('lowers list handler type %s with semantic element %s', (annotation, element) => { + const artifact = decodeStructuralKir( + encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: annotation } }, limits), + limits, + ); + expect(artifact.root.properties.find((entry) => entry.key === 'returns')?.value).toEqual({ + tag: 'record', + value: [ + { key: 'element', value: { tag: 'text', value: element } }, + { key: 'kind', value: { tag: 'text', value: 'list' } }, + ], + }); + }); + + test('lowers void only in the handler return position', () => { + const artifact = decodeStructuralKir( + encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: 'void' } }, limits), + limits, + ); + expect(artifact.root.properties.find((entry) => entry.key === 'returns')?.value).toEqual({ + tag: 'record', + value: [{ key: 'kind', value: { tag: 'text', value: 'void' } }], + }); + }); + + test.each([ + ['param', 'void'], + ['param', 'string[][]'], + ['param', 'String'], + ['param', 'User'], + ['fn', 'void[]'], + ['fn', 'string | null'], + ['fn', '{ value: string }'], + ['fn', '[string, number]'], + ['fn', 'Array'], + ['fn', 'string?'], + ['fn', 'string=default'], + ['fn', "User['name']"], + ['fn', 'value is string'], + ['fn', 'Record'], + ['fn', '(value: string) => string'], + ])('rejects non-portable %s type %s', (kind, annotation) => { + const input: IRNode = + kind === 'param' + ? { + type: 'fn', + props: { name: 'f', returns: 'void' }, + children: [{ type: 'param', props: { name: 'value', type: annotation } }], + } + : { type: 'fn', props: { name: 'f', returns: annotation } }; + expectStructuralCode(() => encodeStructuralKir(input, limits), 'invalid-type'); + }); + + test('rejects typed params outside direct fn children and legacy raw params text', () => { + expectStructuralCode( + () => encodeStructuralKir({ type: 'param', props: { name: 'value', type: 'string' } }, limits), + 'excluded-host-payload', + ); + expectStructuralCode( + () => encodeStructuralKir({ type: 'fn', props: { name: 'f', params: 'value:string', returns: 'void' } }, limits), + 'excluded-host-payload', + ); + }); + + test.each(['', ' '])('canonicalizes an empty legacy params value to omission', (params) => { + const artifact = decodeStructuralKir( + encodeStructuralKir({ type: 'fn', props: { name: 'f', params, returns: 'void' } }, limits), + limits, + ); + expect(artifact.root.properties.some((entry) => entry.key === 'params')).toBe(false); + + const mutated = structuredClone( + decodeCanonicalValue(encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: 'void' } }, limits), limits), + ); + const rootValue = recordField(mutated, 'root'); + const properties = recordField(rootValue, 'properties'); + if (properties.tag !== 'record') throw new Error('expected properties'); + properties.value.push({ key: 'params', value: { tag: 'text', value: params } }); + properties.value.sort((left, right) => left.key.localeCompare(right.key)); + expectStructuralCode( + () => decodeStructuralKir(encodeCanonicalValue(mutated, limits), limits), + 'excluded-host-payload', + ); + }); + test('hard-rejects excluded host payloads and required excluded types', () => { expectStructuralCode( () => encodeStructuralKir({ type: 'let', props: { name: 'x', type: 'User' } }, limits), @@ -202,6 +339,99 @@ describe('internal structural KIR writer and bounded reader', () => { () => decodeStructuralKir(encodeCanonicalValue(typeValue, limits), limits), 'invalid-artifact', ); + + const rawTypeValue = structuredClone( + decodeCanonicalValue( + encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: 'string' } }, limits), + limits, + ), + ); + const rawTypeRoot = recordField(rawTypeValue, 'root'); + const rawTypeProperties = recordField(rawTypeRoot, 'properties'); + if (rawTypeProperties.tag !== 'record') throw new Error('expected properties'); + const rawReturn = rawTypeProperties.value.find((entry) => entry.key === 'returns'); + if (!rawReturn) throw new Error('expected return type'); + rawReturn.value = { tag: 'text', value: 'string' }; + expectStructuralCode(() => decodeStructuralKir(encodeCanonicalValue(rawTypeValue, limits), limits), 'invalid-type'); + }); + + test('reader rejects semantic type shape, catalog, and predecessor-version mutations', () => { + const typed = () => + structuredClone( + decodeCanonicalValue( + encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: 'string[]' } }, limits), + limits, + ), + ); + const returnType = (value: CanonicalValue): CanonicalValue => { + const rootValue = recordField(value, 'root'); + const properties = recordField(rootValue, 'properties'); + if (properties.tag !== 'record') throw new Error('expected properties'); + const result = properties.value.find((entry) => entry.key === 'returns')?.value; + if (!result) throw new Error('expected return type'); + return result; + }; + const mutations: Array<(value: CanonicalValue) => void> = [ + (value) => { + const type = returnType(value); + if (type.tag !== 'record' || type.value[0]?.value.tag !== 'text') throw new Error('expected list element'); + type.value[0].value.value = 'void'; + }, + (value) => { + const type = returnType(value); + if (type.tag !== 'record' || type.value[1]?.value.tag !== 'text') throw new Error('expected list kind'); + type.value[1].value.value = 'array'; + }, + (value) => { + const type = returnType(value); + if (type.tag !== 'record') throw new Error('expected type record'); + type.value.push({ key: 'unknown', value: { tag: 'null' } }); + }, + (value) => { + const type = returnType(value); + if (type.tag !== 'record') throw new Error('expected type record'); + type.value.splice(0, 1); + }, + ]; + for (const mutate of mutations) { + const value = typed(); + mutate(value); + expectStructuralCode(() => decodeStructuralKir(encodeCanonicalValue(value, limits), limits), 'invalid-type'); + } + + const scalarMutation = structuredClone( + decodeCanonicalValue( + encodeStructuralKir({ type: 'fn', props: { name: 'f', returns: 'string' } }, limits), + limits, + ), + ); + const scalarType = returnType(scalarMutation); + if (scalarType.tag !== 'record' || scalarType.value[0]?.value.tag !== 'text') { + throw new Error('expected scalar type'); + } + scalarType.value[0].value.value = 'number'; + expectStructuralCode( + () => decodeStructuralKir(encodeCanonicalValue(scalarMutation, limits), limits), + 'invalid-type', + ); + + const catalogMutation = typed(); + const catalog = recordField(catalogMutation, 'typeCatalog'); + if (catalog.tag !== 'record') throw new Error('expected type catalog'); + catalog.value.splice(1, 1); + expectStructuralCode( + () => decodeStructuralKir(encodeCanonicalValue(catalogMutation, limits), limits), + 'invalid-artifact', + ); + + const predecessor = typed(); + const format = recordField(predecessor, 'format'); + if (format.tag !== 'text') throw new Error('expected format'); + format.value = 'kern.kir.structural.r1.5c.2-alpha'; + expectStructuralCode( + () => decodeStructuralKir(encodeCanonicalValue(predecessor, limits), limits), + 'unsupported-version', + ); }); test('reader rejects canonical unary negative zero that the writer cannot emit', () => { diff --git a/scripts/check-kir-coverage-closure.mjs b/scripts/check-kir-coverage-closure.mjs index 7c4c5a1b1..d088e3354 100644 --- a/scripts/check-kir-coverage-closure.mjs +++ b/scripts/check-kir-coverage-closure.mjs @@ -49,6 +49,12 @@ function expectedCanonicalValue(row) { }; } if (row.disposition === 'lowered-import-path') return { tag: 'text', value: row.fixture }; + if (row.disposition === 'lowered-type') { + return { + tag: 'record', + value: [{ key: 'kind', value: { tag: 'text', value: 'text' } }], + }; + } if (row.disposition !== 'included-value') { throw new Error(`${row.witnessIds[0]}: admitted disposition has no canonical value recipe`); } @@ -76,6 +82,24 @@ function roundTrip(node, witnessId) { return artifact.root; } +function propertyWitness(row, properties) { + if (row.nodeKind === 'param' && row.propertyName === 'type') { + return { + node: { + type: 'fn', + props: { name: 'handler' }, + children: [{ type: 'param', props: properties }], + }, + target(root) { + const parameter = root.children[0]; + if (!parameter || parameter.kind !== 'param') throw new Error(`${row.witnessIds[0]}: param context changed`); + return parameter; + }, + }; + } + return { node: { type: row.nodeKind, props: properties }, target: (root) => root }; +} + export function runCoverageClosure() { validateCoverageLedger(ledger, constitution); const propertiesByNode = groupProperties(ledger.properties); @@ -99,26 +123,28 @@ export function runCoverageClosure() { const properties = propertiesByNode.get(row.nodeKind) ?? []; const base = requiredProps(properties); const populated = { ...base, [row.propertyName]: row.fixture }; + const populatedWitness = propertyWitness(row, populated); const populatedId = row.witnessIds[0]; if (rejectedNode(node.disposition) || row.disposition.startsWith('excluded-')) { const code = node.disposition === 'explicit-missing-schema' ? 'unknown-node-kind' : 'excluded-host-payload'; - expectCode(() => encodeStructuralKir({ type: row.nodeKind, props: populated }, limits), code, populatedId); + expectCode(() => encodeStructuralKir(populatedWitness.node, limits), code, populatedId); } else { - const root = roundTrip({ type: row.nodeKind, props: populated }, populatedId); - assertCanonicalProperty(root, row, populatedId); + const root = roundTrip(populatedWitness.node, populatedId); + assertCanonicalProperty(populatedWitness.target(root), row, populatedId); } executed += 1; if (!row.required) { const omitted = { ...base }; delete omitted[row.propertyName]; + const omittedWitness = propertyWitness(row, omitted); const omittedId = row.witnessIds[1]; if (rejectedNode(node.disposition)) { const code = node.disposition === 'explicit-missing-schema' ? 'unknown-node-kind' : 'excluded-host-payload'; - expectCode(() => encodeStructuralKir({ type: row.nodeKind, props: omitted }, limits), code, omittedId); + expectCode(() => encodeStructuralKir(omittedWitness.node, limits), code, omittedId); } else { - const root = roundTrip({ type: row.nodeKind, props: omitted }, omittedId); - if (root.properties.some((property) => property.key === row.propertyName)) { + const root = roundTrip(omittedWitness.node, omittedId); + if (omittedWitness.target(root).properties.some((property) => property.key === row.propertyName)) { throw new Error(`${omittedId}: omitted property gained a default`); } } diff --git a/scripts/check-kir-structural-codec.mjs b/scripts/check-kir-structural-codec.mjs index b31d23266..408eeba9a 100644 --- a/scripts/check-kir-structural-codec.mjs +++ b/scripts/check-kir-structural-codec.mjs @@ -70,7 +70,7 @@ export function runStructuralKirCodecCheck() { } process.stdout.write( - `Structural KIR codec: PASS (INTERNAL; ${visited.size} browser-safe modules; evidence-only consumer; empty type catalog; ALPHA-NO-GO).\n`, + `Structural KIR codec: PASS (INTERNAL; ${visited.size} browser-safe modules; evidence-only consumer; handler type catalog; ALPHA-NO-GO).\n`, ); } diff --git a/scripts/kir-structural/constitution.json b/scripts/kir-structural/constitution.json index 213af21e6..bbde83cb6 100644 --- a/scripts/kir-structural/constitution.json +++ b/scripts/kir-structural/constitution.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "format": "kern.kir.structural.alpha.1", + "format": "kern.kir.structural.r1.5e.1", "proofLabel": "ALPHA-NO-GO", "counts": { "sourceNodes": 302, @@ -5676,8 +5676,8 @@ "schemaKind": "string", "required": false, "values": null, - "disposition": "included-value", - "reasonId": "portable-text" + "disposition": "excluded-host-type", + "reasonId": "structured-handler-parameters-required" }, { "nodeKind": "fn", @@ -5685,8 +5685,8 @@ "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required" + "disposition": "lowered-type", + "reasonId": "portable-handler-return-type" }, { "nodeKind": "fn", @@ -11805,8 +11805,8 @@ "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required" + "disposition": "lowered-type", + "reasonId": "portable-handler-parameter-type" }, { "nodeKind": "param", diff --git a/scripts/kir-structural/constitution.mjs b/scripts/kir-structural/constitution.mjs index af0e5fe69..785d272f8 100644 --- a/scripts/kir-structural/constitution.mjs +++ b/scripts/kir-structural/constitution.mjs @@ -1,4 +1,4 @@ -export const STRUCTURAL_KIR_FORMAT = 'kern.kir.structural.alpha.1'; +export const STRUCTURAL_KIR_FORMAT = 'kern.kir.structural.r1.5e.1'; const PROPERTY_POLICIES = Object.freeze({ identifier: ['included-value', 'portable-identifier'], @@ -23,7 +23,14 @@ function compareCodePoints(left, right) { } function propertyRow(nodeKind, propertyName, schema) { - const policy = PROPERTY_POLICIES[schema.kind]; + const policy = + nodeKind === 'fn' && propertyName === 'returns' + ? ['lowered-type', 'portable-handler-return-type'] + : nodeKind === 'param' && propertyName === 'type' + ? ['lowered-type', 'portable-handler-parameter-type'] + : nodeKind === 'fn' && propertyName === 'params' + ? ['excluded-host-type', 'structured-handler-parameters-required'] + : PROPERTY_POLICIES[schema.kind]; if (policy === undefined) throw new Error(`unknown property kind ${schema.kind} at ${nodeKind}.${propertyName}`); return { nodeKind, diff --git a/scripts/kir-structural/constitution.test.mjs b/scripts/kir-structural/constitution.test.mjs index 0389d095c..848d42bfd 100644 --- a/scripts/kir-structural/constitution.test.mjs +++ b/scripts/kir-structural/constitution.test.mjs @@ -75,6 +75,30 @@ test('property disposition and ordering mutations fail', () => { assert.throws(() => validateStructuralConstitution(enumValues, NODE_TYPES, NODE_SCHEMAS), /constitution drifted/u); }); +test('only structured runtime-handler type locations are lowered', () => { + const typeRows = checkedIn.properties.filter((row) => row.schemaKind === 'typeAnnotation'); + assert.equal(typeRows.length, 95); + assert.deepEqual( + typeRows + .filter((row) => row.disposition === 'lowered-type') + .map((row) => `${row.nodeKind}.${row.propertyName}`), + ['fn.returns', 'param.type'], + ); + assert.equal(typeRows.filter((row) => row.disposition === 'excluded-host-type').length, 93); + assert.deepEqual( + checkedIn.properties.find((row) => row.nodeKind === 'fn' && row.propertyName === 'params'), + { + nodeKind: 'fn', + propertyName: 'params', + schemaKind: 'string', + required: false, + values: null, + disposition: 'excluded-host-type', + reasonId: 'structured-handler-parameters-required', + }, + ); +}); + test('unknown schema property kinds fail constitution generation', () => { const schemas = clone(NODE_SCHEMAS); schemas.fn.props.name.kind = 'host-mystery'; diff --git a/scripts/kir-v1/alpha-receipt-policy.json b/scripts/kir-v1/alpha-receipt-policy.json index d1092c35b..523eaa464 100644 --- a/scripts/kir-v1/alpha-receipt-policy.json +++ b/scripts/kir-v1/alpha-receipt-policy.json @@ -13,12 +13,14 @@ "packages/core/src/kir-structural/canonical.ts", "packages/core/src/kir-structural/catalog.generated.ts", "packages/core/src/kir-structural/expression.ts", + "packages/core/src/kir-structural/handler-type.ts", "packages/core/src/kir-structural/module-canonical.ts", "packages/core/src/kir-structural/module-graph.ts", "packages/core/src/kir-structural/module-path.ts", "packages/core/src/kir-structural/module-types.ts", "packages/core/src/kir-structural/node.ts", "packages/core/src/kir-structural/types.ts", + "packages/core/src/portable-handler-type.ts", "scripts/kir-structural/constitution.json", "scripts/kir-v1/alpha-receipt-policy.json", "scripts/kir-v1/coverage-witness-config.json", diff --git a/scripts/kir-v1/coverage-witness-ledger.json b/scripts/kir-v1/coverage-witness-ledger.json index 61fc6e108..8096d8874 100644 --- a/scripts/kir-v1/coverage-witness-ledger.json +++ b/scripts/kir-v1/coverage-witness-ledger.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "format": "kern.kir.coverage-witness-ledger.r1.5c.4", - "constitutionFormat": "kern.kir.structural.alpha.1", + "format": "kern.kir.coverage-witness-ledger.r1.5e.1", + "constitutionFormat": "kern.kir.structural.r1.5e.1", "proofLabel": "ALPHA-NO-GO", "counts": { "nodes": 302, @@ -510,8 +510,8 @@ }, { "id": "fn", - "disposition": "included-structural", - "witnessId": "kir-c4.node.fn.included-structural.accept" + "disposition": "lowered-semantic", + "witnessId": "kir-e1.node.fn.lowered-semantic.accept" }, { "id": "const", @@ -6094,12 +6094,12 @@ "schemaKind": "string", "required": false, "values": null, - "disposition": "included-value", - "reasonId": "portable-text", - "fixture": "witness", + "disposition": "excluded-host-type", + "reasonId": "structured-handler-parameters-required", + "fixture": "value:string", "witnessIds": [ - "kir-c4.prop.fn.params.included-value.populated.accept", - "kir-c4.prop.fn.params.included-value.omitted.accept" + "kir-e1.prop.fn.params.excluded-host-type.populated.reject", + "kir-e1.prop.fn.params.excluded-host-type.omitted.accept" ] }, { @@ -6108,12 +6108,12 @@ "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required", - "fixture": "HostType", + "disposition": "lowered-type", + "reasonId": "portable-handler-return-type", + "fixture": "string", "witnessIds": [ - "kir-c4.prop.fn.returns.excluded-host-type.populated.reject", - "kir-c4.prop.fn.returns.excluded-host-type.omitted.accept" + "kir-e1.prop.fn.returns.lowered-type.populated.accept", + "kir-e1.prop.fn.returns.lowered-type.omitted.accept" ] }, { @@ -15391,12 +15391,12 @@ "schemaKind": "typeAnnotation", "required": false, "values": null, - "disposition": "excluded-host-type", - "reasonId": "portable-type-grammar-required", - "fixture": "HostType", + "disposition": "lowered-type", + "reasonId": "portable-handler-parameter-type", + "fixture": "string", "witnessIds": [ - "kir-c4.prop.param.type.excluded-host-type.populated.reject", - "kir-c4.prop.param.type.excluded-host-type.omitted.accept" + "kir-e1.prop.param.type.lowered-type.populated.accept", + "kir-e1.prop.param.type.lowered-type.omitted.accept" ] }, { diff --git a/scripts/kir-v1/eligibility.json b/scripts/kir-v1/eligibility.json index 1b0b105ec..b1f3e657d 100644 --- a/scripts/kir-v1/eligibility.json +++ b/scripts/kir-v1/eligibility.json @@ -350,8 +350,8 @@ }, "coverageWitnessLedger": { "path": "scripts/kir-v1/coverage-witness-ledger.json", - "format": "kern.kir.coverage-witness-ledger.r1.5c.4", - "canonicalSha256": "2b5b7c9bb8e8ada6c8b07153638b8b9d738d920212a208a735fcce4338667a04" + "format": "kern.kir.coverage-witness-ledger.r1.5e.1", + "canonicalSha256": "d358a25dd719df335587ce5759afedcc7cde7f662ece3d2c01c402f10ebd86f5" }, "blockers": [ { @@ -926,8 +926,8 @@ }, { "id": "fn", - "disposition": "included-structural", - "witnessId": "kir-c4.node.fn.included-structural.accept" + "disposition": "lowered-semantic", + "witnessId": "kir-e1.node.fn.lowered-semantic.accept" }, { "id": "const", diff --git a/scripts/kir-v1/validate-coverage-ledger.mjs b/scripts/kir-v1/validate-coverage-ledger.mjs index fe3868265..d88699ca4 100644 --- a/scripts/kir-v1/validate-coverage-ledger.mjs +++ b/scripts/kir-v1/validate-coverage-ledger.mjs @@ -22,7 +22,10 @@ function expectedDisposition(node, properties) { } if ( properties.some( - (property) => property.disposition === 'lowered-expression' || property.disposition === 'lowered-import-path', + (property) => + property.disposition === 'lowered-expression' || + property.disposition === 'lowered-import-path' || + property.disposition === 'lowered-type', ) ) { return 'lowered-semantic'; @@ -48,7 +51,7 @@ export function validateCoverageLedger(ledger, constitution) { ['schemaVersion', 'format', 'constitutionFormat', 'proofLabel', 'counts', 'nodes', 'properties'], 'ledger', ); - if (ledger.schemaVersion !== 1 || ledger.format !== 'kern.kir.coverage-witness-ledger.r1.5c.4') { + if (ledger.schemaVersion !== 1 || ledger.format !== 'kern.kir.coverage-witness-ledger.r1.5e.1') { fail('unsupported ledger version'); } if (ledger.constitutionFormat !== constitution.format || ledger.proofLabel !== 'ALPHA-NO-GO') { diff --git a/scripts/kir-v1/validate-eligibility.mjs b/scripts/kir-v1/validate-eligibility.mjs index 37f0cd4d6..c18a3a410 100644 --- a/scripts/kir-v1/validate-eligibility.mjs +++ b/scripts/kir-v1/validate-eligibility.mjs @@ -204,7 +204,7 @@ function validateCoverageLedgerBinding(policy, sourceIds, readText) { exactKeys(policy.coverageWitnessLedger, ['path', 'format', 'canonicalSha256'], 'coverageWitnessLedger'); const binding = policy.coverageWitnessLedger; if (binding.path !== 'scripts/kir-v1/coverage-witness-ledger.json') fail('coverage ledger path changed'); - if (binding.format !== 'kern.kir.coverage-witness-ledger.r1.5c.4') fail('coverage ledger format changed'); + if (binding.format !== 'kern.kir.coverage-witness-ledger.r1.5e.1') fail('coverage ledger format changed'); if (!/^[0-9a-f]{64}$/u.test(binding.canonicalSha256)) fail('coverage ledger SHA-256 is invalid'); const ledgerText = readText(binding.path); const digest = createHash('sha256').update(ledgerText).digest('hex');