@chestnutlabs/gcode-parser 0.16.0 → 0.18.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.
- package/dist/parse.d.ts +25 -0
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +97 -3
- package/dist/rs274-flow.d.ts +69 -0
- package/dist/rs274-flow.d.ts.map +1 -0
- package/dist/rs274-flow.js +702 -0
- package/dist/rs274.d.ts +107 -0
- package/dist/rs274.d.ts.map +1 -0
- package/dist/rs274.js +723 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +5 -0
- package/package.json +5 -5
|
@@ -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
|
+
}
|