fhirpath-ts 0.2.1 → 0.2.2

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 (64) hide show
  1. package/README.md +12 -5
  2. package/dist/analyzer/analyze-dto.d.ts.map +1 -1
  3. package/dist/analyzer/analyze-dto.js +14 -8
  4. package/dist/analyzer/analyze-dto.js.map +1 -1
  5. package/dist/analyzer/analyze.d.ts +23 -12
  6. package/dist/analyzer/analyze.d.ts.map +1 -1
  7. package/dist/analyzer/analyze.js +138 -46
  8. package/dist/analyzer/analyze.js.map +1 -1
  9. package/dist/analyzer/declarations.d.ts +6 -0
  10. package/dist/analyzer/declarations.d.ts.map +1 -1
  11. package/dist/analyzer/declarations.js +7 -0
  12. package/dist/analyzer/declarations.js.map +1 -1
  13. package/dist/analyzer/expression-policy.d.ts +88 -14
  14. package/dist/analyzer/expression-policy.d.ts.map +1 -1
  15. package/dist/analyzer/expression-policy.js +296 -41
  16. package/dist/analyzer/expression-policy.js.map +1 -1
  17. package/dist/analyzer/operator-rules.d.ts.map +1 -1
  18. package/dist/analyzer/operator-rules.js +7 -6
  19. package/dist/analyzer/operator-rules.js.map +1 -1
  20. package/dist/analyzer/signatures.d.ts +49 -0
  21. package/dist/analyzer/signatures.d.ts.map +1 -1
  22. package/dist/analyzer/signatures.js +76 -31
  23. package/dist/analyzer/signatures.js.map +1 -1
  24. package/dist/analyzer/source-options.d.ts +17 -0
  25. package/dist/analyzer/source-options.d.ts.map +1 -0
  26. package/dist/analyzer/source-options.js +3 -0
  27. package/dist/analyzer/source-options.js.map +1 -0
  28. package/dist/api/dto.d.ts +9 -4
  29. package/dist/api/dto.d.ts.map +1 -1
  30. package/dist/api/dto.js +14 -3
  31. package/dist/api/dto.js.map +1 -1
  32. package/dist/api/strict.d.ts.map +1 -1
  33. package/dist/api/strict.js +8 -3
  34. package/dist/api/strict.js.map +1 -1
  35. package/dist/cli/dto-check.d.ts +9 -0
  36. package/dist/cli/dto-check.d.ts.map +1 -1
  37. package/dist/cli/dto-check.js +49 -6
  38. package/dist/cli/dto-check.js.map +1 -1
  39. package/dist/cli/fhirpath-check.js +161 -31
  40. package/dist/cli/fhirpath-check.js.map +1 -1
  41. package/dist/eslint/index.d.ts.map +1 -1
  42. package/dist/eslint/index.js +13 -8
  43. package/dist/eslint/index.js.map +1 -1
  44. package/dist/sites/index.d.ts +31 -2
  45. package/dist/sites/index.d.ts.map +1 -1
  46. package/dist/sites/index.js +351 -30
  47. package/dist/sites/index.js.map +1 -1
  48. package/dist/typed/infer.d.ts +2 -0
  49. package/dist/typed/infer.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/analyzer/analyze-dto.ts +19 -9
  52. package/src/analyzer/analyze.ts +186 -54
  53. package/src/analyzer/declarations.ts +12 -0
  54. package/src/analyzer/expression-policy.ts +423 -47
  55. package/src/analyzer/operator-rules.ts +7 -6
  56. package/src/analyzer/signatures.ts +100 -36
  57. package/src/analyzer/source-options.ts +19 -0
  58. package/src/api/dto.ts +35 -9
  59. package/src/api/strict.ts +8 -3
  60. package/src/cli/dto-check.ts +56 -6
  61. package/src/cli/fhirpath-check.ts +180 -34
  62. package/src/eslint/index.ts +15 -12
  63. package/src/sites/index.ts +428 -40
  64. package/src/typed/infer.ts +2 -2
@@ -34,9 +34,12 @@ import {
34
34
  type FunctionSignature,
35
35
  type InputSpec,
36
36
  singleAnd,
37
+ singleState,
38
+ singletonOrder,
37
39
  unionStates,
38
40
  withSingle,
39
41
  } from './signatures.ts'
