@pulse-compute/wasm-compiler 1.0.0-beta.5 → 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,57 +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');
8
- const { inspectBoundedPureLoop } = require('./spine/bounded-pure-loop.js');
9
+ const { inspectBoundedPureLoop, inspectBoundedReadLoop } = require('./spine/bounded-pure-loop.js');
10
+ const resultFacts = require('./native-result-facts.js');
9
11
 
10
- function loadNativePlanContract() {
11
- try { return require('@pulse-compute/wasm-contracts/handler/canonical-native-plan'); }
12
- catch (error) {
13
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
14
- return require('../../contracts/src/handler/canonical-native-plan.js');
15
- }
16
- throw error;
17
- }
18
- }
19
-
20
- function loadLoggingContract() {
21
- try { return require('@pulse-compute/wasm-contracts/logging'); }
22
- catch (error) {
23
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
24
- return require('../../contracts/src/logging.js');
25
- }
26
- throw error;
27
- }
28
- }
29
-
30
- function loadCryptoContract() {
31
- try { return require('@pulse-compute/wasm-contracts/crypto/contracts'); }
32
- catch (error) {
33
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
34
- return require('../../contracts/src/crypto/contracts.js');
35
- }
36
- throw error;
37
- }
38
- }
39
-
40
- function loadEventContract() {
41
- try { return require('@pulse-compute/wasm-contracts/events'); }
42
- catch (error) {
43
- if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) {
44
- return require('../../contracts/src/events/contracts.js');
45
- }
46
- throw error;
47
- }
12
+ function containsYield(node) {
13
+ if (ts.isYieldExpression(node)) return true;
14
+ return Boolean(ts.forEachChild(node, child => containsYield(child) || undefined));
48
15
  }
49
16
 
50
- const contract = loadNativePlanContract();
51
- const loggingContract = loadLoggingContract();
52
- const cryptoContract = loadCryptoContract();
53
- const eventContract = loadEventContract();
54
- 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');
55
24
  const GENERATED_HANDLER_NAME = '__pulse_handler';
56
25
  const PULSE_RUNTIME_PARAMETER = '__pulse';
57
26
 
