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
@@ -4,6 +4,8 @@ export interface StaticStateLike {
4
4
  types: string[] | undefined
5
5
  /** True: at most one item. False: may hold several. Undefined: cardinality unknown. */
6
6
  single: boolean | undefined
7
+ /** True: ordered. False: unordered. Undefined: ordering is unknown. */
8
+ ordered: boolean | undefined
7
9
  /** Canonical resource types a Reference state may point to — resolve()'s result. */
8
10
  targets?: string[]
9
11
  }
@@ -34,6 +36,8 @@ export type ValueArgSpec = 'any' | ValueKind
34
36
  export interface InputSpec {
35
37
  kind?: ValueKind
36
38
  singleton?: boolean
39
+ /** True: the function needs an input with a defined order (`first()`, `skip()`). */
40
+ ordered?: boolean
37
41
  /** Canonical or local model type names ('CodeableConcept', 'System.String'). */
38
42
  types?: readonly string[]
39
43
  }
@@ -47,7 +51,12 @@ export interface InputSpec {
47
51
  export interface CustomFunctionSignature {
48
52
  input?: InputSpec
49
53
  args?: readonly ValueArgSpec[]
50
- result?: { types?: readonly string[]; single?: boolean }
54
+ result?: {
55
+ types?: readonly string[]
56
+ single?: boolean
57
+ /** True: ordered. False: unordered. Omit when the function does not declare ordering. */
58
+ ordered?: boolean
59
+ }
51
60
  }
52
61
 
53
62
  /**
@@ -56,14 +65,19 @@ export interface CustomFunctionSignature {
56
65
  * implementations from acquiring separate handwritten function semantics.
57
66
  */
58
67
  export type ResultRule =
59
- | { kind: 'fixed'; types?: readonly string[]; single?: boolean }
60
- | { kind: 'input' }
68
+ | { kind: 'fixed'; types?: readonly string[]; single?: boolean; ordered?: boolean }
69
+ // `ordered: true` marks a function that establishes order (`sort()`).
70
+ | { kind: 'input'; ordered?: true }
61
71
  | { kind: 'input-item' }
62
72
  | { kind: 'argument'; index: number }
63
- | { kind: 'union'; sources: readonly ('input' | number)[]; single: boolean | 'all' }
73
+ // `sequential: true` concatenates the sources (`union()`), so the result keeps
74
+ // order only when every source does; without it the sources are alternatives
75
+ // (`iif()` branches) and the union keeps only what they agree on.
76
+ | { kind: 'union'; sources: readonly ('input' | number)[]; single: boolean | 'all'; sequential?: true }
64
77
  | { kind: 'arguments-union' }
65
78
  | { kind: 'reference-targets' }
66
- | { kind: 'unknown' }
79
+ // An unknown result keeps the input's ordering unless the rule declares one.
80
+ | { kind: 'unknown'; ordered?: boolean }
67
81
 
68
82
  export interface FunctionSignature {
69
83
  input?: InputSpec
@@ -81,6 +95,11 @@ const DATETIME = { kind: 'fixed', types: ['System.DateTime'], single: true } as
81
95
  const TIME = { kind: 'fixed', types: ['System.Time'], single: true } as const satisfies ResultRule
82
96
  const QUANTITY = { kind: 'fixed', types: ['System.Quantity'], single: true } as const satisfies ResultRule
83
97
  const UNKNOWN = { kind: 'unknown' } as const satisfies ResultRule
98
+ // An unknown type that is at most one item at runtime (aggregates, singleton-input
99
+ // conversions), so its order is defined even when the input's is not.
100
+ const UNKNOWN_ITEM = { kind: 'unknown', ordered: true } as const satisfies ResultRule
101
+ // Tree traversals return their matches in no defined order (spec §5.1).
102
+ const UNORDERED = { kind: 'unknown', ordered: false } as const satisfies ResultRule
84
103
  const SAME = { kind: 'input' } as const satisfies ResultRule
85
104
  const ITEM = { kind: 'input-item' } as const satisfies ResultRule
86
105
 
@@ -92,38 +111,75 @@ export function applyResultRule(
92
111
  ): StaticStateLike {
93
112
  switch (rule.kind) {
94
113
  case 'fixed':
95
- return { types: rule.types === undefined ? undefined : [...rule.types], single: rule.single }
114
+ return {
115
+ types: rule.types === undefined ? undefined : [...rule.types],
116
+ single: rule.single,
117
+ ordered: singletonOrder(rule.single, rule.ordered),
118
+ }
96
119
  case 'input':
97
- return input
120
+ return rule.ordered === true ? withOrder(input, true) : input
98
121
  case 'input-item':
99
122
  return withSingle(input, true)
100
123
  case 'argument': {
101
- const argument = args[rule.index] ?? { types: undefined, single: undefined }
102
- return withSingle(argument, singleAnd(input.single, argument.single))
124
+ const argument = args[rule.index] ?? { types: undefined, single: undefined, ordered: undefined }
125
+ const result = withSingle(argument, singleAnd(input.single, argument.single))
126
+ return withOrder(result, sequentialOrder(input.ordered, argument.ordered))
103
127
  }
104
128
  case 'union': {
105
129
  const states = rule.sources.map(source => (source === 'input' ? input : args[source]))
106
130
  const merged = unionStates(states)
107
- return rule.single === 'all' ? merged : withSingle(merged, rule.single)
131
+ const result = rule.single === 'all' ? merged : withSingle(merged, rule.single)
132
+ if (rule.sequential !== true) {
133
+ return result
134
+ }
135
+ const present = states.filter((state): state is StaticStateLike => state !== undefined)
136
+ return withOrder(result, present.map(state => state.ordered).reduce(sequentialOrder, true))
108
137
  }
109
138
  case 'arguments-union':
110
139
  return unionStates([...args])
111
140
  case 'reference-targets':
112
- return { types: input.targets, single: input.single }
141
+ return { types: input.targets, single: input.single, ordered: input.ordered }
113
142
  case 'unknown':
114
- return { types: undefined, single: undefined }
143
+ return { types: undefined, single: undefined, ordered: rule.ordered ?? input.ordered }
115
144
  }
116
145
  }
117
146
 
147
+ /** A collection known to hold at most one item is trivially ordered. */
148
+ export function singletonOrder(single: boolean | undefined, ordered: boolean | undefined): boolean | undefined {
149
+ return single === true ? true : ordered
150
+ }
151
+
152
+ /** Exactly one item of the given types — the state every literal produces. */
153
+ export function singleState(types: string[] | undefined): StaticStateLike {
154
+ return { types, single: true, ordered: true }
155
+ }
156
+
118
157
  /**
119
158
  * The input's candidate types and reference targets at a different cardinality
120
159
  * — the composition every selection/projection result goes through, so target
121
160
  * metadata survives by construction instead of by per-function special cases.
122
161
  */
123
162
  export function withSingle(input: StaticStateLike, single: boolean | undefined): StaticStateLike {
163
+ const ordered = singletonOrder(single, input.ordered)
164
+ return input.targets === undefined
165
+ ? { types: input.types, single, ordered }
166
+ : { types: input.types, single, ordered, targets: input.targets }
167
+ }
168
+
169
+ /** Set ordering without losing type, cardinality, or Reference-target facts. */
170
+ export function withOrder(input: StaticStateLike, ordered: boolean | undefined): StaticStateLike {
171
+ const normalized = singletonOrder(input.single, ordered)
124
172
  return input.targets === undefined
125
- ? { types: input.types, single }
126
- : { types: input.types, single, targets: input.targets }
173
+ ? { types: input.types, single: input.single, ordered: normalized }
174
+ : { types: input.types, single: input.single, ordered: normalized, targets: input.targets }
175
+ }
176
+
177
+ /** Flattening ordered subcollections preserves order only when both levels do. */
178
+ export function sequentialOrder(a: boolean | undefined, b: boolean | undefined): boolean | undefined {
179
+ if (a === false || b === false) {
180
+ return false
181
+ }
182
+ return a === true && b === true ? true : undefined
127
183
  }
128
184
 
129
185
  /**
@@ -136,6 +192,14 @@ export function unionStates(states: (StaticStateLike | undefined)[]): StaticStat
136
192
  const present = states.filter((state): state is StaticStateLike => state !== undefined)
137
193
  const single = present.length > 0 ? present.map(state => state.single).reduce(singleAnd, true) : undefined
138
194
  const contributing = present.filter(state => state.types === undefined || state.types.length > 0)
195
+ const ordered =
196
+ contributing.length === 0
197
+ ? true
198
+ : contributing.every(state => state.ordered === true)
199
+ ? true
200
+ : contributing.every(state => state.ordered === false)
201
+ ? false
202
+ : undefined
139
203
  const targets =
140
204
  contributing.length > 0 && contributing.every(state => state.targets !== undefined)
141
205
  ? [...new Set(contributing.flatMap(state => state.targets as string[]))]
@@ -144,7 +208,7 @@ export function unionStates(states: (StaticStateLike | undefined)[]): StaticStat
144
208
  present.length === 0 || present.some(state => state.types === undefined)
145
209
  ? undefined
146
210
  : [...new Set(present.flatMap(state => state.types as string[]))]
147
- return targets === undefined ? { types, single } : { types, single, targets }
211
+ return targets === undefined ? { types, single, ordered } : { types, single, ordered, targets }
148
212
  }
149
213
 
150
214
  const STRING_FN = {
@@ -183,22 +247,22 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
183
247
  // the input and the projection body are single.
184
248
  result: { kind: 'argument', index: 0 },
185
249
  },
186
- repeat: { args: ['expression'], result: UNKNOWN },
250
+ repeat: { args: ['expression'], result: UNORDERED },
187
251
  // ofType/as results narrow to the named type; the analyzer computes that with
188
252
  // the model (walkCall), so their table results are never consulted.
189
253
  ofType: { args: ['type-name'], result: UNKNOWN },
190
254
  is: { input: { singleton: true }, args: ['type-name'], result: BOOLEAN },
191
255
  as: { input: { singleton: true }, args: ['type-name'], result: UNKNOWN },
192
256
  single: { result: ITEM },
193
- first: { result: ITEM },
194
- last: { result: ITEM },
195
- tail: { result: SAME },
196
- skip: { args: ['Numeric'], result: SAME },
197
- take: { args: ['Numeric'], result: SAME },
257
+ first: { input: { ordered: true }, result: ITEM },
258
+ last: { input: { ordered: true }, result: ITEM },
259
+ tail: { input: { ordered: true }, result: SAME },
260
+ skip: { input: { ordered: true }, args: ['Numeric'], result: SAME },
261
+ take: { input: { ordered: true }, args: ['Numeric'], result: SAME },
198
262
  intersect: { args: ['any'], result: SAME },
199
263
  exclude: { args: ['any'], result: SAME },
200
- union: { args: ['any'], result: { kind: 'union', sources: ['input', 0], single: false } },
201
- combine: { args: ['any'], result: { kind: 'union', sources: ['input', 0], single: false } },
264
+ union: { args: ['any'], result: { kind: 'union', sources: ['input', 0], single: false, sequential: true } },
265
+ combine: { args: ['any'], result: { kind: 'union', sources: ['input', 0], single: false, sequential: true } },
202
266
  iif: {
203
267
  args: ['condition', 'expression', 'expression'],
204
268
  // The union of the branch states; a missing else-branch contributes empty.
@@ -207,14 +271,14 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
207
271
  // not() takes anything a Boolean test accepts (0/1, single items), so no kind pin.
208
272
  not: { input: { singleton: true }, result: BOOLEAN },
209
273
  trace: { args: ['String', 'expression'], result: SAME },
210
- children: { result: UNKNOWN },
211
- descendants: { result: UNKNOWN },
274
+ children: { result: UNORDERED },
275
+ descendants: { result: UNORDERED },
212
276
  // A reference resolves to its declared target types (Reference.targetProfile,
213
277
  // HAPI's TypeDetails.targets); an unconstrained reference stays unknown.
214
278
  resolve: { result: { kind: 'reference-targets' } },
215
279
  extension: { args: ['String'], result: UNKNOWN },
216
280
  hasValue: { input: { singleton: true }, result: BOOLEAN },
217
- getValue: { input: { singleton: true }, result: UNKNOWN },
281
+ getValue: { input: { singleton: true }, result: UNKNOWN_ITEM },
218
282
  htmlChecks: { input: { singleton: true }, result: BOOLEAN },
219
283
  comparable: { input: { kind: 'Quantity', singleton: true }, args: ['Quantity'], result: BOOLEAN },
220
284
  conformsTo: { input: { singleton: true }, args: ['String'], result: BOOLEAN },
@@ -234,13 +298,13 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
234
298
  replaceMatches: { input: { kind: 'String', singleton: true }, args: ['String', 'String'], result: STRING },
235
299
  toChars: {
236
300
  input: { kind: 'String', singleton: true },
237
- result: { kind: 'fixed', types: ['System.String'], single: false },
301
+ result: { kind: 'fixed', types: ['System.String'], single: false, ordered: true },
238
302
  },
239
303
  trim: { input: { kind: 'String', singleton: true }, result: STRING },
240
304
  split: {
241
305
  input: { kind: 'String', singleton: true },
242
306
  args: ['String'],
243
- result: { kind: 'fixed', types: ['System.String'], single: false },
307
+ result: { kind: 'fixed', types: ['System.String'], single: false, ordered: true },
244
308
  },
245
309
  join: { input: { kind: 'String' }, args: ['String'], result: STRING },
246
310
  encode: STRING_FN,
@@ -248,7 +312,7 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
248
312
  escape: STRING_FN,
249
313
  unescape: STRING_FN,
250
314
 
251
- abs: { input: { singleton: true }, result: UNKNOWN },
315
+ abs: { input: { singleton: true }, result: UNKNOWN_ITEM },
252
316
  ceiling: { input: { kind: 'Numeric', singleton: true }, result: INTEGER },
253
317
  floor: { input: { kind: 'Numeric', singleton: true }, result: INTEGER },
254
318
  truncate: { input: { kind: 'Numeric', singleton: true }, result: INTEGER },
@@ -257,15 +321,15 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
257
321
  ln: MATH_FN,
258
322
  sqrt: MATH_FN,
259
323
  log: { input: { kind: 'Numeric', singleton: true }, args: ['Numeric'], result: DECIMAL },
260
- power: { input: { kind: 'Numeric', singleton: true }, args: ['Numeric'], result: UNKNOWN },
324
+ power: { input: { kind: 'Numeric', singleton: true }, args: ['Numeric'], result: UNKNOWN_ITEM },
261
325
  // Each iteration replaces the accumulator with the aggregator result. An
262
326
  // empty input returns init, when supplied, so both arguments can contribute.
263
327
  aggregate: { args: ['expression', 'any'], result: { kind: 'union', sources: [0, 1], single: 'all' } },
264
- sum: { input: { kind: 'Numeric' }, result: UNKNOWN },
265
- min: { input: { kind: 'Numeric' }, result: UNKNOWN },
266
- max: { input: { kind: 'Numeric' }, result: UNKNOWN },
328
+ sum: { input: { kind: 'Numeric' }, result: UNKNOWN_ITEM },
329
+ min: { input: { kind: 'Numeric' }, result: UNKNOWN_ITEM },
330
+ max: { input: { kind: 'Numeric' }, result: UNKNOWN_ITEM },
267
331
  avg: { input: { kind: 'Numeric' }, result: DECIMAL },
268
- sort: { args: ['sort-key'], result: SAME },
332
+ sort: { args: ['sort-key'], result: { kind: 'input', ordered: true } },
269
333
 
270
334
  toBoolean: { input: { singleton: true }, result: BOOLEAN },
271
335
  toInteger: { input: { singleton: true }, result: INTEGER },
@@ -303,8 +367,8 @@ const FUNCTION_SIGNATURE_DEFINITIONS = {
303
367
  timezoneOffsetOf: { input: { kind: 'Temporal', singleton: true }, result: DECIMAL },
304
368
  dateOf: { input: { kind: 'Temporal', singleton: true }, result: DATE },
305
369
  timeOf: { input: { kind: 'Temporal', singleton: true }, result: TIME },
306
- lowBoundary: { input: { singleton: true }, args: ['Numeric'], result: UNKNOWN },
307
- highBoundary: { input: { singleton: true }, args: ['Numeric'], result: UNKNOWN },
370
+ lowBoundary: { input: { singleton: true }, args: ['Numeric'], result: UNKNOWN_ITEM },
371
+ highBoundary: { input: { singleton: true }, args: ['Numeric'], result: UNKNOWN_ITEM },
308
372
  precision: { input: { singleton: true }, result: INTEGER },
309
373
  defineVariable: { args: ['String', 'expression'], result: SAME },
310
374
  // Variadic: the analyzer repeats the last arg spec for every position, so one
@@ -0,0 +1,19 @@
1
+ import type { AnalyzerVariable } from './declarations.ts'
2
+
3
+ /** Internal source-analysis metadata. A symbol keeps it out of the public AnalyzeOptions contract. */
4
+ export const SOURCE_VARIABLE_DEFAULTS = Symbol('sourceVariableDefaults')
5
+
6
+ export interface SourceVariableDefaults {
7
+ /** Final normalized engine-default vars value names in Object.entries order. */
8
+ values: readonly string[]
9
+ /** Engine-default varTypes declarations. */
10
+ declarations: Readonly<Record<string, AnalyzerVariable>>
11
+ }
12
+
13
+ /** Ordered vars metadata shared by source extraction and loaded-context analysis. */
14
+ export interface SourceVariablePlan {
15
+ values: readonly string[]
16
+ declarations: Readonly<Record<string, AnalyzerVariable>>
17
+ inheritsDeclarations: boolean
18
+ before?: string
19
+ }
package/src/api/dto.ts CHANGED
@@ -4,7 +4,7 @@ import { mergeEnvKeys, normalizeEnvKeys } from '../engine/context.ts'
4
4
  import { FhirPathTypeError } from '../errors.ts'
5
5
  import { functions as builtinFunctions } from '../functions/registry.ts'
6
6
  import type { ModelProvider } from '../model/provider.ts'
7
- import type { FhirTypeName } from '../typed/infer.ts'
7
+ import type { FhirpathTypeDeclarations, FhirTypeName } from '../typed/infer.ts'
8
8
  import { canonicalFocusType, typesOverlap } from '../values/type-compat.ts'
9
9
  import type { TypedValue } from '../values/typed-value.ts'
10
10
  import { toSubjects } from './bundle.ts'
@@ -80,8 +80,11 @@ export type DtoRow<C extends DtoClass> = InstanceType<C>
80
80
  export interface DtoOptions {
81
81
  /** Per-row bindings the columns read (EvaluateOptions.vars semantics; may reference per-call env). */
82
82
  vars?: Record<string, AnyExpression | readonly TypedValue[]>
83
- /** Environment names supplied by `project()`, declared so DTO checks can resolve them. */
84
- callerEnv?: readonly string[]
83
+ /**
84
+ * Environment supplied by `project()`. Use names when only presence is known,
85
+ * or declarations when DTO vars navigate through those values.
86
+ */
87
+ callerEnv?: readonly string[] | FhirpathTypeDeclarations
85
88
  }
86
89
 
87
90
  /** Everything a DTO class was declared with; `project()`, the engine, and `analyzeDto` all read it from here. */
@@ -93,11 +96,20 @@ export interface DtoDefinition {
93
96
  readonly env: Record<string, unknown> | undefined
94
97
  readonly vars: Record<string, AnyExpression | readonly TypedValue[]> | undefined
95
98
  /** Env names the projecting call supplies (see DtoOptions.callerEnv). */
96
- readonly callerEnv: readonly string[] | undefined
99
+ readonly callerEnvNames: readonly string[]
100
+ /** Static types for the caller environment, when declared. */
101
+ readonly callerEnvTypes: FhirpathTypeDeclarations | undefined
97
102
  }
98
103
 
99
- /** The `fhirType`/`vars` a `defineDto()` base was created with, by that base class. */
100
- const bases = new WeakMap<object, { fhirType: string } & DtoOptions>()
104
+ interface DtoBaseDefinition {
105
+ fhirType: string
106
+ vars: DtoOptions['vars']
107
+ callerEnvNames: readonly string[]
108
+ callerEnvTypes: FhirpathTypeDeclarations | undefined
109
+ }
110
+
111
+ /** The normalized options a `defineDto()` base was created with. */
112
+ const bases = new WeakMap<object, DtoBaseDefinition>()
101
113
 
102
114
  /**
103
115
  * Columns by the class that declared them. A field decorator runs before its
@@ -128,10 +140,23 @@ export function defineDto<const Root extends FhirTypeName>(fhirType: Root, optio
128
140
  // A readable name for project()/registration errors; a subclass replaces it.
129
141
  Object.defineProperty(base, 'name', { value: `${fhirType}Dto` })
130
142
  Object.defineProperty(base, 'fhirType', { value: fhirType, enumerable: true })
131
- bases.set(base, { fhirType, ...options })
143
+ const callerEnv = options.callerEnv
144
+ const callerEnvIsNames = isCallerEnvNames(callerEnv)
145
+ bases.set(base, {
146
+ fhirType,
147
+ vars: options.vars,
148
+ callerEnvNames: callerEnvIsNames ? callerEnv : Object.keys(callerEnv ?? {}),
149
+ callerEnvTypes: callerEnvIsNames ? undefined : callerEnv,
150
+ })
132
151
  return base as unknown as DtoBase<Root>
133
152
  }
134
153
 
154
+ function isCallerEnvNames(
155
+ callerEnv: readonly string[] | FhirpathTypeDeclarations | undefined
156
+ ): callerEnv is readonly string[] {
157
+ return Array.isArray(callerEnv)
158
+ }
159
+
135
160
  /** Records one column against the class being collected, and does nothing at any other time. */
136
161
  function recordColumn(instance: object, name: string, spec: ColumnSpec): void {
137
162
  const cls = instance.constructor as object
@@ -191,7 +216,7 @@ function* classChain(cls: object): Generator<{ readonly name: string }> {
191
216
  }
192
217
 
193
218
  /** The `defineDto()` base a class descends from, with the fhirType/vars it fixed. */
194
- function baseOf(cls: object): ({ fhirType: string } & DtoOptions) | undefined {
219
+ function baseOf(cls: object): DtoBaseDefinition | undefined {
195
220
  for (const current of classChain(cls)) {
196
221
  const base = bases.get(current)
197
222
  if (base !== undefined) {
@@ -286,7 +311,8 @@ export function dtoDefinition(cls: DtoClass): DtoDefinition {
286
311
  columns,
287
312
  env: declaredEnv(cls),
288
313
  vars: base.vars,
289
- callerEnv: base.callerEnv,
314
+ callerEnvNames: base.callerEnvNames,
315
+ callerEnvTypes: base.callerEnvTypes,
290
316
  }
291
317
  definitions.set(cls, definition)
292
318
  return definition
package/src/api/strict.ts CHANGED
@@ -52,13 +52,17 @@ export function assertStrictExpression(ast: AstNode, root: TypedValue[], options
52
52
  .filter(diagnostic => diagnostic.severity === 'error')
53
53
  .map(diagnostic => ({ diagnostic, subject: `vars.${name}` }))
54
54
  )
55
- variables[name] =
56
- declaration === undefined
55
+ // A declaration overrides the inferred types and cardinality, but ordering
56
+ // always comes from the analyzed expression — declarations cannot state it.
57
+ variables[name] = {
58
+ ...(declaration === undefined
57
59
  ? {
58
60
  ...(details.result.types !== undefined && { types: details.result.types }),
59
61
  ...(details.result.single !== undefined && { single: details.result.single }),
60
62
  }
61
- : analyzerVariable(declaration)
63
+ : analyzerVariable(declaration)),
64
+ ...(details.result.ordered !== undefined && { ordered: details.result.ordered }),
65
+ }
62
66
  }
63
67
 
64
68
  for (const [name, declaration] of Object.entries(declarations)) {
@@ -91,6 +95,7 @@ function runtimeRoot(root: TypedValue[], model: ModelProvider | undefined): Anal
91
95
  return {
92
96
  types: variable.types,
93
97
  single: variable.single,
98
+ ordered: variable.ordered,
94
99
  exactTypes: variable.exactTypes,
95
100
  }
96
101
  }
@@ -9,13 +9,22 @@ import { register } from 'node:module'
9
9
  import { relative } from 'node:path'
10
10
  import { pathToFileURL } from 'node:url'
11
11
 
12
+ import type { AnalyzeOptions } from '../analyzer/analyze.ts'
12
13
  import { type AnalyzedContext, analyzeDto, type DtoDiagnostic } from '../analyzer/analyze-dto.ts'
14
+ import { analyzerEnvironmentVariables, analyzerVariables } from '../analyzer/declarations.ts'
15
+ import { SOURCE_VARIABLE_DEFAULTS } from '../analyzer/source-options.ts'
13
16
  import { type DtoClass, isDtoClass } from '../api/dto.ts'
14
17
  import { type FhirPathEngine, recordEngines } from '../api/engine.ts'
15
18
 
16
19
  /** Where DTO classes live unless `--dtos` says otherwise. */
17
20
  export const DEFAULT_DTO_GLOB = '**/*.dto.ts'
18
21
 
22
+ /**
23
+ * A checker configuration problem, not an expression finding: the imported
24
+ * modules constructed engines whose contexts cannot be merged.
25
+ */
26
+ export class EngineMergeError extends Error {}
27
+
19
28
  const IGNORED = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**']
20
29
 
21
30
  /** One finding, with the DTO and file it belongs to and the expression that produced it. */
@@ -34,6 +43,8 @@ export interface DtoCheckResult {
34
43
  engines: number
35
44
  /** Every DTO analyzed, by module. */
36
45
  dtos: { file: string; dto: string }[]
46
+ /** Merged engine declarations that make ordinary source-site checks complete. */
47
+ sourceOptions: AnalyzeOptions | undefined
37
48
  }
38
49
 
39
50
  /** Imports matching modules and checks their exported DTOs against recorded engines. */
@@ -53,10 +64,17 @@ export async function checkDtoModules(patterns: readonly string[], cwd: string):
53
64
  }
54
65
  }
55
66
  const engines = recorded()
67
+ assertSharedModel(engines)
56
68
  const findings = dtos.flatMap(({ file, dto, cls }) =>
57
69
  analyzeFor(cls, engines).map(finding => ({ ...finding, dto, file }))
58
70
  )
59
- return { findings, files, engines: engines.length, dtos: dtos.map(({ file, dto }) => ({ file, dto })) }
71
+ return {
72
+ findings,
73
+ files,
74
+ engines: engines.length,
75
+ dtos: dtos.map(({ file, dto }) => ({ file, dto })),
76
+ sourceOptions: engines.length === 0 ? undefined : optionsForSource(merged(engines)),
77
+ }
60
78
  }
61
79
 
62
80
  /**
@@ -70,19 +88,36 @@ export async function checkDtoModules(patterns: readonly string[], cwd: string):
70
88
  function analyzeFor(dto: DtoClass, engines: readonly FhirPathEngine[]): DtoDiagnostic[] {
71
89
  const owner = engines.find(engine => engine.dtos.includes(dto))
72
90
  if (owner !== undefined) {
73
- return analyzeDto(dto, { engine: owner })
91
+ return analyzeDto(dto, { engine: owner, reportUnchecked: true })
74
92
  }
75
93
  if (engines.length === 0) {
76
- return analyzeDto(dto)
94
+ return analyzeDto(dto, { reportUnchecked: true })
95
+ }
96
+ return analyzeDto(dto, { engine: merged(engines), reportUnchecked: true })
97
+ }
98
+
99
+ /**
100
+ * Merged analysis (unregistered DTOs, `sourceOptions`) uses the first engine's
101
+ * model for every declaration, so all engines must share one `ModelProvider`
102
+ * instance — a declaration analyzed under another engine's type hierarchy would
103
+ * produce wrong element, subtype, and Reference-target findings. Identity is
104
+ * the only equivalence a `ModelProvider` offers, so two wrappers around the
105
+ * same logical model are still rejected; check such projects in separate runs.
106
+ */
107
+ function assertSharedModel(engines: readonly FhirPathEngine[]): void {
108
+ const model = engines[0]?.defaults.model
109
+ if (engines.some(engine => engine.defaults.model !== model)) {
110
+ throw new EngineMergeError(
111
+ 'the imported modules constructed engines with different ModelProvider instances; ' +
112
+ 'source and unregistered-DTO analysis needs one shared model — check projects with different models in separate runs'
113
+ )
77
114
  }
78
- return analyzeDto(dto, { engine: merged(engines) })
79
115
  }
80
116
 
81
117
  /**
82
118
  * Every engine's context as one: the union of their registered functions,
83
119
  * environment declarations, and vars. The first engine's `model` stands for
84
- * all of them — a project binds one FHIR version, and an unregistered DTO names
85
- * no engine that could pick another.
120
+ * all of them — `assertSharedModel` has proven they all carry the same one.
86
121
  */
87
122
  function merged(engines: readonly FhirPathEngine[]): AnalyzedContext {
88
123
  return {
@@ -96,3 +131,18 @@ function merged(engines: readonly FhirPathEngine[]): AnalyzedContext {
96
131
  },
97
132
  }
98
133
  }
134
+
135
+ /** Turn merged engine runtime defaults into the declarations accepted by analyzeSite(). */
136
+ function optionsForSource(engine: AnalyzedContext): AnalyzeOptions {
137
+ const { model, functions, env, envTypes, vars, varTypes } = engine.defaults
138
+ const options = {
139
+ ...(model !== undefined && { model }),
140
+ ...(functions !== undefined && { functions }),
141
+ variables: analyzerEnvironmentVariables(env, envTypes, model),
142
+ [SOURCE_VARIABLE_DEFAULTS]: {
143
+ values: Object.keys(analyzerVariables(vars, undefined)),
144
+ declarations: analyzerVariables(undefined, varTypes),
145
+ },
146
+ }
147
+ return options
148
+ }