jz 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/infer.js ADDED
@@ -0,0 +1,548 @@
1
+ /**
2
+ * src/infer — unified per-binding inference.
3
+ *
4
+ * Single front door for "what shape is this binding?". Evidence sources walk a
5
+ * function body and report facts about a candidate name set; `inferParams`
6
+ * runs every registered source and merges results (first source wins).
7
+ *
8
+ * ## Evidence ladder (strongest first, registration order = precedence)
9
+ *
10
+ * 1. Literal use — `let x = 0`, `let s = ''`, `let xs = []`. [done: analyzeValTypes]
11
+ * 2. Operator use — `s.charCodeAt(...)` → STRING. [done: method source]
12
+ * 3. Member access — `.push` / `.pop` → ARRAY; index/length-write
13
+ * → notString. [done: method + notString sources]
14
+ * 4. `typeof` guard — `typeof x === 'string'` flow-refines. [done: extractRefinements + B3]
15
+ * 5. Assignment flow — `x = y` propagates y's evidence to x. [done: analyzeValTypes]
16
+ * 6. Comparison shape — `x === null` proves nullable. [out of scope: no nullable rep field, see C2]
17
+ * 7. JSDoc `@type` — explicit hint; advisory, not enforced. [in prepare]
18
+ * 8. Name heuristic — last resort (e.g. `count`/`n`/`i` integer). [out of scope]
19
+ *
20
+ * Rungs 1/5 live in `analyzeValTypes` rather than as registry sources because
21
+ * they share the canonical body-walk machinery (regex tracking, typed-elem
22
+ * tracking, JSON-shape, arr-elem schema, ternary unification) and lifting
23
+ * them would duplicate that walker. Rung 4 lives in `extractRefinements`
24
+ * because flow-scoped narrowing is per-branch, not param-wide — adding it as
25
+ * a registry source would over-narrow params whose other branch handles a
26
+ * non-string (callers correctly stay polymorphic in that case).
27
+ *
28
+ * Ambiguous bindings stay nanbox-tagged f64. Default is never wrong, only
29
+ * sometimes wider than necessary.
30
+ *
31
+ * ## Source contract
32
+ *
33
+ * `(body, candidates: string[]) => Map<name, { val?: VAL, … }>`
34
+ *
35
+ * Sources see the full body AST and a candidate-name set. They return only
36
+ * names for which they have definite evidence. `{ val }` is canonical; future
37
+ * sources may add `arrayElemValType`, `intConst`, etc. — `inferParams` returns
38
+ * the full fact, and the caller passes it straight to `updateRep`.
39
+ *
40
+ * Convenience readers (`infer`, `facts`) sit at the bottom for hot-path
41
+ * lookups after passes have populated the per-binding rep.
42
+ *
43
+ * @module src/infer
44
+ */
45
+
46
+ import { ctx } from './ctx.js'
47
+ import {
48
+ VAL, collectParamNames, valTypeOf,
49
+ updateRep, updateGlobalRep, analyzeValTypes, analyzeIntCertain,
50
+ staticObjectProps, typedElemCtor, ctorFromElemAux, shapeOfObjectLiteralAst,
51
+ } from './analyze.js'
52
+
53
+ // === typeof predicate helper ==============================================
54
+ //
55
+ // `typeof name == lit` / `!= lit` is the canonical narrowing predicate.
56
+ // Two consumers — body-walk evidence (`notStringEvidence` below) and
57
+ // flow-sensitive refinement (`extractRefinements` in src/emit.js) — used to
58
+ // re-implement the recognizer independently with diverging tolerances for
59
+ // the literal form (raw `'string'` vs prepare-normalized typeof-code `-2`).
60
+ // The helper accepts both, so callers stop caring about the normalization
61
+ // boundary.
62
+
63
+ /** Match a `typeof name <op> lit` predicate. Returns `{ name, code, eq }` —
64
+ * `name` is the typeof's operand binding, `code` is either the raw type
65
+ * string ('string'|'number'|'function'|…) or the prepare-normalized typeof
66
+ * code (-1|-2|-3|-4|-5|-6, see TYPEOF_MAP in prepare.js), and `eq` is true
67
+ * for `==`/`===` (false for `!=`/`!==`). Returns null when the node isn't a
68
+ * typeof predicate. */
69
+ export function typeofPredicate(node) {
70
+ if (!Array.isArray(node)) return null
71
+ const op = node[0]
72
+ if (op !== '==' && op !== '===' && op !== '!=' && op !== '!==') return null
73
+ const a = node[1], b = node[2]
74
+ const typeofSide = Array.isArray(a) && a[0] === 'typeof' && typeof a[1] === 'string' ? a
75
+ : Array.isArray(b) && b[0] === 'typeof' && typeof b[1] === 'string' ? b : null
76
+ if (!typeofSide) return null
77
+ const litSide = typeofSide === a ? b : a
78
+ const code = Array.isArray(litSide) && litSide[0] == null ? litSide[1] : null
79
+ if (code == null) return null
80
+ return { name: typeofSide[1], code, eq: op === '==' || op === '===' }
81
+ }
82
+
83
+ // === paramReps lattice primitives ==========================================
84
+ //
85
+ // `paramReps: Map<funcName, Map<paramIdx, ValueRep>>` is the cross-call fact
86
+ // lattice. Evidence sources (body-walk and call-site `infer*`) produce
87
+ // raw observations; these primitives apply them with sticky-null poison
88
+ // semantics: undefined → observe (set); equal → stay; disagreement → null
89
+ // (sticky). null is "no consensus" — readers treat it as missing.
90
+ //
91
+ // Lifecycle phases (chronological — readers should know which fields are
92
+ // valid at which point):
93
+ //
94
+ // 1. prepare ─ no paramReps yet. AST walk collects callSites with raw
95
+ // arg lists; programFacts.paramReps is allocated empty.
96
+ //
97
+ // 2. collectProgramFacts ─ unchanged paramReps; aggregates module-global
98
+ // callSites, valueUsed, hasSchemaLiterals into
99
+ // programFacts. paramReps still empty here.
100
+ //
101
+ // 3. narrowSignatures phase D (call-site lattice) ─
102
+ // runCallsiteLattice merges raw call-site facts via
103
+ // mergeParamFact for fields: val, schemaId, intConst,
104
+ // arrayElemSchema, arrayElemValType, typedCtor, wasm.
105
+ // After D, these may be undefined/null/value.
106
+ // Sticky-null reachable on any field whose call sites
107
+ // disagreed.
108
+ //
109
+ // 4. validateIntConstParams ─ clears intConst on any param whose body
110
+ // contains a write to it (intConst's contract is "no
111
+ // writes after the call").
112
+ //
113
+ // 5. phase E / E2 / E3 (param ABI narrowing) ─
114
+ // applyI32ParamSpecialization sets sig.params[k].type
115
+ // to 'i32' based on rep.wasm consensus. Then
116
+ // applyPointerParamAbi sets rep.ptrKind on
117
+ // consistently-OBJECT/ARRAY/etc params; this prepares
118
+ // the i32-offset ABI for unboxed pointer params.
119
+ //
120
+ // 6. phase F (signature fixpoint) ─ clearStickyNull on val + schemaId,
121
+ // then re-runs D until stable. New evidence from
122
+ // newly-narrowed return types (valResult) can unstick.
123
+ //
124
+ // 7. phase G (narrowReturnArrayElems) ─ propagates Array<T> element
125
+ // facts back through return paths into caller param
126
+ // reps. After G, arrayElemSchema/arrayElemValType
127
+ // reflect the transitive closure.
128
+ //
129
+ // 8. phase H (applyTypedPointerParamAbi) ─ sets ptrKind=TYPED + ptrAux
130
+ // (elem code) for params whose val converged to TYPED.
131
+ // Depends on F having seeded val first.
132
+ //
133
+ // 9. phase I (resetParamWasmFacts + final i32 spec) ─ last WASM-level
134
+ // pass. After H/I, sig.params[k].type and rep.ptrKind
135
+ // are frozen for emit.
136
+ //
137
+ // 10. per-function compile (emit start) ─ each func reads its
138
+ // paramReps[name][k] and folds the consensus facts
139
+ // into ctx.func.localReps via updateRep. Locals and
140
+ // params then share one ValueRep store for the
141
+ // remainder of emit.
142
+
143
+ /** Per-call-site fact merge into a param's ValueRep field. */
144
+ export const mergeParamFact = (rep, key, observed) => {
145
+ if (rep[key] === null) return // sticky poison
146
+ if (observed == null) { rep[key] = null; return } // unknown → poison
147
+ if (rep[key] === undefined) rep[key] = observed
148
+ else if (rep[key] !== observed) rep[key] = null
149
+ }
150
+
151
+ /** Get-or-create per-param rep at (funcName, paramIdx) on a paramReps map. */
152
+ export const ensureParamRep = (paramReps, funcName, k) => {
153
+ let m = paramReps.get(funcName)
154
+ if (!m) { m = new Map(); paramReps.set(funcName, m) }
155
+ let r = m.get(k)
156
+ if (!r) { r = {}; m.set(k, r) }
157
+ return r
158
+ }
159
+
160
+ /** Reset sticky-null on a single field across all params program-wide.
161
+ * Used between fixpoint phases when newly-narrowed facts unblock previously-
162
+ * poisoned observations (e.g. valResult set after first pass). */
163
+ export const clearStickyNull = (paramReps, key) => {
164
+ for (const m of paramReps.values()) for (const r of m.values()) {
165
+ if (r[key] === null) r[key] = undefined
166
+ }
167
+ }
168
+
169
+ // === Source registry =======================================================
170
+
171
+ const SOURCES = []
172
+
173
+ /** Register an evidence source. Insertion order = precedence: earlier sources
174
+ * win the merge for a given name. */
175
+ export const registerEvidence = (name, fn) => { SOURCES.push({ name, fn }) }
176
+
177
+ /** Infer per-name facts by running every registered evidence source.
178
+ * Returns Map<name, fact>; callers pass `fact` straight to updateRep.
179
+ *
180
+ * Merge semantics: first source wins per FIELD. Sources contribute orthogonal
181
+ * facts (`methodEvidence` → `{val}`, future sources may add `{notString}`,
182
+ * `{intConst}`, etc.); a later source's field is only kept if no earlier
183
+ * source set the same key on the same name. */
184
+ export const inferParams = (body, candidates) => {
185
+ if (!candidates || candidates.length === 0) return new Map()
186
+ const merged = new Map()
187
+ for (const { fn } of SOURCES) {
188
+ const facts = fn(body, candidates)
189
+ for (const [n, fact] of facts) {
190
+ const prev = merged.get(n)
191
+ merged.set(n, prev ? { ...fact, ...prev } : fact)
192
+ }
193
+ }
194
+ return merged
195
+ }
196
+
197
+ // === Source: method evidence (rungs 2-3, member-access shape) ==============
198
+ //
199
+ // `name.method(...)` is the strongest cheap signal we get for the STRING vs
200
+ // ARRAY distinction. The method-name partition is three sets:
201
+ //
202
+ // STRING_ONLY — definite STRING (no Array/TypedArray equivalent)
203
+ // ARRAY_INDUCERS — definite plain Array (absent on String + TypedArray)
204
+ // ARRAY_ONLY_POISON— Array + TypedArray (poisons tentative STRING, doesn't
205
+ // induce ARRAY because the receiver may still be TYPED)
206
+ //
207
+ // Reassignment to an unambiguously-typed RHS (`x = 0`) poisons the inference
208
+ // regardless of prior evidence — a later method call can't re-induce a shape
209
+ // already contradicted by an earlier scalar assignment.
210
+
211
+ const STRING_ONLY_METHODS = new Set([
212
+ 'charCodeAt', 'charAt', 'codePointAt', 'startsWith', 'endsWith',
213
+ 'toUpperCase', 'toLowerCase', 'toLocaleLowerCase', 'normalize', 'localeCompare',
214
+ 'padStart', 'padEnd', 'repeat', 'trimStart', 'trimEnd', 'trim',
215
+ 'matchAll', 'match', 'replace', 'replaceAll', 'split',
216
+ ])
217
+ const ARRAY_ONLY_POISON = new Set([
218
+ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'fill', 'reverse',
219
+ 'flat', 'flatMap', 'copyWithin',
220
+ ])
221
+ const ARRAY_INDUCERS = new Set([
222
+ 'push', 'pop', 'shift', 'unshift', 'splice', 'flat', 'flatMap',
223
+ ])
224
+
225
+ const methodEvidence = (body, names) => {
226
+ const scope0 = new Set(names)
227
+ const evidence = new Map() // name → 'string' | 'array' | 'conflict'
228
+ const induce = (name, kind) => {
229
+ const prev = evidence.get(name)
230
+ if (prev === 'conflict') return
231
+ if (prev && prev !== kind) return evidence.set(name, 'conflict')
232
+ evidence.set(name, kind)
233
+ }
234
+ function walk(node, scope) {
235
+ if (!Array.isArray(node) || scope.size === 0) return
236
+ const op = node[0]
237
+ if (op === '=>') {
238
+ // Nested arrow: drop shadowed names so an inner param of the same name
239
+ // is treated independently. Captured names retain their outer evidence.
240
+ const shadowed = collectParamNames([node[1]])
241
+ let inner = scope
242
+ for (const s of shadowed) {
243
+ if (inner.has(s)) {
244
+ if (inner === scope) inner = new Set(scope)
245
+ inner.delete(s)
246
+ }
247
+ }
248
+ walk(node[2], inner)
249
+ return
250
+ }
251
+ if (op === '.' && typeof node[1] === 'string' && scope.has(node[1])) {
252
+ const name = node[1]
253
+ const m = node[2]
254
+ if (typeof m === 'string') {
255
+ if (STRING_ONLY_METHODS.has(m)) induce(name, 'string')
256
+ else if (ARRAY_INDUCERS.has(m)) induce(name, 'array')
257
+ else if (ARRAY_ONLY_POISON.has(m) && evidence.get(name) === 'string') {
258
+ evidence.set(name, 'conflict')
259
+ }
260
+ }
261
+ }
262
+ // Runtime type-discrimination — `typeof x`, `Array.isArray(x)` — proves the
263
+ // binding is polymorphic: the body itself branches on its tag. Narrowing it
264
+ // to a single pointer kind erases the very tag the check reads (`typeof`
265
+ // would fold to a constant, `Array.isArray` likewise), miscompiling a
266
+ // soundly-mixed caller. Force `conflict` so the param stays boxed.
267
+ if (op === 'typeof' && typeof node[1] === 'string' && scope.has(node[1]))
268
+ evidence.set(node[1], 'conflict')
269
+ if (op === '()' && node[1] === 'Array.isArray' && typeof node[2] === 'string' && scope.has(node[2]))
270
+ evidence.set(node[2], 'conflict')
271
+ if (op === '=' && typeof node[1] === 'string' && scope.has(node[1])) {
272
+ const name = node[1]
273
+ const vt = valTypeOf(node[2])
274
+ if (vt && vt !== VAL.STRING && vt !== VAL.ARRAY) evidence.set(name, 'conflict')
275
+ else if (vt === VAL.STRING && evidence.get(name) === 'array') evidence.set(name, 'conflict')
276
+ else if (vt === VAL.ARRAY && evidence.get(name) === 'string') evidence.set(name, 'conflict')
277
+ }
278
+ for (let i = 1; i < node.length; i++) walk(node[i], scope)
279
+ }
280
+ walk(body, scope0)
281
+ const out = new Map()
282
+ for (const [n, ev] of evidence) {
283
+ if (ev === 'string') out.set(n, { val: VAL.STRING })
284
+ else if (ev === 'array') out.set(n, { val: VAL.ARRAY })
285
+ }
286
+ return out
287
+ }
288
+
289
+ registerEvidence('method', methodEvidence)
290
+
291
+ // === Source: not-string evidence (rung 3, write-shape) =====================
292
+ //
293
+ // Strings in JS are immutable: `s[i] = v` is silently dropped (strict throws),
294
+ // `s.length = n` likewise has no effect. An *unambiguous write* through
295
+ // `xs[i]` / `xs[i] op= v` / `xs.length = n` / `++xs.length` would prove the
296
+ // receiver isn't a primitive string — but param flow can mix shapes via
297
+ // `typeof x === 'string'` gates (e.g. watr's AST walker takes both array and
298
+ // string nodes). Without flow-sensitive refinement the narrowing turns
299
+ // soundly-mixed callers into miscompilations: a post-gate `node[0]` read
300
+ // would route through `__typed_idx` and return garbage on a string tag.
301
+ //
302
+ // So we require a *conservative* discharge: ANY string-shape evidence on the
303
+ // same name (typeof string check, STRING_ONLY_METHODS call, string-literal
304
+ // assignment) disables the narrow. Method evidence promoting to VAL.ARRAY
305
+ // (push/pop/etc.) wins via the merge regardless and subsumes notString. The
306
+ // remaining win: pure write+length-only params (e.g. `fill(buf, v)`) skip
307
+ // the runtime `__ptr_type==STRING` gate at every read.
308
+
309
+ const isLengthAccess = (n) =>
310
+ Array.isArray(n) && n[0] === '.' && typeof n[1] === 'string' && n[2] === 'length'
311
+
312
+ const isIndexAccess = (n) =>
313
+ Array.isArray(n) && n[0] === '[]' && typeof n[1] === 'string'
314
+
315
+ const isAssignOp = (op) =>
316
+ typeof op === 'string' && (op === '=' || (op.length > 1 && op.endsWith('=') && op !== '==' && op !== '===' && op !== '!=' && op !== '!==' && op !== '<=' && op !== '>='))
317
+
318
+ const isStringLiteralRhs = (rhs) =>
319
+ Array.isArray(rhs) && (rhs[0] === 'str' || (rhs[0] == null && typeof rhs[1] === 'string'))
320
+
321
+ const notStringEvidence = (body, names) => {
322
+ const scope0 = new Set(names)
323
+ const writes = new Set() // candidates with at least one index/length-write site
324
+ const stringy = new Set() // candidates with positive string-shape evidence (disqualified)
325
+ const markStringy = (n) => { stringy.add(n); writes.delete(n) }
326
+ function walk(node, scope) {
327
+ if (!Array.isArray(node) || scope.size === 0) return
328
+ const op = node[0]
329
+ if (op === '=>') {
330
+ const shadowed = collectParamNames([node[1]])
331
+ let inner = scope
332
+ for (const s of shadowed) {
333
+ if (inner.has(s)) {
334
+ if (inner === scope) inner = new Set(scope)
335
+ inner.delete(s)
336
+ }
337
+ }
338
+ walk(node[2], inner)
339
+ return
340
+ }
341
+ // typeof x === 'string' / 'string' === typeof x → x is sometimes a string.
342
+ // The helper handles both raw-string and prepare-normalized -2 forms; the
343
+ // `eq` flag is intentionally ignored — `!=` 'string' is also positive
344
+ // evidence the binding *can* be string in some flow.
345
+ const tp = typeofPredicate(node)
346
+ if (tp && (tp.code === 'string' || tp.code === -2) && scope.has(tp.name)) markStringy(tp.name)
347
+ // STRING_ONLY method call: x.charCodeAt(...), x.split(...), etc.
348
+ if (op === '.' && typeof node[1] === 'string' && scope.has(node[1]) &&
349
+ typeof node[2] === 'string' && STRING_ONLY_METHODS.has(node[2])) {
350
+ markStringy(node[1])
351
+ }
352
+ // String-literal assignment: `x = 'foo'` — re-binds to a string.
353
+ if (op === '=' && typeof node[1] === 'string' && scope.has(node[1]) && isStringLiteralRhs(node[2])) {
354
+ markStringy(node[1])
355
+ }
356
+ // Index write: `xs[i] = v` or compound `xs[i] op= v`.
357
+ if (isAssignOp(op) && isIndexAccess(node[1]) && scope.has(node[1][1]) && !stringy.has(node[1][1])) {
358
+ writes.add(node[1][1])
359
+ }
360
+ // Length mutation: `xs.length = n`, `xs.length += k`, `xs.length++`.
361
+ if (isAssignOp(op) && isLengthAccess(node[1]) && scope.has(node[1][1]) && !stringy.has(node[1][1])) {
362
+ writes.add(node[1][1])
363
+ }
364
+ if ((op === '++' || op === '--') && isLengthAccess(node[1]) && scope.has(node[1][1]) && !stringy.has(node[1][1])) {
365
+ writes.add(node[1][1])
366
+ }
367
+ for (let i = 1; i < node.length; i++) walk(node[i], scope)
368
+ }
369
+ walk(body, scope0)
370
+ const out = new Map()
371
+ for (const n of writes) if (!stringy.has(n)) out.set(n, { notString: true })
372
+ return out
373
+ }
374
+
375
+ registerEvidence('notString', notStringEvidence)
376
+
377
+ // === Per-function orchestration ============================================
378
+ //
379
+ // Single front door for everything that narrows local + param shape from a
380
+ // function body. Two layers fold together here:
381
+ //
382
+ // • Registry sources (above) seed undecided params with `{ val: VAL.* }`
383
+ // evidence merged across all registered fact-returners.
384
+ // • Body-wide ctx-mutating passes (`analyzeValTypes`, `analyzeIntCertain`)
385
+ // walk the AST and write directly to `ctx.func.localReps` — they also
386
+ // populate `ctx.types.typedElem`, `ctx.schema.vars`, regex tracking, etc.
387
+ // and stay in analyze.js where their helpers live.
388
+ //
389
+ // Callers in compile.js used to repeat the merge boilerplate at every emit
390
+ // entry; centralizing it here keeps the ordering invariant in one place
391
+ // (param facts before body walk — `analyzeValTypes`'s `valTypeOf` consults
392
+ // rep, so seeded params must be visible before the walk starts).
393
+
394
+ /** Run the full per-function inference pipeline against `body`.
395
+ * `candidates` is the param-name set eligible for shape seeding (skip names
396
+ * already typed by an upstream source such as `paramReps`). Side-effects
397
+ * only — facts flow into `ctx.func.localReps` via `updateRep`. */
398
+ export const inferLocals = (body, candidates) => {
399
+ if (candidates && candidates.length) {
400
+ const inferred = inferParams(body, candidates)
401
+ for (const [n, fact] of inferred) updateRep(n, fact)
402
+ }
403
+ analyzeValTypes(body)
404
+ analyzeIntCertain(body)
405
+ }
406
+
407
+ // === Module-global value-fact recording ===================================
408
+ //
409
+ // Top-level `const X = …` / `let X = …` produces module-global facts the
410
+ // emitter consults at every call/read site: VAL.* for tagged-pointer dispatch,
411
+ // a typed-array ctor for elem-load fast paths, and a regex var registration.
412
+ // Single per-decl atomic — prepare.js calls it inline during its depth-0 walk
413
+ // (the unique authoritative pass). plan.js used to re-walk the top-level
414
+ // statement list with the same logic; that duplicate was deleted once tests
415
+ // confirmed prepare's depth-0 catch is a strict superset.
416
+ export function recordGlobalRep(name, expr) {
417
+ if (typeof name !== 'string') return
418
+ const vt = valTypeOf(expr)
419
+ if (vt) {
420
+ ;(ctx.scope.globalValTypes ||= new Map()).set(name, vt)
421
+ if (vt === VAL.REGEX && ctx.runtime.regex) ctx.runtime.regex.vars.set(name, expr)
422
+ }
423
+ const ctor = typedElemCtor(expr)
424
+ if (ctor) (ctx.scope.globalTypedElem ||= new Map()).set(name, ctor)
425
+ // Static-shape capture for module-level object literals — lets `{ ...G.path }`
426
+ // resolve its source schema by walking the global rep's shape tree at the
427
+ // spread site (see shape walk in analyze.js / resolveSchema in object.js).
428
+ const sh = shapeOfObjectLiteralAst(expr)
429
+ if (sh) updateGlobalRep(name, { jsonShape: sh })
430
+ }
431
+
432
+ // === Call-site argument inference =========================================
433
+ //
434
+ // Each `infer*(expr, ...callerCtx)` resolves an argument expression to a
435
+ // single fact (val / schemaId / elem-schema / elem-VAL / typedCtor) using the
436
+ // caller's body-local observations plus module-level program facts. Returns
437
+ // null when the fact can't be pinned down at this call site.
438
+ //
439
+ // These are the call-site mirror of the body-walk evidence sources above:
440
+ // body sources answer "what shape does this binding have?"; call-site
441
+ // extractors answer "what shape does this argument carry into a callee?".
442
+ // Both feed the same `paramReps` lattice via narrow.js' signature fixpoint.
443
+
444
+ /** Infer arg val type using caller's body-local valTypes and module globals. */
445
+ export function inferValType(expr, callerValTypes) {
446
+ if (typeof expr === 'string') return callerValTypes?.get(expr) || ctx.scope.globalValTypes?.get(expr) || null
447
+ return valTypeOf(expr)
448
+ }
449
+
450
+ /** Resolve a constant schemaId for an expression in a caller-or-return scope.
451
+ * Sources (in order): per-name `lookupMap` (caller's per-param schemaId map),
452
+ * module-level `ctx.schema.vars` binding, static-key `{}` literal,
453
+ * call to an OBJECT-narrowed function (carries schemaId in `f.sig.ptrAux`),
454
+ * recursive descent through `?:` / `&&` / `||` when both branches agree.
455
+ * Returns the schemaId (number) or null when no constant exists.
456
+ *
457
+ * Used at both call sites (narrow.js D-phase mergeRule for `schemaId`) and
458
+ * return sites (narrow.js phase G's `narrowReturnArrayElems` and the per-fn
459
+ * return-schema narrowing). At early D-iterations the call-result branch
460
+ * is a no-op (valResult not yet seeded by phase F); strictly accretive. */
461
+ export function inferSchemaId(expr, lookupMap) {
462
+ if (typeof expr === 'string') {
463
+ if (lookupMap?.has(expr)) return lookupMap.get(expr)
464
+ const id = ctx.schema.vars.get(expr)
465
+ return id != null ? id : null
466
+ }
467
+ if (!Array.isArray(expr)) return null
468
+ const op = expr[0]
469
+ if (op === '{}') {
470
+ const parsed = staticObjectProps(expr.slice(1))
471
+ return parsed ? ctx.schema.register(parsed.names) : null
472
+ }
473
+ if (op === '()' && typeof expr[1] === 'string') {
474
+ const f = ctx.func.map?.get(expr[1])
475
+ if (f?.valResult === VAL.OBJECT && f.sig.ptrAux != null) return f.sig.ptrAux
476
+ return null
477
+ }
478
+ if (op === '?:') {
479
+ const a = inferSchemaId(expr[2], lookupMap)
480
+ const b = inferSchemaId(expr[3], lookupMap)
481
+ return a != null && a === b ? a : null
482
+ }
483
+ if (op === '&&' || op === '||') {
484
+ const a = inferSchemaId(expr[1], lookupMap)
485
+ const b = inferSchemaId(expr[2], lookupMap)
486
+ return a != null && a === b ? a : null
487
+ }
488
+ return null
489
+ }
490
+
491
+ /** Infer arg arr-elem-schema. Sources: caller's body-local arr-elem map, caller's
492
+ * per-param arr-elem (transitive), or a call to an arr-narrowed user fn. */
493
+ export function inferArrElemSchema(expr, callerArrElems, callerArrParams) {
494
+ if (typeof expr === 'string') {
495
+ if (callerArrElems?.has(expr)) {
496
+ const v = callerArrElems.get(expr)
497
+ if (v != null) return v
498
+ }
499
+ if (callerArrParams?.has(expr)) {
500
+ const v = callerArrParams.get(expr)
501
+ if (v != null) return v
502
+ }
503
+ return null
504
+ }
505
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
506
+ const f = ctx.func.map?.get(expr[1])
507
+ if (f?.arrayElemSchema != null) return f.arrayElemSchema
508
+ }
509
+ return null
510
+ }
511
+
512
+ /** Infer arg arr-elem-VAL. Mirrors inferArrElemSchema but tracks VAL.* element kind. */
513
+ export function inferArrElemValType(expr, callerArrElemVals, callerArrValParams) {
514
+ if (typeof expr === 'string') {
515
+ if (callerArrElemVals?.has(expr)) {
516
+ const v = callerArrElemVals.get(expr)
517
+ if (v != null) return v
518
+ }
519
+ if (callerArrValParams?.has(expr)) {
520
+ const v = callerArrValParams.get(expr)
521
+ if (v != null) return v
522
+ }
523
+ return null
524
+ }
525
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
526
+ const f = ctx.func.map?.get(expr[1])
527
+ if (f?.arrayElemValType != null) return f.arrayElemValType
528
+ }
529
+ return null
530
+ }
531
+
532
+ /** Infer typed-array ctor (`new.Float64Array` etc.) of an arg expression at a call site.
533
+ * Sources: caller's body-local typedElems, caller's typed params, literal `new TypedArray(...)`,
534
+ * calls to typed-narrowed user funcs. Returns null when the ctor can't be determined. */
535
+ export function inferTypedCtor(expr, callerTypedElems, callerTypedParams) {
536
+ if (typeof expr === 'string') {
537
+ if (callerTypedElems?.has(expr)) return callerTypedElems.get(expr)
538
+ if (callerTypedParams?.has(expr)) return callerTypedParams.get(expr)
539
+ return null
540
+ }
541
+ const ctor = typedElemCtor(expr)
542
+ if (ctor) return ctor
543
+ if (Array.isArray(expr) && expr[0] === '()' && typeof expr[1] === 'string') {
544
+ const f = ctx.func.map?.get(expr[1])
545
+ if (f?.sig?.ptrKind === VAL.TYPED && f.sig.ptrAux != null) return ctorFromElemAux(f.sig.ptrAux)
546
+ }
547
+ return null
548
+ }