@@ -201,6 +170,8 @@ function intrinsicForContextCall(parts) {
201
170
  const entries = {
202
171
  'req.header': ['request.header', 'string-or-undefined'],
203
172
  'req.text': ['request.text', 'string'],
173
+ 'req.body': ['request.body.forward-marker', 'incoming-body-marker'],
174
+ 'output.close': ['response.output.close', 'pulse-result'],
204
175
  'req.json': ['request.json', 'json'],
205
176
  json: ['response.json', 'pulse-result'],
206
177
  encodeJson: ['schema.encode.text', 'string'],
@@ -221,8 +192,10 @@ function resultKindForEffect(site, decoder, continuation, resultMode) {
221
192
  if (site.result === 'opaque-response') return 'opaque-response';
222
193
  if (site.result === 'structured-response') return 'structured-response';
223
194
  if (site.result === 'ack') return 'ack';
195
+ if (site.result === 'string') return 'string';
224
196
  if (site.kind === 'fetch') return 'fetch-response';
225
197
  if (site.kind === 'config.get' || site.kind === 'secret.get') return 'string-or-undefined';
198
+ if (site.kind === 'output.readTextChunk') return 'json';
226
199
  if (site.kind === 'time.now') return 'json';
227
200
  if (site.kind === 'kv.get') return 'json-or-undefined';
228
201
  if (site.kind === 'kv.put') return 'ack';
@@ -231,21 +204,6 @@ function resultKindForEffect(site, decoder, continuation, resultMode) {
231
204
  return 'unknown';
232
205
  }
233
206
 
234
- function inferBinaryValueKind(operator, left, right) {
235
- if (['===', '!==', '==', '!=', '<', '<=', '>', '>=', 'in'].includes(operator)) return 'boolean';
236
- if (['&&', '||', '??'].includes(operator)) {
237
- if (left.valueKind === right.valueKind) return left.valueKind;
238
- if (['||', '??'].includes(operator)) {
239
- const kinds = new Set([left.valueKind, right.valueKind]);
240
- if (kinds.has('string') && kinds.has('string-or-undefined')) return 'string';
241
- }
242
- return 'unknown';
243
- }
244
- if (operator === '+' && (left.valueKind === 'string' || right.valueKind === 'string')) return 'string';
245
- if (['+', '-', '*', '/', '%', '**', '&', '|', '^', '<<', '>>', '>>>'].includes(operator)) return 'number';
246
- return 'unknown';
247
- }
248
-
249
207
  class NativePlanBuilder {
250
208
  constructor(compiled, options = {}) {
251
209
  this.compiled = compiled;
@@ -253,6 +211,9 @@ class NativePlanBuilder {
253
211
  this.diagnostics = [];
254
212
  this.effects = [];
255
213
  this.locals = [];
214
+ this.handlers = [];
215
+ this.helpers = [];
216
+ this.handlerLocalIndex = 0;
256
217
  this.effectOccurrences = new Map();
257
218
  this.continuationOccurrences = new Map();
258
219
  this.localIndex = 0;
@@ -329,6 +290,20 @@ class NativePlanBuilder {
329
290
  }
330
291
 
331
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();
332
307
  this.generatedFile = `${this.metadata.file || 'app.ts'}.canonical.generated.js`;
333
308
  this.sourceFile = ts.createSourceFile(
334
309
  this.generatedFile,
@@ -348,6 +323,14 @@ class NativePlanBuilder {
348
323
  this.ctxName = this.handler && this.handler.parameters[0] && ts.isIdentifier(this.handler.parameters[0].name)
349
324
  ? this.handler.parameters[0].name.text
350
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);
351
334
  this.effectSites = new Map((this.metadata.effectSites || []).map((site) => [String(site.id), site]));
352
335
  this.continuationSites = new Map((this.metadata.continuationSites || []).map((site) => [String(site.id), site]));
353
336
  this.compilerOwnedCalls = new Set((this.metadata.compilerOwnedCalls || []).map(String));
@@ -448,6 +431,11 @@ class NativePlanBuilder {
448
431
  if (ASSIGNMENT_OPERATORS.has(operator)) {
449
432
  const target = this.assignmentTarget(current.left, scope);
450
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
+ }
451
439
  return Object.freeze({ kind: 'assignment', operator, target, value, valueKind: value.valueKind || 'unknown' });
452
440
  }
453
441
  if (!PURE_BINARY_OPERATORS.has(operator)) {
@@ -455,7 +443,7 @@ class NativePlanBuilder {
455
443
  }
456
444
  const left = this.expression(current.left, scope);
457
445
  const right = this.expression(current.right, scope);
458
- 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) });
459
447
  }
460
448
 
461
449
  if (ts.isPrefixUnaryExpression(current)) {
@@ -467,7 +455,7 @@ class NativePlanBuilder {
467
455
  this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Prefix operator ${operator} is outside the canonical native value model.`, { operator });
468
456
  }
469
457
  const value = this.expression(current.operand, scope);
470
- 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) });
471
459
  }
472
460
 
473
461
  if (ts.isPostfixUnaryExpression(current)) {
@@ -512,17 +500,19 @@ class NativePlanBuilder {
512
500
  else if (current.name.text === 'headers') valueKind = 'headers';
513
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 });
514
502
  }
515
- 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);
516
508
  }
517
509
 
518
510
  if (ts.isElementAccessExpression(current)) {
519
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.');
520
- return Object.freeze({
521
- kind: 'element',
522
- object: this.expression(current.expression, scope),
523
- index: this.expression(current.argumentExpression, scope),
524
- valueKind: 'unknown'
525
- });
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);
526
516
  }
527
517
 
528
518
  if (ts.isTypeOfExpression(current)) {
@@ -549,6 +539,21 @@ class NativePlanBuilder {
549
539
  callExpression(call, scope) {
550
540
  if (call.questionDotToken) this.fail(call, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'Optional calls are outside the canonical native value model.');
551
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
+ }
552
557
  const ctxPath = contextPath(target, this.ctxName);
553
558
  if (ctxPath) {
554
559
  const intrinsic = intrinsicForContextCall(ctxPath);
@@ -579,6 +584,9 @@ class NativePlanBuilder {
579
584
 
580
585
  if (ts.isIdentifier(target) && this.compilerOwnedCalls.has(target.text) && this.compilerOwnedIntrinsics.has(target.text)) {
581
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
+ });
582
590
  return Object.freeze({
583
591
  kind: 'intrinsic',
584
592
  name: intrinsic[0],
@@ -615,6 +623,7 @@ class NativePlanBuilder {
615
623
 
616
624
  assignmentTarget(node, scope) {
617
625
  const target = this.expression(node, scope);
626
+ if (target.kind === 'property' || target.kind === 'element') resultFacts.invalidate(scope);
618
627
  if (target.kind === 'local' && scope.get(target.name)?.declaration === 'const') {
619
628
  this.fail(node, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'A const local cannot be reassigned.', { name: target.name });
620
629
  }
@@ -624,13 +633,16 @@ class NativePlanBuilder {
624
633
  allocateLocal(name, valueKind, statementPath, declaration) {
625
634
  this.localIndex += 1;
626
635
  const local = Object.freeze({
627
- 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 } : {}),
628
639
  name: String(name),
629
640
  valueKind: contract.CANONICAL_NATIVE_VALUE_KINDS.includes(valueKind) ? valueKind : 'unknown',
630
641
  declaration,
631
642
  statementPath
632
643
  });
633
644
  this.locals.push(local);
645
+ if (pureValues.scalar(valueKind)) this.pureTypes.set(local.id, valueKind);
634
646
  this.summary.localCount += 1;
635
647
  return local;
636
648
  }
@@ -703,6 +715,7 @@ class NativePlanBuilder {
703
715
  const record = Object.freeze({
704
716
  order: this.effectOrder,
705
717
  id: effectId || `invalid-effect-${this.effectOrder}`,
718
+ ...(this.activeHelper ? { helperId: this.activeHelper.id } : {}),
706
719
  kind: kind || 'invalid',
707
720
  providerKind: site && site.providerKind ? String(site.providerKind) : undefined,
708
721
  operation: site && site.operation ? String(site.operation) : undefined,
@@ -714,6 +727,7 @@ class NativePlanBuilder {
714
727
  contractId: site && site.contractId ? String(site.contractId) : undefined,
715
728
  declaredResult: site && site.result ? String(site.result) : undefined,
716
729
  decoder: site && site.decoder ? String(site.decoder) : null,
730
+ ...(site?.borrowedValue && result?.mode === 'bind' ? { borrowedValue: cloneJson(site.borrowedValue) } : {}),
717
731
  continuationId,
718
732
  statementPath,
719
733
  source: Object.freeze({ file: this.metadata.file, ...(cloneJson(site && site.position) || {}) }),
@@ -757,6 +771,23 @@ class NativePlanBuilder {
757
771
  for (let index = 0; index < statement.declarationList.declarations.length; index += 1) {
758
772
  const item = statement.declarationList.declarations[index];
759
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
+ }
760
791
  const yielded = item.initializer ? this.pulseYield(item.initializer) : undefined;
761
792
 
762
793
  if (yielded && yielded.mode === 'group') {
@@ -821,7 +852,9 @@ class NativePlanBuilder {
821
852
  const local = this.allocateLocal(item.name.text, resultKindForEffect(site || {}, decoder), itemPath, declaration);
822
853
  const result = Object.freeze({ mode: 'bind', localId: local.id, localName: local.name, valueKind: local.valueKind, decoder });
823
854
  const effect = this.prepareEffect(marker, continuationId, scope, itemPath, result);
824
- 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));
825
858
  out.push(Object.freeze({ kind: 'effect', effectId: effect.id, continuationId, result, statementPath: itemPath }));
826
859
  continue;
827
860
  }
@@ -832,6 +865,8 @@ class NativePlanBuilder {
832
865
  }
833
866
  const value = item.initializer ? this.expression(item.initializer, scope) : Object.freeze({ kind: 'undefined', valueKind: 'undefined' });
834
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);
835
870
  scope.set(local.name, local);
836
871
  out.push(Object.freeze({ kind: 'local', localId: local.id, name: local.name, declaration, valueKind: local.valueKind, value, statementPath: itemPath }));
837
872
  }
@@ -907,6 +942,36 @@ class NativePlanBuilder {
907
942
  }
908
943
 
909
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
+ }
910
975
  const statementPath = formatStatementPath(pathParts);
911
976
  this.summary.maxStatementDepth = Math.max(this.summary.maxStatementDepth, depth);
912
977
 
@@ -915,20 +980,40 @@ class NativePlanBuilder {
915
980
  if (ts.isVariableStatement(statement)) return this.lowerVariableStatement(statement, scope, pathParts, depth);
916
981
 
917
982
  if (ts.isForStatement(statement)) {
918
- const loop = inspectBoundedPureLoop(statement, { ctxName: this.ctxName });
919
- for (const error of loop.errors) this.fail(error.node, 'PULSE_CANONICAL_PURE_LOOP_UNSUPPORTED', error.message);
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);
920
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
+ }
921
1002
  const loopScope = new Map(scope);
922
1003
  const local = this.allocateLocal(loop.name, 'number', statementPath, 'let');
923
1004
  loopScope.set(loop.name, local);
924
- const previousDepth = this.pureLoopDepth || 0;
925
- this.pureLoopDepth = previousDepth + 1;
1005
+ const depthField = readLoop ? 'readLoopDepth' : 'pureLoopDepth';
1006
+ const previousDepth = this[depthField] || 0;
1007
+ this[depthField] = previousDepth + 1;
926
1008
  const body = this.statement(statement.statement, loopScope, [...pathParts, 'body'], depth + 1);
927
- this.pureLoopDepth = previousDepth;
928
- return [Object.freeze({ kind: 'pure-loop', localId: local.id, maxIterations: loop.maxIterations,
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) } : {}),
929
1014
  test: this.expression(statement.condition, loopScope), body: Object.freeze(body), statementPath })];
930
1015
  }
931
- if ((ts.isBreakStatement(statement) || ts.isContinueStatement(statement)) && this.pureLoopDepth && !statement.label) {
1016
+ if ((ts.isBreakStatement(statement) || ts.isContinueStatement(statement)) && (this.pureLoopDepth || this.readLoopDepth) && !statement.label) {
932
1017
  return [Object.freeze({ kind: ts.isBreakStatement(statement) ? 'break' : 'continue', statementPath })];
933
1018
  }
934
1019
 
@@ -937,8 +1022,10 @@ class NativePlanBuilder {
937
1022
  const thenScope = new Map(scope);
938
1023
  const elseScope = new Map(scope);
939
1024
  const test = this.expression(statement.expression, scope);
1025
+ resultFacts.refine(test, true, thenScope);
1026
+ resultFacts.refine(test, false, elseScope);
940
1027
  // A const primitive cannot change between this guard and a branch read.
941
- // Keep mutable locals and the scope after the branch unrefined.
1028
+ // A returning branch also lets its surviving sibling dominate later reads.
942
1029
  if (test.kind === 'binary' && ['===', '!=='].includes(test.operator)) {
943
1030
  const read = test.left.kind === 'undefined' ? test.right : test.right.kind === 'undefined' ? test.left : undefined;
944
1031
  const local = read?.kind === 'local' ? scope.get(read.name) : undefined;
@@ -946,14 +1033,34 @@ class NativePlanBuilder {
946
1033
  (test.operator === '!==' ? thenScope : elseScope).set(local.name, Object.freeze({ ...local, valueKind: 'string' }));
947
1034
  }
948
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;
949
1041
  const thenBody = ts.isBlock(statement.thenStatement)
950
1042
  ? this.statementList(statement.thenStatement.statements, thenScope, [...pathParts, 'then'], depth + 1)
951
1043
  : this.statement(statement.thenStatement, thenScope, [...pathParts, 'then', 0], depth + 1);
1044
+ this.activeEntry = previousEntry;
952
1045
  const elseBody = !statement.elseStatement
953
1046
  ? []
954
1047
  : ts.isBlock(statement.elseStatement)
955
1048
  ? this.statementList(statement.elseStatement.statements, elseScope, [...pathParts, 'else'], depth + 1)
956
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
+ }
957
1064
  return [Object.freeze({
958
1065
  kind: 'if',
959
1066
  test,
@@ -997,6 +1104,35 @@ class NativePlanBuilder {
997
1104
  return out;
998
1105
  }
999
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
+
1000
1136
  reconcile() {
1001
1137
  const expectedEffectIds = (this.metadata.effectSites || []).map((site) => String(site.id));
1002
1138
  const actualEffectIds = this.effects.map((site) => site.id);
@@ -1019,8 +1155,9 @@ class NativePlanBuilder {
1019
1155
  this.reconcile();
1020
1156
  if (this.diagnostics.length > 0) throw new CanonicalNativePlanError(`Native-plan lowering failed for ${this.metadata.file}.`, this.diagnostics);
1021
1157
 
1022
- 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({
1023
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 } : {}),
1024
1161
  kind: String(site.kind),
1025
1162
  effectIds: Object.freeze(site.effectIds.map(String)),
1026
1163
  stateIndex: index + 1,
@@ -1043,12 +1180,13 @@ class NativePlanBuilder {
1043
1180
  eventType: site.eventType ? String(site.eventType) : undefined,
1044
1181
  eventSchemaId: site.eventSchemaId !== undefined ? site.eventSchemaId : undefined
1045
1182
  }));
1183
+ if (this.packageApplication) this.effects = [...this.packageApplication.effects];
1046
1184
  const states = [Object.freeze({ id: 'entry', kind: 'entry', stateIndex: 0 })]
1047
1185
  .concat(continuations.map((site) => Object.freeze({ id: site.id, kind: 'continuation', continuationKind: site.kind, effectIds: site.effectIds, stateIndex: site.stateIndex })));
1048
- this.summary = summarizeNativePlan(body, this.locals, this.effects, continuations);
1186
+ this.summary = summarizeNativePlan(body, this.locals, this.effects, continuations, this.handlers);
1049
1187
  const hasInboundEvents = Number(this.metadata.events && this.metadata.events.count || 0) > 0;
1050
1188
 
1051
- const unsigned = {
1189
+ let unsigned = {
1052
1190
  version: contract.CANONICAL_NATIVE_PLAN_VERSION,
1053
1191
  compilerVersion: CANONICAL_NATIVE_PLAN_COMPILER_VERSION,
1054
1192
  hashAlgorithm: contract.CANONICAL_NATIVE_PLAN_HASH_ALGORITHM,
@@ -1107,10 +1245,12 @@ class NativePlanBuilder {
1107
1245
  body: Object.freeze(body)
1108
1246
  }),
1109
1247
  locals: Object.freeze(this.locals),
1248
+ handlers: Object.freeze(this.handlers),
1249
+ ...(this.helpers.length ? { helpers: Object.freeze(this.helpers) } : {}),
1110
1250
  effects: Object.freeze(this.effects),
1111
1251
  continuations: Object.freeze(continuations),
1112
1252
  states: Object.freeze(states),
1113
- capabilities: Object.freeze([...(this.metadata.capabilities || [])].map(String).sort()),
1253
+ capabilities: Object.freeze([...new Set([...(this.metadata.capabilities || []), ...(this.packageApplication?.capabilities || [])])].map(String).sort()),
1114
1254
  schemas: Object.freeze({
1115
1255
  sourceHash: this.metadata.schemaSourceHash,
1116
1256
  ids: Object.freeze([...(this.metadata.schemaIds || [])].map(String)),
@@ -1124,9 +1264,12 @@ class NativePlanBuilder {
1124
1264
  ...(this.compiled.cryptoRealizationPlan && this.compiled.cryptoRealizationPlan.target === 'native' ? {
1125
1265
  crypto: deepFreeze(cloneJson(this.compiled.cryptoRealizationPlan))
1126
1266
  } : {}),
1127
- 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 } : {}) }),
1128
1269
  summary: Object.freeze({ ...this.summary })
1129
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 || [])]);
1130
1273
  const planHash = stableHash(stableStringify(unsigned));
1131
1274
  const plan = deepFreeze({ ...unsigned, planHash });
1132
1275
  assertCanonicalNativePlan(plan);
@@ -1172,6 +1315,7 @@ function walkExpression(expression, visit) {
1172
1315
  walkExpression(expression.object, visit);
1173
1316
  walkExpression(expression.index, visit);
1174
1317
  break;
1318
+ case 'pure-helper-call':
1175
1319
  case 'intrinsic':
1176
1320
  for (const argument of expression.arguments || []) walkExpression(argument, visit);
1177
1321
  break;
@@ -1197,12 +1341,17 @@ function walkExpression(expression, visit) {
1197
1341
  function walkStatements(statements, visitor) {
1198
1342
  for (const statement of statements || []) {
1199
1343
  visitor(statement);
1200
- 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);
1201
1346
  else if (statement.kind === 'if') {
1202
1347
  walkExpression(statement.test, visitor.expression);
1203
1348
  walkStatements(statement.then, visitor);
1204
1349
  walkStatements(statement.else, visitor);
1205
- } else if (statement.kind === 'pure-loop') {
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
+ }
1206
1355
  walkExpression(statement.test, visitor.expression);
1207
1356
  walkStatements(statement.body, visitor);
1208
1357
  } else if (statement.kind === 'return') walkExpression(statement.value, visitor.expression);
@@ -1216,7 +1365,7 @@ function countExpression(expression) {
1216
1365
  return count;
1217
1366
  }
1218
1367
 
1219
- function summarizeNativePlan(body, locals, effects, continuations) {
1368
+ function summarizeNativePlan(body, locals, effects, continuations, handlers = []) {
1220
1369
  const summary = {
1221
1370
  statementCount: 0,
1222
1371
  expressionCount: 0,
@@ -1239,12 +1388,15 @@ function summarizeNativePlan(body, locals, effects, continuations) {
1239
1388
  summary.expressionCount += countExpression(statement.test);
1240
1389
  visitStatements(statement.then, depth + 1);
1241
1390
  visitStatements(statement.else, depth + 1);
1242
- } else if (statement.kind === 'pure-loop') {
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);
1243
1393
  summary.expressionCount += countExpression(statement.test);
1244
1394
  visitStatements(statement.body, depth + 1);
1245
1395
  } else if (statement.kind === 'return') {
1246
1396
  summary.returnCount += 1;
1247
1397
  summary.expressionCount += countExpression(statement.value);
1398
+ } else if (statement.kind === 'helper-call') {
1399
+ for (const arg of statement.arguments || []) summary.expressionCount += countExpression(arg);
1248
1400
  } else if (statement.kind === 'expression') {
1249
1401
  summary.expressionCount += countExpression(statement.expression);
1250
1402
  } else if (statement.kind === 'effect') {
@@ -1256,6 +1408,7 @@ function summarizeNativePlan(body, locals, effects, continuations) {
1256
1408
  }
1257
1409
 
1258
1410
  visitStatements(body, 0);
1411
+ for (const handler of handlers) visitStatements(handler.body, 0);
1259
1412
  for (const effect of effects) {
1260
1413
  for (const input of effect.inputs || []) summary.expressionCount += countExpression(input.value);
1261
1414
  const decoder = effect.result && effect.result.decoder;
@@ -1286,30 +1439,121 @@ function validateResult(result, fail, localIds, detail = {}) {
1286
1439
  }
1287
1440
 
1288
1441
  function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1289
- function validateLoops(statements, counters = new Set(), product = 1) {
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) {
1290
1493
  for (const statement of statements || []) {
1291
- if (statement.kind === 'pure-loop') {
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';
1292
1497
  const limit = statement.maxIterations;
1293
- if (!localIds.has(statement.localId) || counters.has(statement.localId)) fail('pure loop requires its own local counter');
1294
- if (!Number.isInteger(limit) || limit < 0 || limit > contract.CANONICAL_PURE_LOOP_LIMITS.maxIterations
1295
- || product * Math.max(1, limit) > contract.CANONICAL_PURE_LOOP_LIMITS.maxNestedIterations) fail('pure loop iteration bound is invalid');
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
+ }
1296
1510
  let bound = statement.test;
1297
1511
  while (bound && bound.kind === 'binary' && bound.operator === '&&') bound = bound.left;
1298
1512
  if (!bound || bound.kind !== 'binary' || bound.operator !== '<' || bound.left?.kind !== 'local' || bound.left.id !== statement.localId
1299
- || bound.right?.kind !== 'literal' || bound.right.value !== limit) fail('pure loop test must start with its declared literal cap');
1513
+ || bound.right?.kind !== 'literal' || bound.right.value !== limit) fail(`${label} test must start with its declared literal cap`);
1300
1514
  const active = new Set([...counters, statement.localId]);
1301
1515
  checkPureExpression(statement.test, active, true);
1302
- validateLoops(statement.body, active, product * Math.max(1, limit));
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);
1303
1528
  } else {
1304
- if (counters.size && !['local', 'expression', 'if', 'break', 'continue'].includes(statement.kind)) fail('pure loop contains a non-value statement', { kind: statement.kind });
1305
- if (!counters.size && ['break', 'continue'].includes(statement.kind)) fail('loop transfer outside pure loop');
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');
1306
1541
  if (counters.size) {
1307
1542
  if (statement.kind === 'local' && counters.has(statement.localId)) fail('pure loop counter cannot be rebound');
1308
- checkPureExpression(statement.value || statement.expression || statement.test, counters);
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);
1309
1553
  }
1310
1554
  if (statement.kind === 'if') {
1311
- validateLoops(statement.then, counters, product);
1312
- validateLoops(statement.else, counters, product);
1555
+ validateLoops(statement.then, counters, product, mode, pureHelper);
1556
+ validateLoops(statement.else, counters, product, mode, pureHelper);
1313
1557
  }
1314
1558
  }
1315
1559
  }
@@ -1320,7 +1564,15 @@ function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1320
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');
1321
1565
  });
1322
1566
  }
1323
- validateLoops(plan.entry && plan.entry.body);
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);
1324
1576
  const visitor = (statement) => {
1325
1577
  if (!contract.CANONICAL_NATIVE_STATEMENT_KINDS.includes(statement.kind)) fail('statement kind is unknown', { kind: statement.kind });
1326
1578
  if (statement.kind === 'local' && !localIds.has(statement.localId)) fail('local statement references unknown local', { localId: statement.localId });
@@ -1337,6 +1589,73 @@ function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1337
1589
  };
1338
1590
  visitor.expression = (expression) => validateExpression(expression, fail, localIds);
1339
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
+ }
1340
1659
  }
1341
1660
 
1342
1661
  function assertCanonicalNativePlan(plan) {
@@ -1379,6 +1698,7 @@ function assertCanonicalNativePlan(plan) {
1379
1698
  }
1380
1699
  }
1381
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');
1382
1702
  if (!Array.isArray(plan.effects)) fail('plan effects must be an array');
1383
1703
  if (!Array.isArray(plan.continuations)) fail('plan continuations must be an array');
1384
1704
  if (!Array.isArray(plan.states)) fail('plan states must be an array');
@@ -1438,11 +1758,36 @@ function assertCanonicalNativePlan(plan) {
1438
1758
  fail('plan logging evidence is invalid', { logging: plan.logging });
1439
1759
  }
1440
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
+ }
1441
1764
  const unsigned = { ...plan };
1442
1765
  delete unsigned.planHash;
1443
1766
  const expectedHash = stableHash(stableStringify(unsigned));
1444
1767
  if (plan.planHash !== expectedHash) fail('plan hash mismatch', { expectedHash, actual: plan.planHash });
1445
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');
1446
1791
  const locals = Array.isArray(plan.locals) ? plan.locals : [];
1447
1792
  const effects = Array.isArray(plan.effects) ? plan.effects : [];
1448
1793
  const continuations = Array.isArray(plan.continuations) ? plan.continuations : [];
@@ -1480,6 +1825,9 @@ function assertCanonicalNativePlan(plan) {
1480
1825
  }
1481
1826
 
1482
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);
1483
1831
 
1484
1832
  const states = Array.isArray(plan.states) ? plan.states : [];
1485
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 });
@@ -1508,10 +1856,11 @@ function assertCanonicalNativePlan(plan) {
1508
1856
  };
1509
1857
  statementVisitor.expression = () => {};
1510
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);
1511
1860
 
1512
1861
  if (!plan.summary || typeof plan.summary !== 'object') fail('plan summary is required');
1513
1862
  else {
1514
- 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 || [])]);
1515
1864
  for (const [key, expected] of Object.entries(expectedSummary)) {
1516
1865
  if (plan.summary[key] !== expected) fail(`summary ${key} mismatch`, { expected, actual: plan.summary[key] });
1517
1866
  }