fhirpath-ts 0.1.0 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +50 -18
  2. package/dist/analyzer/analyze-dto.d.ts +4 -0
  3. package/dist/analyzer/analyze-dto.d.ts.map +1 -1
  4. package/dist/analyzer/analyze-dto.js +12 -14
  5. package/dist/analyzer/analyze-dto.js.map +1 -1
  6. package/dist/analyzer/analyze.d.ts +22 -6
  7. package/dist/analyzer/analyze.d.ts.map +1 -1
  8. package/dist/analyzer/analyze.js +242 -98
  9. package/dist/analyzer/analyze.js.map +1 -1
  10. package/dist/analyzer/declarations.d.ts +26 -0
  11. package/dist/analyzer/declarations.d.ts.map +1 -0
  12. package/dist/analyzer/declarations.js +68 -0
  13. package/dist/analyzer/declarations.js.map +1 -0
  14. package/dist/analyzer/operator-rules.d.ts +127 -0
  15. package/dist/analyzer/operator-rules.d.ts.map +1 -0
  16. package/dist/analyzer/operator-rules.js +70 -0
  17. package/dist/analyzer/operator-rules.js.map +1 -0
  18. package/dist/analyzer/signatures.d.ts +1097 -10
  19. package/dist/analyzer/signatures.d.ts.map +1 -1
  20. package/dist/analyzer/signatures.js +70 -29
  21. package/dist/analyzer/signatures.js.map +1 -1
  22. package/dist/api/compile.d.ts +32 -8
  23. package/dist/api/compile.d.ts.map +1 -1
  24. package/dist/api/compile.js +2 -0
  25. package/dist/api/compile.js.map +1 -1
  26. package/dist/api/engine.d.ts +42 -16
  27. package/dist/api/engine.d.ts.map +1 -1
  28. package/dist/api/engine.js +2 -1
  29. package/dist/api/engine.js.map +1 -1
  30. package/dist/api/project.d.ts +8 -8
  31. package/dist/api/project.d.ts.map +1 -1
  32. package/dist/api/project.js +31 -19
  33. package/dist/api/project.js.map +1 -1
  34. package/dist/api/strict.d.ts +6 -0
  35. package/dist/api/strict.d.ts.map +1 -0
  36. package/dist/api/strict.js +71 -0
  37. package/dist/api/strict.js.map +1 -0
  38. package/dist/api/tagged.d.ts +4 -4
  39. package/dist/api/tagged.d.ts.map +1 -1
  40. package/dist/api/tagged.js.map +1 -1
  41. package/dist/cli/dto-check.js +7 -3
  42. package/dist/cli/dto-check.js.map +1 -1
  43. package/dist/engine/context.d.ts +3 -1
  44. package/dist/engine/context.d.ts.map +1 -1
  45. package/dist/engine/context.js +5 -1
  46. package/dist/engine/context.js.map +1 -1
  47. package/dist/engine/navigation.d.ts.map +1 -1
  48. package/dist/engine/navigation.js +7 -48
  49. package/dist/engine/navigation.js.map +1 -1
  50. package/dist/engine/type-matching.d.ts.map +1 -1
  51. package/dist/engine/type-matching.js +6 -16
  52. package/dist/engine/type-matching.js.map +1 -1
  53. package/dist/index.d.ts +4 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/parser/parser.d.ts.map +1 -1
  57. package/dist/parser/parser.js +102 -73
  58. package/dist/parser/parser.js.map +1 -1
  59. package/dist/parser/precedence.d.ts +368 -4
  60. package/dist/parser/precedence.d.ts.map +1 -1
  61. package/dist/parser/precedence.js +146 -32
  62. package/dist/parser/precedence.js.map +1 -1
  63. package/dist/r4/generated/resources-data.d.ts +6 -2
  64. package/dist/r4/generated/resources-data.d.ts.map +1 -1
  65. package/dist/r4/generated/resources-data.js +626 -28627
  66. package/dist/r4/generated/resources-data.js.map +1 -1
  67. package/dist/r4/generated/type-maps.d.ts +1367 -0
  68. package/dist/r4/generated/type-maps.d.ts.map +1 -1
  69. package/dist/r4/generated/types-data.d.ts +6 -2
  70. package/dist/r4/generated/types-data.d.ts.map +1 -1
  71. package/dist/r4/generated/types-data.js +84 -2204
  72. package/dist/r4/generated/types-data.js.map +1 -1
  73. package/dist/r4/index.d.ts +3 -1
  74. package/dist/r4/index.d.ts.map +1 -1
  75. package/dist/r4/index.js +10 -4
  76. package/dist/r4/index.js.map +1 -1
  77. package/dist/r4/model-data.d.ts +17 -0
  78. package/dist/r4/model-data.d.ts.map +1 -1
  79. package/dist/r4/model-data.js +118 -1
  80. package/dist/r4/model-data.js.map +1 -1
  81. package/dist/typed/context-maps.d.ts +23 -0
  82. package/dist/typed/context-maps.d.ts.map +1 -0
  83. package/dist/typed/context-maps.js +2 -0
  84. package/dist/typed/context-maps.js.map +1 -0
  85. package/dist/typed/infer.d.ts +99 -229
  86. package/dist/typed/infer.d.ts.map +1 -1
  87. package/dist/typed/infer.js +1 -84
  88. package/dist/typed/infer.js.map +1 -1
  89. package/dist/typed/inference-limits.d.ts +7 -0
  90. package/dist/typed/inference-limits.d.ts.map +1 -0
  91. package/dist/typed/inference-limits.js +6 -0
  92. package/dist/typed/inference-limits.js.map +1 -0
  93. package/dist/typed/metadata-compact.d.ts +189 -0
  94. package/dist/typed/metadata-compact.d.ts.map +1 -0
  95. package/dist/typed/metadata-compact.js +3 -0
  96. package/dist/typed/metadata-compact.js.map +1 -0
  97. package/dist/typed/parser.d.ts +515 -0
  98. package/dist/typed/parser.d.ts.map +1 -0
  99. package/dist/typed/parser.js +2 -0
  100. package/dist/typed/parser.js.map +1 -0
  101. package/dist/values/type-compat.d.ts +8 -0
  102. package/dist/values/type-compat.d.ts.map +1 -1
  103. package/dist/values/type-compat.js +44 -1
  104. package/dist/values/type-compat.js.map +1 -1
  105. package/package.json +20 -1
  106. package/src/analyzer/analyze-dto.ts +21 -17
  107. package/src/analyzer/analyze.ts +335 -109
  108. package/src/analyzer/declarations.ts +120 -0
  109. package/src/analyzer/operator-rules.ts +88 -0
  110. package/src/analyzer/signatures.ts +99 -41
  111. package/src/api/compile.ts +66 -11
  112. package/src/api/engine.ts +97 -33
  113. package/src/api/project.ts +54 -30
  114. package/src/api/strict.ts +112 -0
  115. package/src/api/tagged.ts +5 -5
  116. package/src/cli/dto-check.ts +7 -3
  117. package/src/engine/context.ts +7 -2
  118. package/src/engine/navigation.ts +7 -52
  119. package/src/engine/type-matching.ts +11 -17
  120. package/src/index.ts +25 -2
  121. package/src/parser/parser.ts +106 -74
  122. package/src/parser/precedence.ts +250 -33
  123. package/src/r4/generated/resources-data.ts +624 -28628
  124. package/src/r4/generated/type-maps.ts +1557 -0
  125. package/src/r4/generated/types-data.ts +82 -2205
  126. package/src/r4/index.ts +11 -5
  127. package/src/r4/model-data.ts +127 -0
  128. package/src/typed/context-maps.ts +41 -0
  129. package/src/typed/infer.ts +157 -425
  130. package/src/typed/inference-limits.ts +9 -0
  131. package/src/typed/metadata-compact.ts +307 -0
  132. package/src/typed/parser.ts +2277 -0
  133. package/src/values/type-compat.ts +48 -1
  134. package/dist/typed/perf-fixture.types.d.ts +0 -136
  135. package/dist/typed/perf-fixture.types.d.ts.map +0 -1
  136. package/dist/typed/perf-fixture.types.js +0 -2
  137. package/dist/typed/perf-fixture.types.js.map +0 -1
  138. package/src/typed/perf-fixture.types.ts +0 -143
