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
@@ -12,14 +12,16 @@ import type TS from 'typescript'
12
12
 
13
13
  import {
14
14
  CALL_SITES,
15
+ callExpressionCandidates,
15
16
  type ClassHeritage,
16
17
  columnFunctionDeclaration,
17
18
  constructsEngine,
18
19
  declaredColumnOverloads,
19
20
  DTO_BASE_NAME,
20
21
  dtoRootsOf,
22
+ ENGINE_CLASS_NAME,
21
23
  type ExpressionAst,
22
- expressionEntries,
24
+ type ExpressionProperty,
23
25
  type FileColumnFunction,
24
26
  isCheckedCall,
25
27
  isCheckedTag,
@@ -27,7 +29,6 @@ import {
27
29
  type LocalModuleOptions,
28
30
  rootOf,
29
31
  type SiteContext,
30
- siteContext,
31
32
  type SourceBindings,
32
33
  TAG_NAME,
33
34
  } from '../analyzer/expression-policy.ts'
@@ -45,19 +46,52 @@ export interface ExpressionSite {
45
46
  inputType?: string
46
47
  /** A DTO member site, which `analyzeSite` checks with source-only limits. */
47
48
  dto?: true
49
+ /** Inline per-call environment and row-variable declarations visible to this expression. */
50
+ variables?: NonNullable<SiteContext['variables']>
51
+ /** Ordered per-call vars, interpreted together with loaded engine defaults by analyzeSite. */
52
+ variablePlan?: NonNullable<SiteContext['variablePlan']>
53
+ /** The call binds variables the source cannot name (a computed key or spread) — see `SiteContext`. */
54
+ openVariables?: true
48
55
  /** Callable DTO columns declared in the same file. */
49
56
  functions?: Readonly<Record<string, FileColumnFunction>>
50
57
  }
51
58
 
59
+ export interface SkippedExpressionSite {
60
+ /** Stable reason suitable for CLI/editor policy. */
61
+ reason: 'dynamic-expression' | 'unrecognized-receiver'
62
+ message: string
63
+ line: number
64
+ column: number
65
+ }
66
+
67
+ export interface DtoSourceDeclaration {
68
+ name: string
69
+ line: number
70
+ column: number
71
+ /** True when importing the module can enumerate this class, directly or through an exported subclass. */
72
+ loadable: boolean
73
+ }
74
+
75
+ export interface SiteScanResult {
76
+ sites: ExpressionSite[]
77
+ skipped: SkippedExpressionSite[]
78
+ dtoDeclarations: DtoSourceDeclaration[]
79
+ }
80
+
52
81
  /** Finds every static FHIRPath expression in one source file. */
53
82
  export type SiteFinder = (sourceText: string, fileName: string, options?: LocalModuleOptions) => ExpressionSite[]
54
83
 
84
+ /** Scans expressions plus coverage gaps that a caller may choose to report. */
85
+ export type SiteScanner = (sourceText: string, fileName: string, options?: LocalModuleOptions) => SiteScanResult
86
+
55
87
  /**
56
88
  * Creates a finder for the calls and tags in `CALL_SITES`. DTO fields include a
57
89
  * root type when the class declares one, and each column is exposed as a local
58
90
  * function declaration. Dynamic expressions are skipped.
59
91
  */
60
- export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
92
+ export function createSiteScanner(ts: TypeScriptApi, program?: TS.Program): SiteScanner {
93
+ const checker = program?.getTypeChecker()
94
+ const engineSymbolsByOptions = new Map<string, ReadonlySet<TS.Symbol>>()
61
95
  /** How the shared shape extractor reads TypeScript AST nodes. */
62
96
  const tsAst: ExpressionAst<TS.Node> = {
63
97
  string: node => (ts.isStringLiteralLike(node) ? { node, expression: node.text } : undefined),
@@ -65,15 +99,33 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
65
99
  node.kind === ts.SyntaxKind.TrueKeyword ? true : node.kind === ts.SyntaxKind.FalseKeyword ? false : undefined,
66
100
  properties: node =>
67
101
  ts.isObjectLiteralExpression(node)
68
- ? node.properties
69
- .filter(ts.isPropertyAssignment)
70
- .map(property => ({ name: propertyKeyName(property.name), value: property.initializer }))
102
+ ? node.properties.flatMap<ExpressionProperty<TS.Node>>(property => {
103
+ if (ts.isPropertyAssignment(property)) {
104
+ return [{ name: propertyKeyName(property.name), value: property.initializer }]
105
+ }
106
+ if (ts.isShorthandPropertyAssignment(property)) {
107
+ return [{ name: property.name.text, value: property.name }]
108
+ }
109
+ if (ts.isSpreadAssignment(property)) {
110
+ return [{ name: undefined, value: property.expression, spread: true as const }]
111
+ }
112
+ // Methods and accessors create enumerable own properties too. Their
113
+ // values are dynamic to source analysis, but their names still make
114
+ // env/vars scopes complete when every key is known.
115
+ return 'name' in property ? [{ name: propertyKeyName(property.name), value: property }] : []
116
+ })
71
117
  : undefined,
72
118
  elements: node => (ts.isArrayLiteralExpression(node) ? [...node.elements] : undefined),
73
119
  }
74
120
 
75
- /** Statically-known property key (`path` in `{ path: ... }` or `{ 'path': ... }`), undefined when computed. */
121
+ /**
122
+ * Statically-known property key: `path` in `{ path: ... }`, `{ 'path': ... }`,
123
+ * or `{ ['path']: ... }`; undefined for other computed keys.
124
+ */
76
125
  function propertyKeyName(name: TS.PropertyName): string | undefined {
126
+ if (ts.isComputedPropertyName(name)) {
127
+ return ts.isStringLiteral(name.expression) ? name.expression.text : undefined
128
+ }
77
129
  return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined
78
130
  }
79
131
 
@@ -118,7 +170,8 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
118
170
  /** Collects imports, engine locals, rebound names, and DTO class roots before extracting sites. */
119
171
  function collectFile(
120
172
  source: TS.SourceFile,
121
- options: LocalModuleOptions
173
+ options: LocalModuleOptions,
174
+ engineSymbols: ReadonlySet<TS.Symbol>
122
175
  ): {
123
176
  bindings: SourceBindings
124
177
  dtoRoots: ReadonlyMap<string, string>
@@ -164,12 +217,20 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
164
217
  ) {
165
218
  const set = constructsEngine(node.initializer.expression.text, bindings) ? trusted : rebound
166
219
  set.add(node.name.text)
220
+ } else if (ts.isIdentifier(node.name) && isEngineExpression(node.name, engineSymbols)) {
221
+ trusted.add(node.name.text)
222
+ foreign.delete(node.name.text)
167
223
  } else {
168
224
  // Catch clauses reach here too: `catch (r4)` is a VariableDeclaration.
169
225
  addBindingNames(node.name, rebound)
170
226
  }
171
227
  } else if (ts.isParameter(node)) {
172
- addBindingNames(node.name, rebound)
228
+ if (ts.isIdentifier(node.name) && isEngineExpression(node.name, engineSymbols)) {
229
+ trusted.add(node.name.text)
230
+ foreign.delete(node.name.text)
231
+ } else {
232
+ addBindingNames(node.name, rebound)
233
+ }
173
234
  } else if (
174
235
  (ts.isFunctionDeclaration(node) ||
175
236
  ts.isFunctionExpression(node) ||
@@ -185,6 +246,115 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
185
246
  return { bindings, dtoRoots: dtoRootsOf([...heritage.values()]), heritage }
186
247
  }
187
248
 
249
+ /** Package engine declarations reached through a real `fhirpath-ts` import. */
250
+ function collectTrustedEngineSymbols(options: LocalModuleOptions): ReadonlySet<TS.Symbol> {
251
+ const symbols = new Set<TS.Symbol>()
252
+ if (checker === undefined || program === undefined) {
253
+ return symbols
254
+ }
255
+ const resolvedSymbol = (initial: TS.Symbol | undefined): TS.Symbol | undefined => {
256
+ let symbol = initial
257
+ const seen = new Set<TS.Symbol>()
258
+ while (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0 && !seen.has(symbol)) {
259
+ seen.add(symbol)
260
+ symbol = checker.getAliasedSymbol(symbol)
261
+ }
262
+ return symbol
263
+ }
264
+ const resolved = (node: TS.Node): TS.Symbol | undefined => resolvedSymbol(checker.getSymbolAtLocation(node))
265
+ for (const source of program.getSourceFiles()) {
266
+ for (const statement of source.statements) {
267
+ if (
268
+ !ts.isImportDeclaration(statement) ||
269
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
270
+ isForeignModule(statement.moduleSpecifier.text, options)
271
+ ) {
272
+ continue
273
+ }
274
+ const bindings = statement.importClause?.namedBindings
275
+ if (bindings === undefined) {
276
+ continue
277
+ }
278
+ if (ts.isNamedImports(bindings)) {
279
+ for (const element of bindings.elements) {
280
+ if ((element.propertyName ?? element.name).text === ENGINE_CLASS_NAME) {
281
+ const symbol = resolved(element.name)
282
+ if (symbol !== undefined) {
283
+ symbols.add(symbol)
284
+ }
285
+ }
286
+ }
287
+ continue
288
+ }
289
+ const module = resolved(bindings.name)
290
+ const symbol =
291
+ module === undefined
292
+ ? undefined
293
+ : checker.getExportsOfModule(module).find(entry => entry.name === ENGINE_CLASS_NAME)
294
+ if (symbol !== undefined) {
295
+ symbols.add(resolvedSymbol(symbol) ?? symbol)
296
+ }
297
+ }
298
+ }
299
+ return symbols
300
+ }
301
+
302
+ function engineSymbolsFor(options: LocalModuleOptions): ReadonlySet<TS.Symbol> {
303
+ const key = JSON.stringify({ packages: options.packages, localImports: options.localImports === true })
304
+ const cached = engineSymbolsByOptions.get(key)
305
+ if (cached !== undefined) {
306
+ return cached
307
+ }
308
+ const symbols = collectTrustedEngineSymbols(options)
309
+ engineSymbolsByOptions.set(key, symbols)
310
+ return symbols
311
+ }
312
+
313
+ /** Whether TypeScript resolved an expression to this package's engine class. */
314
+ function isEngineExpression(node: TS.Expression, engineSymbols: ReadonlySet<TS.Symbol>): boolean {
315
+ if (checker === undefined || engineSymbols.size === 0 || node.getSourceFile().isDeclarationFile) {
316
+ return false
317
+ }
318
+ const hasEngineSymbol = (type: TS.Type, seen: Set<TS.Type>): boolean => {
319
+ if (seen.has(type)) {
320
+ return false
321
+ }
322
+ seen.add(type)
323
+ if (type.isUnion()) {
324
+ return type.types.length > 0 && type.types.every(member => hasEngineSymbol(member, new Set(seen)))
325
+ }
326
+ if (type.isIntersection()) {
327
+ return type.types.some(member => hasEngineSymbol(member, new Set(seen)))
328
+ }
329
+ const symbol = type.aliasSymbol ?? type.getSymbol()
330
+ if (symbol !== undefined && engineSymbols.has(symbol)) {
331
+ return true
332
+ }
333
+ const constraint = checker.getBaseConstraintOfType(type)
334
+ if (constraint !== undefined && constraint !== type && hasEngineSymbol(constraint, seen)) {
335
+ return true
336
+ }
337
+ if ((type.flags & ts.TypeFlags.Object) === 0) {
338
+ return false
339
+ }
340
+ const object = type as TS.ObjectType
341
+ if ((object.objectFlags & (ts.ObjectFlags.Class | ts.ObjectFlags.Interface | ts.ObjectFlags.Reference)) === 0) {
342
+ return false
343
+ }
344
+ return checker.getBaseTypes(type as TS.InterfaceType).some(base => hasEngineSymbol(base, seen))
345
+ }
346
+ return hasEngineSymbol(checker.getTypeAtLocation(node), new Set())
347
+ }
348
+
349
+ /** An unresolved receiver may still be an engine; a resolved non-engine is not our call site. */
350
+ function shouldReportUnrecognized(node: TS.Expression): boolean {
351
+ if (checker === undefined || node.getSourceFile().isDeclarationFile) {
352
+ return true
353
+ }
354
+ const flags = checker.getTypeAtLocation(node).flags
355
+ return (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0
356
+ }
357
+
188
358
  /** Add every identifier a binding name declares (`x`, `{ r4 }`, `[a, ...rest]`). */
189
359
  function addBindingNames(name: TS.BindingName, into: Set<string>): void {
190
360
  if (ts.isIdentifier(name)) {
@@ -220,14 +390,129 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
220
390
  return ts.isIdentifier(current) ? current.text : undefined
221
391
  }
222
392
 
223
- return function findExpressionSites(
393
+ /** Strips source wrappers that keep an expression's runtime identity. */
394
+ function unwrapped(node: TS.Expression | undefined): TS.Expression | undefined {
395
+ let current = node
396
+ while (
397
+ current !== undefined &&
398
+ (ts.isParenthesizedExpression(current) ||
399
+ ts.isAsExpression(current) ||
400
+ ts.isSatisfiesExpression(current) ||
401
+ ts.isTypeAssertionExpression(current) ||
402
+ ts.isNonNullExpression(current))
403
+ ) {
404
+ current = current.expression
405
+ }
406
+ return current
407
+ }
408
+
409
+ /** Class bindings made reachable by one immutable initializer or default export. */
410
+ function bindingTargets(node: TS.Expression | undefined): string[] {
411
+ const target = unwrapped(node)
412
+ if (target === undefined) {
413
+ return []
414
+ }
415
+ if (ts.isIdentifier(target)) {
416
+ return [target.text]
417
+ }
418
+ if (!ts.isClassExpression(target)) {
419
+ return []
420
+ }
421
+ // A class expression's own name is scoped to its body, not a reference to a
422
+ // same-named top-level class. Only its base can expose another declaration.
423
+ const { baseName } = heritageOf(target)
424
+ return baseName === undefined ? [] : [baseName]
425
+ }
426
+
427
+ /**
428
+ * The module's exported names, plus what each top-level `const` binding stands
429
+ * for: `export const Row = ProblemRow` makes the loader enumerate `Row`, so
430
+ * `ProblemRow` is reachable through it — directly, through an alias chain, or
431
+ * as the base of an exported anonymous class expression.
432
+ */
433
+ function moduleBindings(source: TS.SourceFile): {
434
+ exported: ReadonlySet<string>
435
+ aliases: ReadonlyMap<string, readonly string[]>
436
+ } {
437
+ const exported = new Set<string>()
438
+ const aliases = new Map<string, string[]>()
439
+ const addAlias = (from: string, to: string): void => {
440
+ const edges = aliases.get(from)
441
+ if (edges === undefined) {
442
+ aliases.set(from, [to])
443
+ } else {
444
+ edges.push(to)
445
+ }
446
+ }
447
+ const isExported = (statement: TS.Statement): boolean =>
448
+ (ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []).some(
449
+ modifier => modifier.kind === ts.SyntaxKind.ExportKeyword
450
+ )
451
+ for (const statement of source.statements) {
452
+ if (ts.isClassDeclaration(statement) && isExported(statement)) {
453
+ if (statement.name !== undefined) {
454
+ exported.add(statement.name.text)
455
+ } else {
456
+ // `export default class extends Base {}`: the loader gets the class,
457
+ // so its base is reachable even though neither has a bound name here.
458
+ const base = heritageOf(statement).baseName
459
+ if (base !== undefined) {
460
+ exported.add(base)
461
+ }
462
+ }
463
+ } else if (ts.isVariableStatement(statement)) {
464
+ const immutable = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0
465
+ for (const declaration of statement.declarationList.declarations) {
466
+ if (!ts.isIdentifier(declaration.name)) {
467
+ continue
468
+ }
469
+ const name = declaration.name.text
470
+ if (isExported(statement)) {
471
+ exported.add(name)
472
+ }
473
+ if (immutable) {
474
+ for (const target of bindingTargets(declaration.initializer)) {
475
+ addAlias(name, target)
476
+ }
477
+ }
478
+ }
479
+ } else if (
480
+ ts.isExportDeclaration(statement) &&
481
+ statement.moduleSpecifier === undefined &&
482
+ statement.isTypeOnly === false &&
483
+ statement.exportClause !== undefined
484
+ ) {
485
+ if (ts.isNamedExports(statement.exportClause)) {
486
+ for (const element of statement.exportClause.elements) {
487
+ if (!element.isTypeOnly) {
488
+ exported.add((element.propertyName ?? element.name).text)
489
+ }
490
+ }
491
+ }
492
+ } else if (ts.isExportAssignment(statement)) {
493
+ for (const target of bindingTargets(statement.expression)) {
494
+ exported.add(target)
495
+ }
496
+ }
497
+ }
498
+ return { exported, aliases }
499
+ }
500
+
501
+ return function scanExpressionSites(
224
502
  sourceText: string,
225
503
  fileName: string,
226
504
  options: LocalModuleOptions = {}
227
- ): ExpressionSite[] {
228
- const source = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true)
229
- const { bindings, dtoRoots, heritage } = collectFile(source, options)
505
+ ): SiteScanResult {
506
+ const programSource = program?.getSourceFile(fileName)
507
+ const source =
508
+ programSource !== undefined && programSource.text === sourceText
509
+ ? programSource
510
+ : ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true)
511
+ const engineSymbols = engineSymbolsFor(options)
512
+ const { bindings, dtoRoots, heritage } = collectFile(source, options, engineSymbols)
230
513
  const sites: ExpressionSite[] = []
514
+ const skipped: SkippedExpressionSite[] = []
515
+ const decoratedClasses = new Set<TS.ClassLikeDeclaration>()
231
516
  const functions: Record<string, FileColumnFunction> = {}
232
517
  // A site points at the first character inside the quote/backtick (node start
233
518
  // + 1), so fhirpath-check can add a diagnostic's span offsets directly. The
@@ -243,43 +528,146 @@ export function createSiteFinder(ts: TypeScriptApi): SiteFinder {
243
528
  ...context,
244
529
  })
245
530
  }
246
- const visit = (node: TS.Node, classRoot: string | undefined): void => {
247
- if (
248
- ts.isTaggedTemplateExpression(node) &&
249
- nameOf(node.tag) === TAG_NAME &&
250
- isCheckedTag(receiverRoot(node.tag), bindings)
251
- ) {
252
- if (ts.isNoSubstitutionTemplateLiteral(node.template)) {
531
+ const skip = (node: TS.Node, reason: SkippedExpressionSite['reason'], message: string): void => {
532
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source))
533
+ skipped.push({ reason, message, line: line + 1, column: character + 1 })
534
+ }
535
+ const visit = (
536
+ node: TS.Node,
537
+ classRoot: string | undefined,
538
+ enclosingClass: TS.ClassLikeDeclaration | undefined
539
+ ): void => {
540
+ if (ts.isTaggedTemplateExpression(node) && nameOf(node.tag) === TAG_NAME) {
541
+ if (!isCheckedTag(receiverRoot(node.tag), bindings)) {
542
+ if (shouldReportUnrecognized(node.tag)) {
543
+ skip(node.tag, 'unrecognized-receiver', 'FHIRPath tag not analyzed: its binding is not from fhirpath-ts')
544
+ }
545
+ } else if (ts.isNoSubstitutionTemplateLiteral(node.template)) {
253
546
  record(node.template.text, node.template)
547
+ } else {
548
+ skip(node.template, 'dynamic-expression', 'FHIRPath template not analyzed: substitutions make it dynamic')
254
549
  }
255
550
  } else if (ts.isCallExpression(node)) {
256
551
  const callee = nameOf(node.expression)
257
552
  const policy = callee === undefined ? undefined : CALL_SITES.get(callee)
258
553
  const argument = policy && (node.arguments[policy.argIndex] as TS.Expression | undefined)
259
- if (
260
- callee !== undefined &&
261
- policy !== undefined &&
262
- argument !== undefined &&
263
- isCheckedCall(policy, callee, receiverRoot(node.expression), bindings)
264
- ) {
265
- const declares = policy.declaresField
266
- const field = declares === undefined ? undefined : decoratedFieldName(node)
267
- if (declares !== undefined && field !== undefined) {
268
- functions[field] = declaredColumnOverloads(
269
- functions[field],
270
- columnFunctionDeclaration<TS.Node>(declares, node.arguments[1], tsAst, classRoot)
271
- )
272
- }
273
- const context = siteContext<TS.Node>(policy, index => node.arguments[index], classRoot, tsAst)
274
- for (const entry of expressionEntries<TS.Node>(argument, policy.shape, tsAst)) {
275
- record(entry.expression, entry.node, context)
554
+ if (callee !== undefined && policy !== undefined && argument !== undefined) {
555
+ const typedEngineReceiver =
556
+ ts.isPropertyAccessExpression(node.expression) &&
557
+ isEngineExpression(node.expression.expression, engineSymbols)
558
+ const checked = isCheckedCall(policy, callee, receiverRoot(node.expression), bindings, {
559
+ ...(typedEngineReceiver && { engine: true }),
560
+ })
561
+ if (!checked) {
562
+ const receiver = receiverRoot(node.expression)
563
+ const receiverExpression = ts.isPropertyAccessExpression(node.expression)
564
+ ? node.expression.expression
565
+ : node.expression
566
+ if (shouldReportUnrecognized(receiverExpression)) {
567
+ skip(
568
+ node.expression,
569
+ 'unrecognized-receiver',
570
+ `${callee}(...) expression not analyzed: ${
571
+ receiver === undefined
572
+ ? `binding '${callee}' is not recognized as fhirpath-ts`
573
+ : `receiver '${receiver}' is not recognized as a FhirPathEngine`
574
+ }`
575
+ )
576
+ }
577
+ } else {
578
+ const declares = policy.declaresField
579
+ const field = declares === undefined ? undefined : decoratedFieldName(node)
580
+ if (declares !== undefined && field !== undefined) {
581
+ if (enclosingClass !== undefined) {
582
+ decoratedClasses.add(enclosingClass)
583
+ }
584
+ functions[field] = declaredColumnOverloads(
585
+ functions[field],
586
+ columnFunctionDeclaration<TS.Node>(declares, node.arguments[1], tsAst, classRoot)
587
+ )
588
+ }
589
+ for (const candidate of callExpressionCandidates<TS.Node>(
590
+ policy,
591
+ index => node.arguments[index],
592
+ classRoot,
593
+ tsAst
594
+ )) {
595
+ if (candidate.uncheckable === 'dynamic-vars') {
596
+ skip(
597
+ candidate.node,
598
+ 'dynamic-expression',
599
+ `${callee}(...) var expressions not analyzed: their final names, values, or order are dynamic`
600
+ )
601
+ } else if (candidate.expression === undefined) {
602
+ skip(
603
+ candidate.node,
604
+ 'dynamic-expression',
605
+ candidate.source === 'option-var'
606
+ ? `${callee}(...) var expression not analyzed: it is not a string literal`
607
+ : `${callee}(...) expression not analyzed: it is not a string literal`
608
+ )
609
+ } else {
610
+ record(candidate.expression, candidate.node, candidate.context)
611
+ }
612
+ }
276
613
  }
277
614
  }
278
615
  }
279
616
  const nested = ts.isClassLike(node) ? rootOf(heritage.get(node), dtoRoots) : classRoot
280
- ts.forEachChild(node, child => visit(child, nested))
617
+ const nestedClass = ts.isClassLike(node) ? node : enclosingClass
618
+ ts.forEachChild(node, child => visit(child, nested, nestedClass))
619
+ }
620
+ visit(source, undefined, undefined)
621
+ const { exported, aliases } = moduleBindings(source)
622
+ const classByName = new Map(
623
+ [...heritage.entries()].flatMap(([node, value]) =>
624
+ ts.isClassLike(node) && value.name !== undefined ? [[value.name, value] as const] : []
625
+ )
626
+ )
627
+ // From every exported binding, follow alias and extends edges: what the
628
+ // loader enumerates under any name makes every class behind it analyzable.
629
+ const reachesExport = (name: string): boolean => {
630
+ const seen = new Set<string>()
631
+ const frontier = [...exported]
632
+ while (frontier.length > 0) {
633
+ const current = frontier.pop() as string
634
+ if (current === name) {
635
+ return true
636
+ }
637
+ if (seen.has(current)) {
638
+ continue
639
+ }
640
+ seen.add(current)
641
+ const base = classByName.get(current)?.baseName
642
+ if (base !== undefined) {
643
+ frontier.push(base)
644
+ }
645
+ frontier.push(...(aliases.get(current) ?? []))
646
+ }
647
+ return false
648
+ }
649
+ const dtoDeclarations = [...decoratedClasses].flatMap(node => {
650
+ const nameNode = node.name
651
+ // Runtime module discovery can only miss top-level module-local classes.
652
+ // A class inside a factory is reached through whatever class the factory
653
+ // returns, rather than as a module export with its source name.
654
+ if (nameNode === undefined || node.parent !== source) {
655
+ return []
656
+ }
657
+ const name = nameNode.text
658
+ const { line, character } = source.getLineAndCharacterOfPosition(nameNode.getStart(source))
659
+ return [{ name, line: line + 1, column: character + 1, loadable: reachesExport(name) }]
660
+ })
661
+ return {
662
+ sites: Object.keys(functions).length === 0 ? sites : sites.map(site => ({ ...site, functions })),
663
+ skipped,
664
+ dtoDeclarations,
281
665
  }
282
- visit(source, undefined)
283
- return Object.keys(functions).length === 0 ? sites : sites.map(site => ({ ...site, functions }))
284
666
  }
285
667
  }
668
+
669
+ /** Creates the compatibility finder that returns only expressions successfully extracted. */
670
+ export function createSiteFinder(ts: TypeScriptApi, program?: TS.Program): SiteFinder {
671
+ const scan = createSiteScanner(ts, program)
672
+ return (sourceText, fileName, options) => scan(sourceText, fileName, options).sites
673
+ }
@@ -32,9 +32,9 @@ export type FhirpathTypeDeclarations = Readonly<Record<string, FhirpathTypeDecla
32
32
  export type FhirpathFunctionDeclaration =
33
33
  | {
34
34
  readonly signature?: {
35
- readonly input?: { readonly types?: readonly string[] }
35
+ readonly input?: { readonly types?: readonly string[]; readonly ordered?: boolean }
36
36
  readonly args?: readonly string[]
37
- readonly result?: { readonly types?: readonly string[]; readonly single?: boolean }
37
+ readonly result?: { readonly types?: readonly string[]; readonly single?: boolean; readonly ordered?: boolean }
38
38
  }
39
39
  readonly expression?: string | { readonly source: string }
40
40
  readonly criteria?: boolean