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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
  > **Stability:** Internal compiler interface; no application-author compatibility guarantee.<br>
9
9
  > **npm:** [`@pulse-compute/wasm-compiler`](https://www.npmjs.com/package/@pulse-compute/wasm-compiler)<br>
10
10
  > **Canonical replacement:** `@pulse-compute/cli`<br>
11
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.4/packages/implementation-packages/)
11
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/)
12
12
  >
13
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.4` package policy.
13
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
14
14
  <!-- pulse-package-status:end -->
15
15
 
16
16
  Whole-project compiler implementation used by the `pulse` workflow. Application projects should use `@pulse-compute/cli`, not import this package directly.
@@ -52,4 +52,4 @@ The package exports its root implementation plus focused compiler, CLI, extracto
52
52
 
53
53
  Compiler changes should be covered by focused static/lowering tests, canonical project inspection, provider-plan parity, packed-package discovery where relevant, and the release documentation gates.
54
54
 
55
- See the installed [implementation package guide](https://pulsecompute.io/v1.0.0-beta.4/packages/implementation-packages/) and [compilation concepts](https://pulsecompute.io/v1.0.0-beta.4/concepts/compilation-and-lowering/).
55
+ See the installed [implementation package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/) and [compilation concepts](https://pulsecompute.io/v1.0.0-beta.5/concepts/compilation-and-lowering/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/wasm-compiler",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.5",
4
4
  "license": "Apache-2.0",
5
5
  "description": "PulseWasm compiler package inside the Pulse monorepo.",
6
6
  "type": "commonjs",
@@ -9,15 +9,15 @@
9
9
  "pulsewasm": "./bin/pulsewasm-extract.js"
10
10
  },
11
11
  "dependencies": {
12
- "@pulse-compute/provider-fastly": "1.0.0-beta.4",
13
- "@pulse-compute/provider-node": "1.0.0-beta.4",
14
- "@pulse-compute/wasm-build-support": "1.0.0-beta.4",
15
- "@pulse-compute/wasm-contracts": "1.0.0-beta.4",
16
- "@pulse-compute/wasm-guest-link": "1.0.0-beta.4",
17
- "@pulse-compute/wasm-host-runtime": "1.0.0-beta.4",
18
- "@pulse-compute/wasm-library-kit": "1.0.0-beta.4",
19
- "@pulse-compute/wasm-runtime-core-as": "1.0.0-beta.4",
20
- "@pulse-compute/wasm-schema-json": "1.0.0-beta.4",
12
+ "@pulse-compute/provider-fastly": "1.0.0-beta.5",
13
+ "@pulse-compute/provider-node": "1.0.0-beta.5",
14
+ "@pulse-compute/wasm-build-support": "1.0.0-beta.5",
15
+ "@pulse-compute/wasm-contracts": "1.0.0-beta.5",
16
+ "@pulse-compute/wasm-guest-link": "1.0.0-beta.5",
17
+ "@pulse-compute/wasm-host-runtime": "1.0.0-beta.5",
18
+ "@pulse-compute/wasm-library-kit": "1.0.0-beta.5",
19
+ "@pulse-compute/wasm-runtime-core-as": "1.0.0-beta.5",
20
+ "@pulse-compute/wasm-schema-json": "1.0.0-beta.5",
21
21
  "assemblyscript": "0.28.18",
22
22
  "json-as": "1.5.0",
23
23
  "typescript": "5.9.3"
@@ -58,7 +58,7 @@
58
58
  "url": "git+https://github.com/pulse-compute/pulse.git",
59
59
  "directory": "wasm/packages/compiler"
60
60
  },
61
- "homepage": "https://pulsecompute.io/v1.0.0-beta.4/packages/implementation-packages/",
61
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/",
62
62
  "bugs": {
63
63
  "url": "https://github.com/pulse-compute/pulse/issues"
64
64
  },
@@ -230,6 +230,11 @@ function realizeCanonicalNativePlan(plan, options = {}, providerRequirements) {
230
230
  '--optimize'
231
231
  ];
232
232
  const optimization = appendAssemblyScriptOptimizationArgs(args, options.nativeOptimization);
233
+ // The text capacity profile bounds each Native module to 256 MiB.
234
+ // Linked guests retain their separately owned fixed-memory ABI.
235
+ if (guestUnits.length === 0 && plan.effects.some(effect => effect.kind === 'crypto.digestText' || effect.kind.startsWith('s3.'))) {
236
+ args.push('--maximumMemory', '4096');
237
+ }
233
238
  if (guestUnits.length > 0) {
234
239
  args.push(
235
240
  '--importMemory',
@@ -5,6 +5,7 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const ts = require('typescript');
7
7
  const { executeCanonicalNativePlanSpine } = require('./spine/canonical-native-plan.js');
8
+ const { inspectBoundedPureLoop } = require('./spine/bounded-pure-loop.js');
8
9
 
9
10
  function loadNativePlanContract() {
10
11
  try { return require('@pulse-compute/wasm-contracts/handler/canonical-native-plan'); }
@@ -203,6 +204,7 @@ function intrinsicForContextCall(parts) {
203
204
  'req.json': ['request.json', 'json'],
204
205
  json: ['response.json', 'pulse-result'],
205
206
  encodeJson: ['schema.encode.text', 'string'],
207
+ decodeJson: ['schema.decode.text', 'json'],
206
208
  text: ['response.text', 'pulse-result'],
207
209
  response: ['response.custom', 'pulse-result'],
208
210
  kv: ['kv.namespace', 'kv-namespace'],
@@ -221,6 +223,7 @@ function resultKindForEffect(site, decoder, continuation, resultMode) {
221
223
  if (site.result === 'ack') return 'ack';
222
224
  if (site.kind === 'fetch') return 'fetch-response';
223
225
  if (site.kind === 'config.get' || site.kind === 'secret.get') return 'string-or-undefined';
226
+ if (site.kind === 'time.now') return 'json';
224
227
  if (site.kind === 'kv.get') return 'json-or-undefined';
225
228
  if (site.kind === 'kv.put') return 'ack';
226
229
  if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(site.kind)) return 'json';
@@ -443,7 +446,7 @@ class NativePlanBuilder {
443
446
  if (ts.isBinaryExpression(current)) {
444
447
  const operator = current.operatorToken.getText(this.sourceFile);
445
448
  if (ASSIGNMENT_OPERATORS.has(operator)) {
446
- const target = this.expression(current.left, scope);
449
+ const target = this.assignmentTarget(current.left, scope);
447
450
  const value = this.expression(current.right, scope);
448
451
  return Object.freeze({ kind: 'assignment', operator, target, value, valueKind: value.valueKind || 'unknown' });
449
452
  }
@@ -458,7 +461,7 @@ class NativePlanBuilder {
458
461
  if (ts.isPrefixUnaryExpression(current)) {
459
462
  const operator = ts.tokenToString(current.operator) || current.getText(this.sourceFile).slice(0, 1);
460
463
  if (UPDATE_OPERATORS.has(operator)) {
461
- return Object.freeze({ kind: 'update', operator, prefix: true, target: this.expression(current.operand, scope), valueKind: 'number' });
464
+ return Object.freeze({ kind: 'update', operator, prefix: true, target: this.assignmentTarget(current.operand, scope), valueKind: 'number' });
462
465
  }
463
466
  if (!PREFIX_OPERATORS.has(operator)) {
464
467
  this.fail(current, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, `Prefix operator ${operator} is outside the canonical native value model.`, { operator });
@@ -470,7 +473,7 @@ class NativePlanBuilder {
470
473
  if (ts.isPostfixUnaryExpression(current)) {
471
474
  const operator = ts.tokenToString(current.operator) || current.getText(this.sourceFile).slice(-2);
472
475
  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' });
476
+ return Object.freeze({ kind: 'update', operator, prefix: false, target: this.assignmentTarget(current.operand, scope), valueKind: 'number' });
474
477
  }
475
478
 
476
479
  if (ts.isConditionalExpression(current)) {
@@ -587,6 +590,9 @@ class NativePlanBuilder {
587
590
  if (ts.isPropertyAccessExpression(target)) {
588
591
  const receiver = this.expression(target.expression, scope);
589
592
  const method = target.name.text;
593
+ if (method === 'trim' && !target.questionDotToken && !call.questionDotToken && call.arguments.length === 0) {
594
+ return Object.freeze({ kind: 'method-call', receiver, method: 'string.trim', arguments: Object.freeze([]), valueKind: 'string' });
595
+ }
590
596
  if (!FETCH_RESPONSE_METHODS.has(method) || receiver.valueKind !== 'fetch-response') {
591
597
  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
598
  }
@@ -607,6 +613,14 @@ class NativePlanBuilder {
607
613
  return Object.freeze({ kind: 'intrinsic', name: 'unsupported:call', arguments: Object.freeze([]), valueKind: 'unknown' });
608
614
  }
609
615
 
616
+ assignmentTarget(node, scope) {
617
+ const target = this.expression(node, scope);
618
+ if (target.kind === 'local' && scope.get(target.name)?.declaration === 'const') {
619
+ this.fail(node, contract.CANONICAL_NATIVE_PLAN_DIAGNOSTIC_CODES.EXPRESSION_UNSUPPORTED, 'A const local cannot be reassigned.', { name: target.name });
620
+ }
621
+ return target;
622
+ }
623
+
610
624
  allocateLocal(name, valueKind, statementPath, declaration) {
611
625
  this.localIndex += 1;
612
626
  const local = Object.freeze({
@@ -900,10 +914,38 @@ class NativePlanBuilder {
900
914
 
901
915
  if (ts.isVariableStatement(statement)) return this.lowerVariableStatement(statement, scope, pathParts, depth);
902
916
 
917
+ 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);
920
+ if (loop.errors.length) return [];
921
+ const loopScope = new Map(scope);
922
+ const local = this.allocateLocal(loop.name, 'number', statementPath, 'let');
923
+ loopScope.set(loop.name, local);
924
+ const previousDepth = this.pureLoopDepth || 0;
925
+ this.pureLoopDepth = previousDepth + 1;
926
+ 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,
929
+ test: this.expression(statement.condition, loopScope), body: Object.freeze(body), statementPath })];
930
+ }
931
+ if ((ts.isBreakStatement(statement) || ts.isContinueStatement(statement)) && this.pureLoopDepth && !statement.label) {
932
+ return [Object.freeze({ kind: ts.isBreakStatement(statement) ? 'break' : 'continue', statementPath })];
933
+ }
934
+
903
935
  if (ts.isIfStatement(statement)) {
904
936
  this.summary.branchCount += 1;
905
937
  const thenScope = new Map(scope);
906
938
  const elseScope = new Map(scope);
939
+ const test = this.expression(statement.expression, scope);
940
+ // A const primitive cannot change between this guard and a branch read.
941
+ // Keep mutable locals and the scope after the branch unrefined.
942
+ if (test.kind === 'binary' && ['===', '!=='].includes(test.operator)) {
943
+ const read = test.left.kind === 'undefined' ? test.right : test.right.kind === 'undefined' ? test.left : undefined;
944
+ const local = read?.kind === 'local' ? scope.get(read.name) : undefined;
945
+ if (local?.declaration === 'const' && local.valueKind === 'string-or-undefined') {
946
+ (test.operator === '!==' ? thenScope : elseScope).set(local.name, Object.freeze({ ...local, valueKind: 'string' }));
947
+ }
948
+ }
907
949
  const thenBody = ts.isBlock(statement.thenStatement)
908
950
  ? this.statementList(statement.thenStatement.statements, thenScope, [...pathParts, 'then'], depth + 1)
909
951
  : this.statement(statement.thenStatement, thenScope, [...pathParts, 'then', 0], depth + 1);
@@ -914,7 +956,7 @@ class NativePlanBuilder {
914
956
  : this.statement(statement.elseStatement, elseScope, [...pathParts, 'else', 0], depth + 1);
915
957
  return [Object.freeze({
916
958
  kind: 'if',
917
- test: this.expression(statement.expression, scope),
959
+ test,
918
960
  then: Object.freeze(thenBody),
919
961
  else: Object.freeze(elseBody),
920
962
  statementPath
@@ -1160,6 +1202,9 @@ function walkStatements(statements, visitor) {
1160
1202
  walkExpression(statement.test, visitor.expression);
1161
1203
  walkStatements(statement.then, visitor);
1162
1204
  walkStatements(statement.else, visitor);
1205
+ } else if (statement.kind === 'pure-loop') {
1206
+ walkExpression(statement.test, visitor.expression);
1207
+ walkStatements(statement.body, visitor);
1163
1208
  } else if (statement.kind === 'return') walkExpression(statement.value, visitor.expression);
1164
1209
  else if (statement.kind === 'expression') walkExpression(statement.expression, visitor.expression);
1165
1210
  }
@@ -1194,6 +1239,9 @@ function summarizeNativePlan(body, locals, effects, continuations) {
1194
1239
  summary.expressionCount += countExpression(statement.test);
1195
1240
  visitStatements(statement.then, depth + 1);
1196
1241
  visitStatements(statement.else, depth + 1);
1242
+ } else if (statement.kind === 'pure-loop') {
1243
+ summary.expressionCount += countExpression(statement.test);
1244
+ visitStatements(statement.body, depth + 1);
1197
1245
  } else if (statement.kind === 'return') {
1198
1246
  summary.returnCount += 1;
1199
1247
  summary.expressionCount += countExpression(statement.value);
@@ -1238,6 +1286,41 @@ function validateResult(result, fail, localIds, detail = {}) {
1238
1286
  }
1239
1287
 
1240
1288
  function validatePlanTree(plan, fail, localIds, effectIds, continuationIds) {
1289
+ function validateLoops(statements, counters = new Set(), product = 1) {
1290
+ for (const statement of statements || []) {
1291
+ if (statement.kind === 'pure-loop') {
1292
+ 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');
1296
+ let bound = statement.test;
1297
+ while (bound && bound.kind === 'binary' && bound.operator === '&&') bound = bound.left;
1298
+ 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');
1300
+ const active = new Set([...counters, statement.localId]);
1301
+ checkPureExpression(statement.test, active, true);
1302
+ validateLoops(statement.body, active, product * Math.max(1, limit));
1303
+ } 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');
1306
+ if (counters.size) {
1307
+ if (statement.kind === 'local' && counters.has(statement.localId)) fail('pure loop counter cannot be rebound');
1308
+ checkPureExpression(statement.value || statement.expression || statement.test, counters);
1309
+ }
1310
+ if (statement.kind === 'if') {
1311
+ validateLoops(statement.then, counters, product);
1312
+ validateLoops(statement.else, counters, product);
1313
+ }
1314
+ }
1315
+ }
1316
+ }
1317
+ function checkPureExpression(expression, counters, test = false) {
1318
+ walkExpression(expression, node => {
1319
+ 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');
1320
+ 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
+ });
1322
+ }
1323
+ validateLoops(plan.entry && plan.entry.body);
1241
1324
  const visitor = (statement) => {
1242
1325
  if (!contract.CANONICAL_NATIVE_STATEMENT_KINDS.includes(statement.kind)) fail('statement kind is unknown', { kind: statement.kind });
1243
1326
  if (statement.kind === 'local' && !localIds.has(statement.localId)) fail('local statement references unknown local', { localId: statement.localId });
@@ -38,7 +38,10 @@ function packageStatus(module, graphBuild) {
38
38
  .find((entry) => entry.packageName === module.packageName);
39
39
  if (product) {
40
40
  const target = product.javascriptTarget;
41
- if (target && target.status === 'supported') {
41
+ // Source loadability is separate from selected-provider eligibility. The
42
+ // target-support owner checks provider-dependent requirements before build.
43
+ if (target && ['supported', 'provider-dependent'].includes(target.status)
44
+ && target.realization === 'javascript-package-runtime' && target.entry) {
42
45
  return Object.freeze({ status: 'package-runtime', runtimeEntry: target.entry, reasonCode: null });
43
46
  }
44
47
  return Object.freeze({
@@ -92,7 +92,8 @@ function classifyPackage(reachable, planned, product, context, options = {}) {
92
92
  }
93
93
 
94
94
  const targetStatus = product && product.javascriptTarget && product.javascriptTarget.status;
95
- if (targetStatus === 'not-realized' || targetStatus === 'provider-dependent') {
95
+ if (targetStatus === 'not-realized'
96
+ || (targetStatus === 'provider-dependent' && (!planned || planned.status !== 'package-runtime'))) {
96
97
  return Object.freeze({ ...common, status: 'pending', reasonId: 'package-javascript-realization-pending', owner: pendingPackageOwner(product) });
97
98
  }
98
99
  if (targetStatus === 'unsupported') {
@@ -335,7 +336,14 @@ function buildJavascriptTargetSupportEvidence(project, options = {}, policy = {}
335
336
  const capabilities = Object.freeze(compilerCapabilities
336
337
  .map((id) => policy.classifyCapability(id, context))
337
338
  .sort((a, b) => a.id.localeCompare(b.id)));
338
- const providerRequirements = Object.freeze(requirementsFromMetadata(compiled.metadata)
339
+ const requiredContracts = new Set(packages.filter((entry) => entry.required).map((entry) => entry.contractId));
340
+ const packageRequirements = (compiled.packageProduct && compiled.packageProduct.contracts || [])
341
+ .filter((entry) => requiredContracts.has(entry.contractId) && entry.javascriptTarget
342
+ && entry.javascriptTarget.status === 'provider-dependent')
343
+ .flatMap((entry) => entry.javascriptTarget.providerRequirements || []);
344
+ const providerRequirements = Object.freeze([...new Set([
345
+ ...requirementsFromMetadata(compiled.metadata), ...packageRequirements
346
+ ])]
339
347
  .map((id) => policy.classifyProviderRequirement(id, compilerCapabilities, context))
340
348
  .sort((a, b) => a.id.localeCompare(b.id)));
341
349
  const status = expectedProjectStatus(application, capabilities, providerRequirements, packages);
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const ts = require('typescript');
4
+ function loadContract() {
5
+ try { return require('@pulse-compute/wasm-contracts/handler/canonical-native-plan'); }
6
+ catch (error) {
7
+ if (error && ['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(error.code)) return require('../../../contracts/src/handler/canonical-native-plan.js');
8
+ throw error;
9
+ }
10
+ }
11
+ const { CANONICAL_PURE_LOOP_LIMITS } = loadContract();
12
+
13
+ function unwrap(node) {
14
+ while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
15
+ return node;
16
+ }
17
+ function number(node, expected) {
18
+ node = unwrap(node);
19
+ return node && ts.isNumericLiteral(node) && Number(node.text) === expected;
20
+ }
21
+ function named(node, name) { node = unwrap(node); return node && ts.isIdentifier(node) && node.text === name; }
22
+ function firstConjunct(node) {
23
+ node = unwrap(node);
24
+ while (node && ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) node = unwrap(node.left);
25
+ return node;
26
+ }
27
+
28
+ // Shared admission for original-source JavaScript and generated Native plans.
29
+ // The literal bound is the first conjunct, so the terminating test cannot run
30
+ // extra application expressions after the counter reaches its cap.
31
+ function inspectBoundedPureLoop(statement, options = {}) {
32
+ const errors = [];
33
+ function fail(node, message) { errors.push({ node, message }); }
34
+ function expression(input, counters, mutation = true) {
35
+ const node = unwrap(input);
36
+ if (!node) return;
37
+ if (ts.isIdentifier(node)) {
38
+ if (node.text === options.ctxName) fail(node, 'Read context values before entering a pure loop.');
39
+ return;
40
+ }
41
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node)
42
+ || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(node.kind)) return;
43
+ if (ts.isPropertyAccessExpression(node)) {
44
+ if (node.questionDotToken) fail(node, 'Optional access is outside the bounded pure loop subset.');
45
+ expression(node.expression, counters, mutation); return;
46
+ }
47
+ if (ts.isElementAccessExpression(node)) {
48
+ if (node.questionDotToken) fail(node, 'Optional access is outside the bounded pure loop subset.');
49
+ expression(node.expression, counters, mutation); expression(node.argumentExpression, counters, mutation); return;
50
+ }
51
+ if (ts.isCallExpression(node)) {
52
+ const target = unwrap(node.expression);
53
+ if (!ts.isPropertyAccessExpression(target) || target.name.text !== 'trim' || node.arguments.length || node.questionDotToken || target.questionDotToken) {
54
+ fail(node, 'Pure loops admit only zero-argument string .trim() calls; effects, helpers and callbacks are separate contracts.'); return;
55
+ }
56
+ expression(target.expression, counters, false); return;
57
+ }
58
+ if (ts.isBinaryExpression(node)) {
59
+ const assignment = node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
60
+ const target = unwrap(node.left);
61
+ if (assignment && !ts.isIdentifier(target) && !ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) fail(node, 'Destructuring assignment is outside the pure loop subset.');
62
+ if (assignment && (!mutation || [...counters].some(name => named(node.left, name)))) fail(node, 'Loop tests cannot mutate values, and loop counters cannot be assigned in the body.');
63
+ expression(node.left, counters, mutation); expression(node.right, counters, mutation); return;
64
+ }
65
+ if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
66
+ const update = node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken;
67
+ if (update && (!mutation || [...counters].some(name => named(node.operand, name)))) fail(node, 'Loop tests cannot mutate values, and loop counters cannot be updated in the body.');
68
+ expression(node.operand, counters, mutation); return;
69
+ }
70
+ if (ts.isConditionalExpression(node)) {
71
+ expression(node.condition, counters, mutation); expression(node.whenTrue, counters, mutation); expression(node.whenFalse, counters, mutation); return;
72
+ }
73
+ if (ts.isTypeOfExpression(node) || ts.isVoidExpression(node)) { expression(node.expression, counters, mutation); return; }
74
+ if (ts.isArrayLiteralExpression(node)) {
75
+ for (const item of node.elements) expression(ts.isSpreadElement(item) ? item.expression : item, counters, mutation);
76
+ return;
77
+ }
78
+ if (ts.isObjectLiteralExpression(node)) {
79
+ for (const item of node.properties) {
80
+ if (ts.isSpreadAssignment(item)) expression(item.expression, counters, mutation);
81
+ else if (ts.isShorthandPropertyAssignment(item)) expression(item.name, counters, mutation);
82
+ else if (ts.isPropertyAssignment(item)) {
83
+ if (ts.isComputedPropertyName(item.name)) expression(item.name.expression, counters, mutation);
84
+ expression(item.initializer, counters, mutation);
85
+ } else fail(item, 'Methods and accessors are outside pure loop values.');
86
+ }
87
+ return;
88
+ }
89
+ if (ts.isTemplateExpression(node)) { for (const span of node.templateSpans) expression(span.expression, counters, mutation); return; }
90
+ fail(node, `${ts.SyntaxKind[node.kind]} is outside bounded pure loop expressions.`);
91
+ }
92
+ function body(node, counters, product) {
93
+ if (ts.isBlock(node)) { for (const child of node.statements) body(child, counters, product); return; }
94
+ if (ts.isForStatement(node)) { loop(node, counters, product); return; }
95
+ if (ts.isVariableStatement(node)) {
96
+ if (!(node.declarationList.flags & ts.NodeFlags.BlockScoped)) fail(node, 'Pure loop locals must use let or const.');
97
+ for (const declaration of node.declarationList.declarations) {
98
+ if (!ts.isIdentifier(declaration.name) || counters.has(declaration.name.text)) fail(declaration, 'Loop locals require identifiers and cannot shadow an active counter.');
99
+ if (declaration.initializer) expression(declaration.initializer, counters);
100
+ }
101
+ return;
102
+ }
103
+ if (ts.isIfStatement(node)) {
104
+ expression(node.expression, counters); body(node.thenStatement, counters, product);
105
+ if (node.elseStatement) body(node.elseStatement, counters, product); return;
106
+ }
107
+ if (ts.isExpressionStatement(node)) { expression(node.expression, counters); return; }
108
+ if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) {
109
+ if (node.label) fail(node, 'Pure loop break and continue must be unlabelled.');
110
+ return;
111
+ }
112
+ if (ts.isEmptyStatement(node)) return;
113
+ fail(node, 'Pure loop bodies accept locals, value assignments, if/else, nested bounded for, break and continue only.');
114
+ }
115
+ function loop(node, inherited, product) {
116
+ const list = node.initializer;
117
+ const declaration = list && ts.isVariableDeclarationList(list) && list.declarations.length === 1 && list.declarations[0];
118
+ if (!declaration || !(list.flags & ts.NodeFlags.Let) || !ts.isIdentifier(declaration.name) || !number(declaration.initializer, 0)) {
119
+ fail(node, 'A bounded pure loop starts with for (let index = 0; ...; index++).'); return;
120
+ }
121
+ const name = declaration.name.text;
122
+ const bound = firstConjunct(node.condition);
123
+ if (!bound || !ts.isBinaryExpression(bound) || !named(bound.left, name) || bound.operatorToken.kind !== ts.SyntaxKind.LessThanToken || !ts.isNumericLiteral(unwrap(bound.right))) {
124
+ fail(node, 'The first loop condition must be index < a literal iteration cap.'); return;
125
+ }
126
+ const maxIterations = Number(unwrap(bound.right).text);
127
+ if (!Number.isInteger(maxIterations) || maxIterations < 0 || maxIterations > CANONICAL_PURE_LOOP_LIMITS.maxIterations || product * Math.max(1, maxIterations) > CANONICAL_PURE_LOOP_LIMITS.maxNestedIterations) {
128
+ fail(node, 'The literal loop cap or nested iteration product exceeds the portable limit.'); return;
129
+ }
130
+ const increment = unwrap(node.incrementor);
131
+ if (!increment || !((ts.isPostfixUnaryExpression(increment) || ts.isPrefixUnaryExpression(increment)) && increment.operator === ts.SyntaxKind.PlusPlusToken && named(increment.operand, name))
132
+ && !(ts.isBinaryExpression(increment) && increment.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken && named(increment.left, name) && number(increment.right, 1))) {
133
+ fail(node, 'The loop increment must be index++ or index += 1.'); return;
134
+ }
135
+ if (inherited.has(name)) fail(declaration, 'Nested loop counters cannot shadow active counters.');
136
+ const counters = new Set([...inherited, name]);
137
+ expression(node.condition, counters, false);
138
+ body(node.statement, counters, product * Math.max(1, maxIterations));
139
+ return { name, maxIterations };
140
+ }
141
+ const result = loop(statement, new Set(), 1);
142
+ return { ...result, errors };
143
+ }
144
+
145
+ module.exports = { inspectBoundedPureLoop };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const ts = require('typescript');
4
+ const { inspectBoundedPureLoop } = require('./bounded-pure-loop.js');
4
5
  const {
5
6
  extractFetchChain,
6
7
  extractKvNamespaceDeclaration,
@@ -185,25 +186,35 @@ function buildPlainHandlerIr(frontend, options = {}) {
185
186
  const continuationSites = [];
186
187
  const effectCounters = new Map();
187
188
  let continuationIndex = 0;
188
- const packageEffectsByStart = new Map();
189
+ // Offsets belong to a source file. Project-wide contributions can share an
190
+ // offset; only duplicate ranges within the same owning source are invalid.
191
+ const sourceKey = (file, start) => `${String(file).replace(/\\/g, '/')}\u0000${start}`;
192
+ const inputKey = (input, start) => sourceKey(input.loc && input.loc.file || sourceFile.fileName, start);
193
+ const callKey = (call) => {
194
+ const owner = call.getSourceFile() || sourceFile;
195
+ return sourceKey(owner.fileName, call.getStart(owner));
196
+ };
197
+ const packageEffectsBySource = new Map();
189
198
  for (const effectInput of options.packageEffects || []) {
190
199
  if (!effectInput || effectInput.version !== canonicalRuntimeContract.CANONICAL_PACKAGE_EFFECT_VERSION || !effectInput.range) continue;
191
200
  const start = Number(effectInput.range.start);
192
- if (!Number.isSafeInteger(start) || packageEffectsByStart.has(start)) {
201
+ const key = inputKey(effectInput, start);
202
+ if (!Number.isSafeInteger(start) || packageEffectsBySource.has(key)) {
193
203
  diagnostics.push(diagnostic(sourceFile, handler, 'PULSE_CANONICAL_PACKAGE_EFFECT_RANGE_INVALID', 'Package-owned canonical effects require unique source ranges.', { effect: effectInput }));
194
204
  continue;
195
205
  }
196
- packageEffectsByStart.set(start, effectInput);
206
+ packageEffectsBySource.set(key, effectInput);
197
207
  }
198
- const packageIntrinsicsByStart = new Map();
199
- const linkedPackageIntrinsicsByStart = new Map();
208
+ const packageIntrinsicsBySource = new Map();
209
+ const linkedPackageIntrinsicsBySource = new Map();
200
210
  for (const intrinsicInput of options.packageIntrinsics || []) {
201
211
  const start = Number(intrinsicInput && intrinsicInput.range && intrinsicInput.range.start);
202
- if (!Number.isSafeInteger(start) || packageIntrinsicsByStart.has(start)) {
212
+ const key = intrinsicInput && inputKey(intrinsicInput, start);
213
+ if (!Number.isSafeInteger(start) || packageIntrinsicsBySource.has(key)) {
203
214
  diagnostics.push(diagnostic(sourceFile, handler, 'PULSE_CANONICAL_PACKAGE_INTRINSIC_RANGE_INVALID', 'Package-owned intrinsics require unique source ranges.', { intrinsic: intrinsicInput }));
204
215
  continue;
205
216
  }
206
- packageIntrinsicsByStart.set(start, intrinsicInput);
217
+ packageIntrinsicsBySource.set(key, intrinsicInput);
207
218
  }
208
219
 
209
220
  function unwrapPackageCall(expression) {
@@ -222,7 +233,7 @@ function buildPlainHandlerIr(frontend, options = {}) {
222
233
  return linked;
223
234
  }
224
235
  }
225
- const effect = packageEffectsByStart.get(call.getStart(sourceFile));
236
+ const effect = packageEffectsBySource.get(callKey(call));
226
237
  if (effect) linkPackageIntrinsicsWithin(call);
227
238
  return effect;
228
239
  }
@@ -232,9 +243,11 @@ function buildPlainHandlerIr(frontend, options = {}) {
232
243
  if (typeof options.packageIntrinsicForCall === 'function') {
233
244
  const linked = options.packageIntrinsicForCall(call);
234
245
  if (linked) {
235
- const start = call.getStart(sourceFile);
236
- if (!linkedPackageIntrinsicsByStart.has(start)) {
237
- linkedPackageIntrinsicsByStart.set(start, Object.freeze({
246
+ const owner = call.getSourceFile() || sourceFile;
247
+ const start = call.getStart(owner);
248
+ const key = callKey(call);
249
+ if (!linkedPackageIntrinsicsBySource.has(key)) {
250
+ linkedPackageIntrinsicsBySource.set(key, Object.freeze({
238
251
  ...linked,
239
252
  range: Object.freeze({ start, end: call.getEnd() })
240
253
  }));
@@ -242,9 +255,9 @@ function buildPlainHandlerIr(frontend, options = {}) {
242
255
  return linked;
243
256
  }
244
257
  }
245
- const start = call.getStart(sourceFile);
246
- const intrinsic = packageIntrinsicsByStart.get(start);
247
- if (intrinsic && !linkedPackageIntrinsicsByStart.has(start)) linkedPackageIntrinsicsByStart.set(start, intrinsic);
258
+ const key = callKey(call);
259
+ const intrinsic = packageIntrinsicsBySource.get(key);
260
+ if (intrinsic && !linkedPackageIntrinsicsBySource.has(key)) linkedPackageIntrinsicsBySource.set(key, intrinsic);
248
261
  return intrinsic;
249
262
  }
250
263
 
@@ -623,8 +636,13 @@ function buildPlainHandlerIr(frontend, options = {}) {
623
636
  return createHandlerOperation('opaque-fetch-return', { statement, chain, site, continuation });
624
637
  }
625
638
  }
626
- if (ts.isTryStatement(statement) || ts.isForStatement(statement) || ts.isForOfStatement(statement) || ts.isWhileStatement(statement) || ts.isDoStatement(statement) || ts.isSwitchStatement(statement)) {
627
- diagnostics.push(diagnostic(sourceFile, statement, 'PULSE_CANONICAL_CONTROL_FLOW_UNSUPPORTED', 'The current canonical lowering supports straight-line code and if/else branching; loops, switch, and try/catch are reserved.'));
639
+ if (ts.isForStatement(statement)) {
640
+ const loop = inspectBoundedPureLoop(statement, { ctxName });
641
+ for (const error of loop.errors) diagnostics.push(diagnostic(sourceFile, error.node, 'PULSE_CANONICAL_PURE_LOOP_UNSUPPORTED', error.message));
642
+ return createHandlerOperation('source-statement', { statement, role: 'bounded-pure-loop' });
643
+ }
644
+ if (ts.isTryStatement(statement) || ts.isForInStatement(statement) || ts.isForOfStatement(statement) || ts.isWhileStatement(statement) || ts.isDoStatement(statement) || ts.isSwitchStatement(statement)) {
645
+ diagnostics.push(diagnostic(sourceFile, statement, 'PULSE_CANONICAL_CONTROL_FLOW_UNSUPPORTED', 'Canonical lowering supports if/else and literal-capped pure for loops; other loops, switch, and try/catch are reserved.'));
628
646
  }
629
647
  return createHandlerOperation('source-statement', { statement, role: 'preserved-source' });
630
648
  }
@@ -709,11 +727,9 @@ function buildPlainHandlerIr(frontend, options = {}) {
709
727
  effectSites: Object.freeze(effectSites),
710
728
  continuationSites: Object.freeze(continuationSites),
711
729
  packageEffects: Object.freeze([...(options.packageEffects || [])]),
712
- packageIntrinsics: Object.freeze(
713
- typeof options.packageIntrinsicForCall === 'function'
714
- ? [...linkedPackageIntrinsicsByStart.values()]
715
- : [...(options.packageIntrinsics || [])]
716
- ),
730
+ // The emitter consumes this handler's linked ranges, not the project-wide
731
+ // recognition index. Keep original loc metadata when rebasing a range.
732
+ packageIntrinsics: Object.freeze([...linkedPackageIntrinsicsBySource.values()]),
717
733
  packageResultAdapters: Object.freeze([...(options.packageResultAdapters || [])]),
718
734
  summary: Object.freeze({
719
735
  operationCount: operationSummary.operationCount,
@@ -174,6 +174,9 @@ function extractProviderCall(expression, ctxName, kvAliases = new Map(), options
174
174
 
175
175
  if (receiver && ts.isPropertyAccessExpression(receiver) && isIdentifierNamed(receiver.expression, ctxName, options)) {
176
176
  const namespace = receiver.name.text;
177
+ if (namespace === 'time' && method === 'now' && current.arguments.length === 0) {
178
+ return Object.freeze({ call: current, surfaceId: 'ctx.time.now', kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock', args: Object.freeze([]) });
179
+ }
177
180
  if (namespace === 'config' && method === 'get' && current.arguments.length === 1) {
178
181
  return Object.freeze({ call: current, surfaceId: 'ctx.config.get', kind: 'config.get', providerKind: 'config', operation: 'get', capability: 'config.get', resource: current.arguments[0], name: current.arguments[0], args: Object.freeze([...current.arguments]) });
179
182
  }
@@ -265,6 +268,7 @@ function recognizeHandlerSurface(node, options = {}) {
265
268
  param: 'ctx.param',
266
269
  json: 'ctx.json',
267
270
  encodeJson: 'ctx.encodeJson',
271
+ decodeJson: 'ctx.decodeJson',
268
272
  text: 'ctx.text',
269
273
  response: 'ctx.response'
270
274
  });
@@ -117,9 +117,11 @@ function analyzeHandler(sourceFile, handler, ctxName, diagnostics, schemaBundleI
117
117
  let schemaBoundJsonCount = 0;
118
118
  const forbiddenCalls = new Set(['require', 'setTimeout', 'setInterval', 'setImmediate', 'queueMicrotask', 'eval', 'Function']);
119
119
  const forbiddenRoots = new Set(['process', 'global', 'globalThis', 'Deno', 'Bun', 'Buffer', 'console', 'WebSocket', 'XMLHttpRequest', 'EventSource', 'Worker']);
120
+ const packageEffectRanges = new Set();
120
121
 
121
122
  for (const effect of options.packageEffects || []) {
122
123
  if (effect && effect.capability) capabilities.add(String(effect.capability));
124
+ if (effect && effect.range) packageEffectRanges.add(`${effect.range.start}:${effect.range.end}`);
123
125
  }
124
126
 
125
127
  function reject(node, code, message, detail = {}) {
@@ -319,7 +321,12 @@ function analyzeHandler(sourceFile, handler, ctxName, diagnostics, schemaBundleI
319
321
  if (root && forbiddenRoots.has(root)) reject(node, 'PULSE_CANONICAL_AMBIENT_AUTHORITY_UNSUPPORTED', `Ambient ${root} authority is not available in canonical user scope.`, { identifier: root });
320
322
  if (root === 'Date' && node.expression.name.text === 'now') reject(node, 'PULSE_CANONICAL_NONDETERMINISM_UNSUPPORTED', 'Ambient clock access is not available in canonical user scope.');
321
323
  if (root === 'Math' && node.expression.name.text === 'random') reject(node, 'PULSE_CANONICAL_NONDETERMINISM_UNSUPPORTED', 'Ambient randomness is not available in canonical user scope.');
322
- if (root === 'crypto') reject(node, 'PULSE_CANONICAL_AMBIENT_AUTHORITY_UNSUPPORTED', 'Ambient crypto authority is not available in canonical user scope.', { identifier: 'crypto' });
324
+ // A trusted package can own this exact call; its arguments still pass
325
+ // through ordinary ambient-authority validation below.
326
+ const packageEffect = typeof options.packageEffectForCall === 'function'
327
+ ? options.packageEffectForCall(node)
328
+ : packageEffectRanges.has(`${node.getStart(sourceFile)}:${node.getEnd()}`);
329
+ if (root === 'crypto' && !packageEffect) reject(node, 'PULSE_CANONICAL_AMBIENT_AUTHORITY_UNSUPPORTED', 'Ambient crypto authority is not available in canonical user scope.', { identifier: 'crypto' });
323
330
 
324
331
  const target = node.expression;
325
332
  const surface = recognizeHandlerSurface(node, { ctxName });
@@ -335,11 +342,12 @@ function analyzeHandler(sourceFile, handler, ctxName, diagnostics, schemaBundleI
335
342
  validateFetchSchema(node);
336
343
  }
337
344
  if (surface.detail.fetch.decoderCall === node) validateDecoderCall(node, 'fetch-decode');
338
- } else if (surface.surfaceId === 'ctx.encodeJson') {
345
+ } else if (surface.surfaceId === 'ctx.encodeJson' || surface.surfaceId === 'ctx.decodeJson') {
346
+ const usage = surface.surfaceId === 'ctx.decodeJson' ? 'text-decode' : 'value-encode';
339
347
  if (node.arguments.length !== 2) {
340
- reject(node, 'PULSE_CANONICAL_DECODER_ARGUMENTS_UNSUPPORTED', 'ctx.encodeJson requires exactly a value and a registered literal schema ID.', { usage: 'value-encode', arguments: node.arguments.length });
348
+ reject(node, 'PULSE_CANONICAL_DECODER_ARGUMENTS_UNSUPPORTED', `${surface.surfaceId} requires exactly ${usage === 'text-decode' ? 'JSON text' : 'a value'} and a registered literal schema ID.`, { usage, arguments: node.arguments.length });
341
349
  } else {
342
- recordSchemaReference(node, node.arguments[1], 'value-encode');
350
+ recordSchemaReference(node, node.arguments[1], usage);
343
351
  schemaBoundJsonCount += 1;
344
352
  }
345
353
  } else if (['ctx.json', 'ctx.text', 'ctx.response'].includes(surface.surfaceId)) {
@@ -327,7 +327,7 @@ function normalizeRouterHandler(topology, descriptor, recognition, classificatio
327
327
  role,
328
328
  nextIndex: entry.nextIndex,
329
329
  transferFlag,
330
- clearNormalMode: call.arguments.length === 0 && role !== 'error',
330
+ clearNormalMode: call.arguments.length === 0,
331
331
  errorExpression: call.arguments.length > 0 ? rewrite(call.arguments[0]) : undefined
332
332
  });
333
333
  }