@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.
- package/README.md +14 -3
- package/bin/provider-proof-composition.js +29 -31
- package/bin/pulsewasm-extract.js +1 -1
- package/package.json +9 -11
- package/src/canonical-api-compiler.js +1 -9
- package/src/canonical-native-compiler.js +37 -64
- package/src/canonical-native-plan.js +450 -101
- package/src/canonical-project-compiler.js +52 -12
- package/src/execution-plan.js +1 -0
- package/src/extractor.js +36 -0
- package/src/native-result-facts.js +50 -0
- package/src/project/router-module-linker.js +64 -0
- package/src/provider-toolchain.js +29 -5
- package/src/pure-helper-values.js +138 -0
- package/src/shared-stage-plan.js +239 -0
- package/src/source-helper-plan.js +482 -0
- package/src/spine/async-surface-normalizer.js +17 -1
- package/src/spine/bounded-pure-loop.js +78 -13
- package/src/spine/canonical-handler-ir.js +3 -1
- package/src/spine/guest-unit-stage.js +11 -2
- package/src/spine/handler-ir-emitter.js +20 -0
- package/src/spine/handler-ir.js +95 -8
- package/src/spine/handler-surface-authority.js +9 -0
- package/src/spine/incoming-body-source.js +48 -0
- package/src/spine/plain-handler-frontend.js +5 -0
- package/src/spine/pure-helper-source.js +123 -0
- package/src/spine/router-handler-frontend.js +38 -1
- package/src/spine/router-handler-ir.js +71 -3
- package/src/spine/router-topology-frontend.js +3 -1
- package/src/spine/terminal-package-native.js +37 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const contract = require('@pulse-compute/wasm-contracts/handler/canonical-native-plan');
|
|
3
|
+
const runtime = require('@pulse-compute/wasm-contracts/handler/canonical-native-runtime');
|
|
4
|
+
const values = require('./pure-helper-values');
|
|
5
|
+
const isPure = helper => helper?.version === contract.CANONICAL_NATIVE_PURE_HELPER_VERSION;
|
|
6
|
+
// Admission belongs to this independent validator, not to inferred result tags.
|
|
7
|
+
// Caller scalars retain their existing coercive rules and optional-string defaults;
|
|
8
|
+
// callee operands must be scalar, arithmetic numeric, and indexes numeric arrays
|
|
9
|
+
// or scalar strings. String index reads may be undefined and cannot escape as a
|
|
10
|
+
// scalar result or be used as an unchecked scalar operand.
|
|
11
|
+
const purePolicy = Object.freeze({
|
|
12
|
+
binary: operator => runtime.CANONICAL_NATIVE_BINARY_OPERATORS.includes(operator) && operator !== 'in',
|
|
13
|
+
unary: operator => runtime.CANONICAL_NATIVE_UNARY_OPERATORS.includes(operator) && operator !== 'void',
|
|
14
|
+
element: (object, index) => index === 'number' && (object === 'string' || object?.kind === 'number-array'),
|
|
15
|
+
calleeOptionalStringDefault: false
|
|
16
|
+
});
|
|
17
|
+
function binaryResultKind(operator, a, b, optionalStringDefault = true) {
|
|
18
|
+
// Unsupported operators are rejected by policy. Preserve their historical
|
|
19
|
+
// diagnostic result kind instead of admitting the vocabulary's broader `in`.
|
|
20
|
+
return values.binaryRule(operator) ? values.binaryKind(operator, a, b, optionalStringDefault) : 'number';
|
|
21
|
+
}
|
|
22
|
+
function validateHelpers(plan, fail, walkStatements, walkExpression) {
|
|
23
|
+
if (plan.helpers !== undefined && !Array.isArray(plan.helpers)) { fail('helpers must be an array'); return; }
|
|
24
|
+
const helpers = new Map(), locals = new Map(plan.locals.map(l => [l.id, l]));
|
|
25
|
+
const effects = new Map(plan.effects.map(e => [e.id, e]));
|
|
26
|
+
const calls = new Map(), sites = new Map();
|
|
27
|
+
const definitions = new Map(), writes = new Map(), memberWrites = [];
|
|
28
|
+
const inputVisitor = s => {
|
|
29
|
+
if (s.kind === 'local') definitions.set(s.localId, s.value);
|
|
30
|
+
// Loop ownership, zero initialization, monotonic steps and immutable active
|
|
31
|
+
// counters are independently checked by validatePlanTree.
|
|
32
|
+
if (['pure-loop', 'read-loop'].includes(s.kind)) definitions.set(s.localId, { kind: 'literal', value: 0, valueKind: 'number' });
|
|
33
|
+
};
|
|
34
|
+
inputVisitor.expression = e => {
|
|
35
|
+
if (['assignment', 'update'].includes(e.kind) && ['property', 'element'].includes(e.target?.kind)) memberWrites.push(e);
|
|
36
|
+
if (['assignment', 'update'].includes(e.kind) && e.target?.kind === 'local') {
|
|
37
|
+
const list = writes.get(e.target.id) || []; list.push(e); writes.set(e.target.id, list);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
for (const body of [plan.entry.body, ...(plan.handlers || []).map(h => h.body), ...(plan.stages || []).map(h => h.body)]) walkStatements(body, inputVisitor);
|
|
41
|
+
for (const effect of plan.effects) {
|
|
42
|
+
for (const input of effect.inputs || []) walkExpression(input.value, inputVisitor.expression);
|
|
43
|
+
for (const arg of effect.result?.decoder?.arguments || []) walkExpression(arg, inputVisitor.expression);
|
|
44
|
+
}
|
|
45
|
+
function proveInputs(expression, visiting = new Set()) {
|
|
46
|
+
if (!expression || typeof expression !== 'object') return;
|
|
47
|
+
if (expression.kind === 'local' && expression.valueKind !== locals.get(expression.id)?.valueKind
|
|
48
|
+
&& !(expression.valueKind === 'string' && locals.get(expression.id)?.valueKind === 'string-or-undefined'
|
|
49
|
+
&& projectionType(expression) === 'string')) fail('pure input local tag mismatch');
|
|
50
|
+
if (['object','array'].includes(expression.kind) && expression.valueKind !== expression.kind) fail('pure input structural tag mismatch');
|
|
51
|
+
if (expression.kind !== 'local' && values.scalar(inputType(expression))) scalarArgumentKind(expression,locals,fail,true,projectionType);
|
|
52
|
+
// Schema decoders and typed effect results establish their own boundary;
|
|
53
|
+
// the encoded input need not itself have the decoded shape.
|
|
54
|
+
if (expression.kind === 'intrinsic' && ['request.json','schema.decode.text'].includes(expression.name)) return;
|
|
55
|
+
if (expression.kind === 'object') { for (const field of expression.entries) proveInputs(field.value, visiting); return; }
|
|
56
|
+
if (expression.kind !== 'local') {
|
|
57
|
+
for (const child of Object.values(expression)) {
|
|
58
|
+
if (Array.isArray(child)) for (const item of child) proveInputs(item,visiting);
|
|
59
|
+
else if (child && typeof child === 'object') proveInputs(child,visiting);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const e=expression;
|
|
64
|
+
if (visiting.has(e.id)) return;
|
|
65
|
+
const next = new Set([...visiting,e.id]), expected=locals.get(e.id)?.valueKind;
|
|
66
|
+
const type=inputType(e), structured=type && !values.scalar(type) && typeof type !== 'string';
|
|
67
|
+
const value=definitions.get(e.id);
|
|
68
|
+
if (value) {
|
|
69
|
+
if (value.valueKind !== expected) fail('pure input initializer tag mismatch');
|
|
70
|
+
if (!structured) {
|
|
71
|
+
const actual=value.kind === 'pure-helper-call' ? helpers.get(value.helperId)?.resultKind : scalarArgumentKind(value,locals,fail,true,projectionType);
|
|
72
|
+
if(actual!==expected || value.valueKind!==expected)fail('pure input local initializer kind mismatch');
|
|
73
|
+
}
|
|
74
|
+
proveInputs(value,next);
|
|
75
|
+
} else if (!resultEffects.has(e.id) || resultEffects.get(e.id).result.valueKind!==expected) fail('pure input local requires a proven initializer or effect result');
|
|
76
|
+
for(const write of writes.get(e.id)||[]) {
|
|
77
|
+
if (!structured && scalarArgumentKind(write,locals,fail,false,projectionType)!==expected)fail('pure input local write kind mismatch');
|
|
78
|
+
proveInputs(write.value,next);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Reconstruct caller provenance from the serialized expression/definition graph.
|
|
82
|
+
// No expression valueKind or call-site type assertion can create a record proof.
|
|
83
|
+
const resultEffects = new Map(plan.effects.filter(e => e.result?.localId).map(e => [e.result.localId, e]));
|
|
84
|
+
for (const effect of plan.effects) if (effect.borrowedValue !== undefined) {
|
|
85
|
+
const b = effect.borrowedValue;
|
|
86
|
+
if (effect.kind !== 'kv.getVersioned' || effect.providerKind !== 'kv' || effect.operation !== 'getVersioned'
|
|
87
|
+
|| effect.capability !== 'kv.getVersioned' || effect.result?.mode !== 'bind' || effect.result.valueKind !== 'json'
|
|
88
|
+
|| effect.result.decoder || b?.version !== contract.CANONICAL_NATIVE_TYPED_KV_BORROW_VERSION
|
|
89
|
+
|| Object.keys(b).sort().join() !== 'type,version' || !values.validType(b.type)) fail('invalid typed KV borrow provenance');
|
|
90
|
+
}
|
|
91
|
+
// Derive dominating null/undefined guards from the serialized control flow.
|
|
92
|
+
// Facts are attached to reads, not trusted from producer valueKind tags.
|
|
93
|
+
const nonNullAt = new WeakMap();
|
|
94
|
+
function nullFacts(e, truth, facts) {
|
|
95
|
+
if(e?.kind!=='binary')return;
|
|
96
|
+
let mutates=false;walkExpression(e,node=>{if(['assignment','update'].includes(node.kind))mutates=true;});
|
|
97
|
+
if(mutates)return;
|
|
98
|
+
if ((e.operator==='&&' && truth) || (e.operator==='||' && !truth)) {
|
|
99
|
+
nullFacts(e.left,truth,facts);nullFacts(e.right,truth,facts);return;
|
|
100
|
+
}
|
|
101
|
+
if(!['===','!=='].includes(e.operator))return;
|
|
102
|
+
const absent = x => x?.kind === 'undefined' ? 'undefined' : x?.kind === 'literal' && x.value === null ? 'null' : undefined;
|
|
103
|
+
const local=e.left?.kind==='local' && absent(e.right) ? e.left
|
|
104
|
+
: e.right?.kind==='local' && absent(e.left) ? e.right : undefined;
|
|
105
|
+
const checked=local && absent(e.left?.kind==='local' ? e.right : e.left);
|
|
106
|
+
// A null check does not rule out undefined, and an undefined check does
|
|
107
|
+
// not rule out null. Only the corresponding proven source can be narrowed.
|
|
108
|
+
if(local && (e.operator==='!==')===truth && (checked==='undefined' && locals.get(local.id)?.valueKind==='string-or-undefined'
|
|
109
|
+
|| checked==='null' && definitions.get(local.id)?.kind==='literal' && definitions.get(local.id).value===null))facts.add(local.id);
|
|
110
|
+
}
|
|
111
|
+
function nonNullValue(e) {
|
|
112
|
+
return e && (e.kind==='literal' && e.value!==null || ['object','array','template'].includes(e.kind)
|
|
113
|
+
|| e.kind==='intrinsic' && ['request.json','schema.decode.text'].includes(e.name));
|
|
114
|
+
}
|
|
115
|
+
function markExpression(e,facts) {
|
|
116
|
+
if(!e || typeof e!=='object')return;
|
|
117
|
+
nonNullAt.set(e,new Set(facts));
|
|
118
|
+
if(e.kind==='conditional' || e.kind==='binary' && ['&&','||','??'].includes(e.operator)) {
|
|
119
|
+
markExpression(e.test || e.left,facts);
|
|
120
|
+
const yes=new Set(facts),no=new Set(facts);
|
|
121
|
+
if(e.kind==='conditional') {
|
|
122
|
+
nullFacts(e.test,true,yes);nullFacts(e.test,false,no);
|
|
123
|
+
markExpression(e.whenTrue,yes);markExpression(e.whenFalse,no);
|
|
124
|
+
} else markExpression(e.right,yes);
|
|
125
|
+
facts.clear();for(const id of yes)if(no.has(id))facts.add(id);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for(const child of Object.values(e)) {
|
|
129
|
+
if(Array.isArray(child))for(const item of child)markExpression(item,facts);
|
|
130
|
+
else if(child && typeof child==='object')markExpression(child,facts);
|
|
131
|
+
}
|
|
132
|
+
if(e.kind==='assignment' && e.target?.kind==='local') {
|
|
133
|
+
facts.delete(e.target.id);
|
|
134
|
+
if(e.operator==='=' && nonNullValue(e.value))facts.add(e.target.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function markBody(body, facts) {
|
|
138
|
+
for(const s of body||[]) {
|
|
139
|
+
if(s.kind==='if') {
|
|
140
|
+
markExpression(s.test,facts);
|
|
141
|
+
const yes=new Set(facts),no=new Set(facts);nullFacts(s.test,true,yes);nullFacts(s.test,false,no);
|
|
142
|
+
const yr=markBody(s.then,yes),nr=markBody(s.else,no);
|
|
143
|
+
if(yr && nr)return true;
|
|
144
|
+
const merged=yr?no:nr?yes:new Set([...yes].filter(id=>no.has(id)));
|
|
145
|
+
facts.clear();for(const id of merged)facts.add(id);
|
|
146
|
+
} else if(['pure-loop','read-loop'].includes(s.kind)) {
|
|
147
|
+
// A later iteration may observe a write from the previous iteration.
|
|
148
|
+
// Do not import a pre-loop non-null fact for any loop-carried binding.
|
|
149
|
+
const inside=new Set(facts);
|
|
150
|
+
const visit=()=>{};visit.expression=e=>{if(e.kind==='assignment' && e.target?.kind==='local'){facts.delete(e.target.id);inside.delete(e.target.id);}};
|
|
151
|
+
walkStatements(s.body,visit);
|
|
152
|
+
markExpression(s.test,inside);markBody(s.body,inside);
|
|
153
|
+
} else {
|
|
154
|
+
markExpression(s.value||s.expression,facts);
|
|
155
|
+
if(s.kind==='local') {facts.delete(s.localId);if(nonNullValue(s.value))facts.add(s.localId);}
|
|
156
|
+
if(s.kind==='return')return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
for(const body of [plan.entry.body,...(plan.handlers||[]).map(h=>h.body),...(plan.stages||[]).map(h=>h.body)])markBody(body,new Set());
|
|
162
|
+
const inputTypes = new WeakMap();
|
|
163
|
+
function inputType(e, seen = new Map()) {
|
|
164
|
+
if(!e || typeof e!=='object')return;
|
|
165
|
+
if(!seen.size && inputTypes.has(e))return inputTypes.get(e);
|
|
166
|
+
const types = {schemas:plan.schemas?.registry, get(id) {
|
|
167
|
+
if (seen.has(id)) return seen.get(id);
|
|
168
|
+
const value = definitions.get(id), effect = resultEffects.get(id);
|
|
169
|
+
const guarded = nonNullAt.get(e)?.has(id);
|
|
170
|
+
const guardedNull = value?.kind==='literal' && value.value===null && guarded;
|
|
171
|
+
const base = value && !guardedNull
|
|
172
|
+
? inputType(value,new Map([...seen,[id,undefined]])) : effect ? values.effectType(effect,plan.schemas?.registry) : undefined;
|
|
173
|
+
const next = new Map([...seen,[id,base]]);
|
|
174
|
+
const candidates=value && !guardedNull || effect ? [base] : [];
|
|
175
|
+
for(const write of writes.get(id)||[]) {
|
|
176
|
+
if(write.kind!=='assignment' || write.operator!=='=') {
|
|
177
|
+
return values.scalar(base) ? base : undefined;
|
|
178
|
+
}
|
|
179
|
+
candidates.push(inputType(write.value,next));
|
|
180
|
+
}
|
|
181
|
+
const type = candidates.length && candidates[0] && candidates.every(t=>values.same(candidates[0],t)) ? candidates[0] : undefined;
|
|
182
|
+
return guarded && locals.get(id)?.declaration === 'const' && type === 'string-or-undefined' ? 'string' : type;
|
|
183
|
+
}};
|
|
184
|
+
const type=values.readType(e,types);
|
|
185
|
+
if(type && !seen.size)inputTypes.set(e,type);
|
|
186
|
+
return type;
|
|
187
|
+
}
|
|
188
|
+
const readonlyChecked=new WeakSet();
|
|
189
|
+
function readonlyInput(e) {
|
|
190
|
+
if(!e || readonlyChecked.has(e))return;
|
|
191
|
+
readonlyChecked.add(e);
|
|
192
|
+
const aliases = new Set(), visited=new Set();
|
|
193
|
+
const scalarType=t=>typeof t==='string';
|
|
194
|
+
function collect(value) {
|
|
195
|
+
if(!value || typeof value!=='object')return;
|
|
196
|
+
if(value.kind==='intrinsic')return;
|
|
197
|
+
if(value.kind==='local') {
|
|
198
|
+
if(visited.has(value.id))return;visited.add(value.id);
|
|
199
|
+
if(!scalarType(inputType(value)))aliases.add(value.id);
|
|
200
|
+
collect(definitions.get(value.id));
|
|
201
|
+
for(const write of writes.get(value.id)||[])collect(write.value);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if(value.kind==='object') {
|
|
205
|
+
for(const field of value.entries||[])if(!scalarType(inputType(field.value)))collect(field.value);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for(const child of Object.values(value)) {
|
|
209
|
+
if(Array.isArray(child))for(const item of child)collect(item);
|
|
210
|
+
else if(child && typeof child==='object')collect(child);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
collect(e);
|
|
214
|
+
function references(value) {
|
|
215
|
+
if(!value || typeof value!=='object')return false;
|
|
216
|
+
if(value.kind==='local')return aliases.has(value.id);
|
|
217
|
+
if(['property','element'].includes(value.kind))return !scalarType(inputType(value)) && references(value.object);
|
|
218
|
+
if(value.kind==='conditional')return references(value.whenTrue)||references(value.whenFalse);
|
|
219
|
+
if(value.kind==='binary' && ['&&','||','??'].includes(value.operator))return references(value.left)||references(value.right);
|
|
220
|
+
if(value.kind==='object')return value.entries.some(f=>references(f.value));
|
|
221
|
+
if(value.kind==='array')return value.items.some(references);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
let changed=true;
|
|
225
|
+
while(changed) {
|
|
226
|
+
changed=false;
|
|
227
|
+
const add=(id,value)=>{if(!aliases.has(id) && references(value)){aliases.add(id);changed=true;}};
|
|
228
|
+
for(const [id,value] of definitions)add(id,value);
|
|
229
|
+
for(const [id,updates] of writes)for(const update of updates)add(id,update.value);
|
|
230
|
+
}
|
|
231
|
+
for(const write of memberWrites)if(references(write.target.object))fail('borrowed caller shape is mutated through an alias');
|
|
232
|
+
}
|
|
233
|
+
function projectionType(e) {
|
|
234
|
+
const type=inputType(e);
|
|
235
|
+
readonlyInput(e);
|
|
236
|
+
return type;
|
|
237
|
+
}
|
|
238
|
+
function containsRecordSpread(e, seen = new Set()) {
|
|
239
|
+
if (!e || typeof e !== 'object') return false;
|
|
240
|
+
if (e.kind === 'object' && e.entries?.some(entry => entry.kind === 'spread')) return true;
|
|
241
|
+
if (e.kind === 'local') {
|
|
242
|
+
if (seen.has(e.id)) return false;
|
|
243
|
+
const next = new Set([...seen,e.id]);
|
|
244
|
+
return containsRecordSpread(definitions.get(e.id),next)
|
|
245
|
+
|| (writes.get(e.id)||[]).some(write => containsRecordSpread(write.value,next));
|
|
246
|
+
}
|
|
247
|
+
return Object.values(e).some(child => Array.isArray(child)
|
|
248
|
+
? child.some(item => containsRecordSpread(item,seen)) : containsRecordSpread(child,seen));
|
|
249
|
+
}
|
|
250
|
+
function proveBorrow(e, expected) {
|
|
251
|
+
if (!values.validType(expected)) { fail('invalid borrow type'); return false; }
|
|
252
|
+
if (containsRecordSpread(e)) { fail('pure structured borrow cannot derive from a record spread'); return false; }
|
|
253
|
+
readonlyInput(e);
|
|
254
|
+
const actual=inputType(e);
|
|
255
|
+
// A literal graph also proves all scalar initializer/write chains.
|
|
256
|
+
proveInputs(e);
|
|
257
|
+
return values.validType(actual) && values.same(actual,expected);
|
|
258
|
+
}
|
|
259
|
+
const admittedCalls = new Map();
|
|
260
|
+
const admit = (body, owner) => { const visit = s => { if (s.kind === 'helper-call') admittedCalls.set(s, owner); }; visit.expression = e => { if (e.kind === 'pure-helper-call') admittedCalls.set(e, owner); }; walkStatements(body, visit); };
|
|
261
|
+
const cursor = plan.locals.find(l => l.scopeId === 'entry' && l.name === '__pulse_router_cursor')?.id;
|
|
262
|
+
for (const entry of plan.routing?.entries || []) {
|
|
263
|
+
if (!['route', 'use'].includes(entry.kind)) continue;
|
|
264
|
+
if (entry.nativeBody) { const handler = plan.handlers.find(h => h.id === entry.stableId); if (handler) admit(handler.body, entry.stableId); continue; }
|
|
265
|
+
const outer = plan.entry.body.find(s => s.kind === 'if' && s.test?.kind === 'binary' && s.test.operator === '===' && s.test.left?.id === cursor && s.test.right?.value === entry.index);
|
|
266
|
+
if (outer?.then?.length === 1 && outer.then[0].kind === 'if') admit(outer.then[0].then, entry.stableId);
|
|
267
|
+
}
|
|
268
|
+
for (const helper of plan.helpers || []) {
|
|
269
|
+
if (!helper || typeof helper.id !== 'string' || !helper.id.startsWith('helper:') || helpers.has(helper.id)
|
|
270
|
+
|| !isPure(helper) && helper.version !== 'pulse.canonical-native-helper.v1' || !Array.isArray(helper.body)
|
|
271
|
+
|| !Array.isArray(helper.parameters) || !Array.isArray(helper.localIds)
|
|
272
|
+
|| JSON.stringify(helper.frame) !== JSON.stringify({ lifetime: 'invocation', reset: 'call', suspension: isPure(helper) ? 'none' : 'retain', nesting: false })
|
|
273
|
+
|| JSON.stringify(helper.outputs) !== JSON.stringify(isPure(helper) ? ['value', 'failure'] : ['value', 'suspend', 'failure'])) { fail('invalid helper contract'); continue; }
|
|
274
|
+
helpers.set(helper.id, helper);
|
|
275
|
+
const owned = new Set(helper.localIds);
|
|
276
|
+
if (owned.size !== helper.localIds.length || helper.localIds.some(id => locals.get(id)?.scopeId !== helper.id)
|
|
277
|
+
|| plan.locals.filter(l => l.scopeId === helper.id).length !== owned.size) fail('helper local ownership mismatch');
|
|
278
|
+
if (new Set(helper.parameters.map(p => p.localId)).size !== helper.parameters.length) fail('duplicate helper inputs');
|
|
279
|
+
for (const p of helper.parameters) if (!(values.scalar(p.valueKind) && p.borrow === undefined || isPure(helper) && p.borrow?.version === contract.CANONICAL_NATIVE_PURE_BORROW_VERSION && Object.keys(p.borrow).sort().join() === 'type,version' && values.validType(p.borrow.type) && !values.scalar(p.borrow.type) && values.kind(p.borrow.type) === p.valueKind)
|
|
280
|
+
|| !owned.has(p.localId) || locals.get(p.localId)?.valueKind !== p.valueKind || isPure(helper) && locals.get(p.localId)?.declaration !== 'const') fail('invalid helper input');
|
|
281
|
+
if (isPure(helper)) validatePureBody(helper, locals, fail);
|
|
282
|
+
const resultKinds = new Set();
|
|
283
|
+
const visitor = s => {
|
|
284
|
+
if (s.kind === 'return') resultKinds.add(s.value?.valueKind);
|
|
285
|
+
if (['helper-call', 'handler-call', 'stage-call', 'effect-group'].includes(s.kind)) fail('nested calls and groups are outside helper v1');
|
|
286
|
+
if (s.localId && !owned.has(s.localId)) fail('helper statement crosses local ownership');
|
|
287
|
+
if (s.kind === 'effect') {
|
|
288
|
+
const effect = effects.get(s.effectId);
|
|
289
|
+
if (effect?.helperId !== helper.id || effect?.routerEntryStableId !== undefined || !['bind','discard'].includes(effect?.result?.mode)) fail('helper effect ownership mismatch');
|
|
290
|
+
sites.set(s.effectId, (sites.get(s.effectId) || 0) + 1);
|
|
291
|
+
for (const input of effect?.inputs || []) walkExpression(input.value, visitor.expression);
|
|
292
|
+
if (effect?.result?.localId && !owned.has(effect.result.localId)) fail('helper result crosses local ownership');
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
visitor.expression = e => {
|
|
296
|
+
if (e.kind === 'pure-helper-call') fail('helper calls cannot nest');
|
|
297
|
+
if (e.kind === 'local' && !owned.has(e.id)) fail('helper captures another lexical owner');
|
|
298
|
+
if (e.kind === 'intrinsic' && (e.name.startsWith('response.') || e.name.startsWith('router.'))) fail('helper cannot own a response or Router transfer');
|
|
299
|
+
if (['assignment','update'].includes(e.kind) && helper.parameters.some(p => p.localId === e.target?.id)) fail('helper inputs are immutable');
|
|
300
|
+
};
|
|
301
|
+
walkStatements(helper.body, visitor);
|
|
302
|
+
if (helper.resultKind !== (resultKinds.size === 1 ? [...resultKinds][0] : 'unknown')) fail('helper result kind mismatch');
|
|
303
|
+
}
|
|
304
|
+
for (const body of [plan.entry.body, ...(plan.handlers || []).map(h => h.body), ...(plan.stages || []).map(s => s.body)]) {
|
|
305
|
+
const visitor = s => {
|
|
306
|
+
if (s.kind === 'effect' && effects.get(s.effectId)?.helperId) fail('helper effect used outside its body');
|
|
307
|
+
if (s.kind === 'effect-group' && s.effectIds.some(id => effects.get(id)?.helperId)) fail('helper effect used outside its body');
|
|
308
|
+
if (s.kind !== 'helper-call') return;
|
|
309
|
+
if (!admittedCalls.has(s) || admittedCalls.get(s) !== s.callerEntryId) fail('helper call requires its owning HTTP admission branch');
|
|
310
|
+
const helper = helpers.get(s.helperId);
|
|
311
|
+
if (!helper || isPure(helper) || !Array.isArray(s.arguments) || s.arguments.length !== helper.parameters.length
|
|
312
|
+
|| s.arguments.some((arg, i) => arg.valueKind !== helper.parameters[i].valueKind) || locals.get(s.localId)?.valueKind !== helper.resultKind) fail('invalid helper call binding');
|
|
313
|
+
for (const arg of s.arguments || []) walkExpression(arg, e => {
|
|
314
|
+
if (['assignment','update'].includes(e.kind)) fail('helper inputs must be read-only values');
|
|
315
|
+
});
|
|
316
|
+
calls.set(s.helperId, (calls.get(s.helperId) || 0) + 1);
|
|
317
|
+
};
|
|
318
|
+
visitor.expression = e => {
|
|
319
|
+
if (e.kind !== 'pure-helper-call') return;
|
|
320
|
+
const helper = helpers.get(e.helperId);
|
|
321
|
+
if (!admittedCalls.has(e) || !isPure(helper) || !Array.isArray(e.arguments)
|
|
322
|
+
|| e.arguments.length !== helper.parameters.length || e.valueKind !== helper.resultKind
|
|
323
|
+
|| e.arguments.some((arg, i) => helper.parameters[i]?.borrow ? !proveBorrow(arg, helper.parameters[i].borrow.type) : scalarArgumentKind(arg, locals, fail, false, projectionType) !== helper.parameters[i]?.valueKind)) fail('invalid pure helper call');
|
|
324
|
+
for (const [i, arg] of (e.arguments || []).entries()) { if (!helper?.parameters[i]?.borrow) proveInputs(arg); }
|
|
325
|
+
for (const arg of e.arguments || []) walkExpression(arg, child => {
|
|
326
|
+
if (child.kind === 'pure-helper-call') fail('pure calls cannot nest in arguments');
|
|
327
|
+
});
|
|
328
|
+
calls.set(e.helperId, (calls.get(e.helperId) || 0) + 1);
|
|
329
|
+
};
|
|
330
|
+
walkStatements(body, visitor);
|
|
331
|
+
}
|
|
332
|
+
function rejectCalls(value) {
|
|
333
|
+
if (!value || typeof value !== 'object') return;
|
|
334
|
+
if (value.kind === 'pure-helper-call') fail('pure call outside an admitted synchronous expression');
|
|
335
|
+
for (const child of Object.values(value)) if (child && typeof child === 'object') rejectCalls(child);
|
|
336
|
+
}
|
|
337
|
+
for (const effect of plan.effects) rejectCalls(effect);
|
|
338
|
+
for (const h of plan.helpers || []) if (!isPure(h)) rejectCalls(h.body);
|
|
339
|
+
const effectfulCallVisitor = s => { if (s.kind === 'helper-call') rejectCalls(s); };
|
|
340
|
+
effectfulCallVisitor.expression = () => {};
|
|
341
|
+
for (const body of [plan.entry.body, ...(plan.handlers || []).map(h => h.body), ...(plan.stages || []).map(h => h.body)]) walkStatements(body, effectfulCallVisitor);
|
|
342
|
+
for (const helper of helpers.values()) if (!calls.has(helper.id)) fail('helper requires a reachable call');
|
|
343
|
+
for (const effect of plan.effects) if (effect.helperId && (!helpers.has(effect.helperId) || sites.get(effect.id) !== 1)) fail('helper effect requires one owning site');
|
|
344
|
+
for (const c of plan.continuations) for (const id of c.effectIds) if (effects.get(id)?.helperId !== c.helperId) fail('helper continuation ownership mismatch');
|
|
345
|
+
}
|
|
346
|
+
module.exports = { validateHelpers };
|
|
347
|
+
|
|
348
|
+
// Independently prove the bounded value grammar after deserialization, rather than
|
|
349
|
+
// trusting valueKind tags or a recomputed hash. No external owner is reachable.
|
|
350
|
+
function validatePureBody(helper, locals, fail) {
|
|
351
|
+
const owned = new Set(helper.localIds);
|
|
352
|
+
const parameters = new Set(helper.parameters.map(p => p.localId));
|
|
353
|
+
const initialized = new Set(parameters);
|
|
354
|
+
const types = new Map(helper.parameters.map(p => [p.localId, values.validType(p.borrow?.type || p.valueKind) ? p.borrow?.type || p.valueKind : 'unknown']));
|
|
355
|
+
if (!values.scalar(helper.resultKind)) fail('pure result must be scalar');
|
|
356
|
+
function expression(e, available) {
|
|
357
|
+
if (!e || typeof e !== 'object') { fail('missing pure expression'); return 'unknown'; }
|
|
358
|
+
let kind = 'unknown';
|
|
359
|
+
const child = value => expression(value, available);
|
|
360
|
+
if (e.kind === 'literal' && values.scalar(typeof e.value) && (typeof e.value !== 'number' || Number.isFinite(e.value))) kind = typeof e.value;
|
|
361
|
+
else if (e.kind === 'local') {
|
|
362
|
+
if (!owned.has(e.id) || !available.has(e.id)) fail('pure local must be initialized and owned');
|
|
363
|
+
kind = types.get(e.id) || locals.get(e.id)?.valueKind;
|
|
364
|
+
} else if (e.kind === 'property') {
|
|
365
|
+
kind = values.member(child(e.object), e.property);
|
|
366
|
+
} else if (e.kind === 'element') {
|
|
367
|
+
const object = child(e.object), index = child(e.index);
|
|
368
|
+
const admitted = purePolicy.element(object, index);
|
|
369
|
+
if (!admitted) fail('pure element requires a numeric array or string and numeric index');
|
|
370
|
+
kind = admitted ? values.element(object, index, undefined, true) : 'number';
|
|
371
|
+
} else if (e.kind === 'binary') {
|
|
372
|
+
const a = child(e.left), b = child(e.right);
|
|
373
|
+
const rule = values.binaryRule(e.operator);
|
|
374
|
+
if ((!values.scalar(a) && !(rule === 'comparison' && a === 'string-or-undefined'))
|
|
375
|
+
|| (!values.scalar(b) && !(rule === 'comparison' && b === 'string-or-undefined'))) fail('pure operators require scalar operands');
|
|
376
|
+
if (!purePolicy.binary(e.operator)) fail('unsupported pure binary operator');
|
|
377
|
+
kind = binaryResultKind(e.operator, a, b, purePolicy.calleeOptionalStringDefault);
|
|
378
|
+
if ((!rule || rule === 'numeric') && kind !== 'string' && (a !== 'number' || b !== 'number')) fail('pure arithmetic requires numbers');
|
|
379
|
+
} else if (e.kind === 'unary') {
|
|
380
|
+
const value = child(e.value);
|
|
381
|
+
if (!values.scalar(value)) fail('pure unary requires a scalar');
|
|
382
|
+
if (!purePolicy.unary(e.operator)) fail('unsupported pure unary operator');
|
|
383
|
+
kind = values.unaryKind(e.operator);
|
|
384
|
+
if (kind === 'number' && value !== 'number') fail('pure numeric unary requires a number');
|
|
385
|
+
} else if (e.kind === 'conditional') {
|
|
386
|
+
child(e.test); const a = child(e.whenTrue), b = child(e.whenFalse); kind = a === b ? a : 'unknown';
|
|
387
|
+
} else if (e.kind === 'template') {
|
|
388
|
+
if (!Array.isArray(e.parts)) fail('invalid pure template');
|
|
389
|
+
for (const part of e.parts || []) {
|
|
390
|
+
if (part.kind === 'value') { if (!values.scalar(child(part.value))) fail('pure templates require scalars'); }
|
|
391
|
+
else if (part.kind !== 'text' || typeof part.value !== 'string') fail('invalid pure template part');
|
|
392
|
+
}
|
|
393
|
+
kind = 'string';
|
|
394
|
+
} else if (e.kind === 'assignment' || e.kind === 'update') {
|
|
395
|
+
const target = e.target, previous = child(target);
|
|
396
|
+
if (!values.scalar(previous) || target?.kind !== 'local' || parameters.has(target.id) || locals.get(target.id)?.declaration !== 'let') fail('pure writes require a mutable owned scalar local');
|
|
397
|
+
if (e.kind === 'assignment') {
|
|
398
|
+
kind = child(e.value);
|
|
399
|
+
if (!runtime.CANONICAL_NATIVE_ASSIGNMENT_OPERATORS.includes(e.operator) || kind !== previous) fail('invalid pure scalar assignment');
|
|
400
|
+
if (!['=', '+=', '&&=', '||=', '??='].includes(e.operator) && kind !== 'number') fail('pure arithmetic assignment requires numbers');
|
|
401
|
+
} else {
|
|
402
|
+
if (!['++', '--'].includes(e.operator) || typeof e.prefix !== 'boolean' || previous !== 'number') fail('invalid pure scalar update');
|
|
403
|
+
kind = 'number';
|
|
404
|
+
}
|
|
405
|
+
} else fail('pure helper expression cannot capture, call, allocate records or access authority');
|
|
406
|
+
if ((!values.validType(kind) && kind !== 'string-or-undefined') || values.kind(kind) !== e.valueKind) fail('pure expression kind mismatch');
|
|
407
|
+
return kind;
|
|
408
|
+
}
|
|
409
|
+
function body(statements, available) {
|
|
410
|
+
if (!Array.isArray(statements)) { fail('invalid pure body'); return false; }
|
|
411
|
+
let returns = false;
|
|
412
|
+
for (const s of statements) {
|
|
413
|
+
if (s.kind === 'local') {
|
|
414
|
+
const kind = expression(s.value, available);
|
|
415
|
+
if (!owned.has(s.localId) || available.has(s.localId) || !['let', 'const'].includes(s.declaration)
|
|
416
|
+
|| locals.get(s.localId)?.declaration !== s.declaration || locals.get(s.localId)?.valueKind !== values.kind(kind) || s.valueKind !== values.kind(kind) || !values.scalar(kind) && s.declaration !== 'const') fail('invalid pure local binding');
|
|
417
|
+
types.set(s.localId, kind);
|
|
418
|
+
available.add(s.localId);
|
|
419
|
+
} else if (s.kind === 'return') {
|
|
420
|
+
if (expression(s.value, available) !== helper.resultKind) fail('pure return kind mismatch');
|
|
421
|
+
returns = true;
|
|
422
|
+
} else if (s.kind === 'expression') expression(s.expression, available);
|
|
423
|
+
else if (s.kind === 'if') {
|
|
424
|
+
if (!values.scalar(expression(s.test, available))) fail('pure conditions require scalars');
|
|
425
|
+
const yes = body(s.then, new Set(available)), no = body(s.else, new Set(available));
|
|
426
|
+
returns ||= yes && no;
|
|
427
|
+
} else if (s.kind === 'pure-loop') {
|
|
428
|
+
if (!owned.has(s.localId) || available.has(s.localId) || parameters.has(s.localId) || locals.get(s.localId)?.valueKind !== 'number') fail('invalid pure counter ownership');
|
|
429
|
+
const inside = new Set([...available, s.localId]);
|
|
430
|
+
expression(s.test, inside); body(s.body, inside);
|
|
431
|
+
} else if (!['break', 'continue'].includes(s.kind)) fail('pure helper cannot suspend or transfer Router control');
|
|
432
|
+
}
|
|
433
|
+
return returns;
|
|
434
|
+
}
|
|
435
|
+
if (!body(helper.body, initialized)) fail('pure helper must return on every path');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function scalarArgumentKind(e, locals, fail, bound = false, projectionType) {
|
|
439
|
+
if (!e || typeof e !== 'object') { fail('missing pure call argument'); return 'unknown'; }
|
|
440
|
+
const child = v => scalarArgumentKind(v, locals, fail, bound, projectionType);
|
|
441
|
+
let kind = 'unknown';
|
|
442
|
+
if (e.kind === 'literal' && values.scalar(typeof e.value)) kind = typeof e.value;
|
|
443
|
+
else if (e.kind === 'local') {
|
|
444
|
+
kind = locals.get(e.id)?.valueKind;
|
|
445
|
+
if (kind === 'string-or-undefined' && e.valueKind === 'string' && projectionType?.(e) === 'string') kind = 'string';
|
|
446
|
+
}
|
|
447
|
+
else if (['property','element'].includes(e.kind) && projectionType) {
|
|
448
|
+
kind = projectionType(e);
|
|
449
|
+
if (e.kind === 'element') child(e.index);
|
|
450
|
+
}
|
|
451
|
+
else if (bound && e.kind === 'intrinsic' && ['schema.encode.text', 'state.get'].includes(e.name)) kind = e.name === 'state.get' ? 'string-or-undefined' : 'string';
|
|
452
|
+
else if (bound && e.kind === 'method-call' && ['string.trim', 'text', 'header'].includes(e.method)) kind = e.method === 'header' ? 'string-or-undefined' : 'string';
|
|
453
|
+
else if (e.kind === 'context-read' && ['req.method', 'req.path', 'req.url'].includes(e.path?.join('.'))) kind = 'string';
|
|
454
|
+
else if (e.kind === 'intrinsic' && ['request.header', 'request.text', 'router.param'].includes(e.name)) {
|
|
455
|
+
for (const arg of e.arguments || []) child(arg);
|
|
456
|
+
const arity = e.name === 'request.text' ? 0 : e.name === 'request.header' ? 1 : 3;
|
|
457
|
+
if (!Array.isArray(e.arguments) || e.arguments.length !== arity) fail('invalid pure scalar input intrinsic');
|
|
458
|
+
kind = e.name === 'request.text' ? 'string' : 'string-or-undefined';
|
|
459
|
+
} else if (e.kind === 'binary') {
|
|
460
|
+
const a = child(e.left), b = child(e.right);
|
|
461
|
+
if (!purePolicy.binary(e.operator)) fail('unsupported pure argument operator');
|
|
462
|
+
kind = binaryResultKind(e.operator, a, b);
|
|
463
|
+
} else if (e.kind === 'unary') {
|
|
464
|
+
child(e.value);
|
|
465
|
+
if (!purePolicy.unary(e.operator)) fail('unsupported pure argument unary');
|
|
466
|
+
kind = values.unaryKind(e.operator);
|
|
467
|
+
} else if (e.kind === 'conditional') {
|
|
468
|
+
child(e.test); const a=child(e.whenTrue), b=child(e.whenFalse); kind=a===b?a:'unknown';
|
|
469
|
+
} else if (e.kind === 'template') {
|
|
470
|
+
for (const part of e.parts || []) if(part.kind==='value') child(part.value);
|
|
471
|
+
kind='string';
|
|
472
|
+
} else if (e.kind === 'assignment' || e.kind === 'update') {
|
|
473
|
+
const target=child(e.target);
|
|
474
|
+
if(e.target?.kind !== 'local' || locals.get(e.target.id)?.declaration !== 'let') fail('pure argument mutation requires a mutable local');
|
|
475
|
+
if(e.kind==='assignment') {
|
|
476
|
+
kind=child(e.value);
|
|
477
|
+
if(!runtime.CANONICAL_NATIVE_ASSIGNMENT_OPERATORS.includes(e.operator) || kind!==target) fail('invalid pure argument assignment');
|
|
478
|
+
} else { if(!['++','--'].includes(e.operator) || typeof e.prefix !== 'boolean' || target!=='number') fail('invalid pure argument update'); kind='number'; }
|
|
479
|
+
} else fail('pure helper argument requires a proven scalar expression or request scalar read');
|
|
480
|
+
if (!values.scalar(kind) && kind !== 'string-or-undefined' || e.valueKind !== kind) fail('pure argument kind mismatch');
|
|
481
|
+
return kind;
|
|
482
|
+
}
|
|
@@ -90,7 +90,10 @@ function normalizeManagedHandler(functionNode, options = {}) {
|
|
|
90
90
|
const handlerAuthoring = resolveHandlerAuthoringMode(options);
|
|
91
91
|
const requireAsync = handlerAuthoring === HANDLER_AUTHORING_MODES.ASYNC_REQUIRED;
|
|
92
92
|
const requireEffectAwait = options.requireEffectAwait === true;
|
|
93
|
-
|
|
93
|
+
// Router handlers have already been checked individually. The generated
|
|
94
|
+
// dispatcher can contain markers from several mutually exclusive routes.
|
|
95
|
+
const diagnostics = options.internalGeneratedHandler === true ? []
|
|
96
|
+
: require('./incoming-body-source.js').validateIncomingBodySource(functionNode, { ...options, ctxName, frontend, sourceFile });
|
|
94
97
|
const warnings = [];
|
|
95
98
|
const changes = [];
|
|
96
99
|
let kvAliases = new Map();
|
|
@@ -152,6 +155,11 @@ function normalizeManagedHandler(functionNode, options = {}) {
|
|
|
152
155
|
if (ts.isAwaitExpression(node)) {
|
|
153
156
|
awaitCount += 1;
|
|
154
157
|
const inner = unwrapExpression(node.expression);
|
|
158
|
+
const helper = options.helperForCall && options.helperForCall(inner);
|
|
159
|
+
if (helper) {
|
|
160
|
+
changes.push(Object.freeze({ kind: 'source-helper-await-erased', helperId: helper.id }));
|
|
161
|
+
return ts.visitNode(node.expression, visit);
|
|
162
|
+
}
|
|
155
163
|
const surface = surfaceFor(inner, 'await-expression');
|
|
156
164
|
if (!surface || surface.surfaceId === 'javascript.await' || surface.class === 'javascript-only') {
|
|
157
165
|
// The explicit JavaScript project path uses this normalized tree only
|
|
@@ -241,6 +249,14 @@ function normalizeManagedHandler(functionNode, options = {}) {
|
|
|
241
249
|
if (ts.isCallExpression(node)) {
|
|
242
250
|
const surface = surfaceFor(node, directReturnExpression(node) ? 'return' : 'expression');
|
|
243
251
|
if (surface) {
|
|
252
|
+
if (surface.surfaceId === 'ctx.output.close' && !directReturnExpression(node)) {
|
|
253
|
+
diagnostics.push(createCanonicalDiagnostic({
|
|
254
|
+
frontend, sourceFile, node,
|
|
255
|
+
code: 'PULSE_OUTPUT_CLOSE_TERMINAL',
|
|
256
|
+
message: 'output.close() is synchronous and must be returned directly.',
|
|
257
|
+
detail: { surfaceId: surface.surfaceId }
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
244
260
|
const awaited = awaitedRoots.has(unwrapExpression(node));
|
|
245
261
|
if (surface.classification && surface.classification.ok === false && !awaited) {
|
|
246
262
|
diagnostics.push(createCanonicalDiagnostic({
|