42
+ import { SOURCE_VARIABLE_DEFAULTS, type SourceVariableDefaults, type SourceVariablePlan } from './source-options.ts'
40
43
 
41
44
  export interface AnalyzerDiagnostic {
42
45
  severity: 'error' | 'warning'
@@ -91,6 +94,8 @@ export interface AnalyzeOptions {
91
94
  functions?: Record<string, DeclaredFunction>
92
95
  /** Host-supplied environment variables by name (with or without the leading `%`). */
93
96
  variables?: Record<string, DeclaredVariable>
97
+ /** Report navigation that remains unchecked because a declared host variable has no type. */
98
+ reportUnchecked?: boolean
94
99
  }
95
100
 
96
101
  /** Internal strict-analysis options whose variables may retain exact runtime focus types. */
@@ -106,6 +111,8 @@ export interface RuntimeAnalyzeOptions extends Omit<AnalyzeOptions, 'variables'>
106
111
  interface StaticState {
107
112
  types: string[] | undefined
108
113
  single: boolean | undefined
114
+ /** True: ordered. False: unordered. Undefined: ordering is unknown. */
115
+ ordered: boolean | undefined
109
116
  /** Exact runtime focus types, present only while strict analysis can still prove them. */
110
117
  exactTypes?: string[]
111
118
  /**
@@ -116,16 +123,22 @@ interface StaticState {
116
123
  targets?: string[]
117
124
  /** True until the raw input has been narrowed or navigated through the model. */
118
125
  rawInput?: boolean
126
+ /** Untyped host variable whose first element navigation cannot be checked. */
127
+ opaqueVariable?: string
119
128
  }
120
129
 
121
- const UNKNOWN: StaticState = { types: undefined, single: undefined }
130
+ const UNKNOWN: StaticState = { types: undefined, single: undefined, ordered: undefined }
122
131
 
123
132
  /** 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 {
133
+ function callResult(
134
+ state: Pick<StaticState, 'types' | 'single' | 'ordered' | 'targets'>,
135
+ runtime: boolean
136
+ ): StaticState {
125
137
  return runtime
126
138
  ? {
127
139
  types: state.types,
128
140
  single: state.single,
141
+ ordered: state.ordered,
129
142
  ...(state.targets !== undefined && { targets: state.targets }),
130
143
  }
131
144
  : state
@@ -152,14 +165,14 @@ function forkScope(scope: VariableScope): VariableScope {
152
165
  export interface AnalysisDetails {
153
166
  diagnostics: AnalyzerDiagnostic[]
154
167
  /**
155
- * The expression's inferred result: canonical type names and cardinality, the
156
- * same StaticState the checks run on. `types: undefined` means the analyzer
157
- * cannot see through the expression (an unknown region), and `single`
158
- * undefined means the cardinality is unknown — neither is an error. Lets a
159
- * caller cross-check a declared type against what the expression really
160
- * yields (see `analyzeDto`).
168
+ * The expression's inferred result: canonical type names, cardinality, and
169
+ * ordering, the same StaticState the checks run on. `types: undefined` means
170
+ * the analyzer cannot see through the expression (an unknown region), and an
171
+ * undefined `single` or `ordered` means that fact is unknown — none of these
172
+ * is an error. Lets a caller cross-check a declared type against what the
173
+ * expression really yields (see `analyzeDto`).
161
174
  */
162
- result: { types: string[] | undefined; single: boolean | undefined }
175
+ result: { types: string[] | undefined; single: boolean | undefined; ordered: boolean | undefined }
163
176
  /**
164
177
  * `Type.element` paths the expression reads (local type names), deduped in
165
178
  * first-visit order — HAPI's `elementDependencies`. Lets callers know which
@@ -172,6 +185,7 @@ export interface AnalysisDetails {
172
185
  export interface AnalyzerRoot {
173
186
  types: string[] | undefined
174
187
  single: boolean | undefined
188
+ ordered: boolean | undefined
175
189
  /** Canonical runtime types in focus order; an empty array means an exactly empty focus. */
176
190
  exactTypes?: string[]
177
191
  }
