Skip to content

Commit fe3012e

Browse files
committed
♻️ refactor(runtime): type a compiled eval block as an Operation
EvalBlock named the generator that compilers happen to emit rather than what callers may do with it. That leaked an implementation detail into the contract, and eval-handler.ts paid for it twice with `as unknown as Operation<unknown>` casts to run a block at all. A compiled block now returns Operation<unknown>. Generated modules are still `function*` — a generator object already satisfies Operation — but that is how a block is built, not what the API promises. compileBlock() returns Operation<EvalBlock> instead of restating the concrete signature, and both casts are gone: a block runs with plain `yield*`. The type change immediately found every place that assumed otherwise. The compilation suites drove blocks by hand with .next() loops, which asserted mechanism rather than behavior; they now run the operation and assert what the block did. T17 no longer inspects an intermediate yield to prove `sleep` is in scope — it asserts the statement after the suspension ran, which is the observable claim. T23 covers a block's return value reaching the caller, which nothing asserted directly before. Enforcement is #185; this PR only avoids introducing what that rule will catch.
1 parent 8618873 commit fe3012e

5 files changed

Lines changed: 46 additions & 74 deletions

File tree

‎packages/core/src/eval-context.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44

55
import type { Operation } from "effection";
66
import { compile as runtimeCompile } from "@executablemd/runtime";
7+
import type { EvalBlock } from "@executablemd/runtime";
78

89
/**
9-
* Compile transformed source code into a generator function.
10+
* Compile transformed source code into a runnable eval block.
1011
*
1112
* Delegates to `@executablemd/runtime` so platform-specific
1213
* compilation can be provided via API.Env.compile middleware.
1314
*/
1415
export function compileBlock(
1516
transformedBodyCode: string,
1617
userImports: string[],
17-
): Operation<(env: Record<string, unknown>) => Generator<unknown, unknown, unknown>> {
18+
): Operation<EvalBlock> {
1819
return runtimeCompile(transformedBodyCode, { imports: userImports });
1920
}

‎packages/core/src/eval-handler.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) =>
4545
Object.assign(evalEnv.values, bindings);
4646

4747
// Compile the eval block via data: URI module import.
48-
// compileBlock is async (returns Operation) — it generates a
49-
// TypeScript module and imports it via data: URI.
48+
// Compiling loads a module, so it is itself an operation.
5049
const fn = yield* compileBlock(transformed.code, transformed.userImports ?? []);
5150