@@ -1,15 +1,34 @@
1
1
  import '../functions/install.ts'
2
2
 
3
- import { BUILTIN_ENV_VARIABLE_NAMES } from '../engine/context.ts'
3
+ import { bareEnvironmentName, BUILTIN_ENV_VARIABLE_NAMES, normalizeEnvKeys } from '../engine/context.ts'
4
4
  import { FhirPathSyntaxError, type SourceSpan } from '../errors.ts'
5
5
  import { describeArity, functions } from '../functions/registry.ts'
6
6
  import type { ElementInfo, ModelProvider } from '../model/provider.ts'
7
7
  import type { AstNode } from '../parser/ast.ts'
8
8
  import { parse } from '../parser/parser.ts'
9
- import { unsatisfiedInput, type ValueKind, valueKindOfTypeName } from '../values/type-compat.ts'
9
+ import type { FhirpathTypeDeclarations } from '../typed/infer.ts'
10
+ import {
11
+ canonicalFocusType,
12
+ commonValueKind,
13
+ resolveByInput,
14
+ resolveSystemTypeName,
15
+ rootTypeMatches,
16
+ SYSTEM_TYPE_LOCAL_NAMES,
17
+ unsatisfiedInput,
18
+ type ValueKind,
19
+ } from '../values/type-compat.ts'
10
20
  import { FHIR_PRIMITIVE_TO_SYSTEM, typeLocalName } from '../values/typed-value.ts'
21
+ import {
22
+ analyzerEnvironmentVariables,
23
+ type AnalyzerVariable,
24
+ type AnalyzerVariableState,
25
+ isRuntimeAnalyzerVariable,
26
+ runtimeAnalyzerEnvironmentVariables,
27
+ } from './declarations.ts'
28
+ import { applyOperatorResultRule, applyTypeOperatorResultRule } from './operator-rules.ts'
11
29
  import { hasNestedUnboundedQuantifier } from './regex-safety.ts'