@@ -188,32 +202,63 @@ export function analyzeExpression(expression: string, options?: AnalyzeOptions):
188
202
 
189
203
  /**
190
204
  * Analyzes one source site with only facts visible in that file. A declared root
191
- * does not prove which variables the later call provides. DTO sites may also
192
- * receive variables and functions from base classes, other modules, or
193
- * `project()`. Such sites omit unknown-variable diagnostics and report an
194
- * unknown function only when it resembles a local column. A DTO without a known
195
- * root receives syntax checks only. Use `analyzeDto` when the class and engine
196
- * are loaded.
205
+ * does not prove which variables the later call provides. A site whose call
206
+ * binds variables the source cannot name (`openVariables`) may resolve any
207
+ * `%variable` at runtime, so an unresolved one is reported as an
208
+ * `unchecked-variable` warning under `reportUnchecked` instead of an error.
209
+ * DTO sites may also receive variables and functions from base classes, other
210
+ * modules, or `project()`. Such sites omit unknown-variable diagnostics and
211
+ * report an unknown function only when it resembles a local column. A DTO
212
+ * without a known root receives syntax checks only. Use `analyzeDto` when the
213
+ * class and engine are loaded.
197
214
  */
198
215
  export function analyzeSite(
199
216
  site: {
200
217
  expression: string
201
218
  inputType?: string
202
219
  dto?: true
220
+ /** Variables declared by the expression's call site, such as inline env/vars options. */
221
+ variables?: Readonly<Record<string, DeclaredVariable>>
222
+ /** The call also binds variables the source cannot name — a computed key or spread in env/vars. */
223
+ openVariables?: true
203
224
  /** Functions the site's file declares — a DTO's `@column` fields (see `columnFunctionDeclaration`). */
204
225
  functions?: Readonly<Record<string, DeclaredFunction>>
205
226
  },
206
227
  options?: AnalyzeOptions
207
228
  ): AnalyzerDiagnostic[] {
229
+ const sourceVariables = (options as { [SOURCE_VARIABLE_DEFAULTS]?: SourceVariableDefaults } | undefined)?.[
230
+ SOURCE_VARIABLE_DEFAULTS
231
+ ]
232
+ const variablePlan = (site as typeof site & { variablePlan?: SourceVariablePlan }).variablePlan
208
233
  const declared = { ...site.functions, ...options?.functions }
234
+ const variables = sourceSiteVariables(site.variables, variablePlan, options?.variables, sourceVariables)
209
235
  const merged: AnalyzeOptions = {
210
236
  ...options,
211
237
  ...(site.inputType !== undefined && { inputType: site.inputType }),
212
238
  ...(Object.keys(declared).length > 0 && { functions: declared }),
239
+ ...(variables !== undefined && { variables }),
213
240
  }
214
241
  const diagnostics = analyzeExpression(site.expression, merged)
215
242
  if (site.dto !== true) {
216
- return site.inputType === undefined
243
+ if (site.openVariables === true) {
244
+ return diagnostics.flatMap(diagnostic => {
245
+ if (diagnostic.code !== 'unknown-variable') {
246
+ return [diagnostic]
247
+ }
248
+ if (options?.reportUnchecked !== true) {
249
+ return []
250
+ }
251
+ return [
252
+ {
253
+ ...diagnostic,
254
+ severity: 'warning' as const,
255
+ code: 'unchecked-variable',
256
+ message: `Environment variable %${diagnostic.name} was not checked: the call's env/vars names are not fully visible in source`,
257
+ },
258
+ ]
259
+ })
260
+ }
261
+ return site.inputType === undefined || options?.variables !== undefined
217
262
  ? diagnostics
218
263
  : diagnostics.filter(diagnostic => diagnostic.code !== 'unknown-variable')
219
264
  }
@@ -232,6 +277,42 @@ export function analyzeSite(
232
277
  })
233
278
  }
234
279
 
