myxo-lang 1.5.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/CONCURRENCY.md +207 -0
- package/HOW_IT_WORKS.md +235 -0
- package/INDEPENDENCE.md +24 -0
- package/MYXO_PROMPT.md +139 -0
- package/README.md +494 -0
- package/ROADMAP.md +200 -0
- package/SPEC.md +181 -0
- package/VISION.md +249 -0
- package/builtins.js +361 -0
- package/command-fence.js +75 -0
- package/errors.js +45 -0
- package/examples/agent.myx +31 -0
- package/examples/fenced-agent.js +78 -0
- package/examples/fib.myx +11 -0
- package/examples/fibers.myx +57 -0
- package/examples/flow-routing.myx +12 -0
- package/examples/geo.myx +12 -0
- package/examples/hello.myx +2 -0
- package/examples/host.js +30 -0
- package/examples/james-myxo-demo.js +60 -0
- package/examples/living-mesh.myx +22 -0
- package/examples/match.myx +18 -0
- package/examples/mathlib.js +8 -0
- package/examples/mathlib.pl +11 -0
- package/examples/mathlib.py +23 -0
- package/examples/mcp-host.js +37 -0
- package/examples/nexus-mesh.myx +22 -0
- package/examples/nexus.myx +29 -0
- package/examples/ouroboros.myx +2 -0
- package/examples/outward-gate.myx +23 -0
- package/examples/physarum.myx +75 -0
- package/examples/polyglot-host.js +17 -0
- package/examples/polyglot.myx +11 -0
- package/examples/resilient.myx +28 -0
- package/examples/scheduler.myx +46 -0
- package/examples/the-law.myx +27 -0
- package/examples/use-geo.myx +9 -0
- package/format.js +206 -0
- package/interpreter.js +1092 -0
- package/lexer.js +173 -0
- package/mcp-bridge.js +77 -0
- package/mcp-framing.js +34 -0
- package/mcp-server.js +57 -0
- package/myxo-concurrent.js +110 -0
- package/myxo-live-worker.js +30 -0
- package/myxo-live.js +83 -0
- package/myxo-lsp.js +226 -0
- package/myxo-par-worker.js +39 -0
- package/myxo-plan.js +207 -0
- package/myxo-run.js +63 -0
- package/myxo.js +296 -0
- package/package.json +27 -0
- package/parser.js +662 -0
- package/polyglot-host.js +39 -0
- package/polyglot.js +191 -0
- package/receipt.js +71 -0
- package/std.myx +88 -0
- package/tools/memo-fuzz.js +224 -0
package/interpreter.js
ADDED
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// interpreter.js — walks the AST and makes it happen.
|
|
3
|
+
// Holds the Environment (where the Nexus law lives) and the host bridge.
|
|
4
|
+
|
|
5
|
+
const { MyxoError, NxAssertError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
// The single void value. Anything missing or unreported is this.
|
|
8
|
+
const VOID = Symbol('void');
|
|
9
|
+
|
|
10
|
+
// `report` unwinds an agent call by throwing this.
|
|
11
|
+
class ReportSignal { constructor(value) { this.value = value; } }
|
|
12
|
+
|
|
13
|
+
// Structural equality for `expect ... is ...` — numbers/strings/bools/void by value,
|
|
14
|
+
// lists element-wise, meshes by key/value; agents/natives by identity.
|
|
15
|
+
function deepEqual(a, b) {
|
|
16
|
+
if (a === b) return true;
|
|
17
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
18
|
+
return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
|
|
19
|
+
if (a instanceof Map && b instanceof Map) {
|
|
20
|
+
if (a.size !== b.size) return false;
|
|
21
|
+
for (const [k, v] of a) if (!b.has(k) || !deepEqual(v, b.get(k))) return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Type-tagged value renderer for assertion messages, so `1` vs `"1"` and `void`/`live`
|
|
28
|
+
// are distinguishable — a test runner's diagnostics must never read like a bug.
|
|
29
|
+
function showv(v) {
|
|
30
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
31
|
+
if (v === VOID) return 'void';
|
|
32
|
+
if (v === true) return 'live';
|
|
33
|
+
if (v === false) return 'dead';
|
|
34
|
+
return stringify(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Gradual types: does a value satisfy a type annotation? `any` always does. Enforced only in strict mode.
|
|
38
|
+
function typeMatches(type, v) {
|
|
39
|
+
switch (type) {
|
|
40
|
+
case 'any': return true;
|
|
41
|
+
case 'number': return typeof v === 'number';
|
|
42
|
+
case 'string': return typeof v === 'string';
|
|
43
|
+
case 'bool': return typeof v === 'boolean';
|
|
44
|
+
case 'list': return Array.isArray(v);
|
|
45
|
+
case 'mesh': return v instanceof Map;
|
|
46
|
+
case 'agent': return !!(v && (v.__agent || v.__native));
|
|
47
|
+
case 'void': return v === VOID;
|
|
48
|
+
default: return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A pathway is a named binding carrying a strength. Reading it reinforces it
|
|
53
|
+
// (strength++). Decaying removes it. `system` marks builtins/std so they don't
|
|
54
|
+
// clutter the mesh view. Scopes chain through `parent` for lexical lookup.
|
|
55
|
+
class Environment {
|
|
56
|
+
constructor(parent = null) { this.vars = new Map(); this.parent = parent; }
|
|
57
|
+
|
|
58
|
+
define(name, value, system = false, type = null) { this.vars.set(name, { value, strength: 1, system, type }); }
|
|
59
|
+
|
|
60
|
+
lookup(name) { // resolve WITHOUT reinforcing -> { entry, env } or null
|
|
61
|
+
for (let env = this; env; env = env.parent) {
|
|
62
|
+
const e = env.vars.get(name);
|
|
63
|
+
if (e) return { entry: e, env };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get(name, line) {
|
|
69
|
+
const f = this.lookup(name);
|
|
70
|
+
if (!f) throw new MyxoError(`unknown pathway '${name}'`, line);
|
|
71
|
+
f.entry.strength++; // useful pathways reinforce
|
|
72
|
+
return f.entry.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
set(name, value, line) {
|
|
76
|
+
for (let env = this; env; env = env.parent) {
|
|
77
|
+
if (env.vars.has(name)) { env.vars.get(name).value = value; return; }
|
|
78
|
+
}
|
|
79
|
+
throw new MyxoError(`cannot assign to unknown pathway '${name}' — seed it first`, line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
remove(name, line) {
|
|
83
|
+
for (let env = this; env; env = env.parent) {
|
|
84
|
+
if (env.vars.has(name)) { env.vars.delete(name); return; }
|
|
85
|
+
}
|
|
86
|
+
throw new MyxoError(`cannot decay unknown pathway '${name}'`, line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- value helpers --------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function typeName(v) {
|
|
93
|
+
if (v === VOID) return 'void';
|
|
94
|
+
if (typeof v === 'number') return 'number';
|
|
95
|
+
if (typeof v === 'string') return 'string';
|
|
96
|
+
if (typeof v === 'boolean') return 'bool';
|
|
97
|
+
if (Array.isArray(v)) return 'list';
|
|
98
|
+
if (v instanceof Map) return 'mesh';
|
|
99
|
+
if (v && v.__agent) return 'agent';
|
|
100
|
+
if (v && v.__native) return 'agent';
|
|
101
|
+
if (v && v.__channel) return 'channel';
|
|
102
|
+
if (v && v.__fiber) return 'fiber';
|
|
103
|
+
return 'unknown';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// a value safe to use as a memo key / cached result (immutable, compares by value)
|
|
107
|
+
function isPrimitive(v) { return v === VOID || typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean'; }
|
|
108
|
+
|
|
109
|
+
// builtins that read live mutable state or are nondeterministic/side-effecting -> a caller of them is NOT pure
|
|
110
|
+
const IMPURE_BUILTINS = new Set(['random', 'prune', 'metabolize', 'mesh', 'strength', 'schedule', 'flows', 'channel', 'drain', 'await', 'route']);
|
|
111
|
+
|
|
112
|
+
// a type-tagged, collision-free memo key (VOID / NaN / Infinity stay distinct — JSON.stringify collapses them to null).
|
|
113
|
+
// Each arg is tagged to an injective string, then LENGTH-PREFIXED (<len>:<part>) rather than separator-joined.
|
|
114
|
+
// A bare join is UNSOUND: a string arg that itself contains the separator byte can forge a boundary —
|
|
115
|
+
// ["a\x1fs:b","c"] and ["a","b\x1fs:c"] both join to the same key. Length framing makes the sequence injective:
|
|
116
|
+
// the decoder reads <digits> ':' then exactly that many code units, so no content can fake a boundary.
|
|
117
|
+
function keyOf(args) {
|
|
118
|
+
let key = '';
|
|
119
|
+
for (const a of args) {
|
|
120
|
+
let part;
|
|
121
|
+
if (a === VOID) part = 'v';
|
|
122
|
+
else {
|
|
123
|
+
const t = typeof a;
|
|
124
|
+
if (t === 'number') part = Number.isNaN(a) ? 'n:NaN' : a === Infinity ? 'n:Inf' : a === -Infinity ? 'n:-Inf' : Object.is(a, -0) ? 'n:-0' : 'n:' + a;
|
|
125
|
+
else if (t === 'boolean') part = 'b:' + a;
|
|
126
|
+
else part = 's:' + a;
|
|
127
|
+
}
|
|
128
|
+
key += part.length + ':' + part;
|
|
129
|
+
}
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function truthy(v) {
|
|
134
|
+
if (v === VOID) return false;
|
|
135
|
+
if (typeof v === 'boolean') return v;
|
|
136
|
+
if (typeof v === 'number') return v !== 0;
|
|
137
|
+
if (typeof v === 'string') return v.length > 0;
|
|
138
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
139
|
+
if (v instanceof Map) return v.size > 0;
|
|
140
|
+
return true; // agents/natives are always live
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Render a value as text. `quoted` adds quotes to strings inside collections.
|
|
144
|
+
function stringify(v, quoted = false) {
|
|
145
|
+
if (v === VOID) return 'void';
|
|
146
|
+
if (typeof v === 'boolean') return v ? 'live' : 'dead';
|
|
147
|
+
if (typeof v === 'number') return String(v);
|
|
148
|
+
if (typeof v === 'string') return quoted ? `"${v}"` : v;
|
|
149
|
+
if (Array.isArray(v)) return '[' + v.map(e => stringify(e, true)).join(', ') + ']';
|
|
150
|
+
if (v instanceof Map) {
|
|
151
|
+
return '{' + [...v.entries()].map(([k, val]) => `${k}: ${stringify(val, true)}`).join(', ') + '}';
|
|
152
|
+
}
|
|
153
|
+
if (v && v.__agent) return `<agent ${v.name || 'anon'}>`;
|
|
154
|
+
if (v && v.__native) return `<native ${v.name}>`;
|
|
155
|
+
if (v && v.__channel) return `<channel${v.cap === Infinity ? '' : ' cap ' + v.cap}>`;
|
|
156
|
+
if (v && v.__fiber) return `<fiber ${v.name || 'anon'}>`;
|
|
157
|
+
return String(v);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- the interpreter ------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
class Interpreter {
|
|
163
|
+
constructor(opts = {}) {
|
|
164
|
+
this.globals = new Environment();
|
|
165
|
+
this.output = opts.output || (s => process.stdout.write(s));
|
|
166
|
+
this.baseDir = opts.dir || '.';
|
|
167
|
+
this.moduleLoader = null; // host grants this; without it, `weave` is fenced
|
|
168
|
+
this.moduleCache = new Map();
|
|
169
|
+
this.moduleInProgress = new Set();
|
|
170
|
+
this.exportStack = [];
|
|
171
|
+
this.dirStack = [];
|
|
172
|
+
this.manifest = null; // null = no `needs` declared
|
|
173
|
+
this.requireManifest = !!opts.requireManifest; // production hosts can require `needs` before any capability call
|
|
174
|
+
this.audit = []; // a ledger of every host-capability call
|
|
175
|
+
this.callStack = []; // live agent frames, for honest stack traces
|
|
176
|
+
this.maxDepth = opts.maxDepth || 500; // runaway-recursion backstop (well under the JS stack)
|
|
177
|
+
this.maxSteps = Number.isFinite(opts.maxSteps) && opts.maxSteps > 0 ? opts.maxSteps : Infinity; // fuel for runaway loops
|
|
178
|
+
this.steps = 0;
|
|
179
|
+
this.capSpent = new Map(); // cumulative numeric arg-0 per VALUE-metered capability
|
|
180
|
+
this.capCalls = new Map(); // cumulative CALL COUNT per capability (the default metering)
|
|
181
|
+
this.valueCaps = new Set(opts.valueCaps || []); // caps the HOST meters by value (e.g. spend); everything else = count.
|
|
182
|
+
// The metering kind is host-fixed, NOT chooseable by the (untrusted) script's manifest.
|
|
183
|
+
this.frames = []; // per-agent-call purity frames — the living mesh tracks taint here
|
|
184
|
+
this.memo = new Map(); // agent -> { calls, impure, hits, cache } — hot-promotion (memoization)
|
|
185
|
+
this.promoteAt = opts.promoteAt || 2; // calls before a provably-pure agent's pathway promotes (memoizes)
|
|
186
|
+
this.epoch = 0; // bumped on ANY global write — invalidates memo caches that could depend on it
|
|
187
|
+
this.routes = new Map(); // name -> { providers, cond[], credit[] } — automatic flow-routing (the slime mold)
|
|
188
|
+
this.pools = new Map(); // name -> { workers, cond[] } — the Physarum scheduler (flow-routing lifted to parallel batches)
|
|
189
|
+
this.fibers = []; // all spawned fibers (for drain/deadlock detection) — cooperative concurrency
|
|
190
|
+
this.ready = []; // the cooperative scheduler's run queue: { fiber, val? }
|
|
191
|
+
this.strict = !!opts.strict; // `--strict` enforces gradual-type annotations as runtime contracts
|
|
192
|
+
this.testMode = !!opts.testMode; // `myxo test` flips this on; otherwise `test` blocks are inert
|
|
193
|
+
this.testResults = []; // collected { name, ok, error } from test blocks
|
|
194
|
+
this.inTest = null; // the active test record while a test body runs
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Install a native (host) function callable from Myxo. The bridge to the Nexus.
|
|
198
|
+
registerNative(name, fn, capability = false, meter = 'count') {
|
|
199
|
+
if (meter === 'value') this.valueCaps.add(name); // host declares this cap is metered by value, not call-count
|
|
200
|
+
this.globals.define(name, { __native: true, name, fn, capability }, true);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
run(ast) {
|
|
204
|
+
this.dirStack.push(this.baseDir);
|
|
205
|
+
this.exportStack.push(new Set());
|
|
206
|
+
try {
|
|
207
|
+
let result = VOID;
|
|
208
|
+
try {
|
|
209
|
+
for (const stmt of ast.body) this.exec(stmt, this.globals);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (e instanceof ReportSignal) result = e.value; // top-level report ends the body...
|
|
212
|
+
else throw e;
|
|
213
|
+
}
|
|
214
|
+
// ...but spawned fibers ALWAYS run to completion at program end, on EITHER exit path, and their held errors
|
|
215
|
+
// and deadlocks ALWAYS surface — a background fiber's failure must never vanish silently. (`if fibers.length`
|
|
216
|
+
// not `if some-not-done`: an errored fiber is marked done, so checking !done would let its error fall through.)
|
|
217
|
+
if (this.fibers.length) { this.pump(); this.checkAllFibersDone(); }
|
|
218
|
+
return result;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
// never let a raw JS stack overflow reach the user — make it Myxo's own error.
|
|
221
|
+
if (e instanceof RangeError && /call stack/i.test(e.message)) {
|
|
222
|
+
throw new MyxoError('call stack went too deep — runaway recursion?');
|
|
223
|
+
}
|
|
224
|
+
throw e;
|
|
225
|
+
} finally {
|
|
226
|
+
this.dirStack.pop();
|
|
227
|
+
this.exportStack.pop();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
execBlock(stmts, env) { for (const s of stmts) this.exec(s, env); }
|
|
232
|
+
|
|
233
|
+
step(line) {
|
|
234
|
+
if (this.maxSteps === Infinity) return;
|
|
235
|
+
this.steps++;
|
|
236
|
+
if (this.steps > this.maxSteps) {
|
|
237
|
+
throw new MyxoError(`execution fuel exhausted after ${this.maxSteps} steps`, line);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- statements ---------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
exec(node, env) {
|
|
244
|
+
this.step(node.line);
|
|
245
|
+
switch (node.type) {
|
|
246
|
+
case 'Seed': {
|
|
247
|
+
const sv = this.eval(node.value, env);
|
|
248
|
+
if (this.strict && node.declType && !typeMatches(node.declType, sv))
|
|
249
|
+
throw new MyxoError(`'${node.name}' expects ${node.declType}, got ${typeName(sv)}`, node.line);
|
|
250
|
+
if (env === this.globals && env.vars.has(node.name)) this.epoch++; // redefining a global -> invalidate dependent caches
|
|
251
|
+
env.define(node.name, sv, false, node.declType); // carry the type so a later reassignment is also contract-checked
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case 'SeedDestructure': {
|
|
255
|
+
const value = this.eval(node.value, env);
|
|
256
|
+
const binds = this.matchPattern(node.pattern, value, env);
|
|
257
|
+
if (!binds) throw new MyxoError('destructuring pattern did not match the value', node.line);
|
|
258
|
+
if (env === this.globals) this.epoch++; // global binding(s) changed -> invalidate dependent caches
|
|
259
|
+
for (const k of Object.keys(binds)) env.define(k, binds[k]);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case 'Assign': return this.execAssign(node, env);
|
|
263
|
+
case 'Decay': return this.execDecay(node, env);
|
|
264
|
+
case 'Emit': {
|
|
265
|
+
const text = node.args.map(a => stringify(this.eval(a, env))).join(' ');
|
|
266
|
+
if (this.frames.length) this.markImpure(); // output is an observable side effect -> never memoize it away
|
|
267
|
+
this.output(text + '\n');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'When': {
|
|
271
|
+
if (truthy(this.eval(node.cond, env))) this.execBlock(node.thenBlock, new Environment(env));
|
|
272
|
+
else if (node.elseBlock) this.execBlock(node.elseBlock, new Environment(env));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
case 'Reinforce':
|
|
276
|
+
while (truthy(this.eval(node.cond, env))) this.execBlock(node.body, new Environment(env));
|
|
277
|
+
return;
|
|
278
|
+
case 'ReinforceTimes': {
|
|
279
|
+
const n = this.eval(node.count, env);
|
|
280
|
+
if (typeof n !== 'number') throw new MyxoError(`reinforce ... times needs a number, got ${typeName(n)}`, node.line);
|
|
281
|
+
for (let k = 0; k < n; k++) this.execBlock(node.body, new Environment(env));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
case 'ForEach': return this.execForEach(node, env);
|
|
285
|
+
case 'Give': case 'Take': case 'Yield': {
|
|
286
|
+
// Suspension points only mean something while a fiber is being stepped. Reaching the synchronous path means
|
|
287
|
+
// either we're outside a fiber entirely, or we're inside one but on a non-suspendable sub-path (a CALLED
|
|
288
|
+
// agent's body / an expression) — the scheduler can't suspend across that boundary. Be honest about which.
|
|
289
|
+
const kw = node.type.toLowerCase();
|
|
290
|
+
throw new MyxoError(this.steppingFiber
|
|
291
|
+
? `'${kw}' can't be used inside a called agent — only in the fiber's own body (and inside when/match/for each/reinforce/attempt)`
|
|
292
|
+
: `'${kw}' is only valid inside a spawned fiber`, node.line);
|
|
293
|
+
}
|
|
294
|
+
case 'Agent':
|
|
295
|
+
if (env === this.globals && env.vars.has(node.name)) this.epoch++; // redefining a global agent -> invalidate (stale free-callee reads)
|
|
296
|
+
env.define(node.name, { __agent: true, name: node.name, params: node.params, body: node.body, closure: env, returnType: node.returnType });
|
|
297
|
+
return;
|
|
298
|
+
case 'Report':
|
|
299
|
+
throw new ReportSignal(this.eval(node.value, env));
|
|
300
|
+
case 'Attempt': {
|
|
301
|
+
try { this.execBlock(node.tryBlock, new Environment(env)); }
|
|
302
|
+
catch (e) {
|
|
303
|
+
if (e instanceof ReportSignal) throw e; // `report` still unwinds
|
|
304
|
+
if (!(e instanceof MyxoError)) throw e; // real JS bugs propagate
|
|
305
|
+
const m = new Map();
|
|
306
|
+
m.set('message', e.message);
|
|
307
|
+
m.set('line', typeof e.line === 'number' ? e.line : VOID);
|
|
308
|
+
m.set('value', e.nxValue !== undefined ? e.nxValue : VOID);
|
|
309
|
+
const child = new Environment(env);
|
|
310
|
+
child.define(node.errName, m);
|
|
311
|
+
this.execBlock(node.catchBlock, child);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case 'Fail': {
|
|
316
|
+
const v = this.eval(node.value, env);
|
|
317
|
+
const err = new MyxoError(typeof v === 'string' ? v : stringify(v), node.line);
|
|
318
|
+
err.nxFail = true;
|
|
319
|
+
err.nxValue = v;
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
case 'Weave': {
|
|
323
|
+
if (this.frames.length) this.markImpure(); // module load can have side effects -> taint the calling agent
|
|
324
|
+
const p = this.eval(node.path, env);
|
|
325
|
+
if (typeof p !== 'string') throw new MyxoError(`weave needs a string path, got ${typeName(p)}`, node.line);
|
|
326
|
+
const exports = this.weaveModule(p, node.line);
|
|
327
|
+
if (node.alias) env.define(node.alias, new Map(exports)); // namespaced
|
|
328
|
+
else for (const [k, v] of exports) env.define(k, v); // flat
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
case 'Expose': {
|
|
332
|
+
const top = this.exportStack[this.exportStack.length - 1];
|
|
333
|
+
if (top) top.add(node.name);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
case 'Needs': {
|
|
337
|
+
if (!this.manifest) this.manifest = new Map(); // name -> limit | null
|
|
338
|
+
for (const it of node.items) this.manifest.set(it.name, it.limit || null);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
case 'Match': {
|
|
342
|
+
const subject = this.eval(node.subject, env);
|
|
343
|
+
for (const arm of node.arms) {
|
|
344
|
+
const binds = this.matchPattern(arm.pattern, subject, env);
|
|
345
|
+
if (binds) {
|
|
346
|
+
const child = new Environment(env);
|
|
347
|
+
for (const k of Object.keys(binds)) child.define(k, binds[k]);
|
|
348
|
+
this.execBlock(arm.body, child);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return; // no arm matched -> nothing runs (use `_` for a catch-all)
|
|
353
|
+
}
|
|
354
|
+
case 'Test': {
|
|
355
|
+
if (!this.testMode) return; // test blocks are inert unless run via `myxo test`
|
|
356
|
+
const rec = { name: node.name, ok: true, error: null, asserts: 0, line: node.line };
|
|
357
|
+
const prev = this.inTest;
|
|
358
|
+
this.inTest = rec;
|
|
359
|
+
try {
|
|
360
|
+
this.execBlock(node.body, new Environment(env));
|
|
361
|
+
if (rec.asserts === 0) { rec.ok = false; rec.error = 'no expectations ran (empty test)'; }
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (e instanceof ReportSignal) { rec.ok = false; rec.error = "a test body cannot 'report' — it ends the test before its assertions"; }
|
|
364
|
+
else if (e instanceof NxAssertError) { rec.ok = false; rec.error = e.message; }
|
|
365
|
+
else if (e instanceof MyxoError) { rec.ok = false; rec.error = 'errored: ' + e.message; }
|
|
366
|
+
else throw e; // a real JS bug aborts the run
|
|
367
|
+
} finally {
|
|
368
|
+
this.inTest = prev;
|
|
369
|
+
}
|
|
370
|
+
this.testResults.push(rec);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
case 'Expect': {
|
|
374
|
+
if (!this.inTest) throw new MyxoError("'expect' is only valid inside a test block", node.line);
|
|
375
|
+
this.inTest.asserts++;
|
|
376
|
+
const m = node.matcher;
|
|
377
|
+
if (m.kind === 'fail' || m.kind === 'failwith') {
|
|
378
|
+
let threw = null;
|
|
379
|
+
try { this.eval(node.actual, env); }
|
|
380
|
+
catch (e) { if (e instanceof MyxoError) threw = e; else throw e; } // report/JS-bug propagate
|
|
381
|
+
if (!threw) throw new NxAssertError('expected the expression to fail, but it succeeded', node.line);
|
|
382
|
+
// Only an INTENTIONAL failure satisfies `to fail`: an explicit `fail`, or a fence/budget denial.
|
|
383
|
+
// An incidental error (unknown pathway, bad arity, type error) re-throws -> the test ERRORS, never
|
|
384
|
+
// silently passes for the wrong reason. (Use the rescue form to assert an arbitrary runtime error.)
|
|
385
|
+
if (!threw.nxFail && !threw.nxFence) throw threw;
|
|
386
|
+
if (m.kind === 'failwith') {
|
|
387
|
+
const want = this.eval(m.expected, env);
|
|
388
|
+
if (typeof want !== 'string') throw new MyxoError("'to fail with' needs a string", node.line);
|
|
389
|
+
if (!String(threw.message).includes(want))
|
|
390
|
+
throw new NxAssertError(`expected failure containing ${showv(want)}, got ${showv(threw.message)}`, node.line);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const actual = this.eval(node.actual, env);
|
|
395
|
+
if (m.kind === 'truthy') {
|
|
396
|
+
if (actual && (actual.__agent || actual.__native))
|
|
397
|
+
throw new NxAssertError(`expected a value, but got ${showv(actual)} — did you forget to call it?`, node.line);
|
|
398
|
+
if (!truthy(actual)) throw new NxAssertError(`expected a live value, got ${showv(actual)}`, node.line);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const expected = this.eval(m.expected, env);
|
|
402
|
+
const eq = deepEqual(actual, expected);
|
|
403
|
+
if (m.kind === 'is' && !eq) throw new NxAssertError(`expected ${showv(expected)}, got ${showv(actual)}`, node.line);
|
|
404
|
+
if (m.kind === 'isnot' && eq) throw new NxAssertError(`expected not ${showv(expected)}, but got it`, node.line);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
case 'ExpressionStatement':
|
|
408
|
+
this.eval(node.expr, env);
|
|
409
|
+
return;
|
|
410
|
+
default:
|
|
411
|
+
throw new MyxoError(`cannot execute ${node.type}`, node.line);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Try a pattern against a value. Returns a bindings object on match ({} = matched, no binds),
|
|
416
|
+
// or null on no match. Used by `match`.
|
|
417
|
+
matchPattern(pat, value, env) {
|
|
418
|
+
switch (pat.type) {
|
|
419
|
+
case 'PWild': return {};
|
|
420
|
+
case 'PBind': return { [pat.name]: value };
|
|
421
|
+
case 'PLit': return deepEqual(this.eval(pat.expr, env), value) ? {} : null;
|
|
422
|
+
case 'PList': {
|
|
423
|
+
if (!Array.isArray(value)) return null;
|
|
424
|
+
const n = pat.elements.length;
|
|
425
|
+
if (pat.rest == null ? value.length !== n : value.length < n) return null;
|
|
426
|
+
const binds = {};
|
|
427
|
+
for (let i = 0; i < n; i++) {
|
|
428
|
+
const b = this.matchPattern(pat.elements[i], value[i], env);
|
|
429
|
+
if (!b) return null;
|
|
430
|
+
Object.assign(binds, b);
|
|
431
|
+
}
|
|
432
|
+
if (pat.rest != null) binds[pat.rest] = value.slice(n);
|
|
433
|
+
return binds;
|
|
434
|
+
}
|
|
435
|
+
case 'PMesh': {
|
|
436
|
+
if (!(value instanceof Map)) return null;
|
|
437
|
+
const binds = {};
|
|
438
|
+
for (const pr of pat.pairs) {
|
|
439
|
+
if (!value.has(pr.key)) return null;
|
|
440
|
+
const b = this.matchPattern(pr.pattern, value.get(pr.key), env);
|
|
441
|
+
if (!b) return null;
|
|
442
|
+
Object.assign(binds, b);
|
|
443
|
+
}
|
|
444
|
+
return binds;
|
|
445
|
+
}
|
|
446
|
+
default: throw new MyxoError(`unknown pattern type ${pat.type}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
execAssign(node, env) {
|
|
451
|
+
const value = this.eval(node.value, env);
|
|
452
|
+
const t = node.target;
|
|
453
|
+
if (t.type === 'Identifier') {
|
|
454
|
+
const f = env.lookup(t.name);
|
|
455
|
+
const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
|
|
456
|
+
if (fr && fr.pure && f && !this.isLocalEnv(f.env, fr.root)) this.markImpure(); // outer write taints the agent
|
|
457
|
+
// global write, OR rebinding an agent value (a memoized caller may close over it via the recursion
|
|
458
|
+
// exemption, and that read is taint-free) -> invalidate dependent caches. Agent rebinds are rare.
|
|
459
|
+
if (f && (f.env === this.globals
|
|
460
|
+
|| (f.entry.value && (f.entry.value.__agent || f.entry.value.__native))
|
|
461
|
+
|| (value && (value.__agent || value.__native)))) this.epoch++;
|
|
462
|
+
if (this.strict && f && f.entry.type && !typeMatches(f.entry.type, value)) // a typed binding stays typed on reassignment
|
|
463
|
+
throw new MyxoError(`'${t.name}' expects ${f.entry.type}, got ${typeName(value)}`, node.line);
|
|
464
|
+
env.set(t.name, value, t.line); return;
|
|
465
|
+
}
|
|
466
|
+
// Index target: list[i] = v or mesh[k] = v
|
|
467
|
+
const obj = this.eval(t.object, env);
|
|
468
|
+
const idx = this.eval(t.index, env);
|
|
469
|
+
if (Array.isArray(obj)) {
|
|
470
|
+
const i = this.asIndex(idx, obj.length, node.line, true);
|
|
471
|
+
obj[i] = value;
|
|
472
|
+
} else if (obj instanceof Map) {
|
|
473
|
+
obj.set(this.asKey(idx, node.line), value);
|
|
474
|
+
} else {
|
|
475
|
+
throw new MyxoError(`cannot index a ${typeName(obj)}`, node.line);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
execDecay(node, env) {
|
|
480
|
+
const t = node.target;
|
|
481
|
+
if (t.type === 'Identifier') {
|
|
482
|
+
const f = env.lookup(t.name);
|
|
483
|
+
const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
|
|
484
|
+
if (fr && fr.pure && f && !this.isLocalEnv(f.env, fr.root)) this.markImpure(); // outer decay taints the agent
|
|
485
|
+
if (f && (f.env === this.globals
|
|
486
|
+
|| (f.entry.value && (f.entry.value.__agent || f.entry.value.__native)))) this.epoch++; // global/agent decay -> invalidate
|
|
487
|
+
env.remove(t.name, t.line); return;
|
|
488
|
+
}
|
|
489
|
+
const obj = this.eval(t.object, env);
|
|
490
|
+
const idx = this.eval(t.index, env);
|
|
491
|
+
if (Array.isArray(obj)) {
|
|
492
|
+
const i = this.asIndex(idx, obj.length, node.line, false);
|
|
493
|
+
obj.splice(i, 1);
|
|
494
|
+
} else if (obj instanceof Map) {
|
|
495
|
+
obj.delete(this.asKey(idx, node.line));
|
|
496
|
+
} else {
|
|
497
|
+
throw new MyxoError(`cannot decay a slot of ${typeName(obj)}`, node.line);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
execForEach(node, env) {
|
|
502
|
+
const it = this.eval(node.iterable, env);
|
|
503
|
+
let items;
|
|
504
|
+
if (Array.isArray(it)) items = it.slice();
|
|
505
|
+
else if (it instanceof Map) items = [...it.keys()];
|
|
506
|
+
else if (typeof it === 'string') items = [...it];
|
|
507
|
+
else throw new MyxoError(`cannot walk a ${typeName(it)} with 'for each'`, node.line);
|
|
508
|
+
for (const item of items) {
|
|
509
|
+
const child = new Environment(env);
|
|
510
|
+
child.define(node.varName, item);
|
|
511
|
+
this.execBlock(node.body, child);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---- expressions --------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
eval(node, env) {
|
|
518
|
+
this.step(node.line);
|
|
519
|
+
switch (node.type) {
|
|
520
|
+
case 'Number': return node.value;
|
|
521
|
+
case 'String': return node.value;
|
|
522
|
+
case 'Interp': return node.parts.map(p => stringify(this.eval(p, env))).join('');
|
|
523
|
+
case 'Bool': return node.value;
|
|
524
|
+
case 'Void': return VOID;
|
|
525
|
+
case 'Identifier': {
|
|
526
|
+
const f = env.lookup(node.name);
|
|
527
|
+
if (!f) throw new MyxoError(`unknown pathway '${node.name}'`, node.line);
|
|
528
|
+
f.entry.strength++; // the law: reads reinforce
|
|
529
|
+
const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
|
|
530
|
+
if (fr && fr.pure && !f.entry.system && !this.isLocalEnv(f.env, fr.root)) {
|
|
531
|
+
const val = f.entry.value; // reading mutable DATA from outside taints purity;
|
|
532
|
+
if (!(val && (val.__agent || val.__native))) this.markImpure(); // reading code (agents/natives) is fine
|
|
533
|
+
}
|
|
534
|
+
return f.entry.value;
|
|
535
|
+
}
|
|
536
|
+
case 'List': return node.elements.map(e => this.eval(e, env));
|
|
537
|
+
case 'Mesh': {
|
|
538
|
+
const m = new Map();
|
|
539
|
+
for (const [k, v] of node.pairs) m.set(this.asKey(this.eval(k, env), node.line), this.eval(v, env));
|
|
540
|
+
return m;
|
|
541
|
+
}
|
|
542
|
+
case 'AgentExpr':
|
|
543
|
+
return { __agent: true, name: 'anon', params: node.params, body: node.body, closure: env, returnType: node.returnType };
|
|
544
|
+
case 'Unary': return this.evalUnary(node, env);
|
|
545
|
+
case 'Binary': return this.evalBinary(node, env);
|
|
546
|
+
case 'Index': return this.evalIndex(node, env);
|
|
547
|
+
case 'Call': return this.evalCall(node, env);
|
|
548
|
+
case 'Pipe': return this.evalPipe(node, env);
|
|
549
|
+
case 'Dispatch': return this.evalDispatch(node, env);
|
|
550
|
+
case 'Gather': return this.evalGather(node, env);
|
|
551
|
+
case 'Spawn': return this.evalSpawn(node, env);
|
|
552
|
+
default:
|
|
553
|
+
throw new MyxoError(`cannot evaluate ${node.type}`, node.line);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
evalUnary(node, env) {
|
|
558
|
+
const v = this.eval(node.operand, env);
|
|
559
|
+
if (node.op === 'not') return !truthy(v);
|
|
560
|
+
if (node.op === '-') {
|
|
561
|
+
if (typeof v !== 'number') throw new MyxoError(`cannot negate a ${typeName(v)}`, node.line);
|
|
562
|
+
return -v;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
evalBinary(node, env) {
|
|
567
|
+
// Short-circuit logic.
|
|
568
|
+
// Value-returning short-circuit: `a and b` -> b if a is live, else a;
|
|
569
|
+
// `a or b` -> a if a is live, else b. Unlocks `x or default`.
|
|
570
|
+
if (node.op === 'and') { const l = this.eval(node.left, env); return truthy(l) ? this.eval(node.right, env) : l; }
|
|
571
|
+
if (node.op === 'or') { const l = this.eval(node.left, env); return truthy(l) ? l : this.eval(node.right, env); }
|
|
572
|
+
|
|
573
|
+
const a = this.eval(node.left, env);
|
|
574
|
+
const b = this.eval(node.right, env);
|
|
575
|
+
switch (node.op) {
|
|
576
|
+
case '==': return this.equals(a, b);
|
|
577
|
+
case '!=': return !this.equals(a, b);
|
|
578
|
+
case '+':
|
|
579
|
+
if (typeof a === 'string' || typeof b === 'string') return stringify(a) + stringify(b);
|
|
580
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.concat(b);
|
|
581
|
+
this.bothNumbers(a, b, node);
|
|
582
|
+
return a + b;
|
|
583
|
+
case '-': this.bothNumbers(a, b, node); return a - b;
|
|
584
|
+
case '*': this.bothNumbers(a, b, node); return a * b;
|
|
585
|
+
case '/': this.bothNumbers(a, b, node); if (b === 0) throw new MyxoError('division by zero', node.line); return a / b;
|
|
586
|
+
case '%': this.bothNumbers(a, b, node); if (b === 0) throw new MyxoError('modulo by zero', node.line); return a % b;
|
|
587
|
+
case '>': return this.compare(a, b, node) > 0;
|
|
588
|
+
case '<': return this.compare(a, b, node) < 0;
|
|
589
|
+
case '>=': return this.compare(a, b, node) >= 0;
|
|
590
|
+
case '<=': return this.compare(a, b, node) <= 0;
|
|
591
|
+
default: throw new MyxoError(`unknown operator ${node.op}`, node.line);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
evalIndex(node, env) {
|
|
596
|
+
const obj = this.eval(node.object, env);
|
|
597
|
+
const idx = this.eval(node.index, env);
|
|
598
|
+
if (Array.isArray(obj)) {
|
|
599
|
+
const i = this.asIndex(idx, obj.length, node.line, false);
|
|
600
|
+
return obj[i];
|
|
601
|
+
}
|
|
602
|
+
if (obj instanceof Map) {
|
|
603
|
+
const key = this.asKey(idx, node.line);
|
|
604
|
+
return obj.has(key) ? obj.get(key) : VOID; // missing key is void, not an error
|
|
605
|
+
}
|
|
606
|
+
if (typeof obj === 'string') {
|
|
607
|
+
const i = this.asIndex(idx, obj.length, node.line, false);
|
|
608
|
+
return obj[i];
|
|
609
|
+
}
|
|
610
|
+
throw new MyxoError(`cannot index a ${typeName(obj)}`, node.line);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
evalCall(node, env) {
|
|
614
|
+
const callee = this.eval(node.callee, env);
|
|
615
|
+
const args = node.args.map(a => this.eval(a, env));
|
|
616
|
+
return this.callValue(callee, args, node.line);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// `x | f` -> f(x); `x | f(a, b)` -> f(x, a, b). The piped value is the FIRST argument.
|
|
620
|
+
evalPipe(node, env) {
|
|
621
|
+
const leftVal = this.eval(node.left, env);
|
|
622
|
+
const r = node.right;
|
|
623
|
+
if (r.type === 'Call') {
|
|
624
|
+
const callee = this.eval(r.callee, env);
|
|
625
|
+
const args = [leftVal, ...r.args.map(a => this.eval(a, env))];
|
|
626
|
+
return this.callValue(callee, args, r.line);
|
|
627
|
+
}
|
|
628
|
+
return this.callValue(this.eval(r, env), [leftVal], node.line);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// `dispatch f(x)` builds an UNSTARTED task: it captures the agent's code + the evaluated args, but runs
|
|
632
|
+
// nothing yet. The args must be plain data — a task crosses a thread boundary, where a closure can't follow.
|
|
633
|
+
evalDispatch(node, env) {
|
|
634
|
+
const agent = this.eval(node.call.callee, env);
|
|
635
|
+
if (!(agent && agent.__agent)) throw new MyxoError('dispatch needs an agent, e.g. dispatch work(x)', node.line);
|
|
636
|
+
const args = node.call.args.map(a => this.eval(a, env));
|
|
637
|
+
const { assertSerializable } = require('./myxo-concurrent');
|
|
638
|
+
args.forEach((a) => { try { assertSerializable(a, 'a dispatch argument'); } catch (e) { throw new MyxoError(e.message, node.line); } });
|
|
639
|
+
return { __task: true, name: agent.name, params: agent.params, body: agent.body, args };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// `gather [t1, t2, ...]` runs every dispatched task on its own worker thread, in parallel, and blocks until
|
|
643
|
+
// all finish — then returns their results in order. Each task runs ISOLATED (stdlib + its args + itself): it
|
|
644
|
+
// cannot see or mutate the parent's pathways, which is exactly what makes parallel execution race-free.
|
|
645
|
+
evalGather(node, env) {
|
|
646
|
+
const list = this.eval(node.expr, env);
|
|
647
|
+
if (!Array.isArray(list)) throw new MyxoError('gather expects a list of dispatched tasks', node.line);
|
|
648
|
+
if (this.frames.length) this.markImpure(); // spawning threads is an effect -> never memoize a gather away
|
|
649
|
+
const { nxToJs, jsToNx } = require('./polyglot');
|
|
650
|
+
const tasks = list.map((h, i) => {
|
|
651
|
+
if (!(h && h.__task)) throw new MyxoError(`gather: item ${i} is not a dispatched task (use 'dispatch f(x)')`, node.line);
|
|
652
|
+
return { name: h.name, params: h.params, body: h.body, args: h.args.map(a => nxToJs(a)) };
|
|
653
|
+
});
|
|
654
|
+
const { runParallel } = require('./myxo-concurrent');
|
|
655
|
+
let raw;
|
|
656
|
+
try { raw = runParallel(tasks); }
|
|
657
|
+
catch (e) { throw new MyxoError('gather: ' + (e && e.message ? e.message : String(e)), node.line); }
|
|
658
|
+
return raw.map(jsToNx);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---- cooperative concurrency: fibers + channels ----------------------------------------------------------
|
|
662
|
+
// `spawn f(x)` starts a fiber: a lightweight task that runs COOPERATIVELY (interleaved on this one thread —
|
|
663
|
+
// not in parallel; that's what `gather` is for). Fibers communicate through channels, parking when a channel
|
|
664
|
+
// blocks so another fiber can run. No shared mutable state — the channel value passes hand to hand — so it's
|
|
665
|
+
// race-free by construction, just like the law says. `drain()`/`join()` run the scheduler to completion.
|
|
666
|
+
|
|
667
|
+
evalSpawn(node, env) {
|
|
668
|
+
const agent = this.eval(node.call.callee, env);
|
|
669
|
+
if (!(agent && agent.__agent)) throw new MyxoError('spawn needs an agent, e.g. spawn worker(ch)', node.line);
|
|
670
|
+
const args = node.call.args.map(a => this.eval(a, env));
|
|
671
|
+
if (this.frames.length) this.markImpure(); // spawning a fiber is an effect -> never memoize it away
|
|
672
|
+
return this.spawnFiber(agent, args, node.line);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
spawnFiber(agent, args, line) {
|
|
676
|
+
const params = agent.params;
|
|
677
|
+
const hasRest = params.length > 0 && params[params.length - 1].rest;
|
|
678
|
+
const required = params.filter(p => !p.rest && p.def == null).length;
|
|
679
|
+
if (args.length < required) throw new MyxoError(`agent ${agent.name} needs at least ${required} argument(s), got ${args.length}`, line);
|
|
680
|
+
if (!hasRest && args.length > params.length) throw new MyxoError(`agent ${agent.name} takes at most ${params.length} argument(s), got ${args.length}`, line);
|
|
681
|
+
const local = new Environment(agent.closure); // bind params exactly as callValue does
|
|
682
|
+
let ai = 0;
|
|
683
|
+
for (const p of params) {
|
|
684
|
+
if (p.rest) { local.define(p.name, args.slice(ai)); ai = args.length; break; }
|
|
685
|
+
if (ai < args.length) local.define(p.name, args[ai++], false, p.paramType);
|
|
686
|
+
else local.define(p.name, p.def != null ? this.eval(p.def, local) : VOID, false, p.paramType);
|
|
687
|
+
}
|
|
688
|
+
if (this.strict) {
|
|
689
|
+
for (const p of params) {
|
|
690
|
+
if (p.paramType && !p.rest) {
|
|
691
|
+
const pv = local.vars.get(p.name).value;
|
|
692
|
+
if (!typeMatches(p.paramType, pv)) throw new MyxoError(`agent ${agent.name || 'anon'} param '${p.name}' expects ${p.paramType}, got ${typeName(pv)}`, line);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const fiber = { __fiber: true, name: agent.name || 'fiber', done: false, result: VOID, error: null };
|
|
697
|
+
fiber.gen = this.stepBlock(agent.body, local);
|
|
698
|
+
this.fibers.push(fiber);
|
|
699
|
+
this.ready.push({ fiber });
|
|
700
|
+
return fiber;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The generator stepper: runs a fiber's STATEMENTS, yielding a park-request at each suspension point. Leaf
|
|
704
|
+
// statements (and ALL expressions) run on the normal synchronous path — only the block-bearing statements and
|
|
705
|
+
// the channel ops need to be generator-aware, so the rest of the interpreter is untouched.
|
|
706
|
+
*stepBlock(stmts, env) {
|
|
707
|
+
for (const s of stmts) yield* this.stepStmt(s, env);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
*stepStmt(node, env) {
|
|
711
|
+
this.step(node.line);
|
|
712
|
+
switch (node.type) {
|
|
713
|
+
case 'Give': {
|
|
714
|
+
const ch = this.eval(node.channel, env);
|
|
715
|
+
if (!(ch && ch.__channel)) throw new MyxoError(`give needs a channel, got ${typeName(ch)}`, node.line);
|
|
716
|
+
const value = this.eval(node.value, env);
|
|
717
|
+
if (ch.recvW.length) { this.ready.push({ fiber: ch.recvW.shift(), val: value }); } // hand straight to a waiting receiver
|
|
718
|
+
else if (ch.buf.length < ch.cap) { ch.buf.push(value); } // room to buffer
|
|
719
|
+
else { yield { t: 'send', ch, value }; } // bounded + full -> park (waker buffers it)
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
case 'Take': {
|
|
723
|
+
const ch = this.eval(node.channel, env);
|
|
724
|
+
if (!(ch && ch.__channel)) throw new MyxoError(`take needs a channel, got ${typeName(ch)}`, node.line);
|
|
725
|
+
let value;
|
|
726
|
+
if (ch.buf.length) {
|
|
727
|
+
value = ch.buf.shift();
|
|
728
|
+
if (ch.sendW.length) { const s = ch.sendW.shift(); ch.buf.push(s.value); this.ready.push({ fiber: s.fiber }); } // freed a slot -> wake a blocked sender
|
|
729
|
+
} else {
|
|
730
|
+
value = yield { t: 'recv', ch }; // empty -> park; resumed with the taken value
|
|
731
|
+
}
|
|
732
|
+
env.define(node.name, value);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
case 'Yield': yield { t: 'yield' }; return;
|
|
736
|
+
case 'When':
|
|
737
|
+
if (truthy(this.eval(node.cond, env))) yield* this.stepBlock(node.thenBlock, new Environment(env));
|
|
738
|
+
else if (node.elseBlock) yield* this.stepBlock(node.elseBlock, new Environment(env));
|
|
739
|
+
return;
|
|
740
|
+
case 'Reinforce':
|
|
741
|
+
while (truthy(this.eval(node.cond, env))) yield* this.stepBlock(node.body, new Environment(env));
|
|
742
|
+
return;
|
|
743
|
+
case 'ReinforceTimes': {
|
|
744
|
+
const n = this.eval(node.count, env);
|
|
745
|
+
if (typeof n !== 'number') throw new MyxoError(`reinforce ... times needs a number, got ${typeName(n)}`, node.line);
|
|
746
|
+
for (let k = 0; k < n; k++) yield* this.stepBlock(node.body, new Environment(env));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
case 'ForEach': {
|
|
750
|
+
const it = this.eval(node.iterable, env);
|
|
751
|
+
let items;
|
|
752
|
+
if (Array.isArray(it)) items = it.slice();
|
|
753
|
+
else if (it instanceof Map) items = [...it.keys()];
|
|
754
|
+
else if (typeof it === 'string') items = [...it];
|
|
755
|
+
else throw new MyxoError(`cannot walk a ${typeName(it)} with 'for each'`, node.line);
|
|
756
|
+
for (const item of items) {
|
|
757
|
+
const child = new Environment(env);
|
|
758
|
+
child.define(node.varName, item);
|
|
759
|
+
yield* this.stepBlock(node.body, child);
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
case 'Match': { // generator-aware so a channel op inside an arm still suspends (mirrors exec's Match)
|
|
764
|
+
const subject = this.eval(node.subject, env);
|
|
765
|
+
for (const arm of node.arms) {
|
|
766
|
+
const binds = this.matchPattern(arm.pattern, subject, env);
|
|
767
|
+
if (binds) {
|
|
768
|
+
const child = new Environment(env);
|
|
769
|
+
for (const k of Object.keys(binds)) child.define(k, binds[k]);
|
|
770
|
+
yield* this.stepBlock(arm.body, child);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
case 'Attempt': { // generator-aware so a channel op in the try/rescue suspends — NOT swallowed as a fake failure (mirrors exec's Attempt)
|
|
777
|
+
try { yield* this.stepBlock(node.tryBlock, new Environment(env)); }
|
|
778
|
+
catch (e) {
|
|
779
|
+
if (e instanceof ReportSignal) throw e;
|
|
780
|
+
if (!(e instanceof MyxoError)) throw e;
|
|
781
|
+
const m = new Map();
|
|
782
|
+
m.set('message', e.message);
|
|
783
|
+
m.set('line', typeof e.line === 'number' ? e.line : VOID);
|
|
784
|
+
m.set('value', e.nxValue !== undefined ? e.nxValue : VOID);
|
|
785
|
+
const child = new Environment(env);
|
|
786
|
+
child.define(node.errName, m);
|
|
787
|
+
yield* this.stepBlock(node.catchBlock, child);
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
default:
|
|
792
|
+
this.exec(node, env); // a non-suspending leaf runs synchronously; a channel op reached here (e.g. in a CALLED agent) hits exec's guard
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// pump(): run the ready queue until no fiber can make progress. Does NOT itself decide deadlock — the CALLER
|
|
798
|
+
// does (drain/program-end want "everyone finished"; await wants only its target). Re-entrancy is forbidden:
|
|
799
|
+
// calling drain/await from inside a fiber would re-enter here and miscount the in-flight fiber as deadlocked.
|
|
800
|
+
pump() {
|
|
801
|
+
if (this.inScheduler) {
|
|
802
|
+
throw new MyxoError("the fiber scheduler is already running — 'await'/'drain' can't be called from inside a fiber (coordinate with channels or 'yield' instead)");
|
|
803
|
+
}
|
|
804
|
+
this.inScheduler = true;
|
|
805
|
+
const cap = Number.isFinite(this.maxSteps) ? this.maxSteps : 10000000; // bound a runaway (e.g. yield-forever) so it errors, never hangs
|
|
806
|
+
let steps = 0;
|
|
807
|
+
try {
|
|
808
|
+
while (this.ready.length) {
|
|
809
|
+
if (++steps > cap) throw new MyxoError('fiber scheduler exceeded its step budget (runaway fibers?)');
|
|
810
|
+
const { fiber, val } = this.ready.shift();
|
|
811
|
+
if (fiber.done) continue;
|
|
812
|
+
let r;
|
|
813
|
+
this.steppingFiber = true; // a channel op reaching exec's guard now knows it's inside a fiber (honest message)
|
|
814
|
+
try { r = fiber.gen.next(val); }
|
|
815
|
+
catch (e) {
|
|
816
|
+
if (e instanceof ReportSignal) { fiber.done = true; fiber.result = e.value; continue; }
|
|
817
|
+
fiber.done = true; fiber.error = e; continue; // hold the error; the caller surfaces it
|
|
818
|
+
} finally { this.steppingFiber = false; }
|
|
819
|
+
if (r.done) { fiber.done = true; continue; } // body fell off the end -> result stays void
|
|
820
|
+
const sig = r.value;
|
|
821
|
+
if (sig.t === 'yield') this.ready.push({ fiber });
|
|
822
|
+
else if (sig.t === 'recv') sig.ch.recvW.push(fiber);
|
|
823
|
+
else if (sig.t === 'send') sig.ch.sendW.push({ fiber, value: sig.value });
|
|
824
|
+
}
|
|
825
|
+
} finally { this.inScheduler = false; }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// After a full drain (drain()/program end), EVERYONE must have finished — surface the first error, then any deadlock.
|
|
829
|
+
checkAllFibersDone() {
|
|
830
|
+
const failed = this.fibers.find(f => f.error);
|
|
831
|
+
if (failed) throw failed.error;
|
|
832
|
+
const stuck = this.fibers.filter(f => !f.done);
|
|
833
|
+
if (stuck.length) throw new MyxoError(`${stuck.length} fiber(s) deadlocked — blocked on a channel with no one to unblock them`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
callValue(callee, args, line) {
|
|
837
|
+
if (callee && callee.__native) {
|
|
838
|
+
if (!callee.capability) {
|
|
839
|
+
if (IMPURE_BUILTINS.has(callee.name) || callee.impure) this.markImpure(); // nondeterministic / state-reading / global-mutating builtin (incl. routers) -> taint the caller
|
|
840
|
+
return callee.fn(args, this); // a language builtin — free
|
|
841
|
+
}
|
|
842
|
+
this.markImpure(); // any host capability call taints purity (side effects)
|
|
843
|
+
// a host capability: enforce the manifest, then record the call in the audit ledger.
|
|
844
|
+
const refuse = (error, msg) => {
|
|
845
|
+
this.audit.push({ cap: callee.name, args: args.map(a => stringify(a)), ok: false, error });
|
|
846
|
+
const err = new MyxoError(msg, line);
|
|
847
|
+
err.nxFence = true; throw err; // a policy denial, not a transient failure — a router must NOT route around it
|
|
848
|
+
};
|
|
849
|
+
if (!this.manifest && this.requireManifest) {
|
|
850
|
+
refuse('missing needs manifest',
|
|
851
|
+
`script must declare a 'needs' manifest before calling capability '${callee.name}'`);
|
|
852
|
+
}
|
|
853
|
+
if (this.manifest && !this.manifest.has(callee.name)) {
|
|
854
|
+
// overreach is logged too — you want to know an agent TRIED.
|
|
855
|
+
refuse('not declared in needs',
|
|
856
|
+
`capability '${callee.name}' is not declared in this script's 'needs'`);
|
|
857
|
+
}
|
|
858
|
+
// a declared `(max N)` / `(total N)` cap is the script's OWN ceiling — enforced here, so even a host that
|
|
859
|
+
// grants unlimited power is bounded by what the script said it may do. CRUCIALLY, whether a cap is metered
|
|
860
|
+
// by CALL COUNT or by VALUE is fixed by the HOST (valueCaps), never by the untrusted script — otherwise a
|
|
861
|
+
// hostile script would declare a count cap as a value budget and pay 0 per call. Default = count.
|
|
862
|
+
const limit = this.manifest ? this.manifest.get(callee.name) : null;
|
|
863
|
+
const isValue = this.valueCaps.has(callee.name);
|
|
864
|
+
if (limit) {
|
|
865
|
+
if (isValue) {
|
|
866
|
+
// VALUE metering: the first arg IS the amount. Must be a finite, non-negative number.
|
|
867
|
+
const v = args[0];
|
|
868
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
869
|
+
refuse('invalid value-budget argument',
|
|
870
|
+
`capability '${callee.name}' has a value budget and needs a finite non-negative numeric first argument (got ${stringify(v)})`);
|
|
871
|
+
}
|
|
872
|
+
if (limit.max != null && v > limit.max) {
|
|
873
|
+
refuse(`over the per-call max of ${limit.max}`,
|
|
874
|
+
`capability '${callee.name}' exceeds its per-call max of ${limit.max} (got ${v})`);
|
|
875
|
+
}
|
|
876
|
+
if (limit.total != null) {
|
|
877
|
+
const prior = this.capSpent.get(callee.name) || 0;
|
|
878
|
+
if (prior + v > limit.total) {
|
|
879
|
+
refuse(`over the total budget of ${limit.total}`,
|
|
880
|
+
`capability '${callee.name}' would exceed its total value budget of ${limit.total} (${prior} already used)`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
// COUNT metering (default): every call costs exactly 1, whatever the arguments. Both `max` and `total`
|
|
885
|
+
// bound the cumulative call count; the tighter one binds. The script CANNOT cheapen a call to 0.
|
|
886
|
+
const cap = Math.min(limit.total != null ? limit.total : Infinity,
|
|
887
|
+
limit.max != null ? limit.max : Infinity);
|
|
888
|
+
if (Number.isFinite(cap)) {
|
|
889
|
+
const prior = this.capCalls.get(callee.name) || 0;
|
|
890
|
+
if (prior + 1 > cap) {
|
|
891
|
+
refuse(`over the call budget of ${cap}`,
|
|
892
|
+
`capability '${callee.name}' would exceed its budget of ${cap} call(s) (${prior} already used)`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const entry = { cap: callee.name, args: args.map(a => stringify(a)) };
|
|
898
|
+
try {
|
|
899
|
+
const r = callee.fn(args, this);
|
|
900
|
+
entry.ok = true; entry.result = stringify(r);
|
|
901
|
+
if (limit) {
|
|
902
|
+
if (isValue) this.capSpent.set(callee.name, (this.capSpent.get(callee.name) || 0) + args[0]);
|
|
903
|
+
else this.capCalls.set(callee.name, (this.capCalls.get(callee.name) || 0) + 1);
|
|
904
|
+
}
|
|
905
|
+
this.audit.push(entry);
|
|
906
|
+
return r;
|
|
907
|
+
} catch (e) {
|
|
908
|
+
// Compute the message DEFENSIVELY first — a native may `throw null` / `throw "str"` / `throw {}`,
|
|
909
|
+
// and reading `.message` off a non-object would itself throw a TypeError that escapes rescue
|
|
910
|
+
// (the gate's exact repro). Do this before touching the ledger so the audit entry always records.
|
|
911
|
+
const msg = (e && e.message != null) ? String(e.message) : String(e);
|
|
912
|
+
entry.ok = false; entry.error = msg;
|
|
913
|
+
this.audit.push(entry);
|
|
914
|
+
// A FAILING host capability must be rescuable in-script, exactly like a fence denial —
|
|
915
|
+
// `attempt { lookup(x) } rescue e { ... }` is the documented pattern (§15 catches MyxoError only).
|
|
916
|
+
// A raw JS error from the host (network refused, timeout, host bug) previously leaked through
|
|
917
|
+
// attempt/rescue and killed the whole run — the lichen-sentry dogfood caught it: a DOWN brain
|
|
918
|
+
// crashed the monitor instead of producing its ALERT verdict.
|
|
919
|
+
if (e instanceof MyxoError) throw e;
|
|
920
|
+
const wrapped = new MyxoError(`capability '${callee.name}' failed: ${msg}`, line);
|
|
921
|
+
// PRESERVE policy flags: command-fence's policyError throws a PLAIN Error with nxFence=true (a
|
|
922
|
+
// denial a flow-router must NOT route around) — and nxFail marks an intentional failure. Stripping
|
|
923
|
+
// them would let a router bypass a policy refusal and would break `expect ... to fail`. Carry them.
|
|
924
|
+
if (e && e.nxFence) wrapped.nxFence = true;
|
|
925
|
+
if (e && e.nxFail) wrapped.nxFail = true;
|
|
926
|
+
throw wrapped;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (callee && callee.__agent) {
|
|
930
|
+
const params = callee.params;
|
|
931
|
+
const hasRest = params.length > 0 && params[params.length - 1].rest;
|
|
932
|
+
const required = params.filter(p => !p.rest && p.def == null).length;
|
|
933
|
+
if (args.length < required) {
|
|
934
|
+
throw new MyxoError(`agent ${callee.name} needs at least ${required} argument(s), got ${args.length}`, line);
|
|
935
|
+
}
|
|
936
|
+
if (!hasRest && args.length > params.length) {
|
|
937
|
+
throw new MyxoError(`agent ${callee.name} takes at most ${params.length} argument(s), got ${args.length}`, line);
|
|
938
|
+
}
|
|
939
|
+
// --- THE LIVING MESH: a hot agent's pathway PROMOTES (memoizes) ONLY where the runtime can prove it safe. ---
|
|
940
|
+
// SOUND conditions, all required: (a) plain params only -> args fully determine the call (no default-expr to track
|
|
941
|
+
// or omit from the key); (b) the call was never tainted -> no capability / random / emit / weave / state-reading
|
|
942
|
+
// builtin, no outer write, no free DATA read (reading a free AGENT is allowed, for recursion); (c) args AND result
|
|
943
|
+
// are primitive; (d) the global EPOCH is unchanged -> any global write since caching invalidates. Any taint, ever,
|
|
944
|
+
// permanently disables the agent. (Memoization is an optimization; it is NOT claimed invisible to mesh strengths.)
|
|
945
|
+
const memoEligible = params.every(p => !p.rest && p.def == null);
|
|
946
|
+
const argsPrim = memoEligible && args.every(isPrimitive);
|
|
947
|
+
let rec = this.memo.get(callee);
|
|
948
|
+
if (!rec) { rec = { calls: 0, impure: false, hits: 0, cache: new Map(), epoch: this.epoch }; this.memo.set(callee, rec); }
|
|
949
|
+
rec.calls++;
|
|
950
|
+
if (rec.epoch !== this.epoch) { rec.cache.clear(); rec.epoch = this.epoch; } // a global changed -> drop possibly-stale entries
|
|
951
|
+
if (argsPrim && !rec.impure && rec.calls > this.promoteAt) {
|
|
952
|
+
const k = keyOf(args);
|
|
953
|
+
if (rec.cache.has(k)) { rec.hits++; return rec.cache.get(k); } // promoted pathway short-circuits
|
|
954
|
+
}
|
|
955
|
+
const local = new Environment(callee.closure);
|
|
956
|
+
let ai = 0;
|
|
957
|
+
for (const p of params) {
|
|
958
|
+
if (p.rest) { local.define(p.name, args.slice(ai)); ai = args.length; break; }
|
|
959
|
+
if (ai < args.length) local.define(p.name, args[ai++], false, p.paramType);
|
|
960
|
+
else local.define(p.name, p.def != null ? this.eval(p.def, local) : VOID, false, p.paramType); // default in call scope
|
|
961
|
+
}
|
|
962
|
+
if (this.strict) { // gradual-type contracts on the parameters (read-only; never taints purity)
|
|
963
|
+
for (const p of params) {
|
|
964
|
+
if (p.paramType && !p.rest) {
|
|
965
|
+
const pv = local.vars.get(p.name).value;
|
|
966
|
+
if (!typeMatches(p.paramType, pv))
|
|
967
|
+
throw new MyxoError(`agent ${callee.name || 'anon'} param '${p.name}' expects ${p.paramType}, got ${typeName(pv)}`, line);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (this.callStack.length >= this.maxDepth) {
|
|
972
|
+
throw new MyxoError(`call stack went too deep (over ${this.maxDepth}) — runaway recursion?`, line);
|
|
973
|
+
}
|
|
974
|
+
this.callStack.push({ name: callee.name || 'anon', line });
|
|
975
|
+
const frame = { root: local, pure: true };
|
|
976
|
+
this.frames.push(frame);
|
|
977
|
+
let result = VOID, completed = false;
|
|
978
|
+
try {
|
|
979
|
+
this.execBlock(callee.body, local);
|
|
980
|
+
completed = true;
|
|
981
|
+
} catch (e) {
|
|
982
|
+
if (e instanceof ReportSignal) { result = e.value; completed = true; }
|
|
983
|
+
else {
|
|
984
|
+
// stamp the trace at the deepest agent the failure passes through, while every outer frame is still live.
|
|
985
|
+
if (e instanceof MyxoError && !e.nxStack) e.nxStack = this.callStack.slice();
|
|
986
|
+
throw e;
|
|
987
|
+
}
|
|
988
|
+
} finally {
|
|
989
|
+
this.callStack.pop();
|
|
990
|
+
this.frames.pop();
|
|
991
|
+
if (!frame.pure) { rec.impure = true; rec.cache.clear(); } // taint sticks even if the call threw
|
|
992
|
+
}
|
|
993
|
+
// cache only a clean, hot, primitive-result call, at the current epoch
|
|
994
|
+
if (this.strict && callee.returnType && !typeMatches(callee.returnType, result))
|
|
995
|
+
throw new MyxoError(`agent ${callee.name || 'anon'} should return ${callee.returnType}, got ${typeName(result)}`, line);
|
|
996
|
+
// cache only AFTER the return contract passes -> a violating result is never cached and every call errors
|
|
997
|
+
if (completed && !rec.impure && argsPrim && isPrimitive(result) && rec.calls >= this.promoteAt && rec.cache.size < 50000) {
|
|
998
|
+
rec.cache.set(keyOf(args), result);
|
|
999
|
+
}
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
throw new MyxoError(`${typeName(callee)} is not an agent — cannot call it`, line);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ---- small typed helpers ------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
bothNumbers(a, b, node) {
|
|
1008
|
+
if (typeof a !== 'number' || typeof b !== 'number') {
|
|
1009
|
+
throw new MyxoError(`'${node.op}' needs two numbers, got ${typeName(a)} and ${typeName(b)}`, node.line);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
compare(a, b, node) {
|
|
1014
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
1015
|
+
if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0;
|
|
1016
|
+
throw new MyxoError(`cannot compare ${typeName(a)} and ${typeName(b)}`, node.line);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
equals(a, b) {
|
|
1020
|
+
if (a === VOID || b === VOID) return a === b;
|
|
1021
|
+
if (typeof a !== typeof b) return false;
|
|
1022
|
+
return a === b; // primitives by value; collections/agents by identity
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
asIndex(idx, length, line, allowAppend) {
|
|
1026
|
+
if (typeof idx !== 'number' || !Number.isInteger(idx)) {
|
|
1027
|
+
throw new MyxoError(`index must be a whole number, got ${typeName(idx)}`, line);
|
|
1028
|
+
}
|
|
1029
|
+
const i = idx < 0 ? length + idx : idx; // negative indexes from the end
|
|
1030
|
+
if (i < 0 || i > length || (!allowAppend && i >= length)) {
|
|
1031
|
+
throw new MyxoError(`index ${idx} is outside the list (length ${length})`, line);
|
|
1032
|
+
}
|
|
1033
|
+
return i;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
asKey(idx, line) {
|
|
1037
|
+
if (typeof idx === 'string') return idx;
|
|
1038
|
+
if (typeof idx === 'number') return String(idx);
|
|
1039
|
+
throw new MyxoError(`mesh keys must be strings or numbers, got ${typeName(idx)}`, line);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// weave a strand: load its AST via the host loader, run it in its OWN scope,
|
|
1043
|
+
// and return a mesh of the names it `expose`d. Loading is a granted capability
|
|
1044
|
+
// (no loader -> weaving is fenced). Modules are cached; cycles are caught.
|
|
1045
|
+
weaveModule(reqPath, line) {
|
|
1046
|
+
if (!this.moduleLoader) throw new MyxoError(`weaving '${reqPath}' is not granted in this context`, line);
|
|
1047
|
+
const fromDir = this.dirStack[this.dirStack.length - 1] || this.baseDir;
|
|
1048
|
+
let loaded;
|
|
1049
|
+
try { loaded = this.moduleLoader(reqPath, fromDir); }
|
|
1050
|
+
catch (e) { throw new MyxoError(`cannot weave '${reqPath}': ${e.message}`, line); }
|
|
1051
|
+
if (this.moduleCache.has(loaded.key)) return this.moduleCache.get(loaded.key);
|
|
1052
|
+
if (this.moduleInProgress.has(loaded.key)) throw new MyxoError(`circular weave of '${reqPath}'`, line);
|
|
1053
|
+
this.moduleInProgress.add(loaded.key);
|
|
1054
|
+
const modEnv = new Environment(this.globals);
|
|
1055
|
+
const exportSet = new Set();
|
|
1056
|
+
this.exportStack.push(exportSet);
|
|
1057
|
+
this.dirStack.push(loaded.dir);
|
|
1058
|
+
try {
|
|
1059
|
+
this.execBlock(loaded.ast.body, modEnv);
|
|
1060
|
+
} finally {
|
|
1061
|
+
this.exportStack.pop();
|
|
1062
|
+
this.dirStack.pop();
|
|
1063
|
+
this.moduleInProgress.delete(loaded.key);
|
|
1064
|
+
}
|
|
1065
|
+
const exports = new Map();
|
|
1066
|
+
for (const name of exportSet) {
|
|
1067
|
+
if (!modEnv.vars.has(name)) throw new MyxoError(`'${name}' was exposed but never seeded in '${reqPath}'`, line);
|
|
1068
|
+
exports.set(name, modEnv.vars.get(name).value);
|
|
1069
|
+
}
|
|
1070
|
+
this.moduleCache.set(loaded.key, exports);
|
|
1071
|
+
return exports;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// a side-effect / free-read taints the current agent call AND every caller above it
|
|
1075
|
+
// (a caller's result depends on the tainted callee), so it can never be memoized.
|
|
1076
|
+
markImpure() { for (const f of this.frames) f.pure = false; }
|
|
1077
|
+
|
|
1078
|
+
// is a resolved binding inside THIS call's own scope (local), or a free/outer var?
|
|
1079
|
+
isLocalEnv(foundEnv, root) {
|
|
1080
|
+
for (let a = root.parent; a; a = a.parent) if (a === foundEnv) return false; // resolved above the call root -> free
|
|
1081
|
+
return true; // root itself or a descendant -> local
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// A snapshot of the living mesh: user pathways and their strengths.
|
|
1085
|
+
meshSnapshot() {
|
|
1086
|
+
const rows = [];
|
|
1087
|
+
for (const [name, e] of this.globals.vars) if (!e.system) rows.push({ name, strength: e.strength });
|
|
1088
|
+
return rows.sort((x, y) => y.strength - x.strength);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
module.exports = { Interpreter, Environment, VOID, ReportSignal, stringify, truthy, typeName };
|