• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / py-slang / 29571170123

17 Jul 2026 09:46AM UTC coverage: 80.555% (-0.5%) from 81.011%
29571170123

push

github

web-flow
Add Module Loader  (#217)

* feat(cse): add data handler

* feat: add module interop

* fix: linting issues

* fix: arity calculation

* feat: add tests

* fix: make Gemini's changes

* fix: allow it to work with cse machine

* fix: use opaqueidentifier in opaquemap

* fix: wrap body in lambda

* fix: Gemini's changes

* fix: ptm(mtp(closure)) returns the same closure

* chore: update module loader package

* Fix module interop list/pair round-tripping and closure identity

pythonToModule's "list" case only ever built a DataType.ARRAY, so a
PAIR returned by one module call (e.g. a binary tree node) came back
as an ARRAY when passed into another module call, breaking anything
checking value.type against PAIR/LIST at every level of a chain.

Fixed by tagging genuine Python list literals at construction time
(InstrType.LIST, and the *args rest-parameter collection in
environment.ts - both build flat, arbitrary-length collections the
same way) in a listLiteralValues WeakSet, rather than trying to guess
from shape alone. Tagged values always reconstruct as a proper
PAIR/EMPTY_LIST chain regardless of length. Untagged values (from
pair()/llist(), or a module PAIR round-tripped through moduleToPython)
reconstruct as a single pair_make(head, tail) without requiring the
chain to terminate in None, since a module PAIR need not be a proper
list (e.g. sound's Sound is (wavesPair, duration), a dotted pair whose
second element is a number, not another list link). This closes the
exact gap raised in review: ptm(mtp(pairObject)) is now a fixpoint
regardless of whether the pair happens to be a proper list.

Also fix PyCseEvaluator's closureMap, which was keyed by the
TypedValue<DataType.CLOSURE> object itself rather than its
ClosureIdentifier. A closure that round-trips through Python gets a
freshly-constructed TypedValue with the same id, so a reference-keyed
map missed it silently (via optional chaining), returning undefined
where an AsyncGenerator was expected - su... (continued)

3361 of 4465 branches covered (75.27%)

Branch coverage included in aggregate %.

80 of 146 new or added lines in 10 files covered. (54.79%)

7356 of 8839 relevant lines covered (83.22%)

79349.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.64
/src/conductor/plugins/PyCseMachinePlugin.ts
1
import type {
2
  CseSnapshot,
3
  CseSerializedEnvFrame as SerializedEnvFrame,
4
  CseSerializedInstruction as SerializedInstruction,
5
  CseSerializedValue as SerializedValue,
6
} from "@sourceacademy/common-cse-machine";
7
import { Closure } from "../../engines/cse/closure";
8
import { Context } from "../../engines/cse/context";
9
import { Control } from "../../engines/cse/control";
10
import { Environment, UNASSIGNED } from "../../engines/cse/environment";
19✔
11
import { generateCSEMachineStateStream } from "../../engines/cse/interpreter";
19✔
12
import { Stash, Value } from "../../engines/cse/stash";
13
import { InstrType, operatorTranslator, typeTranslator } from "../../engines/cse/types";
19✔
14
import { toPythonFloat } from "../../stdlib/utils";
19✔
15
import { TokenType } from "../../tokenizer";
16

17
type ControlStackItem = {
18
  instrType?: string;
19
  env?: Environment;
20
  kind?: string;
21
  startToken?: { indexInSource?: number; line?: number; lexeme?: string; synthetic?: boolean };
22
  endToken?: { indexInSource?: number; line?: number; lexeme?: string; synthetic?: boolean } | null;
23
  body?: Array<{ kind: string }> | { kind: string; body: Array<{ kind: string }> };
24
  syntheticLabel?: string;
25
  numOfArgs?: number;
26
  numOfElements?: number;
27
  symbol?: string | TokenType;
28
  value?: unknown;
29
};
30

31
// ── Value serialisation ───────────────────────────────────────────────────────
32

33
// Stable integer ID for each list VALUE object across all snapshots in a run.
34
// Using a WeakMap keyed on the actual Value object preserves identity: the same
35
// Python list referenced by multiple bindings gets the same id everywhere, which
36
// lets the frontend deduplicate it to one DataArray canvas box.
37
const _listIdMap = new WeakMap<object, number>();
19✔
38
let _listSeq = 0;
19✔
39
function getListId(v: object): number {
40
  if (!_listIdMap.has(v)) _listIdMap.set(v, ++_listSeq);
3✔
41
  return _listIdMap.get(v)!;
3✔
42
}
43

44
function formatValue(v: Value): string {
45
  if (v === undefined || v === null) return "None";
401✔
46
  switch (v.type) {
399!
47
    case "bigint":
48
      return v.value.toString();
306✔
49
    case "number":
50
      return toPythonFloat(v.value);
3✔
51
    case "bool":
52
      return v.value ? "True" : "False";
3✔
53
    case "string":
54
      return `"${v.value}"`;
2✔
55
    case "none":
56
      return "None";
18✔
57
    case "complex":
58
      return v.value.toString();
1✔
59
    case "closure": {
60
      const cl: Closure = v.closure;
26✔
61
      return cl.node.kind === "FunctionDef" ? cl.node.name.lexeme : "lambda";
26✔
62
    }
63
    case "function":
64
      return v.name || "function";
×
65
    case "multi_lambda":
66
      return "lambda";
×
67
    case "error":
68
      return v.message;
1✔
69
    case "list": {
70
      const items = v.value.slice(0, 4).map(i => formatValue(i));
15✔
71
      const suffix = v.value.length > 4 ? ", ..." : "";
9✔
72
      return `[${items.join(", ")}${suffix}]`;
9✔
73
    }
74
    case "builtin":
75
      // Just the name, matching how "closure" above shows funcName rather than
76
      // Python's full repr() — this is a compact stash chip, not repr() output.
77
      // The "this is a builtin" fact is carried separately via `label`
78
      // (see serializeValue / typeTranslator), same pattern as closures.
79
      return v.name;
30✔
80
    case "opaque":
NEW
81
      return `<opaque value>`;
×
82
    default:
83
      v satisfies never;
×
84
      return "?";
×
85
  }
86
}
87

88
function serializeValue(v: Value | typeof UNASSIGNED, envId = ""): SerializedValue {
10✔
89
  // A local that createEnvironment preallocated at CALL time but that hasn't been
90
  // assigned yet — mirrors js-slang's uninitialized-`const`/`let` placeholder rendering.
91
  if (v === UNASSIGNED) return { displayValue: "", label: "unassigned" };
368!
92
  if (v === undefined || v === null) return { displayValue: "None", label: "NoneType" };
368✔
93
  const base = { displayValue: formatValue(v), label: typeTranslator(v.type) };
367✔
94
  if (v.type === "closure") {
367✔
95
    const cl = v.closure;
26✔
96
    const funcName = cl.node.kind === "FunctionDef" ? cl.node.name.lexeme : "lambda";
26✔
97
    const params = cl.node.parameters.map((p: { lexeme: string }) => p.lexeme);
26✔
98
    return { ...base, metadata: { closureFrameId: cl.environment.id, params, funcName } };
26✔
99
  }
100
  if (v.type === "list") {
341✔
101
    return {
3✔
102
      displayValue: formatValue(v),
103
      label: "list",
104
      metadata: {
105
        id: getListId(v),
106
        envId,
107
        elements: v.value.map((el: Value) => serializeValue(el, envId)),
4✔
108
      },
109
    };
110
  }
111
  return base;
338✔
112
}
113

114
// ── Control serialisation ─────────────────────────────────────────────────────
115

116
// Friendly fallback labels for AST node kinds that have no source tokens.
117
const KIND_LABELS: Record<string, string> = {
19✔
118
  FileInput: "program",
119
  FunctionDef: "def",
120
  Lambda: "lambda",
121
  Assign: "assign",
122
  Return: "return",
123
  SimpleExpr: "expr",
124
  If: "if",
125
  While: "while",
126
  For: "for",
127
  Binary: "bin op",
128
  Unary: "unary op",
129
  Compare: "cmp",
130
  BoolOp: "bool op",
131
  Ternary: "ternary",
132
  Call: "call",
133
  Variable: "var",
134
  Literal: "literal",
135
  BigIntLiteral: "int",
136
  None: "None",
137
  List: "list",
138
  Subscript: "index",
139
  Starred: "starred",
140
  StatementSequence: "stmts",
141
  Grouping: "group",
142
  FromImport: "import",
143
  Pass: "pass",
144
  Break: "break",
145
  Continue: "continue",
146
};
147

148
// Map py-slang InstrType string values → js-slang InstrType string values so the
149
// frontend animation system can dispatch on the correct type.  Values that differ
150
// between the two enum declarations are listed; identical values fall through.
151
const PY_TO_JS_INSTR_TYPE: Partial<Record<InstrType, string>> = {
19✔
152
  [InstrType.APPLICATION]: "Application",
153
  [InstrType.ASSIGNMENT]: "Assignment",
154
  [InstrType.BINARY_OP]: "BinaryOperation",
155
  [InstrType.BOOL_OP]: "BinaryOperation", // no separate BoolOp in js-slang
156
  [InstrType.UNARY_OP]: "UnaryOperation",
157
  [InstrType.POP]: "Pop",
158
  [InstrType.BRANCH]: "Branch",
159
  [InstrType.WHILE]: "While", // py: "WhileInstr" → js: "While"
160
  [InstrType.FOR]: "For", // py: "ForInstr"   → js: "For"
161
  [InstrType.LIST]: "ArrayLiteral", // py: "ListLiteral" → js: "ArrayLiteral"
162
  [InstrType.LIST_ACCESS]: "ArrayAccess", // py: "ListAccess"  → js: "ArrayAccess"
163
  [InstrType.LIST_ASSIGNMENT]: "ArrayAssignment", // py: "ListAssignment" → js: "ArrayAssignment"
164
  [InstrType.CONTINUE_MARKER]: "ContinueMarker", // py: "continueMarker" → js: "ContinueMarker"
165
  [InstrType.BREAK]: "Break", // py: "BreakInstr"  → js: "Break"
166
  [InstrType.CONTINUE]: "Continue", // py: "ContinueInstr" → js: "Continue"
167
  [InstrType.MODULE_FUNCTION_CALL]: "ModuleFunctionCall",
168
};
169

170
// Map py-slang AST node kinds → js-slang ESTree node type names so the animation
171
// system dispatches the right animation (ControlExpansionAnimation, LookupAnimation, etc.).
172
const PY_TO_JS_NODE_TYPE: Record<string, string> = {
19✔
173
  FileInput: "StatementSequence",
174
  StatementSequence: "StatementSequence",
175
  FunctionDef: "FunctionDeclaration",
176
  Lambda: "ArrowFunctionExpression",
177
  Return: "ReturnStatement",
178
  Assign: "VariableDeclaration",
179
  If: "IfStatement",
180
  While: "WhileStatement",
181
  For: "ForStatement",
182
  Binary: "BinaryExpression",
183
  Compare: "BinaryExpression",
184
  BoolOp: "BinaryExpression",
185
  Unary: "UnaryExpression",
186
  Call: "CallExpression",
187
  Variable: "Identifier",
188
  Literal: "Literal",
189
  BigIntLiteral: "Literal",
190
  None: "Literal",
191
  List: "ArrayExpression",
192
  Subscript: "MemberExpression",
193
  Ternary: "ConditionalExpression",
194
  SimpleExpr: "ExpressionStatement",
195
  Grouping: "ExpressionStatement",
196
};
197

198
function instrDisplayText(item: ControlStackItem): string {
199
  switch (item.instrType as InstrType) {
473!
200
    case InstrType.END_OF_FUNCTION_BODY:
201
      return "return None";
8✔
202
    case InstrType.POP:
203
      return "pop";
82✔
204
    case InstrType.CONTINUE_MARKER:
205
      return "mark";
88✔
206
    case InstrType.CONTINUE:
207
      return "continue";
1✔
208
    case InstrType.BREAK:
209
      return "break";
1✔
210
    case InstrType.BRANCH:
211
      return "branch";
1✔
212
    case InstrType.WHILE:
213
      return "while";
1✔
214
    case InstrType.FOR:
215
      return "for";
163✔
216
    case InstrType.LIST:
217
      return `arr lit ${item.numOfElements}`;
2✔
218
    case InstrType.LIST_ACCESS:
219
      return "arr acc";
1✔
220
    case InstrType.LIST_ASSIGNMENT:
221
      return "arr asgn";
1✔
222
    case InstrType.ASSIGNMENT:
223
      return `asgn ${item.symbol}`;
46✔
224
    case InstrType.APPLICATION:
225
      return `call ${item.numOfArgs}`;
42✔
226
    case InstrType.UNARY_OP:
227
    case InstrType.BINARY_OP:
228
    case InstrType.BOOL_OP:
229
      return operatorTranslator(item.symbol!);
36✔
230
    case InstrType.MODULE_FUNCTION_CALL:
NEW
231
      return `mod call ${item.numOfArgs}`;
×
232
    default:
233
      return String(item.instrType);
×
234
  }
235
}
236

237
function serializeControlItem(item: ControlStackItem, code: string): SerializedInstruction {
238
  // Instructions have 'instrType'.
239
  if (item.instrType !== undefined) {
1,056✔
240
    if (item.instrType === InstrType.ENVIRONMENT && item.env?.id !== undefined) {
469✔
241
      return { displayText: "ENVIRONMENT", metadata: { envId: item.env.id } };
12✔
242
    }
243
    const jsInstrType = PY_TO_JS_INSTR_TYPE[item.instrType as InstrType];
457✔
244
    const meta: Record<string, unknown> = {};
457✔
245
    if (jsInstrType !== undefined) {
457✔
246
      meta.instrType = jsInstrType;
450✔
247
    }
248
    // Extra fields consumed by specific animations.
249
    if (item.instrType === InstrType.APPLICATION && item.numOfArgs !== undefined) {
457✔
250
      meta.numOfArgs = item.numOfArgs;
41✔
251
    }
252
    if (item.instrType === InstrType.MODULE_FUNCTION_CALL && item.numOfArgs !== undefined) {
457!
NEW
253
      meta.numOfArgs = item.numOfArgs;
×
254
    }
255
    if (item.instrType === InstrType.ASSIGNMENT && item.symbol !== undefined) {
457✔
256
      meta.symbol = item.symbol;
45✔
257
    }
258
    if (
457✔
259
      (item.instrType === InstrType.BINARY_OP ||
1,341✔
260
        item.instrType === InstrType.BOOL_OP ||
261
        item.instrType === InstrType.UNARY_OP) &&
262
      item.symbol !== undefined
263
    ) {
264
      meta.symbol = operatorTranslator(item.symbol);
33✔
265
    }
266
    if (item.instrType === InstrType.LIST && item.numOfElements !== undefined) {
457✔
267
      meta.arity = item.numOfElements;
1✔
268
    }
269
    return Object.keys(meta).length > 0
457✔
270
      ? { displayText: instrDisplayText(item), metadata: meta }
271
      : { displayText: instrDisplayText(item) };
272
  }
273

274
  // AST nodes have 'kind' and startToken/endToken with source positions.
275
  if (item.kind !== undefined) {
587✔
276
    const start: number = item.startToken?.indexInSource ?? -1;
586✔
277
    const endTok = item.endToken;
586✔
278
    const end: number =
279
      endTok != null && endTok.indexInSource !== undefined
586✔
280
        ? endTok.indexInSource + (endTok.lexeme?.length ?? 0)
579!
281
        : -1;
282
    // Synthetic nodes generated at runtime (e.g. loop range BigIntLiterals) carry tokens
283
    // explicitly marked `synthetic` — they don't correspond to real source text, even
284
    // when their indexInSource coincides with a genuine position (e.g. 0, the very first
285
    // token of the file). Do not infer syntheticness from position alone.
286
    const isRealSourceNode = !item.startToken?.synthetic && !endTok?.synthetic;
586✔
287
    let displayText: string;
288
    let loc: { startLine: number; endLine: number } | undefined;
289
    if (start >= 0 && end > start && end <= code.length && isRealSourceNode) {
586✔
290
      displayText = code.slice(start, end).trim();
151✔
291
      // Token.line is 1-based (matches ESTree convention expected by ControlStack).
292
      const startLine: number | undefined = item.startToken?.line;
151✔
293
      const endLine: number | undefined = endTok?.line;
151✔
294
      if (startLine !== undefined) {
151✔
295
        loc = { startLine, endLine: endLine ?? startLine };
151!
296
      }
297
    } else if (item.kind === "BigIntLiteral" && item.value !== undefined) {
435✔
298
      // Synthetic BigIntLiteral (implicit range start/step) — show its value.
299
      displayText = String(item.value);
323✔
300
    } else {
301
      displayText = KIND_LABELS[item.kind] ?? `<${item.kind}>`;
112✔
302
    }
303
    if (item.syntheticLabel) {
586✔
304
      displayText = item.syntheticLabel;
97✔
305
    }
306

307
    const jsNodeType = PY_TO_JS_NODE_TYPE[item.kind];
586✔
308
    const nodeMeta: Record<string, unknown> = {};
586✔
309
    if (loc) {
586✔
310
      nodeMeta.startLine = loc.startLine;
151✔
311
      nodeMeta.endLine = loc.endLine;
151✔
312
    }
313
    if (jsNodeType !== undefined) {
586✔
314
      nodeMeta.nodeType = jsNodeType;
585✔
315
    }
316
    // For block-like nodes pass body length and child types so the adapter can build
317
    // stub body arrays (used by ControlExpansionAnimation / StatementSequence handling).
318
    if (
586✔
319
      (jsNodeType === "StatementSequence" ||
1,758✔
320
        jsNodeType === "FunctionDeclaration" ||
321
        jsNodeType === "ArrowFunctionExpression") &&
322
      item.body !== undefined
323
    ) {
324
      const body =
325
        item.body !== undefined &&
5✔
326
        item.body !== null &&
327
        "body" in item.body &&
328
        jsNodeType === "StatementSequence"
329
          ? item.body.body
330
          : item.body;
331
      const bodyArray = Array.isArray(body) ? body : [body];
5✔
332
      nodeMeta.bodyLength = bodyArray.length;
5✔
333
      nodeMeta.bodyNodeTypes = bodyArray.map(n => PY_TO_JS_NODE_TYPE[n.kind] ?? "Identifier");
8!
334
    }
335

336
    return Object.keys(nodeMeta).length > 0 ? { displayText, metadata: nodeMeta } : { displayText };
586✔
337
  }
338

339
  return { displayText: "<unknown>" };
1✔
340
}
341

342
// ── Environment serialisation ─────────────────────────────────────────────────
343

344
function serializeEnvChain(
345
  environments: Environment[],
346
  stashValues: Value[],
347
  controlItems: ControlStackItem[],
348
  activeEnv: Environment,
349
): SerializedEnvFrame[] {
350
  const seen = new Set<string>();
271✔
351
  const queue: Environment[] = [];
271✔
352

353
  // BFS — visit env + its tail chain, then recursively follow any closure environments
354
  // found in its head bindings. This ensures closure environments that have already
355
  // returned (and are no longer on the active call stack) still appear in the snapshot.
356
  const visit = (env: Environment | null | undefined) => {
271✔
357
    if (!env || seen.has(env.id)) return;
1,131✔
358
    seen.add(env.id);
548✔
359
    queue.push(env);
548✔
360
    visit(env.tail);
548✔
361
    for (const val of Object.values(env.head)) {
548✔
362
      if (val && val !== UNASSIGNED && val.type === "closure") visit(val.closure?.environment);
448✔
363
    }
364
  };
365

366
  for (const env of environments) visit(env);
546✔
367

368
  // Also follow closures sitting on the stash whose environments may not be on the active stack
369
  for (const item of stashValues) {
271✔
370
    if (item && item.type === "closure") visit(item.closure.environment);
168✔
371
  }
372

373
  // ENV instructions on the control stack keep frames alive that are not on the
374
  // call stack right now (e.g. h's frame while g(x) is executing inside h).
375
  // Without this seed, those frames die and then "resurrect" when control returns —
376
  // violating the invariant that dead frames never come back.
377
  for (const item of controlItems) {
271✔
378
    if (item.instrType === "environment" && item.env) {
1,036✔
379
      visit(item.env);
11✔
380
    }
381
  }
382

383
  const callStackIds = new Set(environments.map(e => e.id));
546✔
384

385
  // Walk up the tail chain skipping any filtered frames to find the visible parent.
386
  const visibleParentId = (env: Environment): string | null => {
271✔
387
    let cur = env.tail;
546✔
388
    while (cur) {
546✔
389
      if (cur.name !== "prelude") return cur.id;
276✔
390
      cur = cur.tail;
2✔
391
    }
392
    return null;
272✔
393
  };
394

395
  return queue
271✔
396
    .filter(env => env.name !== "prelude")
548✔
397
    .map(env => ({
546✔
398
      id: env.id,
399
      name: env.name,
400
      parentId: visibleParentId(env),
401
      closureFrameId: env.closure?.environment?.id,
402
      bindings: Object.entries(env.head)
403
        .filter(([name]) => name !== "__program__")
448✔
404
        .map(([name, val]) => ({
186✔
405
          name,
406
          value: serializeValue(val, env.id),
407
        })),
408
      isActive: env.id === activeEnv.id,
409
      isOnCallStack: callStackIds.has(env.id),
410
      globalNames: env.closure?.globalVariables.size ? [...env.closure.globalVariables] : undefined,
546✔
411
    }));
412
}
413

414
// ── Snapshot collection ───────────────────────────────────────────────────────
415

416
export async function collectSnapshots(
19✔
417
  context: Context,
418
  control: Control,
419
  stash: Stash,
420
  envSteps: number,
421
  stepLimit: number,
422
  variant: number,
423
  code: string,
424
  maxSnapshots: number = 1000,
16✔
425
): Promise<{ snapshots: CseSnapshot[]; breakpointSteps: number[] }> {
426
  const snapshots: CseSnapshot[] = [];
18✔
427
  // Runtime-fabricated nodes (e.g. implicit range() bounds, the for-loop increment —
428
  // see utils.ts's evaluateForIterator/generateForIncrement) carry tokens pinned to
429
  // line 0, since they don't correspond to any line the user actually wrote. Real
430
  // tokens are always 1-based (see parser/token-bridge.ts), so line 0 is an
431
  // unambiguous "not a real line" signal. Keep showing the last real line instead of
432
  // flickering to 0 while these synthetic nodes are being evaluated.
433
  let lastKnownLine: number | undefined;
434

435
  const stream = generateCSEMachineStateStream(
18✔
436
    code,
437
    context,
438
    control,
439
    stash,
440
    envSteps,
441
    stepLimit,
442
    1024,
443
    variant,
444
    false,
445
  );
446

447
  for await (const { stash: s, control: c, steps } of stream) {
18✔
448
    // maxSnapshots === 0 → run the program to completion (for stdout/errors) but
449
    // collect nothing. Used for chapters where the CSE machine is disabled.
450
    if (maxSnapshots === 0) continue;
266✔
451
    if (snapshots.length >= maxSnapshots) break;
262✔
452

453
    const activeEnv = context.runtime.environments[0];
261✔
454
    const rawControlStack = c.getStack() as unknown as ControlStackItem[];
261✔
455
    const controlItems = rawControlStack
261✔
456
      .slice()
457
      .reverse()
458
      .map(item => serializeControlItem(item, code));
1,036✔
459
    const stashItems = s
261✔
460
      .getStack()
461
      .slice()
462
      .reverse()
463
      .map(sv => serializeValue(sv, activeEnv.id));
168✔
464
    const environments = serializeEnvChain(
261✔
465
      context.runtime.environments,
466
      s.getStack(),
467
      rawControlStack,
468
      activeEnv,
469
    );
470

471
    // The node most recently evaluated at this step. Mirrors the non-conductor CSE
472
    // machine's updateInspector, which reads context.runtime.nodes[0] for the blue
473
    // "current line" highlight. py-slang nodes carry a 1-based startToken.line.
474
    const currentNode = context.runtime.nodes[0] as { startToken?: { line?: number } } | undefined;
261✔
475
    const rawLine = currentNode?.startToken?.line;
261✔
476
    if (rawLine) {
261✔
477
      lastKnownLine = rawLine;
261✔
478
    }
479

480
    snapshots.push({
261✔
481
      stepIndex: steps - 1,
482
      control: controlItems,
483
      stash: stashItems,
484
      environments,
485
      currentLine: lastKnownLine,
486
    });
487
  }
488

489
  // context.runtime.breakpointSteps is recorded by the interpreter regardless of the
490
  // maxSnapshots cutoff above; filter out any indices past what was actually collected so the
491
  // host never gets pointed at a step with no corresponding snapshot.
492
  const breakpointSteps = context.runtime.breakpointSteps.filter(step => step < snapshots.length);
18✔
493

494
  return { snapshots, breakpointSteps };
18✔
495
}
496

497
// The runner-side plugin that transports these snapshots now lives in
498
// @sourceacademy/runner-cse-machine (CseMachinePlugin). This module only owns the
499
// Python-specific serialization of control/stash/environment into CseSnapshots.
500

501
// Exported for unit testing only — not part of the public API.
502
export { formatValue, instrDisplayText, serializeControlItem, serializeEnvChain, serializeValue };
19✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc