@pulse-compute/wasm-compiler 1.0.0-beta.4 → 1.0.0-beta.6

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.
@@ -1,56 +1,26 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('node:crypto');
4
+ const { terminalPackageNativeForCompiled } = require('./spine/terminal-package-native.js');
4
5
  const fs = require('node:fs');
5
6
  const path = require('node:path');
6
7
  const ts = require('typescript');
7
8
  const { executeCanonicalNativePlanSpine } = require('./spine/canonical-native-plan.js');
9
+ const { inspectBoundedPureLoop, inspectBoundedReadLoop } = require('./spine/bounded-pure-loop.js');
10
+ const resultFacts = require('./native-result-facts.js');
8
11
 
9
- function loadNativePlanContract() {
10
- try { return require('@pulse-compute/wasm-contracts/handler/canonical-native-plan'); }
11
- catch (error) {
12
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
13
- return require('../../contracts/src/handler/canonical-native-plan.js');
14
- }
15
- throw error;
16
- }
17
- }
18
-
19
- function loadLoggingContract() {
20
- try { return require('@pulse-compute/wasm-contracts/logging'); }
21
- catch (error) {
22
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
23
- return require('../../contracts/src/logging.js');
24
- }
25
- throw error;
26
- }
27
- }
28
-
29
- function loadCryptoContract() {
30
- try { return require('@pulse-compute/wasm-contracts/crypto/contracts'); }
31
- catch (error) {
32
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
33
- return require('../../contracts/src/crypto/contracts.js');
34
- }
35
- throw error;
36
- }
37
- }
38
-
39
- function loadEventContract() {
40
- try { return require('@pulse-compute/wasm-contracts/events'); }
41
- catch (error) {
42
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
43
- return require('../../contracts/src/events/contracts.js');
44
- }
45
- throw error;
46
- }
12
+ function containsYield(node) {
13
+ if (ts.isYieldExpression(node)) return true;
14
+ return Boolean(ts.forEachChild(node, child => containsYield(child) || undefined));
47
15
  }
48
16
 
49
- const contract = loadNativePlanContract();
50
- const loggingContract = loadLoggingContract();
51
- const cryptoContract = loadCryptoContract();
52
- const eventContract = loadEventContract();
53
- const CANONICAL_NATIVE_PLAN_COMPILER_VERSION = 'pulse.canonical-native-plan-compiler.v2';
17
+ const contract = require('@pulse-compute/wasm-contracts/handler/canonical-native-plan');
18
+ const loggingContract = require('@pulse-compute/wasm-contracts/logging');
19
+ const cryptoContract = require('@pulse-compute/wasm-contracts/crypto/contracts');
20
+ const eventContract = require('@pulse-compute/wasm-contracts/events');
21
+ const pureValues = require('./pure-helper-values');
22
+ const CANONICAL_NATIVE_PLAN_COMPILER_VERSION = 'pulse.canonical-native-plan-compiler.v10';
23
+ const { lowerSharedStages, validateSharedStages } = require('./shared-stage-plan');
54
24
  const GENERATED_HANDLER_NAME = '__pulse_handler';
55
25
  const PULSE_RUNTIME_PARAMETER = '__pulse';
56
26
 
@@ -200,9 +170,12 @@ function intrinsicForContextCall(parts) {
200
170
  const entries = {
201
171
  'req.header': ['request.header', 'string-or-undefined'],
202
172
  'req.text': ['request.text', 'string'],
173
+ 'req.body': ['request.body.forward-marker', 'incoming-body-marker'],
174
+ 'output.close': ['response.output.close', 'pulse-result'],
203
175
  'req.json': ['request.json', 'json'],
204
176
  json: ['response.json', 'pulse-result'],
205
177
  encodeJson: ['schema.encode.text', 'string'],
178
+ decodeJson: ['schema.decode.text', 'json'],
206
179
  text: ['response.text', 'pulse-result'],
207
180
  response: ['response.custom', 'pulse-result'],
208
181
  kv: ['kv.namespace', 'kv-namespace'],
@@ -219,8 +192,11 @@ function resultKindForEffect(site, decoder, continuation, resultMode) {
219
192
  if (site.result === 'opaque-response') return 'opaque-response';
220
193
  if (site.result === 'structured-response') return 'structured-response';
221
194
  if (site.result === 'ack') return 'ack';
195
+ if (site.result === 'string') return 'string';
222
196
  if (site.kind === 'fetch') return 'fetch-response';
223
197
  if (site.kind === 'config.get' || site.kind === 'secret.get') return 'string-or-undefined';
198
+ if (site.kind === 'output.readTextChunk') return 'json';
199
+ if (site.kind === 'time.now') return 'json';
224
200
  if (site.kind === 'kv.get') return 'json-or-undefined';
225
201
  if (site.kind === 'kv.put') return 'ack';
226
202
  if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(site.kind)) return 'json';
@@ -228,21 +204,6 @@ function resultKindForEffect(site, decoder, continuation, resultMode) {
228
204
  return 'unknown';
229
205
  }
230
206
 
231
- function inferBinaryValueKind(operator, left, right) {
232
- if (['===', '!==', '==', '!=', '<', '<=', '>', '>=', 'in'].includes(operator)) return 'boolean';
233
- if (['&&', '||', '??'].includes(operator)) {
234
- if (left.valueKind === right.valueKind) return left.valueKind;
235
- if (['||', '??'].includes(operator)) {
236
- const kinds = new Set([left.valueKind, right.valueKind]);
237
- if (kinds.has('string') && kinds.has('string-or-undefined')) return 'string';
238
- }
239
- return 'unknown';
240
- }
241
- if (operator === '+' && (left.valueKind === 'string' || right.valueKind === 'string')) return 'string';
242
- if (['+', '-', '*', '/', '%', '**', '&', '|', '^', '<<', '>>', '>>>'].includes(operator)) return 'number';
243
- return 'unknown';
244
- }
245
-
246
207
  class NativePlanBuilder {
247
208
  constructor(compiled, options = {}) {
248
209
  this.compiled = compiled;
@@ -250,6 +211,9 @@ class NativePlanBuilder {
250
211
  this.diagnostics = [];
251
212
  this.effects = [];
252
213
  this.locals = [];
214
+ this.handlers = [];
215
+ this.helpers = [];
216
+ this.handlerLocalIndex = 0;
253
217
  this.effectOccurrences = new Map();
254
218
  this.continuationOccurrences = new Map();
255
219
  this.localIndex = 0;
@@ -326,6 +290,20 @@ class NativePlanBuilder {
326
290
  }
327
291
 
328
292
  this.metadata = compiled.metadata;
293
+ this.pureTypes = new Map();
294
+ this.pureTypes.schemas = compiled.metadata.schemaRegistry;
295
+ this.helperBodies = new Map((this.metadata.router?.helpers || []).map(helper => [helper.name, helper]));
296
+ if (this.metadata.capabilities?.some(id => ['request.body.forward', 'request.body.transform'].includes(id))
297
+ && this.metadata.capabilities.some(id => ['request.text', 'request.json'].includes(id))) {
298
+ throw new CanonicalNativePlanError('Native incoming forwarding/transforms cannot be combined with structured request reads.', [
299
+ diagnostic(undefined, undefined, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED,
300
+ 'Native forwarding/transform applications cannot also project structured request bodies.', { automaticFallback: false })
301
+ ]);
302
+ }
303
+ this.packageApplication = terminalPackageNativeForCompiled(compiled);
304
+ this.routerBodies = new Map((this.metadata.router?.entries || [])
305
+ .filter(entry => entry.nativeBody).map(entry => [entry.nativeBody.name, entry]));
306
+ this.bodyNodes = new Map();
329
307
  this.generatedFile = `${this.metadata.file || 'app.ts'}.canonical.generated.js`;
330
308
  this.sourceFile = ts.createSourceFile(
331
309
  this.generatedFile,
@@ -345,6 +323,14 @@ class NativePlanBuilder {
345
323
  this.ctxName = this.handler && this.handler.parameters[0] && ts.isIdentifier(this.handler.parameters[0].name)
346
324
  ? this.handler.parameters[0].name.text
347
325
  : String(this.metadata.ctxParameter || 'ctx');
326
+ const collectBodies = node => {
327
+ if (ts.isFunctionDeclaration(node) && (this.routerBodies.has(node.name?.text) || this.helperBodies.has(node.name?.text))) {
328
+ if (this.bodyNodes.has(node.name.text)) this.fail(node, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'Duplicate private Router body.');
329
+ this.bodyNodes.set(node.name.text, node);
330
+ }
331
+ ts.forEachChild(node, collectBodies);
332
+ };
333
+ if (this.handler) collectBodies(this.handler);
348
334
  this.effectSites = new Map((this.metadata.effectSites || []).map((site) => [String(site.id), site]));
349
335
  this.continuationSites = new Map((this.metadata.continuationSites || []).map((site) => [String(site.id), site]));
350
336
  this.compilerOwnedCalls = new Set((this.metadata.compilerOwnedCalls || []).map(String));
@@ -443,8 +429,13 @@ class NativePlanBuilder {
443
429
  if (ts.isBinaryExpression(current)) {
444
430
  const operator = current.operatorToken.getText(this.sourceFile);
445
431
  if (ASSIGNMENT_OPERATORS.has(operator)) {
446
- const target = this.expression(current.left, scope);
432
+ const target = this.assignmentTarget(current.left, scope);
447
433
  const value = this.expression(current.right, scope);
434
+ if (operator === '=' && target.kind === 'local') {
435
+ const type = pureValues.readType(value, this.pureTypes, this.activeHelper?.pure === true);
436
+ if (type) this.pureTypes.set(target.id, type);
437
+ else this.pureTypes.delete(target.id);
438
+ }
448
439
  return Object.freeze({ kind: 'assignment', operator, target, value, valueKind: value.valueKind || 'unknown' });
449
440
  }
450
441
  if (!PURE_BINARY_OPERATORS.has(operator)) {
@@ -452,25 +443,25 @@ class NativePlanBuilder {
452
443
  }
453
444
  const left = this.expression(current.left, scope);
454
445
  const right = this.expression(current.right, scope);
455
- return Object.freeze({ kind: 'binary', operator, left, right, valueKind: inferBinaryValueKind(operator, left, right) });
446
+ return Object.freeze({ kind: 'binary', operator, left, right, valueKind: pureValues.binaryKind(operator, left.valueKind, right.valueKind) });
456
447
  }
457
448
 
458
449
  if (ts.isPrefixUnaryExpression(current)) {
459
450
  const operator = ts.tokenToString(current.operator) || current.getText(this.sourceFile).slice(0, 1);
460
451
  if (UPDATE_OPERATORS.has(operator)) {
461
- return Object.freeze({ kind: 'update', operator, prefix: true, target: this.expression(current.operand, scope), valueKind: 'number' });
452
+ return Object.freeze({ kind: 'update', operator, prefix: true, target: this.assignmentTarget(current.operand, scope), valueKind: 'number' });
462
453
  }
463
454
  if (!PREFIX_OPERATORS.has(operator)) {
464
455
  this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Prefix operator ${operator} is outside the canonical native value model.`, { operator });
465
456
  }
466
457
  const value = this.expression(current.operand, scope);
467
- return Object.freeze({ kind: 'unary', operator, value, valueKind: operator === '!' ? 'boolean' : (operator === 'typeof' ? 'string' : value.valueKind || 'unknown') });
458
+ return Object.freeze({ kind: 'unary', operator, value, valueKind: pureValues.unaryKind(operator) });
468
459
  }
469
460
 
470
461
  if (ts.isPostfixUnaryExpression(current)) {
471
462
  const operator = ts.tokenToString(current.operator) || current.getText(this.sourceFile).slice(-2);
472
463
  if (!UPDATE_OPERATORS.has(operator)) this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Postfix operator ${operator} is outside the canonical native value model.`, { operator });
473
- return Object.freeze({ kind: 'update', operator, prefix: false, target: this.expression(current.operand, scope), valueKind: 'number' });
464
+ return Object.freeze({ kind: 'update', operator, prefix: false, target: this.assignmentTarget(current.operand, scope), valueKind: 'number' });
474
465
  }
475
466
 
476
467
  if (ts.isConditionalExpression(current)) {
@@ -509,17 +500,19 @@ class NativePlanBuilder {
509
500
  else if (current.name.text === 'headers') valueKind = 'headers';
510
501
  else this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Fetch response property .${current.name.text} is outside the canonical native response contract.`, { property: current.name.text });
511
502
  }
512
- return Object.freeze({ kind: 'property', object, property: current.name.text, valueKind });
503
+ const property = { kind: 'property', object, property: current.name.text, valueKind };
504
+ const type = pureValues.readType(property, this.pureTypes);
505
+ if (type) property.valueKind = pureValues.kind(type);
506
+ property.valueKind = resultFacts.propertyKind(object, current.name.text, scope) || property.valueKind;
507
+ return Object.freeze(property);
513
508
  }
514
509
 
515
510
  if (ts.isElementAccessExpression(current)) {
516
511
  if (current.questionDotToken) this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'Optional element access is outside the canonical native value model.');
517
- return Object.freeze({
518
- kind: 'element',
519
- object: this.expression(current.expression, scope),
520
- index: this.expression(current.argumentExpression, scope),
521
- valueKind: 'unknown'
522
- });
512
+ const element = { kind: 'element', object: this.expression(current.expression, scope), index: this.expression(current.argumentExpression, scope), valueKind: 'unknown' };
513
+ const type = pureValues.readType(element, this.pureTypes, this.activeHelper?.pure === true);
514
+ if (type) element.valueKind = pureValues.kind(type);
515
+ return Object.freeze(element);
523
516
  }
524
517
 
525
518
  if (ts.isTypeOfExpression(current)) {
@@ -546,6 +539,21 @@ class NativePlanBuilder {
546
539
  callExpression(call, scope) {
547
540
  if (call.questionDotToken) this.fail(call, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'Optional calls are outside the canonical native value model.');
548
541
  const target = unwrap(call.expression);
542
+ const helper = ts.isIdentifier(target) && this.helperBodies.get(target.text);
543
+ if (helper?.pure) {
544
+ if (this.activeHelper || this.pureCallDepth) {
545
+ this.fail(call, 'PULSE_NATIVE_PURE_HELPER_NESTING_UNSUPPORTED', 'Pure helper calls cannot nest in helpers or call arguments.');
546
+ }
547
+ this.pureCallDepth = (this.pureCallDepth || 0) + 1;
548
+ const args = call.arguments.map(arg => this.expression(arg, scope));
549
+ this.pureCallDepth--;
550
+ if (args.length !== helper.parameters.length || args.some((arg, i) => arg.valueKind !== helper.parameters[i]?.valueKind && !(helper.parameters[i]?.borrow && arg.valueKind === 'json'))) {
551
+ this.fail(call, 'PULSE_NATIVE_PURE_HELPER_ARGUMENT_UNSUPPORTED', 'Pure helper arguments must match their declared scalar or borrowed structural kinds.', { arguments: args.map((arg,i) => ({ index:i, actual:arg.valueKind, expected:helper.parameters[i]?.valueKind })) });
552
+ }
553
+ return Object.freeze({ kind: 'pure-helper-call', helperId: helper.id,
554
+ ...((this.pureLoopDepth || this.readLoopDepth) ? { loopContract: contract.CANONICAL_NATIVE_PURE_LOOP_CALL_VERSION } : {}),
555
+ arguments: Object.freeze(args), valueKind: helper.resultKind });
556
+ }
549
557
  const ctxPath = contextPath(target, this.ctxName);
550
558
  if (ctxPath) {
551
559
  const intrinsic = intrinsicForContextCall(ctxPath);
@@ -576,6 +584,9 @@ class NativePlanBuilder {
576
584
 
577
585
  if (ts.isIdentifier(target) && this.compilerOwnedCalls.has(target.text) && this.compilerOwnedIntrinsics.has(target.text)) {
578
586
  const intrinsic = this.compilerOwnedIntrinsics.get(target.text);
587
+ if (this.packageApplication?.intrinsic === intrinsic[0]) return Object.freeze({
588
+ kind: 'intrinsic', name: 'package.application', arguments: Object.freeze([]), valueKind: 'response'
589
+ });
579
590
  return Object.freeze({
580
591
  kind: 'intrinsic',
581
592
  name: intrinsic[0],
@@ -587,6 +598,9 @@ class NativePlanBuilder {
587
598
  if (ts.isPropertyAccessExpression(target)) {
588
599
  const receiver = this.expression(target.expression, scope);
589
600
  const method = target.name.text;
601
+ if (method === 'trim' && !target.questionDotToken && !call.questionDotToken && call.arguments.length === 0) {
602
+ return Object.freeze({ kind: 'method-call', receiver, method: 'string.trim', arguments: Object.freeze([]), valueKind: 'string' });
603
+ }
590
604
  if (!FETCH_RESPONSE_METHODS.has(method) || receiver.valueKind !== 'fetch-response') {
591
605
  this.fail(call, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Method call .${method}() is outside the canonical native fetch-response contract.`, { method, receiverKind: receiver.valueKind });
592
606
  }
@@ -607,16 +621,28 @@ class NativePlanBuilder {
607
621
  return Object.freeze({ kind: 'intrinsic', name: 'unsupported:call', arguments: Object.freeze([]), valueKind: 'unknown' });
608
622
  }
609
623
 
624
+ assignmentTarget(node, scope) {
625
+ const target = this.expression(node, scope);
626
+ if (target.kind === 'property' || target.kind === 'element') resultFacts.invalidate(scope);
627
+ if (target.kind === 'local' && scope.get(target.name)?.declaration === 'const') {
628
+ this.fail(node, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'A const local cannot be reassigned.', { name: target.name });
629
+ }
630
+ return target;
631
+ }
632
+
610
633
  allocateLocal(name, valueKind, statementPath, declaration) {
611
634
  this.localIndex += 1;
612
635
  const local = Object.freeze({
613
- id: `local-${this.localIndex}`,
636
+ id: this.activeHandler ? `local:${this.activeHandler.stableId}:${++this.handlerLocalIndex}` : `local-${this.localIndex}`,
637
+ scopeId: this.activeHelper?.id || (this.activeHandler ? this.activeHandler.stableId : 'entry'),
638
+ ...(this.activeHandler ? { routerEntryStableId: this.activeHandler.stableId } : {}),
614
639
  name: String(name),
615
640
  valueKind: contract.CANONICAL_NATIVE_VALUE_KINDS.includes(valueKind) ? valueKind : 'unknown',
616
641
  declaration,
617
642
  statementPath
618
643
  });
619
644
  this.locals.push(local);
645
+ if (pureValues.scalar(valueKind)) this.pureTypes.set(local.id, valueKind);
620
646
  this.summary.localCount += 1;
621
647
  return local;
622
648
  }
@@ -689,6 +715,7 @@ class NativePlanBuilder {
689
715
  const record = Object.freeze({
690
716
  order: this.effectOrder,
691
717
  id: effectId || `invalid-effect-${this.effectOrder}`,
718
+ ...(this.activeHelper ? { helperId: this.activeHelper.id } : {}),
692
719
  kind: kind || 'invalid',
693
720
  providerKind: site && site.providerKind ? String(site.providerKind) : undefined,
694
721
  operation: site && site.operation ? String(site.operation) : undefined,
@@ -700,6 +727,7 @@ class NativePlanBuilder {
700
727
  contractId: site && site.contractId ? String(site.contractId) : undefined,
701
728
  declaredResult: site && site.result ? String(site.result) : undefined,
702
729
  decoder: site && site.decoder ? String(site.decoder) : null,
730
+ ...(site?.borrowedValue && result?.mode === 'bind' ? { borrowedValue: cloneJson(site.borrowedValue) } : {}),
703
731
  continuationId,
704
732
  statementPath,
705
733
  source: Object.freeze({ file: this.metadata.file, ...(cloneJson(site && site.position) || {}) }),
@@ -743,6 +771,23 @@ class NativePlanBuilder {
743
771
  for (let index = 0; index < statement.declarationList.declarations.length; index += 1) {
744
772
  const item = statement.declarationList.declarations[index];
745
773
  const itemPath = formatStatementPath(statement.declarationList.declarations.length === 1 ? pathParts : [...pathParts, 'declaration', index]);
774
+ const helperYield = item.initializer && unwrap(item.initializer);
775
+ const helperCall = helperYield && ts.isYieldExpression(helperYield) && helperYield.asteriskToken && unwrap(helperYield.expression);
776
+ const helper = helperCall && ts.isCallExpression(helperCall) && ts.isIdentifier(helperCall.expression) && this.helperBodies.get(helperCall.expression.text);
777
+ if (helper) {
778
+ if (this.activeHelper || !ts.isIdentifier(item.name) || this.pureLoopDepth) {
779
+ this.fail(item, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'Helper calls require a direct binding outside pure loops and cannot nest.'); continue;
780
+ }
781
+ const args = helperCall.arguments.map(arg => this.expression(arg, scope));
782
+ if (args.length !== helper.parameters.length || args.some((arg, i) => arg.valueKind !== helper.parameters[i]?.valueKind)) {
783
+ this.fail(item, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'Helper inputs must match their explicit scalar types.'); continue;
784
+ }
785
+ const local = this.allocateLocal(item.name.text, this.helpers.find(h => h.id === helper.id)?.resultKind || 'unknown', itemPath, declaration);
786
+ scope.set(local.name, local);
787
+ out.push(Object.freeze({ kind: 'helper-call', helperId: helper.id, callerEntryId: this.activeHandler?.stableId || this.activeEntry?.stableId, arguments: Object.freeze(args), localId: local.id, statementPath: itemPath,
788
+ ...(this.readLoopDepth ? { loopContract: contract.CANONICAL_READ_LOOP_CONTRACT.helperCallVersion } : {}) }));
789
+ continue;
790
+ }
746
791
  const yielded = item.initializer ? this.pulseYield(item.initializer) : undefined;
747
792
 
748
793
  if (yielded && yielded.mode === 'group') {
@@ -807,7 +852,9 @@ class NativePlanBuilder {
807
852
  const local = this.allocateLocal(item.name.text, resultKindForEffect(site || {}, decoder), itemPath, declaration);
808
853
  const result = Object.freeze({ mode: 'bind', localId: local.id, localName: local.name, valueKind: local.valueKind, decoder });
809
854
  const effect = this.prepareEffect(marker, continuationId, scope, itemPath, result);
810
- scope.set(local.name, local);
855
+ const resultType = pureValues.effectType(effect, this.pureTypes.schemas);
856
+ if (resultType) this.pureTypes.set(local.id, resultType);
857
+ scope.set(local.name, resultFacts.bind(local, effect));
811
858
  out.push(Object.freeze({ kind: 'effect', effectId: effect.id, continuationId, result, statementPath: itemPath }));
812
859
  continue;
813
860
  }
@@ -818,6 +865,8 @@ class NativePlanBuilder {
818
865
  }
819
866
  const value = item.initializer ? this.expression(item.initializer, scope) : Object.freeze({ kind: 'undefined', valueKind: 'undefined' });
820
867
  const local = this.allocateLocal(item.name.text, value.valueKind || 'unknown', itemPath, declaration);
868
+ const type = pureValues.readType(value, this.pureTypes, this.activeHelper?.pure === true);
869
+ if (type) this.pureTypes.set(local.id, type);
821
870
  scope.set(local.name, local);
822
871
  out.push(Object.freeze({ kind: 'local', localId: local.id, name: local.name, declaration, valueKind: local.valueKind, value, statementPath: itemPath }));
823
872
  }
@@ -893,6 +942,36 @@ class NativePlanBuilder {
893
942
  }
894
943
 
895
944
  statement(statement, scope, pathParts, depth) {
945
+ if (ts.isFunctionDeclaration(statement) && this.helperBodies.has(statement.name?.text)) {
946
+ const helper = this.helperBodies.get(statement.name.text);
947
+ if (this.activeHelper || this.activeHandler) { this.fail(statement, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'Helper declarations require the entry owner.'); return []; }
948
+ this.activeHelper = helper;
949
+ const start = this.locals.length, helperScope = new Map();
950
+ const parameters = helper.parameters.map(parameter => {
951
+ const local = this.allocateLocal(parameter.name, parameter.valueKind, `helpers.${helper.id}.parameters`, 'const');
952
+ this.pureTypes ||= new Map();
953
+ this.pureTypes.set(local.id, parameter.borrow?.type || parameter.valueKind);
954
+ helperScope.set(parameter.name, local);
955
+ return { ...parameter, localId: local.id };
956
+ });
957
+ const body = this.statementList(statement.body.statements, helperScope, ['helpers', helper.id, 'body'], 0);
958
+ const resultKinds = new Set();
959
+ const visitor = s => { if (s.kind === 'return') resultKinds.add(s.value.valueKind); }; visitor.expression = () => {};
960
+ walkStatements(body, visitor);
961
+ const resultKind = resultKinds.size === 1 ? [...resultKinds][0] : 'unknown';
962
+ if (helper.pure && resultKind !== helper.resultKind) this.fail(statement, 'PULSE_NATIVE_PURE_HELPER_RESULT_UNSUPPORTED', 'Pure helper return paths must match the declared scalar result.');
963
+ this.helpers.push({ resultKind, version: helper.pure ? contract.CANONICAL_NATIVE_PURE_HELPER_VERSION : 'pulse.canonical-native-helper.v1', id: helper.id, source: helper.source,
964
+ parameters, localIds: this.locals.slice(start).map(local => local.id), body,
965
+ frame: { lifetime: 'invocation', reset: 'call', suspension: helper.pure ? 'none' : 'retain', nesting: false },
966
+ outputs: helper.pure ? ['value', 'failure'] : ['value', 'suspend', 'failure'] });
967
+ this.activeHelper = undefined;
968
+ return [];
969
+ }
970
+ if (ts.isFunctionDeclaration(statement) && this.routerBodies.has(statement.name?.text)) return [];
971
+ if (ts.isReturnStatement(statement)) {
972
+ const call = this.lowerHandlerCall(statement);
973
+ if (call) return call;
974
+ }
896
975
  const statementPath = formatStatementPath(pathParts);
897
976
  this.summary.maxStatementDepth = Math.max(this.summary.maxStatementDepth, depth);
898
977
 
@@ -900,21 +979,91 @@ class NativePlanBuilder {
900
979
 
901
980
  if (ts.isVariableStatement(statement)) return this.lowerVariableStatement(statement, scope, pathParts, depth);
902
981
 
982
+ if (ts.isForStatement(statement)) {
983
+ // A later iteration can observe a write through an alias in an earlier
984
+ // iteration. Retire outer facts before lowering any loop-body read.
985
+ const writesProperty = node => {
986
+ const target = ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.getText(this.sourceFile)) ? unwrap(node.left)
987
+ : (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && UPDATE_OPERATORS.has(ts.tokenToString(node.operator)) ? unwrap(node.operand) : undefined;
988
+ return target && (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target))
989
+ || Boolean(ts.forEachChild(node, child => writesProperty(child) || undefined));
990
+ };
991
+ if (writesProperty(statement)) resultFacts.invalidate(scope);
992
+ const readLoop = containsYield(statement);
993
+ const loop = readLoop
994
+ ? inspectBoundedReadLoop(statement, { ctxName: this.ctxName, headerOnly: true })
995
+ : inspectBoundedPureLoop(statement, { ctxName: this.ctxName, pureHelper: this.activeHelper?.pure === true,
996
+ pureHelperForCall: call => ts.isIdentifier(call.expression) && this.helperBodies.get(call.expression.text)?.pure });
997
+ for (const error of loop.errors) this.fail(error.node, readLoop ? 'PULSE_CANONICAL_READ_LOOP_UNSUPPORTED' : 'PULSE_CANONICAL_PURE_LOOP_UNSUPPORTED', error.message);
998
+ if (loop.errors.length) return [];
999
+ if (readLoop && (this.readLoopDepth || this.pureLoopDepth)) {
1000
+ this.fail(statement, 'PULSE_CANONICAL_READ_LOOP_UNSUPPORTED', 'Effect loops cannot nest inside another loop.'); return [];
1001
+ }
1002
+ const loopScope = new Map(scope);
1003
+ const local = this.allocateLocal(loop.name, 'number', statementPath, 'let');
1004
+ loopScope.set(loop.name, local);
1005
+ const depthField = readLoop ? 'readLoopDepth' : 'pureLoopDepth';
1006
+ const previousDepth = this[depthField] || 0;
1007
+ this[depthField] = previousDepth + 1;
1008
+ const body = this.statement(statement.statement, loopScope, [...pathParts, 'body'], depth + 1);
1009
+ this[depthField] = previousDepth;
1010
+ return [Object.freeze({ kind: readLoop ? 'read-loop' : 'pure-loop', localId: local.id, maxIterations: loop.maxIterations,
1011
+ ...(readLoop ? { version: contract.CANONICAL_READ_LOOP_CONTRACT.version,
1012
+ initial: this.expression(statement.initializer.declarations[0].initializer, loopScope),
1013
+ increment: this.expression(statement.incrementor, loopScope) } : {}),
1014
+ test: this.expression(statement.condition, loopScope), body: Object.freeze(body), statementPath })];
1015
+ }
1016
+ if ((ts.isBreakStatement(statement) || ts.isContinueStatement(statement)) && (this.pureLoopDepth || this.readLoopDepth) && !statement.label) {
1017
+ return [Object.freeze({ kind: ts.isBreakStatement(statement) ? 'break' : 'continue', statementPath })];
1018
+ }
1019
+
903
1020
  if (ts.isIfStatement(statement)) {
904
1021
  this.summary.branchCount += 1;
905
1022
  const thenScope = new Map(scope);
906
1023
  const elseScope = new Map(scope);
1024
+ const test = this.expression(statement.expression, scope);
1025
+ resultFacts.refine(test, true, thenScope);
1026
+ resultFacts.refine(test, false, elseScope);
1027
+ // A const primitive cannot change between this guard and a branch read.
1028
+ // A returning branch also lets its surviving sibling dominate later reads.
1029
+ if (test.kind === 'binary' && ['===', '!=='].includes(test.operator)) {
1030
+ const read = test.left.kind === 'undefined' ? test.right : test.right.kind === 'undefined' ? test.left : undefined;
1031
+ const local = read?.kind === 'local' ? scope.get(read.name) : undefined;
1032
+ if (local?.declaration === 'const' && local.valueKind === 'string-or-undefined') {
1033
+ (test.operator === '!==' ? thenScope : elseScope).set(local.name, Object.freeze({ ...local, valueKind: 'string' }));
1034
+ }
1035
+ }
1036
+ const previousEntry = this.activeEntry;
1037
+ const entryGuard = test.kind === 'binary' && test.operator === '===' && test.left?.kind === 'local'
1038
+ && test.left.name === '__pulse_router_cursor' && test.right?.kind === 'literal'
1039
+ && this.metadata.router?.entries?.find(entry => entry.index === test.right.value);
1040
+ if (entryGuard) this.activeEntry = entryGuard;
907
1041
  const thenBody = ts.isBlock(statement.thenStatement)
908
1042
  ? this.statementList(statement.thenStatement.statements, thenScope, [...pathParts, 'then'], depth + 1)
909
1043
  : this.statement(statement.thenStatement, thenScope, [...pathParts, 'then', 0], depth + 1);
1044
+ this.activeEntry = previousEntry;
910
1045
  const elseBody = !statement.elseStatement
911
1046
  ? []
912
1047
  : ts.isBlock(statement.elseStatement)
913
1048
  ? this.statementList(statement.elseStatement.statements, elseScope, [...pathParts, 'else'], depth + 1)
914
1049
  : this.statement(statement.elseStatement, elseScope, [...pathParts, 'else', 0], depth + 1);
1050
+ const returns = body => {
1051
+ const last = body.at(-1);
1052
+ return last?.kind === 'return' || last?.kind === 'if' && returns(last.then) && returns(last.else);
1053
+ };
1054
+ const thenReturns = returns(thenBody), elseReturns = returns(elseBody);
1055
+ resultFacts.join(scope, thenScope, elseScope, thenReturns, elseReturns);
1056
+ for (const [name, original] of scope) {
1057
+ if (original.declaration !== 'const' || original.valueKind !== 'string-or-undefined') continue;
1058
+ const yes = thenScope.get(name)?.valueKind === 'string';
1059
+ const no = elseScope.get(name)?.valueKind === 'string';
1060
+ if (thenReturns && no || elseReturns && yes || yes && no) {
1061
+ scope.set(name, Object.freeze({ ...original, valueKind: 'string' }));
1062
+ }
1063
+ }
915
1064
  return [Object.freeze({
916
1065
  kind: 'if',
917
- test: this.expression(statement.expression, scope),
1066
+ test,
918
1067
  then: Object.freeze(thenBody),
919
1068
  else: Object.freeze(elseBody),
920
1069
  statementPath
@@ -955,6 +1104,35 @@ class NativePlanBuilder {
955
1104
  return out;
956
1105
  }
957
1106
 
1107
+ lowerHandlerCall(statement) {
1108
+ const yielded = unwrap(statement.expression);
1109
+ if (!yielded || !ts.isYieldExpression(yielded) || !yielded.asteriskToken) return undefined;
1110
+ const call = unwrap(yielded.expression);
1111
+ const entry = call && ts.isCallExpression(call) && ts.isIdentifier(call.expression)
1112
+ && this.routerBodies.get(call.expression.text);
1113
+ if (!entry) return undefined;
1114
+ const node = this.bodyNodes.get(entry.nativeBody.name);
1115
+ if (this.activeHandler || !node?.body || call.arguments.length || this.handlers.some(handler => handler.id === entry.stableId)) {
1116
+ this.fail(statement, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'Private Router bodies require one non-recursive terminal call.');
1117
+ return [];
1118
+ }
1119
+ const localStart = this.locals.length;
1120
+ this.activeHandler = entry;
1121
+ this.handlerLocalIndex = 0;
1122
+ const body = this.statementList(node.body.statements, new Map(), ['handlers', entry.stableId, 'body'], 0);
1123
+ this.activeHandler = undefined;
1124
+ this.handlers.push(Object.freeze({
1125
+ version: contract.CANONICAL_NATIVE_HANDLER_BODY_VERSION,
1126
+ id: entry.stableId,
1127
+ handlerId: entry.handlerId,
1128
+ family: 'terminal-route',
1129
+ source: deepFreeze(cloneJson(entry.nativeBody.source)),
1130
+ localIds: Object.freeze(this.locals.slice(localStart).map(local => local.id)),
1131
+ body: Object.freeze(body)
1132
+ }));
1133
+ return [Object.freeze({ kind: 'handler-call', handlerId: entry.stableId })];
1134
+ }
1135
+
958
1136
  reconcile() {
959
1137
  const expectedEffectIds = (this.metadata.effectSites || []).map((site) => String(site.id));
960
1138
  const actualEffectIds = this.effects.map((site) => site.id);
@@ -977,8 +1155,9 @@ class NativePlanBuilder {
977
1155
  this.reconcile();
978
1156
  if (this.diagnostics.length > 0) throw new CanonicalNativePlanError(`Native-plan lowering failed for ${this.metadata.file}.`, this.diagnostics);
979
1157
 
980
- const continuations = (this.metadata.continuationSites || []).map((site, index) => Object.freeze({
1158
+ const continuations = this.packageApplication ? this.packageApplication.continuations : (this.metadata.continuationSites || []).map((site, index) => Object.freeze({
981
1159
  id: String(site.id),
1160
+ ...(this.effects.find(effect => effect.continuationId === site.id)?.helperId ? { helperId: this.effects.find(effect => effect.continuationId === site.id).helperId } : {}),
982
1161
  kind: String(site.kind),
983
1162
  effectIds: Object.freeze(site.effectIds.map(String)),
984
1163
  stateIndex: index + 1,
@@ -1001,12 +1180,13 @@ class NativePlanBuilder {
1001
1180
  eventType: site.eventType ? String(site.eventType) : undefined,
1002
1181
  eventSchemaId: site.eventSchemaId !== undefined ? site.eventSchemaId : undefined
1003
1182
  }));
1183
+ if (this.packageApplication) this.effects = [...this.packageApplication.effects];
1004
1184
  const states = [Object.freeze({ id: 'entry', kind: 'entry', stateIndex: 0 })]
1005
1185
  .concat(continuations.map((site) => Object.freeze({ id: site.id, kind: 'continuation', continuationKind: site.kind, effectIds: site.effectIds, stateIndex: site.stateIndex })));
1006
- this.summary = summarizeNativePlan(body, this.locals, this.effects, continuations);
1186
+ this.summary = summarizeNativePlan(body, this.locals, this.effects, continuations, this.handlers);
1007
1187
  const hasInboundEvents = Number(this.metadata.events && this.metadata.events.count || 0) > 0;
1008
1188
 
1009
- const unsigned = {
1189
+ let unsigned = {
1010
1190
  version: contract.CANONICAL_NATIVE_PLAN_VERSION,
1011
1191
  compilerVersion: CANONICAL_NATIVE_PLAN_COMPILER_VERSION,
1012
1192
  hashAlgorithm: contract.CANONICAL_NATIVE_PLAN_HASH_ALGORITHM,
@@ -1065,10 +1245,12 @@ class NativePlanBuilder {
1065
1245
  body: Object.freeze(body)
1066
1246
  }),
1067
1247
  locals: Object.freeze(this.locals),
1248
+ handlers: Object.freeze(this.handlers),
1249
+ ...(this.helpers.length ? { helpers: Object.freeze(this.helpers) } : {}),
1068
1250
  effects: Object.freeze(this.effects),
1069
1251
  continuations: Object.freeze(continuations),
1070
1252
  states: Object.freeze(states),
1071
- capabilities: Object.freeze([...(this.metadata.capabilities || [])].map(String).sort()),
1253
+ capabilities: Object.freeze([...new Set([...(this.metadata.capabilities || []), ...(this.packageApplication?.capabilities || [])])].map(String).sort()),
1072
1254
  schemas: Object.freeze({
1073
1255
  sourceHash: this.metadata.schemaSourceHash,
1074
1256
  ids: Object.freeze([...(this.metadata.schemaIds || [])].map(String)),
@@ -1082,9 +1264,12 @@ class NativePlanBuilder {
1082
1264
  ...(this.compiled.cryptoRealizationPlan && this.compiled.cryptoRealizationPlan.target === 'native' ? {
1083
1265
  crypto: deepFreeze(cloneJson(this.compiled.cryptoRealizationPlan))
1084
1266
  } : {}),
1085
- packages: Object.freeze({ effects: deepFreeze(cloneJson(this.metadata.packageEffects || [])) }),
1267
+ packages: Object.freeze({ effects: deepFreeze(cloneJson(this.metadata.packageEffects || [])),
1268
+ ...(this.packageApplication ? { application: this.packageApplication } : {}) }),
1086
1269
  summary: Object.freeze({ ...this.summary })
1087
1270
  };
1271
+ if (this.options.sharedStages !== false) unsigned = lowerSharedStages(unsigned);
1272
+ unsigned.summary = summarizeNativePlan(unsigned.entry.body, unsigned.locals, unsigned.effects, continuations, [...unsigned.handlers, ...(unsigned.stages || []), ...(unsigned.helpers || [])]);
1088
1273
  const planHash = stableHash(stableStringify(unsigned));
1089
1274
  const plan = deepFreeze({ ...unsigned, planHash });
1090
1275
  assertCanonicalNativePlan(plan);
@@ -1130,6 +1315,7 @@ function walkExpression(expression, visit) {
1130
1315
  walkExpression(expression.object, visit);
1131
1316
  walkExpression(expression.index, visit);
1132
1317
  break;
1318
+ case 'pure-helper-call':
1133
1319
  case 'intrinsic':
1134
1320
  for (const argument of expression.arguments || []) walkExpression(argument, visit);
1135
1321
  break;
@@ -1155,11 +1341,19 @@ function walkExpression(expression, visit) {
1155
1341
  function walkStatements(statements, visitor) {
1156
1342
  for (const statement of statements || []) {
1157
1343
  visitor(statement);
1158
- if (statement.kind === 'local') walkExpression(statement.value, visitor.expression);
1344
+ if (statement.kind === 'helper-call') for (const arg of statement.arguments || []) walkExpression(arg, visitor.expression);
1345
+ else if (statement.kind === 'local') walkExpression(statement.value, visitor.expression);
1159
1346
  else if (statement.kind === 'if') {
1160
1347
  walkExpression(statement.test, visitor.expression);
1161
1348
  walkStatements(statement.then, visitor);
1162
1349
  walkStatements(statement.else, visitor);
1350
+ } else if (statement.kind === 'pure-loop' || statement.kind === 'read-loop') {
1351
+ if (statement.kind === 'read-loop') {
1352
+ walkExpression(statement.initial, visitor.expression);
1353
+ walkExpression(statement.increment, visitor.expression);
1354
+ }
1355
+ walkExpression(statement.test, visitor.expression);
1356
+ walkStatements(statement.body, visitor);
1163
1357
  } else if (statement.kind === 'return') walkExpression(statement.value, visitor.expression);
1164
1358
  else if (statement.kind === 'expression') walkExpression(statement.expression, visitor.expression);
1165
1359
  }
@@ -1171,7 +1365,7 @@ function countExpression(expression) {
1171
1365
  return count;
1172
1366
  }
1173
1367
 
1174
- function summarizeNativePlan(body, locals, effects, continuations) {
1368
+ function summarizeNativePlan(body, locals, effects, continuations, handlers = []) {
1175
1369
  const summary = {
1176
1370
  statementCount: 0,
1177
1371
  expressionCount: 0,
@@ -1194,9 +1388,15 @@ function summarizeNativePlan(body, locals, effects, continuations) {
1194
1388
  summary.expressionCount += countExpression(statement.test);
1195
1389
  visitStatements(statement.then, depth + 1);
1196
1390
  visitStatements(statement.else, depth + 1);
1391
+ } else if (statement.kind === 'pure-loop' || statement.kind === 'read-loop') {
1392
+ if (statement.kind === 'read-loop') summary.expressionCount += countExpression(statement.initial) + countExpression(statement.increment);
1393
+ summary.expressionCount += countExpression(statement.test);
1394
+ visitStatements(statement.body, depth + 1);
1197
1395
  } else if (statement.kind === 'return') {
1198
1396
  summary.returnCount += 1;
1199
1397
  summary.expressionCount += countExpression(statement.value);
1398
+ } else if (statement.kind === 'helper-call') {
1399
+ for (const arg of statement.arguments || []) summary.expressionCount += countExpression(arg);
1200
1400
  } else if (statement.kind === 'expression') {
1201
1401
  summary.expressionCount += countExpression(statement.expression);
1202
1402
  } else if (statement.kind === 'effect') {
@@ -1208,6 +1408,7 @@ function summarizeNativePlan(body, locals, effects, continuations) {
1208
1408
  }
1209
1409
 
1210
1410
  visitStatements(body, 0);
1411
+ for (const handler of handlers) visitStatements(handler.body, 0);
1211
1412
  for (const effect of effects) {
1212
1413
  for (const input of effect.inputs || []) summary.expressionCount += countExpression(input.value);
1213
1414
  const decoder = effect.result && effect.result.decoder;
@@ -1238,6 +1439,140 @@ function validateResult(result, fail, localIds, detail = {}) {
1238
1439
  }
1239
1440
 
1240
1441
  function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1442
+ const effects = new Map((plan.effects || []).map(effect => [effect.id, effect]));
1443
+ const helpers = new Map((plan.helpers || []).map(helper => [helper.id, helper]));
1444
+ function validateHelperLoopProduct(helper, callerProduct, label) {
1445
+ // A synchronous call does not reset the enclosing loop's work bound.
1446
+ const check = (body, product) => {
1447
+ for (const statement of body || []) {
1448
+ if (statement.kind === 'pure-loop') {
1449
+ const nested = product * Math.max(1, statement.maxIterations);
1450
+ if (!Number.isSafeInteger(nested) || nested > contract.CANONICAL_PURE_LOOP_LIMITS.maxNestedIterations) fail(`${label} exceeds the combined iteration bound`);
1451
+ check(statement.body, nested);
1452
+ } else if (statement.kind === 'if') {
1453
+ check(statement.then, product); check(statement.else, product);
1454
+ }
1455
+ }
1456
+ };
1457
+ check(helper.body, callerProduct);
1458
+ }
1459
+ function validateReadHelper(id, callerProduct) {
1460
+ const helper = helpers.get(id);
1461
+ if (!helper) { fail('read loop helper is unknown'); return; }
1462
+ validateHelperLoopProduct(helper, callerProduct, 'read loop helper');
1463
+ let sites = 0;
1464
+ const visit = statement => {
1465
+ if (['read-loop', 'helper-call', 'handler-call', 'stage-call', 'effect-group'].includes(statement.kind)) fail('read loop helper cannot nest effect loops, calls or groups');
1466
+ if (statement.kind === 'effect') {
1467
+ const effect = effects.get(statement.effectId); sites++;
1468
+ if (!effect || !contract.CANONICAL_READ_LOOP_CONTRACT.effectKinds.includes(effect.kind)) fail('read loop helper effect kind is not admitted');
1469
+ for (const input of effect?.inputs || []) checkReadExpression(input.value, new Set(), true);
1470
+ }
1471
+ };
1472
+ visit.expression = expression => checkReadExpression(expression, new Set());
1473
+ walkStatements(helper.body, visit);
1474
+ if (!sites) fail('read loop helper requires a sequential read effect site');
1475
+ }
1476
+ function validatePureLoopCalls(expression, counters, product, header = false) {
1477
+ walkExpression(expression, node => {
1478
+ if (node.kind !== 'pure-helper-call') return;
1479
+ if (!counters.size) {
1480
+ if (node.loopContract !== undefined) fail('pure loop call contract requires an enclosing loop');
1481
+ return;
1482
+ }
1483
+ if (header || node.loopContract !== contract.CANONICAL_NATIVE_PURE_LOOP_CALL_VERSION) fail('pure loop call contract or placement is invalid');
1484
+ const helper = helpers.get(node.helperId);
1485
+ if (helper?.version !== contract.CANONICAL_NATIVE_PURE_HELPER_VERSION) { fail('pure loop call requires a pure helper'); return; }
1486
+ for (const argument of node.arguments || []) walkExpression(argument, child => {
1487
+ if (['assignment', 'update'].includes(child.kind)) fail('pure loop call inputs must be read-only');
1488
+ });
1489
+ validateHelperLoopProduct(helper, product, 'pure loop helper');
1490
+ });
1491
+ }
1492
+ function validateLoops(statements, counters = new Set(), product = 1, mode = 'outside', pureHelper = false) {
1493
+ for (const statement of statements || []) {
1494
+ if (statement.kind === 'pure-loop' || statement.kind === 'read-loop') {
1495
+ const readLoop = statement.kind === 'read-loop';
1496
+ const label = readLoop ? 'read loop' : 'pure loop';
1497
+ const limit = statement.maxIterations;
1498
+ if (!localIds.has(statement.localId) || counters.has(statement.localId)) fail(`${label} requires its own local counter`);
1499
+ if (!Number.isInteger(limit) || limit < 0 || limit > (readLoop ? contract.CANONICAL_READ_LOOP_CONTRACT.maxIterations : contract.CANONICAL_PURE_LOOP_LIMITS.maxIterations)
1500
+ || product * Math.max(1, limit) > contract.CANONICAL_PURE_LOOP_LIMITS.maxNestedIterations) fail(`${label} iteration bound is invalid`);
1501
+ if (readLoop) {
1502
+ if (counters.size) fail('read loops cannot nest inside another loop');
1503
+ if (statement.version !== contract.CANONICAL_READ_LOOP_CONTRACT.version) fail('read loop contract version is invalid');
1504
+ if (statement.initial?.kind !== 'literal' || statement.initial.value !== 0) fail('read loop must initialize its counter to zero');
1505
+ const step = statement.increment;
1506
+ if (step?.target?.kind !== 'local' || step.target.id !== statement.localId
1507
+ || !(step.kind === 'update' && step.operator === '++'
1508
+ || step.kind === 'assignment' && step.operator === '+=' && step.value?.kind === 'literal' && step.value.value === 1)) fail('read loop increment must advance its own counter by one');
1509
+ }
1510
+ let bound = statement.test;
1511
+ while (bound && bound.kind === 'binary' && bound.operator === '&&') bound = bound.left;
1512
+ if (!bound || bound.kind !== 'binary' || bound.operator !== '<' || bound.left?.kind !== 'local' || bound.left.id !== statement.localId
1513
+ || bound.right?.kind !== 'literal' || bound.right.value !== limit) fail(`${label} test must start with its declared literal cap`);
1514
+ const active = new Set([...counters, statement.localId]);
1515
+ checkPureExpression(statement.test, active, true);
1516
+ validatePureLoopCalls(statement.test, active, product, true);
1517
+ validatePureLoopCalls(statement.initial, active, product, true);
1518
+ validatePureLoopCalls(statement.increment, active, product, true);
1519
+ if (!Array.isArray(statement.body)) { fail(`${label} body must be an array`); continue; }
1520
+ if (readLoop) {
1521
+ let sites = 0;
1522
+ const countSites = item => { if (item.kind === 'effect' || item.kind === 'helper-call') sites += 1; };
1523
+ countSites.expression = () => {};
1524
+ walkStatements(statement.body, countSites);
1525
+ if (!sites) fail('read loop requires a sequential effect site');
1526
+ }
1527
+ validateLoops(statement.body, active, product * Math.max(1, limit), readLoop ? 'read' : 'pure', pureHelper);
1528
+ } else {
1529
+ validatePureLoopCalls(statement.value || statement.expression || statement.test, counters, product);
1530
+ if (mode === 'pure' && !(pureHelper && statement.kind === 'return') && !['local', 'expression', 'if', 'break', 'continue'].includes(statement.kind)) fail('pure loop contains a non-value statement', { kind: statement.kind });
1531
+ if (mode === 'read' && !['local', 'expression', 'if', 'break', 'continue', 'return', 'effect', 'helper-call'].includes(statement.kind)) fail('read loop contains an unsupported statement', { kind: statement.kind });
1532
+ if (statement.kind === 'helper-call') {
1533
+ if (mode === 'read') {
1534
+ if (statement.loopContract !== contract.CANONICAL_READ_LOOP_CONTRACT.helperCallVersion) fail('read loop helper contract version is invalid');
1535
+ if (counters.has(statement.localId)) fail('read loop helper result cannot replace the counter');
1536
+ for (const argument of statement.arguments || []) checkReadExpression(argument, counters, true);
1537
+ validateReadHelper(statement.helperId, product);
1538
+ } else if (statement.loopContract !== undefined) fail('loop helper contract requires an enclosing read loop');
1539
+ }
1540
+ if (!counters.size && ['break', 'continue'].includes(statement.kind)) fail('loop transfer outside a loop');
1541
+ if (counters.size) {
1542
+ if (statement.kind === 'local' && counters.has(statement.localId)) fail('pure loop counter cannot be rebound');
1543
+ if (mode === 'pure') checkPureExpression(statement.value || statement.expression || statement.test, counters);
1544
+ else checkReadExpression(statement.value || statement.expression || statement.test, counters);
1545
+ }
1546
+ if (mode === 'read' && statement.kind === 'effect') {
1547
+ const effect = effects.get(statement.effectId);
1548
+ if (!effect || !contract.CANONICAL_READ_LOOP_CONTRACT.effectKinds.includes(effect.kind)) fail('read loop effect kind is not admitted');
1549
+ for (const result of [statement.result, effect?.result]) {
1550
+ if (!['bind', 'discard'].includes(result?.mode) || counters.has(result?.localId)) fail('read loop effect result cannot replace the counter or return an effect');
1551
+ }
1552
+ for (const input of effect?.inputs || []) checkReadExpression(input.value, counters, true);
1553
+ }
1554
+ if (statement.kind === 'if') {
1555
+ validateLoops(statement.then, counters, product, mode, pureHelper);
1556
+ validateLoops(statement.else, counters, product, mode, pureHelper);
1557
+ }
1558
+ }
1559
+ }
1560
+ }
1561
+ function checkPureExpression(expression, counters, test = false) {
1562
+ walkExpression(expression, node => {
1563
+ if (node.kind === 'intrinsic' || node.kind === 'context-read' || (node.kind === 'method-call' && (node.method !== 'string.trim' || node.arguments?.length))) fail('pure loop contains a non-value call or context read');
1564
+ if (['assignment', 'update'].includes(node.kind) && (test || (node.target?.kind === 'local' && counters.has(node.target.id)))) fail('pure loop test or counter mutation is invalid');
1565
+ });
1566
+ }
1567
+ function checkReadExpression(expression, counters, readonly = false) {
1568
+ walkExpression(expression, node => {
1569
+ if (node.kind === 'context-read' || node.kind === 'intrinsic' && !contract.CANONICAL_READ_LOOP_CONTRACT.valueIntrinsics.includes(node.name)
1570
+ || node.kind === 'method-call' && (node.method !== 'string.trim' || node.arguments?.length)) fail('read loop contains an unsupported value operation');
1571
+ if (['assignment', 'update'].includes(node.kind) && (readonly || node.target?.kind === 'local' && counters.has(node.target.id))) fail('read loop counter or effect input mutation is invalid');
1572
+ });
1573
+ }
1574
+ for (const body of [plan.entry && plan.entry.body, ...[...(plan.handlers || []), ...(plan.stages || [])].map(handler => handler.body)]) validateLoops(body);
1575
+ for (const helper of plan.helpers || []) validateLoops(helper.body, new Set(), 1, 'outside', helper.version === contract.CANONICAL_NATIVE_PURE_HELPER_VERSION);
1241
1576
  const visitor = (statement) => {
1242
1577
  if (!contract.CANONICAL_NATIVE_STATEMENT_KINDS.includes(statement.kind)) fail('statement kind is unknown', { kind: statement.kind });
1243
1578
  if (statement.kind === 'local' && !localIds.has(statement.localId)) fail('local statement references unknown local', { localId: statement.localId });
@@ -1254,6 +1589,73 @@ function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1254
1589
  };
1255
1590
  visitor.expression = (expression) => validateExpression(expression, fail, localIds);
1256
1591
  walkStatements(plan.entry && plan.entry.body, visitor);
1592
+ for (const handler of [...(plan.handlers || []), ...(plan.stages || []), ...(plan.helpers || [])]) walkStatements(handler.body, visitor);
1593
+ }
1594
+
1595
+ function validateHandlerBodies(plan, fail) {
1596
+ const handlers = new Map();
1597
+ const stages = new Set([...(plan.stages || []), ...(plan.helpers || [])].map(stage => stage.id));
1598
+ const entries = new Map((plan.routing?.entries || []).map(entry => [entry.stableId, entry]));
1599
+ const locals = Array.isArray(plan.locals) ? plan.locals : [];
1600
+ const localsByOwner = new Map();
1601
+ for (const local of locals) {
1602
+ if (!localsByOwner.has(local.scopeId)) localsByOwner.set(local.scopeId, []);
1603
+ localsByOwner.get(local.scopeId).push(local);
1604
+ }
1605
+ const effects = new Map((plan.effects || []).map(effect => [effect.id, effect]));
1606
+ const calls = new Map();
1607
+ for (const handler of plan.handlers || []) {
1608
+ const entry = handler && entries.get(handler.id);
1609
+ if (!handler || handlers.has(handler.id) || handler.version !== contract.CANONICAL_NATIVE_HANDLER_BODY_VERSION
1610
+ || handler.family !== 'terminal-route' || entry?.kind !== 'route' || entry.nativeBody?.version !== 'pulse.router-native-body.v1'
1611
+ || handler.handlerId !== entry.handlerId || !Array.isArray(handler.body)
1612
+ || stableStringify(handler.source) !== stableStringify(entry.nativeBody.source)) {
1613
+ fail('private handler body must have a unique terminal route identity and original source');
1614
+ continue;
1615
+ }
1616
+ handlers.set(handler.id, handler);
1617
+ const owned = localsByOwner.get(handler.id) || [];
1618
+ if (stableStringify(handler.localIds) !== stableStringify(owned.map(local => local.id))) fail('private handler local namespace mismatch', { handlerId: handler.id });
1619
+ if (owned.some(local => local.routerEntryStableId !== handler.id)) fail('private handler local ownership mismatch', { handlerId: handler.id });
1620
+ }
1621
+ for (const local of locals) {
1622
+ if (local.scopeId !== 'entry' && !handlers.has(local.scopeId) && !stages.has(local.scopeId)) fail('local requires an existing lexical owner', { localId: local.id });
1623
+ if (local.scopeId === 'entry' && local.routerEntryStableId !== undefined) fail('dispatcher local cannot claim private handler ownership', { localId: local.id });
1624
+ }
1625
+ function validateBody(body, owner) {
1626
+ const owned = new Set((localsByOwner.get(owner) || []).map(local => local.id));
1627
+ const checkEffect = id => {
1628
+ const effect = effects.get(id);
1629
+ if (owner !== 'entry' && effect?.routerEntryStableId !== owner) fail('private handler effect ownership mismatch', { effectId: id, owner });
1630
+ for (const input of effect?.inputs || []) walkExpression(input.value, visitor.expression);
1631
+ for (const arg of effect?.result?.decoder?.arguments || []) walkExpression(arg, visitor.expression);
1632
+ if (effect?.result?.localId && !owned.has(effect.result.localId)) fail('effect result crosses a lexical boundary', { effectId: id });
1633
+ };
1634
+ const visitor = statement => {
1635
+ if (statement.localId && !owned.has(statement.localId)) fail('statement crosses a lexical boundary', { localId: statement.localId });
1636
+ if (statement.kind === 'stage-call' && owner !== 'entry') fail('stage calls cannot nest inside private handlers');
1637
+ if (statement.kind === 'handler-call') {
1638
+ if (owner !== 'entry' || !handlers.has(statement.handlerId)) fail('private handler call must originate in the dispatcher and select an existing body');
1639
+ calls.set(statement.handlerId, (calls.get(statement.handlerId) || 0) + 1);
1640
+ }
1641
+ if (statement.kind === 'effect') checkEffect(statement.effectId);
1642
+ if (statement.kind === 'effect-group') for (const id of statement.effectIds || []) checkEffect(id);
1643
+ };
1644
+ visitor.expression = expression => {
1645
+ if (expression.kind === 'local' && !owned.has(expression.id)) fail('expression crosses a lexical boundary', { localId: expression.id, owner });
1646
+ };
1647
+ walkStatements(body, visitor);
1648
+ }
1649
+ validateBody(plan.entry?.body, 'entry');
1650
+ for (const handler of handlers.values()) {
1651
+ validateBody(handler.body, handler.id);
1652
+ if (calls.get(handler.id) !== 1) fail('private handler requires exactly one static call', { handlerId: handler.id });
1653
+ }
1654
+ for (const continuation of plan.continuations || []) {
1655
+ for (const effectId of continuation.effectIds || []) {
1656
+ if (effects.get(effectId)?.routerEntryStableId !== continuation.routerEntryStableId) fail('continuation must resume in its effect owner', { effectId });
1657
+ }
1658
+ }
1257
1659
  }
1258
1660
 
1259
1661
  function assertCanonicalNativePlan(plan) {
@@ -1296,6 +1698,7 @@ function assertCanonicalNativePlan(plan) {
1296
1698
  }
1297
1699
  }
1298
1700
  if (!Array.isArray(plan.locals)) fail('plan locals must be an array');
1701
+ if (!Array.isArray(plan.handlers)) fail('plan handlers must be an array');
1299
1702
  if (!Array.isArray(plan.effects)) fail('plan effects must be an array');
1300
1703
  if (!Array.isArray(plan.continuations)) fail('plan continuations must be an array');
1301
1704
  if (!Array.isArray(plan.states)) fail('plan states must be an array');
@@ -1355,11 +1758,36 @@ function assertCanonicalNativePlan(plan) {
1355
1758
  fail('plan logging evidence is invalid', { logging: plan.logging });
1356
1759
  }
1357
1760
 
1761
+ if (plan.helpers !== undefined && !Array.isArray(plan.helpers)) {
1762
+ throw new CanonicalNativePlanError('Invalid helper table.', [diagnostic(undefined, undefined, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.PLAN_INVALID, 'helpers must be an array')]);
1763
+ }
1358
1764
  const unsigned = { ...plan };
1359
1765
  delete unsigned.planHash;
1360
1766
  const expectedHash = stableHash(stableStringify(unsigned));
1361
1767
  if (plan.planHash !== expectedHash) fail('plan hash mismatch', { expectedHash, actual: plan.planHash });
1362
1768
 
1769
+ const application = plan.packages && plan.packages.application;
1770
+ let packageCalls = 0;
1771
+ const packageVisitor = () => {};
1772
+ packageVisitor.expression = expression => { if (expression.kind === 'intrinsic' && expression.name === 'package.application') packageCalls++; };
1773
+ walkStatements(plan.entry && plan.entry.body, packageVisitor);
1774
+ for (const handler of plan.handlers || []) walkStatements(handler.body, packageVisitor);
1775
+ if (application) {
1776
+ const terminal = plan.entry?.body?.[0];
1777
+ if (application.version !== 'pulse.package-native-application.v1' || application.automaticFallback !== false
1778
+ || typeof application.contractId !== 'string' || !application.contractId
1779
+ || typeof application.package !== 'string' || !application.package
1780
+ || typeof application.intrinsic !== 'string' || !application.intrinsic
1781
+ || typeof application.source !== 'string' || stableHash(application.source) !== application.sourceHash
1782
+ || application.effectFailure !== 'package-completion'
1783
+ || stableStringify(application.effects) !== stableStringify(plan.effects)
1784
+ || stableStringify(application.continuations) !== stableStringify(plan.continuations)
1785
+ || packageCalls !== 1 || plan.entry.kind !== 'handler' || plan.entry.body.length !== 1
1786
+ || terminal.kind !== 'return' || terminal.value?.name !== 'package.application'
1787
+ || terminal.value.arguments?.length !== 0 || plan.handlers?.length !== 0) {
1788
+ fail('terminal package Native application does not match its source, entry, or effect plan');
1789
+ }
1790
+ } else if (packageCalls) fail('package application intrinsic requires a trusted Native source contribution');
1363
1791
  const locals = Array.isArray(plan.locals) ? plan.locals : [];
1364
1792
  const effects = Array.isArray(plan.effects) ? plan.effects : [];
1365
1793
  const continuations = Array.isArray(plan.continuations) ? plan.continuations : [];
@@ -1397,6 +1825,9 @@ function assertCanonicalNativePlan(plan) {
1397
1825
  }
1398
1826
 
1399
1827
  if (plan.entry && Array.isArray(plan.entry.body)) validatePlanTree(plan, fail, localIds, effectIds, continuationIds);
1828
+ if (Array.isArray(plan.handlers)) validateHandlerBodies(plan, fail);
1829
+ validateSharedStages(plan, fail, walkStatements, walkExpression, stableStringify);
1830
+ require("./source-helper-plan").validateHelpers(plan, fail, walkStatements, walkExpression);
1400
1831
 
1401
1832
  const states = Array.isArray(plan.states) ? plan.states : [];
1402
1833
  if (states.length !== continuations.length + 1) fail('state table must contain entry plus one state per continuation', { expected: continuations.length + 1, actual: states.length });
@@ -1425,10 +1856,11 @@ function assertCanonicalNativePlan(plan) {
1425
1856
  };
1426
1857
  statementVisitor.expression = () => {};
1427
1858
  if (plan.entry && Array.isArray(plan.entry.body)) walkStatements(plan.entry.body, statementVisitor);
1859
+ for (const handler of [...(plan.handlers || []), ...(plan.stages || []), ...(plan.helpers || [])]) walkStatements(handler.body, statementVisitor);
1428
1860
 
1429
1861
  if (!plan.summary || typeof plan.summary !== 'object') fail('plan summary is required');
1430
1862
  else {
1431
- const expectedSummary = summarizeNativePlan(plan.entry && plan.entry.body, locals, effects, continuations);
1863
+ const expectedSummary = summarizeNativePlan(plan.entry && plan.entry.body, locals, effects, continuations, [...(plan.handlers || []), ...(plan.stages || []), ...(plan.helpers || [])]);
1432
1864
  for (const [key, expected] of Object.entries(expectedSummary)) {
1433
1865
  if (plan.summary[key] !== expected) fail(`summary ${key} mismatch`, { expected, actual: plan.summary[key] });
1434
1866
  }