280
+ /** Recreate strict evaluation's vars prefix without flattening away engine/default insertion order. */
281
+ function sourceSiteVariables(
282
+ siteEnvironment: Readonly<Record<string, DeclaredVariable>> | undefined,
283
+ plan: SourceVariablePlan | undefined,
284
+ engineEnvironment: Readonly<Record<string, DeclaredVariable>> | undefined,
285
+ defaults: SourceVariableDefaults | undefined
286
+ ): Record<string, DeclaredVariable> | undefined {
287
+ if (
288
+ siteEnvironment === undefined &&
289
+ engineEnvironment === undefined &&
290
+ defaults === undefined &&
291
+ plan === undefined
292
+ ) {
293
+ return undefined
294
+ }
295
+ const variables: Record<string, DeclaredVariable> = { ...engineEnvironment, ...siteEnvironment }
296
+ const declarations =
297
+ plan?.inheritsDeclarations === false ? plan.declarations : { ...defaults?.declarations, ...plan?.declarations }
298
+ const valueNames = [...(defaults?.values ?? [])]
299
+ for (const name of plan?.values ?? []) {
300
+ if (!valueNames.includes(name)) {
301
+ valueNames.push(name)
302
+ }
303
+ }
304
+ const end = plan?.before === undefined ? valueNames.length : valueNames.indexOf(plan.before)
305
+ for (const name of valueNames.slice(0, end < 0 ? valueNames.length : end)) {
306
+ variables[name] = declarations[name] ?? {}
307
+ }
308
+ if (plan?.before === undefined) {
309
+ for (const [name, declaration] of Object.entries(declarations)) {
310
+ variables[name] ??= declaration
311
+ }
312
+ }
313
+ return variables
314
+ }
315
+
235
316
  /** `analyzeExpression` plus the element paths the expression touches and its inferred result type. */