5251
if (persist) {
@@ -58,17 +57,15 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) =>
5857
`persist eval block "${ctx.blockId}" requires a component eval scope; none is in scope.`,
5958
);
6059
}
61-
const blockResult = yield* scope.eval(
62-
() => fn(evalEnv.values) as unknown as Operation<unknown>,
63-
);
60+
const blockResult = yield* scope.eval(() => fn(evalEnv.values));
6461
const returnValue = unbox(blockResult);
6562
if (!outputRef.text && returnValue != null) {
6663
outputRef.text = String(returnValue);
6764
}
6865
} else {
6966
// Normal mode: run the compiled block in the current scope.
7067
// Resources are torn down when this operation completes.
71-
const returnValue = yield* fn(evalEnv.values) as unknown as Operation<unknown>;
68+
const returnValue = yield* fn(evalEnv.values);
7269
if (!outputRef.text && returnValue != null) {
7370
outputRef.text = String(returnValue);
7471
}

‎packages/core/tests/eval-context.test.ts‎

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
* Tier T2 — Eval block compilation tests (spec §11).
33
*
44
* Tests the module compilation system (compileBlock) and verifies that
5-
* compiled generators can interact with Effection APIs, write to env, and
5+
* compiled blocks can interact with Effection APIs, write to env, and
66
* propagate errors correctly.
77
*
8+
* A compiled block is applied to an environment and run with `yield*` — the
9+
* generator underneath it is an implementation detail, so these drive it as
10+
* an operation rather than stepping it by hand.
11+
*
812
* These assertions are about compileBlock, not about a particular compiler,
913
* so they install the temp-file one: it is the only implementation that
1014
* loads on all three runtimes.
@@ -14,26 +18,23 @@ import { expect } from "@effectionx/bdd/expect";
1418
import { compileBlock } from "../src/eval-context.ts";
1519
import { useTempFileCompiler } from "../src/temp-file-compiler.ts";
1620

17-
describe("Tier T2 — VM context and compiled generator", () => {
21+
describe("Tier T2 — VM context and compiled blocks", () => {
1822
beforeAll(() => useTempFileCompiler());
19-
// T17: Compiled generator can yield* Effection globals from imports
20-
it("T17: compiled generator can use Effection globals from sandbox", function* () {
21-
const fn = yield* compileBlock("yield* sleep(0);", []);
22-
const gen = fn({});
23-
// The generator should produce a value — it yields sleep(0)
24-
const first = gen.next();
25-
expect(first.done).not.toBe(true);
23+
24+
// T17: Compiled block can yield* Effection globals from imports
25+
it("T17: compiled block can use Effection globals from sandbox", function* () {
26+
const fn = yield* compileBlock("yield* sleep(0);\nenv.slept = true;", []);
27+
const env: Record<string, unknown> = {};
28+
yield* fn(env);
29+
// Reaching the assignment means the suspension resumed rather than hanging.
30+
expect(env["slept"]).toBe(true);
2631
});
2732

2833
// T18: Value written to env.x inside block is readable by host
2934
it("T18: value written to env is readable by host", function* () {
3035
const fn = yield* compileBlock("env.x = 42;", []);
3136
const env: Record<string, unknown> = {};
32-
const gen = fn(env);
33-
let result = gen.next();
34-
while (!result.done) {
35-
result = gen.next();
36-
}
37+
yield* fn(env);
3738
expect(env["x"]).toBe(42);
3839
});
3940

@@ -42,34 +43,21 @@ describe("Tier T2 — VM context and compiled generator", () => {
4243
const liveObj = { key: "value", nested: { deep: true } };
4344
const env: Record<string, unknown> = { liveObj };
4445
const fn = yield* compileBlock("env.ref = env.liveObj;", []);
45-
const gen = fn(env);
46-
let result = gen.next();
47-
while (!result.done) {
48-
result = gen.next();
49-
}
46+
yield* fn(env);
5047
// Same reference, not a copy
5148
expect(env["ref"]).toBe(liveObj);
5249
});
5350

5451
// T20: Block re-executed after code change — no error from re-declaration
5552
it("T20: re-execution without const re-declaration error", function* () {
56-
// First execution
5753
const fn1 = yield* compileBlock("env.x = 1;", []);
5854
const env1: Record<string, unknown> = {};
59-
const gen1 = fn1(env1);
60-
let r1 = gen1.next();
61-
while (!r1.done) {
62-
r1 = gen1.next();
63-
}
55+
yield* fn1(env1);
6456

6557
// Second execution — different code
6658
const fn2 = yield* compileBlock("env.x = 2;", []);
6759
const env2: Record<string, unknown> = {};
68-
const gen2 = fn2(env2);
69-
let r2 = gen2.next();
70-
while (!r2.done) {
71-
r2 = gen2.next();
72-
}
60+
yield* fn2(env2);
7361

7462
expect(env1["x"]).toBe(1);
7563
expect(env2["x"]).toBe(2);
@@ -78,10 +66,9 @@ describe("Tier T2 — VM context and compiled generator", () => {
7866
// T21: Block that throws propagates error
7967
it("T21: block that throws propagates error", function* () {
8068
const fn = yield* compileBlock('throw new Error("test error");', []);
81-
const gen = fn({});
8269
let threw = false;
8370
try {
84-
gen.next();
71+
yield* fn({});
8572
} catch (e: unknown) {
8673
threw = true;
8774
expect(String(e)).toContain("test error");
@@ -93,17 +80,20 @@ describe("Tier T2 — VM context and compiled generator", () => {
9380
it("T22: sync computation writes result to env", function* () {
9481
const fn = yield* compileBlock("const result = 40 + 2; env.result = result;", []);
9582
const env: Record<string, unknown> = {};
96-
const gen = fn(env);
97-
let r = gen.next();
98-
while (!r.done) {
99-
r = gen.next();
100-
}
83+
yield* fn(env);
10184
expect(env["result"]).toBe(42);
10285
});
86+
87+
// T23: A block's return value is what running it produces
88+
it("T23: return value surfaces from the operation", function* () {
89+
const fn = yield* compileBlock("return 7;", []);
90+
expect(yield* fn({})).toBe(7);
91+
});
10392
});
10493

10594
describe("compileBlock edge cases", () => {
10695
beforeAll(() => useTempFileCompiler());
96+
10797
it("throws on syntax error in code", function* () {
10898
let threw = false;
10999
try {
@@ -114,22 +104,14 @@ describe("compileBlock edge cases", () => {
114104
expect(threw).toBe(true);
115105
});
116106

117-
it("creates distinct generator instances per call", function* () {
107+
it("applies to each environment independently", function* () {
118108
const fn = yield* compileBlock("env.count = (env.count || 0) + 1;", []);
119109

120110
const env1: Record<string, unknown> = {};
121-
const gen1 = fn(env1);
122-
let r1 = gen1.next();
123-
while (!r1.done) {
124-
r1 = gen1.next();
125-
}
111+
yield* fn(env1);
126112

127113
const env2: Record<string, unknown> = {};
128-
const gen2 = fn(env2);
129-
let r2 = gen2.next();
130-
while (!r2.done) {
131-
r2 = gen2.next();
132-
}
114+
yield* fn(env2);
133115

134116
expect(env1["count"]).toBe(1);
135117
expect(env2["count"]).toBe(1);

‎packages/core/tests/find-free-port.test.ts‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,15 @@ describe("Tier R — Eval module globals", () => {
9393
// in the generated module via standard imports
9494
const fn = yield* compileBlock("env.hasWhen = typeof when === 'function';", []);
9595
const env: Record<string, unknown> = {};
96-
const gen = fn(env);
97-
let r = gen.next();
98-
while (!r.done) {
99-
r = gen.next();
100-
}
96+
yield* fn(env);
10197
expect(env["hasWhen"]).toBe(true);
10298
});
10399

104100
// R1c: findFreePort is accessible via generated module imports
105101
it("R1c: findFreePort is accessible in eval sandbox", function* () {
106102
const fn = yield* compileBlock("env.hasFindFreePort = typeof findFreePort === 'function';", []);
107103
const env: Record<string, unknown> = {};
108-
const gen = fn(env);
109-
let r = gen.next();
110-
while (!r.done) {
111-
r = gen.next();
112-
}
104+
yield* fn(env);
113105
expect(env["hasFindFreePort"]).toBe(true);
114106
});
115107

@@ -133,11 +125,7 @@ describe("Tier R — Eval module globals", () => {
133125
.join("\n");
134126
const fn = yield* compileBlock(checkCode, []);
135127
const env: Record<string, unknown> = {};
136-
const gen = fn(env);
137-
let r = gen.next();
138-
while (!r.done) {
139-
r = gen.next();
140-
}
128+
yield* fn(env);
141129
for (const name of checks) {
142130
expect(env[`has_${name}`]).toBe(true);
143131
}

‎packages/runtime/apis.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,14 @@ interface FetchHandler {
159159
}
160160

161161
/**
162-
* A compiled eval block: a generator function over the document's binding
163-
* environment. What a compiler produces and `compile` returns.
162+
* A compiled eval block: applied to the document's binding environment, it
163+
* yields an operation the caller runs with `yield*`.
164+
*
165+
* Compilers generate `function*` modules, and a generator object already
166+
* satisfies `Operation` — but that is how the block happens to be built, not
167+
* what callers may depend on.
164168
*/
165-
export type EvalBlock = (env: Record<string, unknown>) => Generator<unknown, unknown, unknown>;
169+
export type EvalBlock = (env: Record<string, unknown>) => Operation<unknown>;
166170

167171
interface EnvHandler {
168172
cwd(): Operation<string>;

0 commit comments

Comments
 (0)