12
30
  import {
31
+ applyResultRule,
13
32
  type CustomFunctionSignature,
14
33
  FUNCTION_SIGNATURES,
15
34
  type FunctionSignature,
@@ -30,22 +49,27 @@ export interface AnalyzerDiagnostic {
30
49
  }
31
50
 
32
51
  /** A host variable known to the analyzer. Omit its types to keep the value unknown. */
33
- export interface DeclaredVariable {
34
- /** Candidate type names ('Patient', 'System.String'); omit to leave the type unknown. */
35
- types?: string[]
36
- /** True when the variable always holds at most one item. */
37
- single?: boolean
38
- }
52
+ export type DeclaredVariable = AnalyzerVariable
39
53
 
40
54
  /** A host function declaration. Arity resolves the call; an optional signature checks it. */
41
55
  export type SingleDeclaredFunction =
42
- | { minArity?: number; maxArity?: number; expression?: never; signature?: CustomFunctionSignature }
56
+ | {
57
+ minArity?: number
58
+ maxArity?: number
59
+ expression?: never
60
+ signature?: CustomFunctionSignature
61
+ criteria?: never
62
+ envTypes?: never
63
+ }
43
64
  | {
44
65
  /** The body of an expression-defined CustomFunction; its presence pins the arity to 0. */
45
66
  expression: unknown
46
67
  minArity?: never
47
68
  maxArity?: never
48
69
  signature?: CustomFunctionSignature
70
+ criteria?: boolean
71
+ env?: Record<string, unknown>
72
+ envTypes?: FhirpathTypeDeclarations
49
73
  }
50
74
 
51
75
  /** Same-name functions selected by the call focus. Unknown focus keeps only their shared claims. */
@@ -69,6 +93,11 @@ export interface AnalyzeOptions {
69
93
  variables?: Record<string, DeclaredVariable>
70
94
  }
71
95
 
96
+ /** Internal strict-analysis options whose variables may retain exact runtime focus types. */
97
+ export interface RuntimeAnalyzeOptions extends Omit<AnalyzeOptions, 'variables'> {
98
+ variables?: Record<string, AnalyzerVariableState>
99
+ }
100
+
72
101
  /**
73
102
  * Candidate type names and cardinality for one sub-expression. `undefined`
74
103
  * means unknown and pauses checks that need that fact. `single` is true for at
@@ -77,6 +106,8 @@ export interface AnalyzeOptions {
77
106
  interface StaticState {
78
107
  types: string[] | undefined
79
108
  single: boolean | undefined
109
+ /** Exact runtime focus types, present only while strict analysis can still prove them. */
110
+ exactTypes?: string[]
80
111
  /**
81
112
  * Canonical resource types a Reference-valued state may point to (from
82
113
  * `Reference.targetProfile`) — what resolve() yields. Carried by element
@@ -89,6 +120,17 @@ interface StaticState {
89
120
 
90
121
  const UNKNOWN: StaticState = { types: undefined, single: undefined }
91
122
 
123
+ /** A runtime-analyzed function result no longer has exact facts about its input focus. */
124
+ function callResult(state: Pick<StaticState, 'types' | 'single' | 'targets'>, runtime: boolean): StaticState {
125
+ return runtime
126
+ ? {
127
+ types: state.types,
128
+ single: state.single,
129
+ ...(state.targets !== undefined && { targets: state.targets }),
130
+ }
131
+ : state
132
+ }
133
+
92
134
  /**
93
135
  * Variables visible at one point in analysis. Operators and arguments receive a
94
136
  * copy. A dynamic variable name prevents later unknown-variable diagnostics in
@@ -126,6 +168,14 @@ export interface AnalysisDetails {
126
168
  elementDependencies: string[]
127
169
  }
128
170
 
171
+ /** Runtime-known input state used by strict evaluation. Not part of the public analyzer API. */
172
+ export interface AnalyzerRoot {
173
+ types: string[] | undefined
174
+ single: boolean | undefined
175
+ /** Canonical runtime types in focus order; an empty array means an exactly empty focus. */
176
+ exactTypes?: string[]
177
+ }
178
+
129
179
  /**
130
180
  * Statically check one expression against the model: spec §11's strict-mode rules
131
181
  * (singleton misuse, wrong operand and argument types, incomparable equality)
@@ -199,7 +249,16 @@ export function analyzeExpressionDetailed(expression: string, options?: AnalyzeO
199
249
  result: { types: undefined, single: undefined },
200
250
  }
201
251
  }
202
- const analyzer = new Analyzer(options)
252
+ return analyzeAstDetailed(ast, options)
253
+ }
254
+
255
+ /** Analyze an already parsed expression, optionally with a runtime-derived input state. */
256
+ function analyzeAstDetailed(
257
+ ast: AstNode,
258
+ options?: AnalyzeOptions | RuntimeAnalyzeOptions,
259
+ root?: AnalyzerRoot
260
+ ): AnalysisDetails {
261
+ const analyzer = new Analyzer(options, root)
203
262
  const state = analyzer.walk(ast, analyzer.rootState(), emptyScope())
204
263
  return {
205
264
  diagnostics: analyzer.diagnostics,
@@ -208,36 +267,54 @@ export function analyzeExpressionDetailed(expression: string, options?: AnalyzeO
208
267
  }
209
268
  }
210
269
 
270
+ /** Analyze a parsed expression using facts from the actual evaluation focus. */
271
+ export function analyzeRuntimeAstDetailed(
272
+ ast: AstNode,
273
+ options: RuntimeAnalyzeOptions,
274
+ root: AnalyzerRoot
275
+ ): AnalysisDetails {
276
+ return analyzeAstDetailed(ast, options, root)
277
+ }
278
+
211
279
  class Analyzer {
212
280
  readonly diagnostics: AnalyzerDiagnostic[] = []
213
281
  readonly dependencies = new Set<string>()
214
282
  private readonly model: ModelProvider | undefined
215
- private readonly inputType: string | undefined
283
+ private readonly root: AnalyzerRoot
284
+ private readonly runtime: boolean
216
285
  private readonly frames: StaticState[] = []
217
286
  /** Every declaration of each host-supplied name; one entry unless the name is overloaded. */
218
287
  private readonly customFunctions: ReadonlyMap<string, readonly ResolvedDeclaration[]>
219
- private readonly declaredVariables: ReadonlyMap<string, DeclaredVariable>
288
+ private readonly declaredVariables: ReadonlyMap<string, AnalyzerVariableState>
289
+ private readonly activeExpressionFunctions = new Set<string>()
220
290
 
221
- constructor(options: AnalyzeOptions | undefined) {
291
+ constructor(options: AnalyzeOptions | RuntimeAnalyzeOptions | undefined, root: AnalyzerRoot | undefined) {
222
292
  this.model = options?.model
293
+ this.runtime = root !== undefined
223
294
  const inputType = options?.inputType
224
- this.inputType = inputType === undefined ? undefined : (this.model?.resolveType(inputType) ?? inputType)
295
+ this.root =
296
+ root ??
297
+ (inputType === undefined
298
+ ? { types: undefined, single: undefined }
299
+ : { types: [this.model?.resolveType(inputType) ?? inputType], single: true })
225
300
  this.customFunctions = new Map(
226
301
  Object.entries(options?.functions ?? {}).map(([name, declared]) => [
227
302
  name,
228
- ('overloads' in declared ? declared.overloads : [declared]).map(resolvedDeclaration),
229
- ])
230
- )
231
- this.declaredVariables = new Map(
232
- Object.entries(options?.variables ?? {}).map(([name, variable]) => [
233
- name.startsWith('%') ? name.slice(1) : name,
234
- variable,
303
+ ('overloads' in declared ? declared.overloads : [declared]).map(declaration =>
304
+ resolvedDeclaration(declaration, this.model, this.runtime)
305
+ ),
235
306
  ])
236
307
  )
308
+ this.declaredVariables = new Map(Object.entries(normalizeEnvKeys(options?.variables)))
237
309
  }
238
310
 
239
311
  rootState(): StaticState {
240
- return this.inputType === undefined ? UNKNOWN : { types: [this.inputType], single: true, rawInput: true }
312
+ return {
313
+ types: this.root.types,
314
+ single: this.root.single,
315
+ rawInput: true,
316
+ ...(this.root.exactTypes !== undefined && { exactTypes: this.root.exactTypes }),
317
+ }
241
318
  }
242
319
 
243
320
  walk(node: AstNode, input: StaticState, scope: VariableScope): StaticState {
@@ -312,7 +389,7 @@ class Analyzer {
312
389
  }
313
390
  const declared = this.declaredVariables.get(node.name)
314
391
  if (declared !== undefined) {
315
- return { types: declared.types?.map(type => this.canonicalize(type)), single: declared.single }
392
+ return this.declaredVariableState(declared)
316
393
  }
317
394
  switch (node.name) {
318
395
  case 'context':
@@ -362,30 +439,54 @@ class Analyzer {
362
439
  if (input.types !== undefined && input.types.length === 0) {
363
440
  return { types: [], single: true }
364
441
  }
442
+ const exactTypes = input.exactTypes?.filter(type => rootTypeMatches(this.model, type, node.name))
443
+ if (exactTypes !== undefined && exactTypes.length > 0) {
444
+ const types = input.types?.filter(type => rootTypeMatches(this.model, type, node.name))
445
+ return {
446
+ types: types === undefined ? undefined : [...new Set(types)],
447
+ single: input.single,
448
+ exactTypes,
449
+ ...(input.rawInput === true && { rawInput: true }),
450
+ }
451
+ }
365
452
  if (input.types === undefined) {
366
453
  // Even with an unknown input, a root identifier naming a model type anchors
367
454
  // the state — this is what checks `Patient.nope` without an inputType option.
368
- const asType = this.model?.resolveType(node.name)
455
+ const asType = this.model?.resolveType(node.name) ?? resolveSystemTypeName(node.name)
369
456
  if (asType !== undefined) {
370
- return { types: [asType], single: true }
457
+ return {
458
+ types: [asType],
459
+ single: true,
460
+ ...(input.exactTypes !== undefined && {
461
+ exactTypes: input.exactTypes.filter(type => this.model?.isSubtypeOf(type, asType) === true),
462
+ }),
463
+ }
371
464
  }
372
465
  return UNKNOWN
373
466
  }
374
467
  // Root rule: an identifier naming the (super)type of the context is the context.
375
- if (this.model) {
376
- const asType = this.model.resolveType(node.name)
377
- if (asType !== undefined && input.types.some(type => this.model?.isSubtypeOf(type, asType))) {
468
+ {
469
+ const asType = this.model?.resolveType(node.name) ?? resolveSystemTypeName(node.name)
470
+ let matchingTypes: string[] = []
471
+ if (asType !== undefined) {
472
+ matchingTypes = input.types.filter(type => rootTypeMatches(this.model, type, node.name))
473
+ }
474
+ if (asType !== undefined && matchingTypes.length > 0) {
378
475
  // The runtime matches a type name against the raw input only through the
379
476
  // resourceType discriminator (values/typed-value.ts), so a non-resource
380
477
  // name never matches there and the whole path navigates to empty.
381
- if (input.rawInput === true && !this.isResourceType(asType)) {
478
+ if (input.rawInput === true && !asType.startsWith('System.') && !this.isResourceType(asType)) {
382
479
  this.report(
383
480
  'datatype-root',
384
481
  `'${node.name}' is not a resource type, and a type-name root matches only a resource's resourceType, so this always evaluates to empty — navigate from the input with a relative path`,
385
482
  node.span
386
483
  )
387
484
  }
388
- return input
485
+ return {
486
+ ...input,
487
+ types: matchingTypes,
488
+ ...(input.exactTypes !== undefined && { exactTypes: input.exactTypes }),
489
+ }
389
490
  }
390
491
  }
391
492
  const found: string[] = []
@@ -528,7 +629,8 @@ class Analyzer {
528
629
  const signature = registered !== undefined ? FUNCTION_SIGNATURES[node.name] : this.toSignature(custom?.signature)
529
630
  if (!signature) {
530
631
  this.walkUncheckedArguments(node, input, scope)
531
- return UNKNOWN
632
+ const expressionResult = this.expressionFunctionResult(node.name, custom, input, scope)
633
+ return expressionResult === undefined ? UNKNOWN : callResult(expressionResult, this.runtime)
532
634
  }
533
635
  this.checkCallInput(node, signature, input)
534
636
  const { argStates, typeTarget } = this.walkArguments(node, signature, input, scope)
@@ -541,7 +643,125 @@ class Analyzer {
541
643
  if ((node.name === 'ofType' || node.name === 'as') && typeTarget !== undefined) {
542
644
  return { types: this.narrowTypes(input, typeTarget, node.span), single: input.single }
543
645
  }
544
- return signature.result(input, argStates)
646
+ const expressionResult = this.expressionFunctionResult(node.name, custom, input, scope)
647
+ if (expressionResult !== undefined) {
648
+ return callResult(expressionResult, this.runtime)
649
+ }
650
+ return callResult(applyResultRule(signature.result, input, argStates), this.runtime)
651
+ }
652
+
653
+ /** Apply the expression-body and criteria rules in one place for signed and unsigned declarations. */
654
+ private expressionFunctionResult(
655
+ name: string,
656
+ declaration: ResolvedDeclaration | undefined,
657
+ input: StaticState,
658
+ scope: VariableScope
659
+ ): StaticState | undefined {
660
+ if (declaration?.expression === undefined) {
661
+ for (const possible of declaration?.possibleBodies ?? []) {
662
+ if (possible.expression !== undefined) {
663
+ this.walkExpressionFunction(
664
+ name,
665
+ possible.expression,
666
+ possible.variables,
667
+ this.expressionBodyInput(possible, input),
668
+ scope
669
+ )
670
+ }
671
+ }
672
+ return undefined
673
+ }
674
+ const expression = declaration.expression
675
+ const bodyInput = this.expressionBodyInput(declaration, input)
676
+ if (declaration.criteria === true) {
677
+ this.walkExpressionFunction(name, expression, declaration.variables, bodyInput, scope)
678
+ return { types: ['System.Boolean'], single: true }
679
+ }
680
+ if (declaration.signature?.result === undefined) {
681
+ return this.walkExpressionFunction(name, expression, declaration.variables, bodyInput, scope)
682
+ }
683
+ if (this.runtime) {
684
+ this.walkExpressionFunction(name, expression, declaration.variables, bodyInput, scope)
685
+ }
686
+ return undefined
687
+ }
688
+
689
+ /** A declared input type is the contract an expression body must satisfy. */
690
+ private expressionBodyInput(declaration: ResolvedDeclaration, callInput: StaticState): StaticState {
691
+ const declared = declaration.signature?.input?.types
692
+ if (declared === undefined) {
693
+ return callInput
694
+ }
695
+ const types = declared
696
+ .map(type =>
697
+ this.model === undefined ? resolveSystemTypeName(typeLocalName(type)) : canonicalFocusType(this.model, type)
698
+ )
699
+ .filter((type): type is string => type !== undefined)
700
+ if (types.length === 0) {
701
+ return callInput
702
+ }
703
+ return {
704
+ types: [...new Set(types)],
705
+ single: callInput.single,
706
+ ...(callInput.targets !== undefined && { targets: callInput.targets }),
707
+ }
708
+ }
709
+
710
+ /** Infer a literal expression-function body under its call focus and temporary local environment declarations. */
711
+ private walkExpressionFunction(
712
+ name: string,
713
+ expression: NonNullable<ResolvedDeclaration['expression']>,
714
+ variables: ResolvedDeclaration['variables'],
715
+ input: StaticState,
716
+ callerScope: VariableScope
717
+ ): StaticState {
718
+ if (this.activeExpressionFunctions.has(name)) {
719
+ return UNKNOWN
720
+ }
721
+ let ast = expression.ast
722
+ if (ast === undefined) {
723
+ try {
724
+ ast = parse(expression.source)
725
+ } catch {
726
+ return UNKNOWN
727
+ }
728
+ }
729
+ const functionScope = forkScope(callerScope)
730
+ for (const [declaredName, variable] of Object.entries(variables ?? {})) {
731
+ const bare = bareEnvironmentName(declaredName)
732
+ // defineVariable() values have priority over environment overlays at runtime.
733
+ if (!functionScope.vars.has(bare)) {
734
+ functionScope.vars.set(bare, this.declaredVariableState(variable))
735
+ }
736
+ }
737
+ const diagnosticCount = this.diagnostics.length
738
+ this.activeExpressionFunctions.add(name)
739
+ this.frames.push(input)
740
+ try {
741
+ return this.walk(ast, input, functionScope)
742
+ } finally {
743
+ if (this.runtime) {
744
+ for (const diagnostic of this.diagnostics.slice(diagnosticCount)) {
745
+ diagnostic.message = `Custom function '${name}': ${diagnostic.message}`
746
+ }
747
+ } else {
748
+ // Body spans index another source string, so callers cannot display these diagnostics correctly.
749
+ this.diagnostics.length = diagnosticCount
750
+ }
751
+ this.frames.pop()
752
+ this.activeExpressionFunctions.delete(name)
753
+ }
754
+ }
755
+
756
+ /** Convert one declared environment value into the same state used for scoped variables. */
757
+ private declaredVariableState(declared: AnalyzerVariableState): StaticState {
758
+ const exactTypes = this.runtime && isRuntimeAnalyzerVariable(declared) ? declared.exactTypes : undefined
759
+ return {
760
+ types: declared.types?.map(type => this.canonicalize(type)),
761
+ single: declared.single,
762
+ ...(declared.targets !== undefined && { targets: declared.targets.map(type => this.canonicalize(type)) }),
763
+ ...(exactTypes !== undefined && { exactTypes }),
764
+ }
545
765
  }
546
766
 
547
767
  /**
@@ -554,11 +774,25 @@ class Analyzer {
554
774
  if (candidates === undefined || candidates.length <= 1) {
555
775
  return candidates?.[0]
556
776
  }
777
+ if (this.runtime && (input.exactTypes !== undefined || this.model === undefined)) {
778
+ const resolution = resolveByInput(
779
+ this.model,
780
+ candidates,
781
+ candidate => candidate.signature?.input?.types,
782
+ input.exactTypes ?? []
783
+ )
784
+ return 'resolved' in resolution ? resolution.resolved : mergedDeclaration(candidates)
785
+ }
557
786
  const focus = input.types ?? []
558
787
  const fitting = candidates.filter(
559
788
  candidate => unsatisfiedInput(this.model, candidate.signature?.input?.types, focus) === undefined
560
789
  )
561
- return fitting.length === 1 ? fitting[0] : mergedDeclaration(fitting.length === 0 ? candidates : fitting)
790
+ if (fitting.length === 1) {
791
+ return fitting[0]
792
+ }
793
+ const possible = fitting.length === 0 ? candidates : fitting
794
+ const merged = mergedDeclaration(possible)
795
+ return this.runtime ? { ...merged, possibleBodies: possible } : merged
562
796
  }
563
797
 
564
798
  /** Without a signature the arguments still walk (for their own diagnostics), each in a scope fork. */
@@ -740,7 +974,11 @@ class Analyzer {
740
974
  return {
741
975
  ...(declared.input !== undefined && { input: declared.input }),
742
976
  ...(declared.args !== undefined && { args: declared.args }),
743
- result: () => ({ types, single }),
977
+ result: {
978
+ kind: 'fixed',
979
+ ...(types !== undefined && { types }),
980
+ ...(single !== undefined && { single }),
981
+ },
744
982
  }
745
983
  }
746
984
 
@@ -793,7 +1031,7 @@ class Analyzer {
793
1031
  /** Check a type name; returns its canonical form, or undefined when it was reported unknown. */
794
1032
  private checkTypeName(parts: string[], span: SourceSpan): string | undefined {
795
1033
  if (parts.length === 2 && parts[0] === 'System') {
796
- if (!SYSTEM_TYPE_NAMES.has(parts[1] as string)) {
1034
+ if (!SYSTEM_TYPE_LOCAL_NAMES.has(parts[1] as string)) {
797
1035
  this.report('unknown-type', `Unknown type 'System.${parts[1]}'`, span)
798
1036
  return undefined
799
1037
  }
@@ -805,11 +1043,11 @@ class Analyzer {
805
1043
  return undefined
806
1044
  }
807
1045
  const resolved = this.model?.resolveType(name)
808
- if (this.model && resolved === undefined && !SYSTEM_TYPE_NAMES.has(name)) {
1046
+ if (this.model && resolved === undefined && !SYSTEM_TYPE_LOCAL_NAMES.has(name)) {
809
1047
  this.report('unknown-type', `Unknown type '${parts.join('.')}'`, span)
810
1048
  return undefined
811
1049
  }
812
- return resolved ?? (SYSTEM_TYPE_NAMES.has(name) ? `System.${name}` : name)
1050
+ return resolved ?? (SYSTEM_TYPE_LOCAL_NAMES.has(name) ? `System.${name}` : name)
813
1051
  }
814
1052
 
815
1053
  private walkBinary(node: AstNode & { kind: 'binary' }, input: StaticState, scope: VariableScope): StaticState {
@@ -826,24 +1064,14 @@ class Analyzer {
826
1064
  case 'div':
827
1065
  case 'mod': {
828
1066
  this.checkArithmetic(node.operator, left, right, node.span)
829
- // Quantity arithmetic yields Quantity (4.0 'g' / 2.0 'm' is 2 'g/m');
830
- // plain division yields Decimal; everything else keeps the operand type.
831
- const quantity =
832
- (node.operator === '*' || node.operator === '/') &&
833
- (kindOf(left) === 'Quantity' || kindOf(right) === 'Quantity')
834
- const types = quantity
835
- ? ['System.Quantity']
836
- : node.operator === '/'
837
- ? ['System.Decimal']
838
- : (left.types ?? right.types)
839
- return { types, single: true }
1067
+ return applyOperatorResultRule(node.operator, left, right)
840
1068
  }
841
1069
  case '&':
842
1070
  this.requireSingle(left, node.left.span, "'&' expects single-item operands")
843
1071
  this.requireSingle(right, node.right.span, "'&' expects single-item operands")
844
1072
  this.requireKind(left, 'String', node.left.span, "'&' expects String operands")
845
1073
  this.requireKind(right, 'String', node.right.span, "'&' expects String operands")
846
- return { types: ['System.String'], single: true }
1074
+ return applyOperatorResultRule(node.operator, left, right)
847
1075
  case '<':
848
1076
  case '>':
849
1077
  case '<=':
@@ -851,13 +1079,13 @@ class Analyzer {
851
1079
  this.requireSingle(left, node.left.span, `Operator '${node.operator}' expects single-item operands`)
852
1080
  this.requireSingle(right, node.right.span, `Operator '${node.operator}' expects single-item operands`)
853
1081
  this.checkComparable(left, right, node.span)
854
- return { types: ['System.Boolean'], single: true }
1082
+ return applyOperatorResultRule(node.operator, left, right)
855
1083
  case '=':
856
1084
  case '!=':
857
1085
  case '~':
858
1086
  case '!~':
859
1087
  this.checkEquality(left, right, node.span)
860
- return { types: ['System.Boolean'], single: true }
1088
+ return applyOperatorResultRule(node.operator, left, right)
861
1089
  case 'and':
862
1090
  case 'or':
863
1091
  case 'xor':
@@ -866,17 +1094,10 @@ class Analyzer {
866
1094
  // rule, so only cardinality is checkable here.
867
1095
  this.requireSingle(left, node.left.span, `'${node.operator}' expects single-item operands`)
868
1096
  this.requireSingle(right, node.right.span, `'${node.operator}' expects single-item operands`)
869
- return { types: ['System.Boolean'], single: true }
1097
+ return applyOperatorResultRule(node.operator, left, right)
870
1098
  }
871
1099
  case '|': {
872
- // A statically empty side contributes nothing: `{} | true` is one item.
873
- if (left.types?.length === 0) {
874
- return right
875
- }
876
- if (right.types?.length === 0) {
877
- return left
878
- }
879
- return withSingle(unionStates([left, right]), false)
1100
+ return applyOperatorResultRule(node.operator, left, right)
880
1101
  }
881
1102
  case 'in':
882
1103
  case 'contains': {
@@ -887,7 +1108,7 @@ class Analyzer {
887
1108
  singletonSpan,
888
1109
  `The ${node.operator === 'in' ? 'left' : 'right'} operand of '${node.operator}' must be a single item`
889
1110
  )
890
- return { types: ['System.Boolean'], single: true }
1111
+ return applyOperatorResultRule(node.operator, left, right)
891
1112
  }
892
1113
  /* v8 ignore start -- the parser produces no other binary operators */
893
1114
  default:
@@ -907,13 +1128,9 @@ class Analyzer {
907
1128
  const operand = this.walk(node.operand, input, scope)
908
1129
  this.requireSingle(operand, node.operand.span, `'${node.operator}' expects a single item operand`)
909
1130
  const resolved = this.checkTypeName(node.type.parts, node.type.span)
910
- if (node.operator === 'is') {
911
- return { types: ['System.Boolean'], single: true }
912
- }
913
- if (resolved === undefined) {
914
- return UNKNOWN
915
- }
916
- return { types: this.narrowTypes(operand, resolved, node.span), single: true }
1131
+ const narrowed =
1132
+ node.operator === 'as' && resolved !== undefined ? this.narrowTypes(operand, resolved, node.span) : undefined
1133
+ return applyTypeOperatorResultRule(node.operator, narrowed)
917
1134
  }
918
1135
 
919
1136
  private checkArithmetic(operator: string, left: StaticState, right: StaticState, span: SourceSpan): void {
@@ -921,8 +1138,8 @@ class Analyzer {
921
1138
  this.report('singleton-required', `Operator '${operator}' expects single-item operands${NARROW_HINT}`, span)
922
1139
  return
923
1140
  }
924
- const leftKind = kindOf(left)
925
- const rightKind = kindOf(right)
1141
+ const leftKind = commonValueKind(left.types)
1142
+ const rightKind = commonValueKind(right.types)
926
1143
  if (leftKind === undefined || rightKind === undefined || leftKind === 'Complex' || rightKind === 'Complex') {
927
1144
  return
928
1145
  }
@@ -941,8 +1158,8 @@ class Analyzer {
941
1158
  }
942
1159
 
943
1160
  private checkComparable(left: StaticState, right: StaticState, span: SourceSpan): void {
944
- const leftKind = kindOf(left)
945
- const rightKind = kindOf(right)
1161
+ const leftKind = commonValueKind(left.types)
1162
+ const rightKind = commonValueKind(right.types)
946
1163
  if (leftKind === undefined || rightKind === undefined) {
947
1164
  return
948
1165
  }
@@ -952,8 +1169,8 @@ class Analyzer {
952
1169
  }
953
1170
 
954
1171
  private checkEquality(left: StaticState, right: StaticState, span: SourceSpan): void {
955
- const leftKind = kindOf(left)
956
- const rightKind = kindOf(right)
1172
+ const leftKind = commonValueKind(left.types)
1173
+ const rightKind = commonValueKind(right.types)
957
1174
  if (leftKind === undefined || rightKind === undefined || leftKind === 'Complex' || rightKind === 'Complex') {
958
1175
  return
959
1176
  }
@@ -963,7 +1180,7 @@ class Analyzer {
963
1180
  }
964
1181
 
965
1182
  private requireKind(state: StaticState, kind: ValueKind, span: SourceSpan, message: string): void {
966
- const actual = kindOf(state)
1183
+ const actual = commonValueKind(state.types)
967
1184
  if (actual === undefined) {
968
1185
  return
969
1186
  }
@@ -987,19 +1204,6 @@ class Analyzer {
987
1204
  /** Functions whose first argument is a regular expression pattern. */
988
1205
  const REGEX_PATTERN_FUNCTIONS = new Set(['matches', 'matchesFull', 'replaceMatches'])
989
1206
 
990
- const SYSTEM_TYPE_NAMES = new Set([
991
- 'Any',
992
- 'Boolean',
993
- 'String',
994
- 'Integer',
995
- 'Long',
996
- 'Decimal',
997
- 'Date',
998
- 'DateTime',
999
- 'Time',
1000
- 'Quantity',
1001
- ])
1002
-
1003
1207
  /** Appended to singleton-misuse messages so the fix is spelled out, not just the rule. */
1004
1208
  const NARROW_HINT = ' — narrow it to one item with first(), last(), or single()'
1005
1209
 
@@ -1016,15 +1220,54 @@ interface ResolvedDeclaration {
1016
1220
  minArity?: number
1017
1221
  maxArity?: number
1018
1222
  signature?: CustomFunctionSignature
1223
+ expression?: { source: string; ast?: AstNode }
1224
+ criteria?: boolean
1225
+ variables?: Readonly<Record<string, AnalyzerVariableState>>
1226
+ /** Expression declarations runtime dispatch may select when static focus is not exact. */
1227
+ possibleBodies?: readonly ResolvedDeclaration[]
1019
1228
  }
1020
1229
 
1021
- function resolvedDeclaration(declared: SingleDeclaredFunction): ResolvedDeclaration {
1230
+ function resolvedDeclaration(
1231
+ declared: SingleDeclaredFunction,
1232
+ model: ModelProvider | undefined,
1233
+ runtime: boolean
1234
+ ): ResolvedDeclaration {
1022
1235
  if (declared.expression === undefined) {
1023
- return declared
1236
+ return {
1237
+ ...(declared.minArity !== undefined && { minArity: declared.minArity }),
1238
+ ...(declared.maxArity !== undefined && { maxArity: declared.maxArity }),
1239
+ ...(declared.signature !== undefined && { signature: declared.signature }),
1240
+ }
1024
1241
  }
1025
1242
  // An expression-defined function takes no arguments, which is how the runtime
1026
1243
  // calls it (`evaluateHostFunction`).
1027
- return { minArity: 0, maxArity: 0, ...(declared.signature !== undefined && { signature: declared.signature }) }
1244
+ const expression = resolvedExpression(declared.expression)
1245
+ return {
1246
+ minArity: 0,
1247
+ maxArity: 0,
1248
+ ...(declared.signature !== undefined && { signature: declared.signature }),
1249
+ ...(expression !== undefined && { expression }),
1250
+ ...(declared.criteria !== undefined && { criteria: declared.criteria }),
1251
+ ...((declared.env !== undefined || declared.envTypes !== undefined) && {
1252
+ variables: runtime
1253
+ ? runtimeAnalyzerEnvironmentVariables(declared.env, declared.envTypes, model)
1254
+ : analyzerEnvironmentVariables(declared.env, declared.envTypes, model),
1255
+ }),
1256
+ }
1257
+ }
1258
+
1259
+ function resolvedExpression(expression: unknown): { source: string; ast?: AstNode } | undefined {
1260
+ if (typeof expression === 'string') {
1261
+ return { source: expression }
1262
+ }
1263
+ const compiled = expression as { source?: unknown; ast?: unknown } | undefined
1264
+ if (typeof compiled?.source !== 'string') {
1265
+ return undefined
1266
+ }
1267
+ return {
1268
+ source: compiled.source,
1269
+ ...(compiled.ast !== undefined && { ast: compiled.ast as AstNode }),
1270
+ }
1028
1271
  }
1029
1272
 
1030
1273
  /**
@@ -1061,7 +1304,7 @@ function mergedInput(candidates: readonly ResolvedDeclaration[]): InputSpec | un
1061
1304
  function mergedResult(candidates: readonly ResolvedDeclaration[]): { types?: string[]; single?: boolean } | undefined {
1062
1305
  const union = unionStates(
1063
1306
  candidates.map(candidate => ({
1064
- types: candidate.signature?.result?.types,
1307
+ types: candidate.signature?.result?.types === undefined ? undefined : [...candidate.signature.result.types],
1065
1308
  single: candidate.signature?.result?.single,
1066
1309
  }))
1067
1310
  )
@@ -1074,23 +1317,6 @@ function mergedResult(candidates: readonly ResolvedDeclaration[]): { types?: str
1074
1317
  }
1075
1318
  }
1076
1319
 
1077
- /** The behavior kind shared by every candidate type, or undefined when mixed/unknown. */
1078
- function kindOf(state: StaticState): ValueKind | undefined {
1079
- if (state.types === undefined || state.types.length === 0) {
1080
- return undefined
1081
- }
1082
- let kind: ValueKind | undefined
1083
- for (const type of state.types) {
1084
- const typeKind = valueKindOfTypeName(type)
1085
- if (kind === undefined) {
1086
- kind = typeKind
1087
- } else if (kind !== typeKind) {
1088
- return undefined
1089
- }
1090
- }
1091
- return kind
1092
- }
1093
-
1094
1320
  function typeSpecifierParts(node: AstNode): string[] | undefined {
1095
1321
  if (node.kind === 'identifier') {
1096
1322
  return [node.name]