@chestnutlabs/gcode-parser 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ /*
2
+ * RS274NGC parametric programs — Phase 2: O-word control flow (DD-017 §4.4/§4.5, phase 7 of #189).
3
+ *
4
+ * Phase 1 (`rs274.ts`) added the parameter store + expression evaluator on a forward-only line model.
5
+ * Control flow breaks that model: `while`/`repeat`/`do` RE-RUN a line range and `if`/`elseif`/`else`
6
+ * SKIP one, which a once-per-line driver cannot express (DD-017 §1). This module is the program-buffered
7
+ * interpreter of DD-017 D1 Option A: engaged only when a program actually contains an O-word control line
8
+ * (`programUsesOWords`), it buffers the whole program (small — hand/CAM-written, not million-line FDM),
9
+ * builds a block tree, and executes it, feeding every non-control line back through the SAME engine
10
+ * `processLine` (so geometry/params/expressions stay on one code path). Non-O-word input never reaches
11
+ * here and stays byte-identical.
12
+ *
13
+ * SUPPORTED: `if`/`elseif`/`else`/`endif`, `while`/`endwhile`, `do`/`while`, `repeat`/`endrepeat`,
14
+ * `break`/`continue` (Phase 2), and in-file subroutines `sub`/`endsub`/`call`/`return` (Phase 3 — a
15
+ * `call` binds args to `#1`..`#30` in a fresh scope, runs the named sub's body, and returns; subs may
16
+ * recurse and be forward-referenced). External subroutine files (`M98`) stay a non-goal (no I/O).
17
+ *
18
+ * BOUNDED EXECUTION (DD-017 §4.5 — this is a small interpreter, so a hostile `o while [1]` or an
19
+ * exponential recursive fan-out must not hang/OOM the worker): one shared counter is charged per loop
20
+ * pass AND per statement executed inside any loop OR subroutine, stopping the program at
21
+ * `maxProgramIterations` (disclosed) — this bounds both a large loop body and the total call-tree size.
22
+ * Subroutine recursion depth is separately bounded by `maxCallDepth`, and combined nesting + call-depth
23
+ * JS recursion by `MAX_EXEC_DEPTH`; the engine's geometry limits still halt emission. There is no `eval`
24
+ * and no I/O, so an adversarial program can waste only bounded CPU.
25
+ *
26
+ * We interpret a BLOCK TREE rather than a raw program-counter + jump table (both satisfy DD-017 D4's
27
+ * "match each opener to its closer"): the tree makes `if`/`elseif`/`else` and nested loops correct by
28
+ * construction, makes `break`/`continue` a local signal, and — critically — makes the bounded-execution
29
+ * proof a single charge point. Semantics follow RS274NGC / LinuxCNC behaviour (no spec text copied).
30
+ */
31
+ import { stripParenComments } from './rs274.js';
32
+ /** Warning codes surfaced to the IR by the flow interpreter (DD-017 §4.6). */
33
+ export const RS274_FLOW_WARN = {
34
+ iterationLimit: 'rs274-iteration-limit',
35
+ unbalancedOword: 'rs274-unbalanced-oword',
36
+ unsupportedOword: 'rs274-unsupported-oword',
37
+ controlNesting: 'rs274-control-nesting',
38
+ misplacedControl: 'rs274-misplaced-control',
39
+ streamingUnsupported: 'rs274-streaming-flow-unsupported',
40
+ callDepth: 'rs274-call-depth', // subroutine recursion exceeded maxCallDepth (Phase 3)
41
+ unknownSub: 'rs274-unknown-sub', // `call` of a subroutine that was never defined (Phase 3)
42
+ duplicateSub: 'rs274-duplicate-sub' // a subroutine id defined more than once (first wins, Phase 3)
43
+ };
44
+ /** Structural nesting cap (adversarial `o if`/`o if`/… stacks). Real programs nest < 10; 256 is
45
+ * unambiguously hostile and bounds the block BUILD's structural depth (per opener frame). */
46
+ const MAX_CONTROL_NESTING = 256;
47
+ /**
48
+ * Runtime execution-recursion backstop (DD-017 Phase 3). With subroutines, JS `execBlock` recursion
49
+ * grows with BOTH structural nesting AND `call` depth combined, so neither `MAX_CONTROL_NESTING` nor
50
+ * `maxCallDepth` alone bounds the JS stack. This directly caps live `execBlock` nesting — real programs
51
+ * stay well under 50; 512 is an unambiguously adversarial combination that halts (disclosed) rather than
52
+ * risking a stack overflow escaping the parse.
53
+ */
54
+ const MAX_EXEC_DEPTH = 512;
55
+ /** The recognized O-word keywords. An `o<id>` line with any OTHER trailing word is NOT a control line
56
+ * (e.g. a stray `o5 g1`) — it falls through to the normal lexer, so detection never hijacks plain code. */
57
+ export const KNOWN_OWORD_KEYWORDS = new Set([
58
+ 'sub',
59
+ 'endsub',
60
+ 'call',
61
+ 'return',
62
+ 'if',
63
+ 'elseif',
64
+ 'else',
65
+ 'endif',
66
+ 'while',
67
+ 'endwhile',
68
+ 'do',
69
+ 'repeat',
70
+ 'endrepeat',
71
+ 'break',
72
+ 'continue'
73
+ ]);
74
+ /**
75
+ * Classify one raw line as an O-word control line, or `null` when it is not one. `null` covers every
76
+ * plain line (all FDM, all geometry/param lines) AND an `o<id>` whose trailing word is not a recognized
77
+ * keyword (e.g. `o5 g1 x10`) — those fall through to the normal lexer, so classification never hijacks
78
+ * ordinary code. The id is read as an opaque token (numeric / `<name>` / balanced `[expr]`) and is used
79
+ * only to pair an opener with its closer — it is deliberately NOT evaluated here.
80
+ */
81
+ export function classifyOWord(rawLine) {
82
+ let body = stripParenComments(rawLine);
83
+ const semi = body.indexOf(';');
84
+ if (semi !== -1)
85
+ body = body.slice(0, semi);
86
+ body = body.trim();
87
+ if (body === '')
88
+ return null;
89
+ body = body.replace(/^n\d+\s*/i, ''); // an optional leading N line-number (LinuxCNC allows it)
90
+ if (body[0] !== 'o' && body[0] !== 'O')
91
+ return null;
92
+ let i = 1;
93
+ while (i < body.length && /\s/.test(body[i]))
94
+ i++;
95
+ let id;
96
+ const c = body[i];
97
+ if (c === '<') {
98
+ const e = body.indexOf('>', i);
99
+ if (e === -1)
100
+ return null;
101
+ id = body.slice(i, e + 1);
102
+ i = e + 1;
103
+ }
104
+ else if (c === '[') {
105
+ let depth = 0;
106
+ let j = i;
107
+ for (; j < body.length; j++) {
108
+ if (body[j] === '[')
109
+ depth++;
110
+ else if (body[j] === ']') {
111
+ depth--;
112
+ if (depth === 0) {
113
+ j++;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ if (depth !== 0)
119
+ return null;
120
+ id = body.slice(i, j);
121
+ i = j;
122
+ }
123
+ else if (c >= '0' && c <= '9') {
124
+ let j = i;
125
+ while (j < body.length && /[0-9.]/.test(body[j]))
126
+ j++;
127
+ id = body.slice(i, j);
128
+ i = j;
129
+ }
130
+ else {
131
+ return null;
132
+ }
133
+ while (i < body.length && /\s/.test(body[i]))
134
+ i++;
135
+ let kw = '';
136
+ while (i < body.length && /[a-zA-Z]/.test(body[i]))
137
+ kw += body[i++];
138
+ kw = kw.toLowerCase();
139
+ if (!KNOWN_OWORD_KEYWORDS.has(kw))
140
+ return null;
141
+ return { keyword: kw, id: id.trim().toLowerCase(), expr: body.slice(i).trim() };
142
+ }
143
+ /** True once the built tree used any recognized control construct (drives the capability). */
144
+ export function programUsesOWords(text) {
145
+ const len = text.length;
146
+ let i = 0;
147
+ while (i < len) {
148
+ let nl = text.indexOf('\n', i);
149
+ if (nl === -1)
150
+ nl = len;
151
+ if (lineStartsOWord(text, i, nl))
152
+ return true;
153
+ i = nl + 1;
154
+ }
155
+ return false;
156
+ }
157
+ /** True when a single line is a recognized O-word control line (used by the streaming disclosure). */
158
+ export function isOWordControlLine(line) {
159
+ return lineStartsOWord(line, 0, line.length);
160
+ }
161
+ /**
162
+ * Cheap gate: does `text[start,end)` begin (after leading `( )` comments, whitespace, and an optional
163
+ * `N` line-number) with an `o<id>` whose trailing word is a recognized keyword? The peek costs a few
164
+ * char comparisons for a normal FDM line (first non-space is `G`/`M`/`;`/digit → immediate reject, no
165
+ * allocation); only a genuine `o…`-leading candidate is sliced and fully classified.
166
+ */
167
+ function lineStartsOWord(s, start, end) {
168
+ let i = start;
169
+ let sawN = false;
170
+ // Whitespace `classifyOWord` would strip (via .trim()/`\s`): ASCII plus the common non-breaking space.
171
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\f' || c === '\v' || c === ' ';
172
+ // Skip anything `classifyOWord` removes before the O-word — whitespace, `( )` comment groups, and one
173
+ // optional `N` line-number — in ANY order, so the pre-filter is a strict SUPERSET of the classifier's
174
+ // leading region (never a false negative: a missed O-word would be silent wrong geometry). A false
175
+ // positive is harmless — `classifyOWord` below is the authority and rejects it.
176
+ for (;;) {
177
+ while (i < end && isWs(s[i]))
178
+ i++;
179
+ if (i < end && s[i] === '(') {
180
+ let depth = 1;
181
+ i++;
182
+ while (i < end && depth > 0) {
183
+ if (s[i] === '(')
184
+ depth++;
185
+ else if (s[i] === ')')
186
+ depth--;
187
+ i++;
188
+ }
189
+ continue;
190
+ }
191
+ if (!sawN && i + 1 < end && (s[i] === 'n' || s[i] === 'N') && s[i + 1] >= '0' && s[i + 1] <= '9') {
192
+ i += 2;
193
+ while (i < end && s[i] >= '0' && s[i] <= '9')
194
+ i++;
195
+ sawN = true;
196
+ continue;
197
+ }
198
+ break;
199
+ }
200
+ if (i >= end || (s[i] !== 'o' && s[i] !== 'O'))
201
+ return false;
202
+ // A genuine `o…`-leading candidate — defer entirely to the authoritative classifier (which strips
203
+ // comments globally and confirms a recognized keyword), so the two never diverge.
204
+ return classifyOWord(s.slice(start, end)) !== null;
205
+ }
206
+ // --- interpreter --------------------------------------------------------------------------------
207
+ /** Internal control signals for `break` / `continue` — caught by the nearest enclosing loop. Carry the
208
+ * source offset so a misplaced (loop-less) `break`/`continue` can be disclosed with a location. */
209
+ class BreakSignal {
210
+ offset;
211
+ constructor(offset) {
212
+ this.offset = offset;
213
+ }
214
+ }
215
+ class ContinueSignal {
216
+ offset;
217
+ constructor(offset) {
218
+ this.offset = offset;
219
+ }
220
+ }
221
+ /** `return` control signal (Phase 3) — caught by the nearest enclosing subroutine `call`. */
222
+ class ReturnSignal {
223
+ offset;
224
+ constructor(offset) {
225
+ this.offset = offset;
226
+ }
227
+ }
228
+ /** Split a `call`'s argument text into its top-level bracket expressions, e.g. `[1] [2+3] [#5]`.
229
+ * `malformed` is true when an unbalanced trailing `[` was found (and dropped) — the caller discloses it. */
230
+ function splitBracketArgs(s) {
231
+ const args = [];
232
+ let i = 0;
233
+ let malformed = false;
234
+ while (i < s.length) {
235
+ if (s[i] === '[') {
236
+ let depth = 0;
237
+ const start = i;
238
+ for (; i < s.length; i++) {
239
+ if (s[i] === '[')
240
+ depth++;
241
+ else if (s[i] === ']') {
242
+ depth--;
243
+ if (depth === 0) {
244
+ i++;
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ if (depth !== 0) {
250
+ malformed = true; // unbalanced trailing `[` — drop it, disclosed by the caller
251
+ break;
252
+ }
253
+ args.push(s.slice(start, i));
254
+ }
255
+ else {
256
+ i++;
257
+ }
258
+ }
259
+ return { args, malformed };
260
+ }
261
+ /**
262
+ * Interpret a fully-buffered O-word program. Builds the block tree (tolerant of unbalanced O-words —
263
+ * disclosed, never thrown out), then executes it with bounded iteration. Returns whether execution was
264
+ * degraded so the caller can tier the `parametricProgram` capability.
265
+ */
266
+ export function interpretOWordProgram(lines, host) {
267
+ let degraded = false;
268
+ const disclose = (code, message, offset) => {
269
+ degraded = true;
270
+ host.warn(code, message, offset);
271
+ };
272
+ // --- build the block tree -------------------------------------------------------------------
273
+ const root = { kind: 'root', id: '', offset: 0, body: [] };
274
+ // Subroutine definitions (Phase 3), collected during the build so a `call` can forward-reference a
275
+ // `sub` defined later in the file. A sub body is captured here and executed only on `call`, never inline.
276
+ const subs = new Map();
277
+ const stack = [root];
278
+ const top = () => stack[stack.length - 1];
279
+ const pushChild = (n) => {
280
+ top().body.push(n);
281
+ };
282
+ const controlDepth = () => stack.length - 1; // frames beyond root
283
+ let buildOk = true;
284
+ for (const line of lines) {
285
+ if (!buildOk)
286
+ break;
287
+ const info = classifyOWord(line.text);
288
+ if (info === null) {
289
+ pushChild({ t: 'line', text: line.text, offset: line.offset });
290
+ continue;
291
+ }
292
+ const { keyword, id, expr } = info;
293
+ switch (keyword) {
294
+ case 'if': {
295
+ if (!openFrame({ kind: 'if', id, offset: line.offset, body: [], branches: [] }))
296
+ break;
297
+ const f = top();
298
+ const branch = { expr, offset: line.offset, body: [] };
299
+ f.branches.push(branch);
300
+ f.body = branch.body;
301
+ break;
302
+ }
303
+ case 'elseif':
304
+ case 'else': {
305
+ const f = top();
306
+ if (f.kind !== 'if') {
307
+ disclose(RS274_FLOW_WARN.misplacedControl, `'o${id} ${keyword}' without an enclosing if`, line.offset);
308
+ break;
309
+ }
310
+ const branches = f.branches;
311
+ if (branches.length > 0 && branches[branches.length - 1].expr === null) {
312
+ // A branch already followed `else` — it can never run. Disclose and route its body to a
313
+ // DETACHED sink (not one of `branches`) so the dead lines are dropped, not leaked into `else`.
314
+ disclose(RS274_FLOW_WARN.misplacedControl, `'o${id} ${keyword}' after else is unreachable`, line.offset);
315
+ f.body = [];
316
+ break;
317
+ }
318
+ const branch = { expr: keyword === 'else' ? null : expr, offset: line.offset, body: [] };
319
+ branches.push(branch);
320
+ f.body = branch.body;
321
+ break;
322
+ }
323
+ case 'endif': {
324
+ closeFrame('if', 'endif', id, line.offset, (f) => ({ t: 'if', branches: f.branches }));
325
+ break;
326
+ }
327
+ case 'while': {
328
+ // `while` closes a matching `do` (post-tested loop); otherwise it opens a pre-tested loop.
329
+ if (top().kind === 'do' && idMatches(top().id, id)) {
330
+ closeFrame('do', 'while', id, line.offset, (f) => ({
331
+ t: 'loop',
332
+ kind: 'do',
333
+ expr,
334
+ offset: f.offset,
335
+ body: f.body
336
+ }));
337
+ }
338
+ else {
339
+ openFrame({ kind: 'while', id, offset: line.offset, body: [], expr });
340
+ }
341
+ break;
342
+ }
343
+ case 'endwhile': {
344
+ closeFrame('while', 'endwhile', id, line.offset, (f) => ({
345
+ t: 'loop',
346
+ kind: 'while',
347
+ expr: f.expr ?? '',
348
+ offset: f.offset,
349
+ body: f.body
350
+ }));
351
+ break;
352
+ }
353
+ case 'do': {
354
+ openFrame({ kind: 'do', id, offset: line.offset, body: [] });
355
+ break;
356
+ }
357
+ case 'repeat': {
358
+ openFrame({ kind: 'repeat', id, offset: line.offset, body: [], expr });
359
+ break;
360
+ }
361
+ case 'endrepeat': {
362
+ closeFrame('repeat', 'endrepeat', id, line.offset, (f) => ({
363
+ t: 'loop',
364
+ kind: 'repeat',
365
+ expr: f.expr ?? '',
366
+ offset: f.offset,
367
+ body: f.body
368
+ }));
369
+ break;
370
+ }
371
+ case 'sub': {
372
+ // Phase 3: open a frame so the body is captured; it is stored (not inlined) at the matching
373
+ // `endsub` and runs only on `call`. A sub definition must never execute where it is written.
374
+ openFrame({ kind: 'sub', id, offset: line.offset, body: [] });
375
+ break;
376
+ }
377
+ case 'endsub': {
378
+ const f = top();
379
+ if (f.kind !== 'sub') {
380
+ disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} endsub' without a matching sub`, line.offset);
381
+ break;
382
+ }
383
+ if (!idMatches(f.id, id)) {
384
+ disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} endsub' does not match 'o${f.id} sub'`, line.offset);
385
+ }
386
+ stack.pop();
387
+ if (subs.has(f.id)) {
388
+ disclose(RS274_FLOW_WARN.duplicateSub, `subroutine 'o${f.id}' redefined; keeping the first`, f.offset);
389
+ }
390
+ else {
391
+ subs.set(f.id, { body: f.body, offset: f.offset });
392
+ }
393
+ break;
394
+ }
395
+ case 'call': {
396
+ const { args, malformed } = splitBracketArgs(expr);
397
+ if (malformed) {
398
+ disclose(RS274_FLOW_WARN.unsupportedOword, `'o${id} call' has a malformed argument; it was dropped`, line.offset);
399
+ }
400
+ pushChild({ t: 'call', id, args, offset: line.offset });
401
+ break;
402
+ }
403
+ case 'return': {
404
+ pushChild({ t: 'return', offset: line.offset });
405
+ break;
406
+ }
407
+ case 'break': {
408
+ pushChild({ t: 'break', offset: line.offset });
409
+ break;
410
+ }
411
+ case 'continue': {
412
+ pushChild({ t: 'continue', offset: line.offset });
413
+ break;
414
+ }
415
+ default: {
416
+ disclose(RS274_FLOW_WARN.unsupportedOword, `unsupported O-word '${keyword}'; ignored`, line.offset);
417
+ pushChild({ t: 'noop' });
418
+ }
419
+ }
420
+ }
421
+ // Any frames still open at EOF are unterminated (missing `endif`/`endwhile`/…). Disclose each and drop
422
+ // it (its captured body is undefined-in-extent, so executing it would be a guess) — root siblings that
423
+ // closed cleanly still run.
424
+ while (stack.length > 1) {
425
+ const f = stack.pop();
426
+ disclose(RS274_FLOW_WARN.unbalancedOword, `unterminated 'o${f.id} ${f.kind}' (missing its end)`, f.offset);
427
+ }
428
+ /** Push a new control frame, enforcing the nesting cap. Returns false if the cap aborted the build. */
429
+ function openFrame(f) {
430
+ if (controlDepth() + 1 > MAX_CONTROL_NESTING) {
431
+ disclose(RS274_FLOW_WARN.controlNesting, `control nesting exceeds ${MAX_CONTROL_NESTING}; program not executed`, f.offset);
432
+ buildOk = false;
433
+ return false;
434
+ }
435
+ stack.push(f);
436
+ return true;
437
+ }
438
+ /** Close the top frame, verifying it matches the expected opener kind + id, and append its node. */
439
+ function closeFrame(expectKind, closer, id, offset, make) {
440
+ const f = top();
441
+ if (f.kind !== expectKind) {
442
+ disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} ${closer}' without a matching opener`, offset);
443
+ return;
444
+ }
445
+ if (!idMatches(f.id, id)) {
446
+ // Structurally it closes the top frame; the id label just disagrees — close it but disclose.
447
+ disclose(RS274_FLOW_WARN.unbalancedOword, `'o${id} ${closer}' does not match 'o${f.id}'`, offset);
448
+ }
449
+ stack.pop();
450
+ top().body.push(make(f));
451
+ }
452
+ if (!buildOk)
453
+ return { degraded: true }; // nesting cap tripped — nothing executed (adversarial input)
454
+ // --- execute the tree -----------------------------------------------------------------------
455
+ let iterations = 0;
456
+ let halted = false; // set by the iteration cap or a geometry stop; unwinds cleanly
457
+ let iterationWarned = false;
458
+ let loopDepth = 0; // > 0 anywhere inside a loop (stays elevated across calls) — gates the charge
459
+ let frameLoopDepth = 0; // loops in the CURRENT call frame (reset per call) — gates break/continue locality
460
+ let callDepth = 0; // active subroutine call frames (Phase 3) — bounded by host.maxCallDepth
461
+ let execDepth = 0; // live execBlock recursion — bounded by MAX_EXEC_DEPTH (JS-stack backstop)
462
+ let execDepthWarned = false;
463
+ /**
464
+ * Charge ONE unit of loop work against the shared budget; returns false (and halts) when exhausted.
465
+ * Charged both per loop-body *pass* (so an empty-bodied `o while [1]` is bounded) AND per statement
466
+ * *inside* a loop body (so a loop with a huge body cannot multiply the real work past the limit —
467
+ * the bound is total loop work, `maxProgramIterations`, independent of body size). Root-level (non-loop)
468
+ * statements are bounded by input size and are not charged, so a large straight-line program is not
469
+ * truncated.
470
+ */
471
+ const charge = (offset) => {
472
+ if (++iterations > host.maxProgramIterations) {
473
+ if (!iterationWarned) {
474
+ iterationWarned = true;
475
+ disclose(RS274_FLOW_WARN.iterationLimit, `program iteration limit ${host.maxProgramIterations} reached; execution stopped`, offset);
476
+ }
477
+ halted = true;
478
+ return false;
479
+ }
480
+ return true;
481
+ };
482
+ const truthy = (expr, offset) => {
483
+ const v = host.evalExpression(expr, offset);
484
+ if (v === null) {
485
+ degraded = true; // malformed/non-finite condition — already disclosed by evalExpression
486
+ return false;
487
+ }
488
+ return v !== 0;
489
+ };
490
+ const execBlock = (nodes) => {
491
+ // Runtime recursion backstop (Phase 3): structural nesting + call depth combine here, so cap live
492
+ // execBlock depth directly to keep the JS stack safe regardless of how they multiply.
493
+ if (execDepth >= MAX_EXEC_DEPTH) {
494
+ if (!execDepthWarned) {
495
+ execDepthWarned = true;
496
+ disclose(RS274_FLOW_WARN.controlNesting, `execution nesting exceeded ${MAX_EXEC_DEPTH}; program stopped`);
497
+ }
498
+ halted = true;
499
+ return;
500
+ }
501
+ execDepth++;
502
+ try {
503
+ for (const n of nodes) {
504
+ if (halted || host.stopped()) {
505
+ halted = true;
506
+ return;
507
+ }
508
+ // Charge every executed statement inside a loop OR a subroutine. Loops bound `bodySize ×
509
+ // iterations`; subroutines bound the CALL-TREE SIZE — each `call` node is itself charged once
510
+ // callDepth > 0, so a loop-free fan-out (`o100 sub` calling `o100` twice) that maxCallDepth
511
+ // bounds only in DEPTH is bounded in total work here. Root-level statements (both depths 0) run
512
+ // once and stay bounded by input size, so a large straight-line program is not truncated.
513
+ if ((loopDepth > 0 || callDepth > 0) && n.t !== 'if' && n.t !== 'loop') {
514
+ if (!charge(n.t === 'line' || n.t === 'call' ? n.offset : undefined))
515
+ return;
516
+ }
517
+ switch (n.t) {
518
+ case 'line':
519
+ host.processLine(n.text, n.offset);
520
+ break;
521
+ case 'noop':
522
+ break;
523
+ case 'break':
524
+ case 'continue':
525
+ // Valid only inside a loop IN THE CURRENT frame. Outside one (top level, or a sub with no
526
+ // loop of its own) it is a disclosed no-op — never a cross-frame jump, never a program abort.
527
+ if (frameLoopDepth === 0) {
528
+ disclose(RS274_FLOW_WARN.misplacedControl, `'${n.t}' outside a loop; ignored`, n.offset);
529
+ }
530
+ else if (n.t === 'break') {
531
+ throw new BreakSignal(n.offset);
532
+ }
533
+ else {
534
+ throw new ContinueSignal(n.offset);
535
+ }
536
+ break;
537
+ case 'return':
538
+ // Valid only inside a subroutine. Outside one it is a disclosed no-op, not a program abort.
539
+ if (callDepth === 0) {
540
+ disclose(RS274_FLOW_WARN.misplacedControl, 'return outside a subroutine; ignored', n.offset);
541
+ }
542
+ else {
543
+ throw new ReturnSignal(n.offset);
544
+ }
545
+ break;
546
+ case 'call':
547
+ execCall(n);
548
+ break;
549
+ case 'if': {
550
+ for (const br of n.branches) {
551
+ if (br.expr === null || truthy(br.expr, br.offset)) {
552
+ execBlock(br.body);
553
+ break;
554
+ }
555
+ }
556
+ break;
557
+ }
558
+ case 'loop':
559
+ execLoop(n);
560
+ break;
561
+ }
562
+ }
563
+ }
564
+ finally {
565
+ execDepth--;
566
+ }
567
+ };
568
+ /** Invoke a subroutine (Phase 3): bind args → a fresh scope, run its body, honor `return`, bound depth. */
569
+ const execCall = (n) => {
570
+ const sub = subs.get(n.id);
571
+ if (sub === undefined) {
572
+ disclose(RS274_FLOW_WARN.unknownSub, `call of undefined subroutine 'o${n.id}'; ignored`, n.offset);
573
+ return;
574
+ }
575
+ if (callDepth >= host.maxCallDepth) {
576
+ disclose(RS274_FLOW_WARN.callDepth, `subroutine recursion exceeded maxCallDepth ${host.maxCallDepth}`, n.offset);
577
+ return;
578
+ }
579
+ // Args evaluate in the CALLER's scope, then bind to `#1`..`#30` in the fresh callee scope.
580
+ const args = n.args.map((a) => {
581
+ const v = host.evalExpression(a, n.offset);
582
+ if (v === null)
583
+ degraded = true; // malformed arg — disclosed by evalExpression; passes 0
584
+ return v ?? 0;
585
+ });
586
+ host.pushScope(args);
587
+ callDepth++;
588
+ // A subroutine's break/continue see only loops WITHIN the sub — reset frame-loop depth for the body
589
+ // (but NOT `loopDepth`, which stays elevated so a sub called inside a loop still charges its work).
590
+ const savedFrameLoopDepth = frameLoopDepth;
591
+ frameLoopDepth = 0;
592
+ try {
593
+ execBlock(sub.body);
594
+ }
595
+ catch (e) {
596
+ if (!(e instanceof ReturnSignal))
597
+ throw e; // `return` = normal early exit; anything else propagates
598
+ }
599
+ finally {
600
+ frameLoopDepth = savedFrameLoopDepth;
601
+ callDepth--;
602
+ host.popScope();
603
+ }
604
+ };
605
+ const execLoop = (n) => {
606
+ loopDepth++; // gate the per-statement charge in execBlock for this loop's body
607
+ frameLoopDepth++; // this loop can catch a break/continue in the current call frame
608
+ try {
609
+ if (n.kind === 'repeat') {
610
+ const cv = host.evalExpression(n.expr, n.offset);
611
+ if (cv === null) {
612
+ degraded = true;
613
+ return;
614
+ }
615
+ const count = Math.floor(cv);
616
+ for (let k = 0; k < count; k++) {
617
+ if (halted || host.stopped()) {
618
+ halted = true;
619
+ return;
620
+ }
621
+ if (!charge(n.offset))
622
+ return;
623
+ try {
624
+ execBlock(n.body);
625
+ }
626
+ catch (e) {
627
+ if (e instanceof BreakSignal)
628
+ return;
629
+ if (e instanceof ContinueSignal)
630
+ continue;
631
+ throw e;
632
+ }
633
+ }
634
+ return;
635
+ }
636
+ if (n.kind === 'while') {
637
+ while (!halted && !host.stopped() && truthy(n.expr, n.offset)) {
638
+ if (!charge(n.offset))
639
+ return;
640
+ try {
641
+ execBlock(n.body);
642
+ }
643
+ catch (e) {
644
+ if (e instanceof BreakSignal)
645
+ return;
646
+ if (e instanceof ContinueSignal)
647
+ continue;
648
+ throw e;
649
+ }
650
+ }
651
+ return;
652
+ }
653
+ // post-tested `do … while [cond]` — the body runs at least once, then the condition is tested.
654
+ do {
655
+ if (halted || host.stopped()) {
656
+ halted = true;
657
+ return;
658
+ }
659
+ if (!charge(n.offset))
660
+ return;
661
+ try {
662
+ execBlock(n.body);
663
+ }
664
+ catch (e) {
665
+ if (e instanceof BreakSignal)
666
+ return;
667
+ if (e instanceof ContinueSignal)
668
+ continue; // fall through to the condition test
669
+ throw e;
670
+ }
671
+ } while (!halted && !host.stopped() && truthy(n.expr, n.offset));
672
+ }
673
+ finally {
674
+ loopDepth--;
675
+ frameLoopDepth--;
676
+ }
677
+ };
678
+ try {
679
+ execBlock(root.body);
680
+ }
681
+ catch (e) {
682
+ if (e instanceof BreakSignal || e instanceof ContinueSignal) {
683
+ disclose(RS274_FLOW_WARN.misplacedControl, 'break/continue outside a loop; ignored', e.offset);
684
+ }
685
+ else if (e instanceof ReturnSignal) {
686
+ disclose(RS274_FLOW_WARN.misplacedControl, 'return outside a subroutine; ignored', e.offset);
687
+ }
688
+ else {
689
+ throw e;
690
+ }
691
+ }
692
+ // A geometry limit (maxSegments/maxBufferBytes) that halted interpretation mid-program is a degraded
693
+ // run — the parametric program did not fully execute (Finding 4a). Global `complete=false` also covers
694
+ // it, but the per-capability tier should not claim a clean `known`.
695
+ if (host.stopped())
696
+ degraded = true;
697
+ return { degraded };
698
+ }
699
+ /** O-word ids match on their normalized token (numeric, `<name>`, or a literal `[expr]` text). */
700
+ function idMatches(a, b) {
701
+ return a === b;
702
+ }