236
317
  export function analyzeExpressionDetailed(expression: string, options?: AnalyzeOptions): AnalysisDetails {
237
318
  let ast: AstNode
@@ -246,7 +327,7 @@ export function analyzeExpressionDetailed(expression: string, options?: AnalyzeO
246
327
  return {
247
328
  diagnostics: [{ severity: 'error', code: 'syntax', message: error.message, span: error.span }],
248
329
  elementDependencies: [],
249
- result: { types: undefined, single: undefined },
330
+ result: { types: undefined, single: undefined, ordered: undefined },
250
331
  }
251
332
  }
252
333
  return analyzeAstDetailed(ast, options)
@@ -263,7 +344,7 @@ function analyzeAstDetailed(
263
344
  return {
264
345
  diagnostics: analyzer.diagnostics,
265
346
  elementDependencies: [...analyzer.dependencies],
266
- result: { types: state.types, single: state.single },
347
+ result: { types: state.types, single: state.single, ordered: state.ordered },
267
348
  }
268
349
  }
269
350
 
@@ -287,16 +368,20 @@ class Analyzer {
287
368
  private readonly customFunctions: ReadonlyMap<string, readonly ResolvedDeclaration[]>
288
369
  private readonly declaredVariables: ReadonlyMap<string, AnalyzerVariableState>
289
370
  private readonly activeExpressionFunctions = new Set<string>()
371
+ private readonly reportUnchecked: boolean
290
372
 
291
373
  constructor(options: AnalyzeOptions | RuntimeAnalyzeOptions | undefined, root: AnalyzerRoot | undefined) {
292
374
  this.model = options?.model
293
375
  this.runtime = root !== undefined
376
+ this.reportUnchecked = options?.reportUnchecked === true
294
377
  const inputType = options?.inputType
378
+ // The root focus is a real collection, so its order is defined even when
379
+ // its types and cardinality are not.
295
380
  this.root =
296
381
  root ??
297
382
  (inputType === undefined
298
- ? { types: undefined, single: undefined }
299
- : { types: [this.model?.resolveType(inputType) ?? inputType], single: true })
383
+ ? { types: undefined, single: undefined, ordered: true }
384
+ : singleState([this.model?.resolveType(inputType) ?? inputType]))
300
385
  this.customFunctions = new Map(
301
386
  Object.entries(options?.functions ?? {}).map(([name, declared]) => [
302
387
  name,
@@ -312,6 +397,7 @@ class Analyzer {
312
397
  return {
313
398
  types: this.root.types,
314
399
  single: this.root.single,
400
+ ordered: this.root.ordered,
315
401
  rawInput: true,
316
402
  ...(this.root.exactTypes !== undefined && { exactTypes: this.root.exactTypes }),
317
403
  }
@@ -322,24 +408,21 @@ class Analyzer {
322
408
  case 'null':
323
409
  // The empty literal `{}` is statically at most one item, so it satisfies
324
410
  // singleton operands (`{} + 1`, `{} and true` are spec-legal).
325
- return { types: [], single: true }
411
+ return singleState([])
326
412
  case 'boolean':
327
- return { types: ['System.Boolean'], single: true }
413
+ return singleState(['System.Boolean'])
328
414
  case 'string':
329
- return { types: ['System.String'], single: true }
415
+ return singleState(['System.String'])
330
416
  case 'number':
331
- return {
332
- types: [node.isLong ? 'System.Long' : node.isDecimal ? 'System.Decimal' : 'System.Integer'],
333
- single: true,
334
- }
417
+ return singleState([node.isLong ? 'System.Long' : node.isDecimal ? 'System.Decimal' : 'System.Integer'])
335
418
  case 'date':
336
- return { types: ['System.Date'], single: true }
419
+ return singleState(['System.Date'])
337
420
  case 'dateTime':
338
- return { types: ['System.DateTime'], single: true }
421
+ return singleState(['System.DateTime'])
339
422
  case 'time':
340
- return { types: ['System.Time'], single: true }
423
+ return singleState(['System.Time'])
341
424
  case 'quantity':
342
- return { types: ['System.Quantity'], single: true }
425
+ return singleState(['System.Quantity'])
343
426
  case 'external':
344
427
  return this.walkExternal(node, scope)
345
428
  case 'special':
@@ -353,6 +436,7 @@ class Analyzer {
353
436
  case 'indexer': {
354
437
  const target = this.walk(node.target, input, scope)
355
438
  const index = this.walk(node.index, input, scope)
439
+ this.requireOrdered(target, node.target.span, 'The indexer')
356
440
  this.requireKind(index, 'Numeric', node.index.span, 'the indexer expects a single Integer')
357
441
  return withSingle(target, true)
358
442
  }
@@ -389,7 +473,7 @@ class Analyzer {
389
473
  }
390
474
  const declared = this.declaredVariables.get(node.name)
391
475
  if (declared !== undefined) {
392
- return this.declaredVariableState(declared)
476
+ return this.declaredVariableState(declared, node.name)
393
477
  }
394
478
  switch (node.name) {
395
479
  case 'context':
@@ -400,13 +484,13 @@ class Analyzer {
400
484
  case 'ucum':
401
485
  case 'sct':
402
486
  case 'loinc':
403
- return { types: ['System.String'], single: true }
487
+ return singleState(['System.String'])
404
488
  default:
405
489
  break
406
490
  }
407
491
  // FHIR-defined families expand to HL7 urls: %`vs-[name]`, %`ext-[name]`.
408
492
  if (node.name.startsWith('vs-') || node.name.startsWith('ext-')) {
409
- return { types: ['System.String'], single: true }
493
+ return singleState(['System.String'])
410
494
  }
411
495
  // A dynamically-named defineVariable() earlier in the chain may have bound
412
496
  // this name, so reporting it as undefined could be wrong — stay quiet.
@@ -420,7 +504,7 @@ class Analyzer {
420
504
  if (name === 'this') {
421
505
  return this.frames.at(-1) ?? this.rootState()
422
506
  }
423
- return name === 'index' ? { types: ['System.Integer'], single: true } : UNKNOWN
507
+ return name === 'index' ? singleState(['System.Integer']) : UNKNOWN
424
508
  }
425
509
 
426
510
  /**
@@ -437,7 +521,7 @@ class Analyzer {
437
521
  private walkIdentifier(node: AstNode & { kind: 'identifier' }, input: StaticState): StaticState {
438
522
  // Navigating from a statically empty input yields empty — nothing to check.
439
523
  if (input.types !== undefined && input.types.length === 0) {
440
- return { types: [], single: true }
524
+ return singleState([])
441
525
  }
442
526
  const exactTypes = input.exactTypes?.filter(type => rootTypeMatches(this.model, type, node.name))
443
527
  if (exactTypes !== undefined && exactTypes.length > 0) {
@@ -445,6 +529,7 @@ class Analyzer {
445
529
  return {
446
530
  types: types === undefined ? undefined : [...new Set(types)],
447
531
  single: input.single,
532
+ ordered: singletonOrder(input.single, input.ordered),
448
533
  exactTypes,
449
534
  ...(input.rawInput === true && { rawInput: true }),
450
535
  }
@@ -455,14 +540,22 @@ class Analyzer {
455
540
  const asType = this.model?.resolveType(node.name) ?? resolveSystemTypeName(node.name)
456
541
  if (asType !== undefined) {
457
542
  return {
458
- types: [asType],
459
- single: true,
543
+ ...singleState([asType]),
460
544
  ...(input.exactTypes !== undefined && {
461
545
  exactTypes: input.exactTypes.filter(type => this.model?.isSubtypeOf(type, asType) === true),
462
546
  }),
463
547
  }
464
548
  }
465
- return UNKNOWN
549
+ if (input.opaqueVariable !== undefined && this.reportUnchecked) {
550
+ this.report(
551
+ 'unchecked-navigation',
552
+ `Element '${node.name}' after untyped environment variable %${input.opaqueVariable} was not checked; declare the variable's type`,
553
+ node.span,
554
+ 'warning',
555
+ node.name
556
+ )
557
+ }
558
+ return { ...UNKNOWN, ordered: input.ordered }
466
559
  }
467
560
  // Root rule: an identifier naming the (super)type of the context is the context.
468
561
  {
@@ -528,9 +621,14 @@ class Analyzer {
528
621
  node.name
529
622
  )
530
623
  }
531
- return UNKNOWN
624
+ return { ...UNKNOWN, ordered: input.ordered }
625
+ }
626
+ const single = singleAnd(input.single, !isCollection)
627
+ const state: StaticState = {
628
+ types: [...new Set(found)],
629
+ single,
630
+ ordered: singletonOrder(single, input.ordered),
532
631
  }
533
- const state: StaticState = { types: [...new Set(found)], single: singleAnd(input.single, !isCollection) }
534
632
  if (targets !== undefined && targets.length > 0) {
535
633
  state.targets = [...new Set(targets)]
536
634
  }
@@ -641,7 +739,11 @@ class Analyzer {
641
739
  // ofType(X) filters and as(X) casts: both narrow to the named type,
642
740
  // intersected with the known candidates.
643
741
  if ((node.name === 'ofType' || node.name === 'as') && typeTarget !== undefined) {
644
- return { types: this.narrowTypes(input, typeTarget, node.span), single: input.single }
742
+ return {
743
+ types: this.narrowTypes(input, typeTarget, node.span),
744
+ single: input.single,
745
+ ordered: singletonOrder(input.single, input.ordered),
746
+ }
645
747
  }
646
748
  const expressionResult = this.expressionFunctionResult(node.name, custom, input, scope)
647
749
  if (expressionResult !== undefined) {
@@ -675,7 +777,7 @@ class Analyzer {
675
777
  const bodyInput = this.expressionBodyInput(declaration, input)
676
778
  if (declaration.criteria === true) {
677
779
  this.walkExpressionFunction(name, expression, declaration.variables, bodyInput, scope)
678
- return { types: ['System.Boolean'], single: true }
780
+ return singleState(['System.Boolean'])
679
781
  }
680
782
  if (declaration.signature?.result === undefined) {
681
783
  return this.walkExpressionFunction(name, expression, declaration.variables, bodyInput, scope)
@@ -703,6 +805,7 @@ class Analyzer {
703
805
  return {
704
806
  types: [...new Set(types)],
705
807
  single: callInput.single,
808
+ ordered: callInput.ordered,
706
809
  ...(callInput.targets !== undefined && { targets: callInput.targets }),
707
810
  }
708
811
  }
@@ -731,7 +834,7 @@ class Analyzer {
731
834
  const bare = bareEnvironmentName(declaredName)
732
835
  // defineVariable() values have priority over environment overlays at runtime.
733
836
  if (!functionScope.vars.has(bare)) {
734
- functionScope.vars.set(bare, this.declaredVariableState(variable))
837
+ functionScope.vars.set(bare, this.declaredVariableState(variable, bare))
735
838
  }
736
839
  }
737
840
  const diagnosticCount = this.diagnostics.length
@@ -754,13 +857,15 @@ class Analyzer {
754
857
  }
755
858
 
756
859
  /** Convert one declared environment value into the same state used for scoped variables. */
757
- private declaredVariableState(declared: AnalyzerVariableState): StaticState {
860
+ private declaredVariableState(declared: AnalyzerVariableState, name?: string): StaticState {
758
861
  const exactTypes = this.runtime && isRuntimeAnalyzerVariable(declared) ? declared.exactTypes : undefined
759
862
  return {
760
863
  types: declared.types?.map(type => this.canonicalize(type)),
761
864
  single: declared.single,
865
+ ordered: singletonOrder(declared.single, declared.ordered),
762
866
  ...(declared.targets !== undefined && { targets: declared.targets.map(type => this.canonicalize(type)) }),
763
867
  ...(exactTypes !== undefined && { exactTypes }),
868
+ ...(declared.types === undefined && name !== undefined && { opaqueVariable: name }),
764
869
  }
765
870
  }
766
871
 
@@ -814,11 +919,14 @@ class Analyzer {
814
919
  }
815
920
  }
816
921
 
817
- /** The signature's input constraints: cardinality, value kind, and declared types. */
922
+ /** The signature's input constraints: cardinality, ordering, value kind, and declared types. */
818
923
  private checkCallInput(node: AstNode & { kind: 'call' }, signature: FunctionSignature, input: StaticState): void {
819
924
  if (!signature.input) {
820
925
  return
821
926
  }
927
+ if (signature.input.ordered === true) {
928
+ this.requireOrdered(input, node.span, `${node.name}()`)
929
+ }
822
930
  // A function written for one type (a DTO's `@column`), called on a focus
823
931
  // that can never be that type. `unsatisfiedInput` holds the same rule the
824
932
  // engine applies, so the two halves agree on what counts as a mistake.
@@ -840,7 +948,7 @@ class Analyzer {
840
948
  }
841
949
  if (signature.input.kind) {
842
950
  this.requireKind(
843
- { types: input.types, single: true },
951
+ singleState(input.types),
844
952
  signature.input.kind,
845
953
  node.span,
846
954
  `${node.name}() expects a ${signature.input.kind} input`
@@ -970,14 +1078,14 @@ class Analyzer {
970
1078
  return undefined
971
1079
  }
972
1080
  const types = declared.result?.types?.map(type => this.canonicalize(type))
973
- const single = declared.result?.single
974
1081
  return {
975
1082
  ...(declared.input !== undefined && { input: declared.input }),
976
1083
  ...(declared.args !== undefined && { args: declared.args }),
977
1084
  result: {
978
1085
  kind: 'fixed',
979
1086
  ...(types !== undefined && { types }),
980
- ...(single !== undefined && { single }),
1087
+ ...(declared.result?.single !== undefined && { single: declared.result.single }),
1088
+ ...(declared.result?.ordered !== undefined && { ordered: declared.result.ordered }),
981
1089
  },
982
1090
  }
983
1091
  }
@@ -1124,6 +1232,17 @@ class Analyzer {
1124
1232
  }
1125
1233
  }
1126
1234
 
1235
+ /** Report an order-dependent operation only when the input is provably unordered. */
1236
+ private requireOrdered(state: StaticState, span: SourceSpan, subject: string): void {
1237
+ if (state.ordered === false && state.single !== true) {
1238
+ this.report(
1239
+ 'order-dependent',
1240
+ `${subject} depends on collection order, but the input collection has no defined order`,
1241
+ span
1242
+ )
1243
+ }
1244
+ }
1245
+
1127
1246
  private walkTypeOp(node: AstNode & { kind: 'typeOp' }, input: StaticState, scope: VariableScope): StaticState {
1128
1247
  const operand = this.walk(node.operand, input, scope)
1129
1248
  this.requireSingle(operand, node.operand.span, `'${node.operator}' expects a single item operand`)
@@ -1291,29 +1410,42 @@ function mergedDeclaration(candidates: readonly ResolvedDeclaration[]): Resolved
1291
1410
  }
1292
1411
  }
1293
1412
 
1294
- /** Every type any declaration accepts, or undefined when one of them accepts anything. */
1413
+ /**
1414
+ * Every type any declaration accepts, or no type constraint when one of them
1415
+ * accepts anything. An ordered input stays required when every candidate
1416
+ * requires it — whichever declaration the call resolves to would reject an
1417
+ * unordered focus, so keeping the requirement cannot flag valid code.
1418
+ */
1295
1419
  function mergedInput(candidates: readonly ResolvedDeclaration[]): InputSpec | undefined {
1420
+ const ordered = candidates.every(candidate => candidate.signature?.input?.ordered === true)
1296
1421
  const declared = candidates.map(candidate => candidate.signature?.input?.types)
1297
- if (declared.some(types => types === undefined)) {
1422
+ const types = declared.some(types => types === undefined)
1423
+ ? undefined
1424
+ : [...new Set(declared.flatMap(types => types ?? []))]
1425
+ if (types === undefined && !ordered) {
1298
1426
  return undefined
1299
1427
  }
1300
- return { types: [...new Set(declared.flatMap(types => types ?? []))] }
1428
+ return { ...(types !== undefined && { types }), ...(ordered && { ordered: true }) }
1301
1429
  }
1302
1430
 
1303
1431
  /** The union of the declarations' results, unknown as soon as one of them is. */
1304
- function mergedResult(candidates: readonly ResolvedDeclaration[]): { types?: string[]; single?: boolean } | undefined {
1432
+ function mergedResult(
1433
+ candidates: readonly ResolvedDeclaration[]
1434
+ ): { types?: string[]; single?: boolean; ordered?: boolean } | undefined {
1305
1435
  const union = unionStates(
1306
1436
  candidates.map(candidate => ({
1307
1437
  types: candidate.signature?.result?.types === undefined ? undefined : [...candidate.signature.result.types],
1308
1438
  single: candidate.signature?.result?.single,
1439
+ ordered: singletonOrder(candidate.signature?.result?.single, candidate.signature?.result?.ordered),
1309
1440
  }))
1310
1441
  )
1311
- if (union.types === undefined && union.single === undefined) {
1442
+ if (union.types === undefined && union.single === undefined && union.ordered === undefined) {
1312
1443
  return undefined
1313
1444
  }
1314
1445
  return {
1315
1446
  ...(union.types !== undefined && { types: union.types }),
1316
1447
  ...(union.single !== undefined && { single: union.single }),
1448
+ ...(union.ordered !== undefined && { ordered: union.ordered }),
1317
1449
  }
1318
1450
  }
1319
1451
 
@@ -7,11 +7,21 @@ import { OBJECT_TYPE, toCollection, type TypedValue, typeLocalName } from '../va
7
7
  export interface AnalyzerVariable {
8
8
  types?: string[]
9
9
  single?: boolean
10
+ /** True: ordered. False: unordered. Omit when ordering is unknown. */
11
+ ordered?: boolean
10
12
  targets?: string[]
11
13
  }
12
14
 
15
+ /** Projection variables supplied by the runtime for every row. */
16
+ export const PROJECT_ROW_VARIABLES: Readonly<Record<'rowIndex' | 'rowTotal', AnalyzerVariable>> = {
17
+ rowIndex: { types: ['System.Integer'], single: true },
18
+ rowTotal: { types: ['System.Integer'], single: true },
19
+ }
20
+
13
21
  /** Internal variable state with the exact focus types used by runtime host-function dispatch. */
14
22
  export interface RuntimeAnalyzerVariable extends AnalyzerVariable {
23
+ /** A runtime collection is a real array, so its order is always defined. */
24
+ ordered: true
15
25
  exactTypes: string[]
16
26
  }
17
27
 
@@ -41,6 +51,7 @@ export function runtimeAnalyzerVariable(
41
51
  const inferred = analyzerVariableFromCollection(collection, model)
42
52
  return {
43
53
  ...(declaration === undefined ? inferred : analyzerVariable(declaration)),
54
+ ordered: true,
44
55
  exactTypes: collection.map(item => item.type),
45
56
  }
46
57
  }
@@ -54,6 +65,7 @@ function analyzerVariableFromCollection(
54
65
  return {
55
66
  ...(types !== undefined && types.length > 0 && { types }),
56
67
  single: collection.length <= 1,
68
+ ordered: true,
57
69
  }
58
70
  }
59
71