fhirpath-ts 0.2.0 → 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 (83) 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/r4/generated/resources-data.d.ts +6 -2
  45. package/dist/r4/generated/resources-data.d.ts.map +1 -1
  46. package/dist/r4/generated/resources-data.js +626 -28627
  47. package/dist/r4/generated/resources-data.js.map +1 -1
  48. package/dist/r4/generated/types-data.d.ts +6 -2
  49. package/dist/r4/generated/types-data.d.ts.map +1 -1
  50. package/dist/r4/generated/types-data.js +84 -2204
  51. package/dist/r4/generated/types-data.js.map +1 -1
  52. package/dist/r4/index.d.ts.map +1 -1
  53. package/dist/r4/index.js +10 -4
  54. package/dist/r4/index.js.map +1 -1
  55. package/dist/r4/model-data.d.ts +17 -0
  56. package/dist/r4/model-data.d.ts.map +1 -1
  57. package/dist/r4/model-data.js +118 -1
  58. package/dist/r4/model-data.js.map +1 -1
  59. package/dist/sites/index.d.ts +31 -2
  60. package/dist/sites/index.d.ts.map +1 -1
  61. package/dist/sites/index.js +351 -30
  62. package/dist/sites/index.js.map +1 -1
  63. package/dist/typed/infer.d.ts +2 -0
  64. package/dist/typed/infer.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/analyzer/analyze-dto.ts +19 -9
  67. package/src/analyzer/analyze.ts +186 -54
  68. package/src/analyzer/declarations.ts +12 -0
  69. package/src/analyzer/expression-policy.ts +423 -47
  70. package/src/analyzer/operator-rules.ts +7 -6
  71. package/src/analyzer/signatures.ts +100 -36
  72. package/src/analyzer/source-options.ts +19 -0
  73. package/src/api/dto.ts +35 -9
  74. package/src/api/strict.ts +8 -3
  75. package/src/cli/dto-check.ts +56 -6
  76. package/src/cli/fhirpath-check.ts +180 -34
  77. package/src/eslint/index.ts +15 -12
  78. package/src/r4/generated/resources-data.ts +624 -28628
  79. package/src/r4/generated/types-data.ts +82 -2205
  80. package/src/r4/index.ts +11 -5
  81. package/src/r4/model-data.ts +127 -0
  82. package/src/sites/index.ts +428 -40
  83. package/src/typed/infer.ts +2 -2
@@ -5,32 +5,43 @@
5
5
  * exit status. Use `--no-import` for source only and `--dtos` to change the DTO
6
6
  * module glob.
7
7
  *
8
- * Usage: fhirpath-check [--dtos <glob>]... [--no-import] <file...>
8
+ * Usage: fhirpath-check [--dtos <glob>]... [--no-import] [--local-imports] [--strict] <file...>
9
9
  */
10
10
  /* v8 ignore file -- covered end-to-end as a subprocess in fhirpath-check.test.ts */
11
11
  import { readFileSync } from 'node:fs'
12
+ import { dirname, resolve } from 'node:path'
12
13
 
13
14
  import ts from 'typescript'
14
15
 
15
16
  import { analyzeSite } from '../analyzer/analyze.ts'
16
17
  import { r4Model } from '../r4/index.ts'
17
- import { createSiteFinder, type ExpressionSite } from '../sites/index.ts'
18
- import { checkDtoModules, DEFAULT_DTO_GLOB, type DtoFinding } from './dto-check.ts'
19
-
20
- const findExpressionSites = createSiteFinder(ts)
18
+ import { createSiteScanner, type ExpressionSite, type SiteScanner, type SiteScanResult } from '../sites/index.ts'
19
+ import {
20
+ checkDtoModules,
21
+ DEFAULT_DTO_GLOB,
22
+ type DtoCheckResult,
23
+ type DtoFinding,
24
+ EngineMergeError,
25
+ } from './dto-check.ts'
21
26
 
22
27
  interface Args {
23
28
  files: string[]
24
29
  dtoGlobs: string[]
25
30
  imports: boolean
31
+ localImports: boolean
32
+ strict: boolean
26
33
  }
27
34
 
28
35
  function parseArgs(argv: readonly string[]): Args {
29
- const args: Args = { files: [], dtoGlobs: [], imports: true }
36
+ const args: Args = { files: [], dtoGlobs: [], imports: true, localImports: false, strict: false }
30
37
  for (let index = 0; index < argv.length; index++) {
31
38
  const arg = argv[index]!
32
39
  if (arg === '--no-import') {
33
40
  args.imports = false
41
+ } else if (arg === '--local-imports') {
42
+ args.localImports = true
43
+ } else if (arg === '--strict') {
44
+ args.strict = true
34
45
  } else if (arg === '--dtos') {
35
46
  const glob = argv[++index]
36
47
  if (glob === undefined) {
@@ -47,17 +58,89 @@ function parseArgs(argv: readonly string[]): Args {
47
58
 
48
59
  const args = parseArgs(process.argv.slice(2))
49
60
  if (args.files.length === 0 && !args.imports) {
50
- console.error('usage: fhirpath-check [--dtos <glob>]... [--no-import] <file...>')
61
+ console.error('usage: fhirpath-check [--dtos <glob>]... [--no-import] [--local-imports] [--strict] <file...>')
51
62
  process.exit(2)
52
63
  }
64
+ const dtoGlobs = args.dtoGlobs.length > 0 ? args.dtoGlobs : [DEFAULT_DTO_GLOB]
65
+
66
+ /** A type-aware program lets the site finder follow imported and aliased engine receivers. */
67
+ function sourceProgram(files: readonly string[], configPath: string | undefined): ts.Program {
68
+ const defaults: ts.CompilerOptions = {
69
+ allowJs: true,
70
+ skipLibCheck: true,
71
+ target: ts.ScriptTarget.Latest,
72
+ }
73
+ const nodeNext: ts.CompilerOptions = {
74
+ module: ts.ModuleKind.NodeNext,
75
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
76
+ }
77
+ let options: ts.CompilerOptions = { ...defaults, ...nodeNext }
78
+ if (configPath !== undefined) {
79
+ const config = ts.readConfigFile(configPath, ts.sys.readFile)
80
+ if (config.error === undefined) {
81
+ // Resolve `extends`/`paths` from the config's own directory, and layer
82
+ // the declared options over the defaults instead of replacing them —
83
+ // a `strict`-only tsconfig must not lose the module resolution the
84
+ // checker needs to follow package exports. The NodeNext pair applies
85
+ // only when the config declares neither half, so an explicit
86
+ // module/moduleResolution keeps TypeScript's own pairing rules.
87
+ const declared = ts.parseJsonConfigFileContent(
88
+ config.config,
89
+ ts.sys,
90
+ dirname(configPath),
91
+ undefined,
92
+ configPath
93
+ ).options
94
+ const resolution = declared.module === undefined && declared.moduleResolution === undefined ? nodeNext : {}
95
+ options = { ...defaults, ...resolution, ...declared }
96
+ }
97
+ }
98
+ return ts.createProgram({ rootNames: files.map(file => resolve(file)), options })
99
+ }
100
+
101
+ /**
102
+ * One semantic scanner per tsconfig project. A monorepo command may select
103
+ * files from packages whose aliases and module settings differ, so each file
104
+ * starts its config search in its own directory. Files sharing a config share
105
+ * one Program and TypeChecker.
106
+ */
107
+ function sourceScanners(files: readonly string[]): ReadonlyMap<string, SiteScanner> {
108
+ const filesByConfig = new Map<string | undefined, string[]>()
109
+ for (const file of files) {
110
+ const absolute = resolve(file)
111
+ const configPath = ts.findConfigFile(dirname(absolute), ts.sys.fileExists)
112
+ const grouped = filesByConfig.get(configPath)
113
+ if (grouped === undefined) {
114
+ filesByConfig.set(configPath, [absolute])
115
+ } else {
116
+ grouped.push(absolute)
117
+ }
118
+ }
119
+
120
+ const scannersByFile = new Map<string, SiteScanner>()
121
+ for (const [configPath, groupedFiles] of filesByConfig) {
122
+ const scanner = createSiteScanner(ts, sourceProgram(groupedFiles, configPath))
123
+ for (const file of groupedFiles) {
124
+ scannersByFile.set(file, scanner)
125
+ }
126
+ }
127
+ return scannersByFile
128
+ }
129
+
130
+ const syntaxScanner = createSiteScanner(ts)
131
+ let semanticScannersByFile: ReadonlyMap<string, SiteScanner> = new Map()
53
132
 
54
133
  let failures = 0
134
+ let warnings = 0
55
135
 
56
- function report(location: string, diagnostic: { severity: string; code: string; message: string }): void {
57
- if (diagnostic.severity === 'error') {
136
+ function report(location: string, diagnostic: { severity: 'error' | 'warning'; code: string; message: string }): void {
137
+ const severity = args.strict && diagnostic.severity === 'warning' ? 'error' : diagnostic.severity
138
+ if (severity === 'error') {
58
139
  failures += 1
140
+ } else {
141
+ warnings += 1
59
142
  }
60
- const prefix = diagnostic.severity === 'warning' ? 'warning:' : ''
143
+ const prefix = severity === 'warning' ? 'warning:' : ''
61
144
  console.error(`${location} [${prefix}${diagnostic.code}] ${diagnostic.message}`)
62
145
  }
63
146
 
@@ -73,33 +156,85 @@ function positionIn(site: { line: number; column: number }, span: { line: number
73
156
  return `${line}:${column}`
74
157
  }
75
158
 
76
- /** Expression sites cached so each source file is read once. */
77
- const sitesByFile = new Map<string, ExpressionSite[]>()
159
+ /** Source scans cached so each file is read once. */
160
+ const scansByFile = new Map<string, SiteScanResult>()
78
161
 
79
- function sitesOf(file: string): ExpressionSite[] {
80
- const cached = sitesByFile.get(file)
162
+ function scanOf(file: string): SiteScanResult {
163
+ const cached = scansByFile.get(file)
81
164
  if (cached !== undefined) {
82
165
  return cached
83
166
  }
84
- const sites = findExpressionSites(readFileSync(file, 'utf8'), file)
85
- sitesByFile.set(file, sites)
86
- return sites
167
+ const absolute = resolve(file)
168
+ const scanner = semanticScannersByFile.get(absolute) ?? syntaxScanner
169
+ const scan = scanner(readFileSync(file, 'utf8'), absolute, {
170
+ ...(args.localImports && { localImports: true }),
171
+ })
172
+ scansByFile.set(file, scan)
173
+ return scan
87
174
  }
88
175
 
89
- // Check source literals first.
176
+ function sitesOf(file: string): ExpressionSite[] {
177
+ return scanOf(file).sites
178
+ }
179
+
180
+ // Extract source sites before executing project modules. Analysis waits until
181
+ // after the import pass so those sites can use the engines' real environment.
90
182
  for (const file of args.files) {
91
- let sites: ExpressionSite[]
92
183
  try {
93
- sites = sitesOf(file)
184
+ scanOf(file)
94
185
  } catch (error) {
95
186
  console.error(`fhirpath-check: cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`)
96
187
  process.exit(2)
97
188
  }
189
+ }
190
+ const unresolvedFiles = args.localImports
191
+ ? []
192
+ : args.files.filter(file => scanOf(file).skipped.some(skipped => skipped.reason === 'unrecognized-receiver'))
193
+ if (unresolvedFiles.length > 0) {
194
+ // Pay the Program/TypeChecker cost only when the syntax-only pass found a
195
+ // receiver it could not prove. Most source files need no compiler graph.
196
+ semanticScannersByFile = sourceScanners(unresolvedFiles)
197
+ scansByFile.clear()
198
+ for (const file of args.files) {
199
+ scanOf(file)
200
+ }
201
+ }
202
+
203
+ let dtoResult: DtoCheckResult | undefined
204
+ if (args.imports) {
205
+ try {
206
+ dtoResult = await checkDtoModules(dtoGlobs, process.cwd())
207
+ } catch (error) {
208
+ if (error instanceof EngineMergeError) {
209
+ console.error(`fhirpath-check: ${error.message}`)
210
+ } else {
211
+ console.error(
212
+ `fhirpath-check: cannot import DTO modules (${dtoGlobs.join(', ')}): ${error instanceof Error ? error.message : String(error)}`
213
+ )
214
+ }
215
+ process.exit(2)
216
+ }
217
+ }
218
+
219
+ // Check source literals first.
220
+ for (const file of args.files) {
221
+ const sites = sitesOf(file)
98
222
  for (const site of sites) {
99
- for (const diagnostic of analyzeSite(site, { model: r4Model })) {
223
+ for (const diagnostic of analyzeSite(site, {
224
+ model: r4Model,
225
+ ...dtoResult?.sourceOptions,
226
+ reportUnchecked: true,
227
+ })) {
100
228
  report(`${file}:${positionIn(site, diagnostic.span)}`, diagnostic)
101
229
  }
102
230
  }
231
+ for (const skipped of scanOf(file).skipped) {
232
+ report(`${file}:${skipped.line}:${skipped.column}`, {
233
+ severity: 'warning',
234
+ code: 'skipped',
235
+ message: skipped.message,
236
+ })
237
+ }
103
238
  }
104
239
 
105
240
  /**
@@ -124,26 +259,35 @@ function locate(finding: DtoFinding): string {
124
259
  }
125
260
 
126
261
  // --- 2. the project's DTO modules, imported ---
127
- if (args.imports) {
128
- const globs = args.dtoGlobs.length > 0 ? args.dtoGlobs : [DEFAULT_DTO_GLOB]
129
- let result
130
- try {
131
- result = await checkDtoModules(globs, process.cwd())
132
- } catch (error) {
133
- console.error(
134
- `fhirpath-check: cannot import DTO modules (${globs.join(', ')}): ${error instanceof Error ? error.message : String(error)}`
135
- )
136
- process.exit(2)
262
+ if (dtoResult !== undefined) {
263
+ const result = dtoResult
264
+ for (const file of result.files) {
265
+ let scan: SiteScanResult
266
+ try {
267
+ scan = scanOf(file)
268
+ } catch {
269
+ continue
270
+ }
271
+ for (const dto of scan.dtoDeclarations) {
272
+ if (!dto.loadable) {
273
+ report(`${file}:${dto.line}:${dto.column}`, {
274
+ severity: 'warning',
275
+ code: 'unloaded-dto',
276
+ message: `DTO ${dto.name} is module-local and was not loaded for full analysis; export it or an extending class`,
277
+ })
278
+ }
279
+ }
137
280
  }
138
281
  for (const finding of result.findings) {
139
282
  // With no engine in reach, an unresolved column call is unresolvable rather
140
283
  // than wrong, so it is reported without failing the run.
141
284
  const unresolvable = result.engines === 0 && finding.code === 'unknown-function'
142
- report(locate(finding), unresolvable ? { ...finding, severity: 'warning' } : finding)
285
+ const reported = unresolvable ? { ...finding, severity: 'warning' as const } : finding
286
+ report(locate(finding), reported)
143
287
  }
144
288
  if (result.files.length === 0) {
145
289
  console.error(
146
- `fhirpath-check: no DTO modules matched ${globs.join(', ')} — DTOs live in *.dto.ts, or pass --dtos <glob>`
290
+ `fhirpath-check: no DTO modules matched ${dtoGlobs.join(', ')} — DTOs live in *.dto.ts, or pass --dtos <glob>`
147
291
  )
148
292
  } else if (result.dtos.length === 0) {
149
293
  console.error(`fhirpath-check: ${result.files.length} DTO module(s) matched but export no DTO class`)
@@ -165,4 +309,6 @@ if (failures > 0) {
165
309
  console.error(`fhirpath-check: ${failures} problem(s) found`)
166
310
  process.exit(1)
167
311
  }
168
- console.log('fhirpath-check: no problems found')
312
+ console.log(
313
+ warnings > 0 ? `fhirpath-check: ${warnings} warning(s), no errors found` : 'fhirpath-check: no problems found'
314
+ )
@@ -6,6 +6,7 @@ import type * as ESTree from 'estree'
6
6
  import { analyzeSite, type DeclaredFunction, type DeclaredVariable } from '../analyzer/analyze.ts'
7
7
  import {
8
8
  CALL_SITES,
9
+ callExpressionCandidates,
9
10
  type CallSitePolicy,
10
11
  type ClassHeritage,
11
12
  columnFunctionDeclaration,
@@ -14,7 +15,6 @@ import {
14
15
  DTO_BASE_NAME,
15
16
  dtoRootsOf,
16
17
  type ExpressionAst,
17
- expressionEntries,
18
18
  type FileColumnFunction,
19
19
  isCheckedCall,
20
20
  isCheckedTag,
@@ -22,7 +22,6 @@ import {
22
22
  type LocalModuleOptions,
23
23
  rootOf,
24
24
  type SiteContext,
25
- siteContext,
26
25
  type SourceBindings,
27
26
  TAG_NAME,
28
27
  } from '../analyzer/expression-policy.ts'
@@ -32,10 +31,13 @@ function isStringLiteral(node: ESTree.Node): node is ESTree.SimpleLiteral & { va
32
31
  return node.type === 'Literal' && typeof node.value === 'string'
33
32
  }
34
33
 
35
- /** Statically-known property key (`path` in `{ path: ... }` or `{ 'path': ... }`), undefined when computed. */
34
+ /**
35
+ * Statically-known property key: `path` in `{ path: ... }`, `{ 'path': ... }`,
36
+ * or `{ ['path']: ... }`; undefined for other computed keys.
37
+ */
36
38
  function propertyKeyName(property: ESTree.Property): string | undefined {
37
39
  if (property.computed) {
38
- return undefined
40
+ return isStringLiteral(property.key) ? property.key.value : undefined
39
41
  }
40
42
  if (property.key.type === 'Identifier') {
41
43
  return property.key.name
@@ -49,8 +51,10 @@ const estreeAst: ExpressionAst<ESTree.Node> = {
49
51
  boolean: node => (node.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined),
50
52
  properties: node =>
51
53
  node.type === 'ObjectExpression'
52
- ? node.properties.flatMap(property =>
53
- property.type === 'Property' ? [{ name: propertyKeyName(property), value: property.value }] : []
54
+ ? node.properties.map(property =>
55
+ property.type === 'Property'
56
+ ? { name: propertyKeyName(property), value: property.value }
57
+ : { name: undefined, value: property.argument, spread: true as const }
54
58
  )
55
59
  : undefined,
56
60
  elements: node => (node.type === 'ArrayExpression' ? node.elements.filter(element => element !== null) : undefined),
@@ -198,7 +202,6 @@ const noInvalidExpressions: Rule.RuleModule = {
198
202
  policy: CallSitePolicy
199
203
  name: string
200
204
  receiverRoot: string | undefined
201
- argument: ESTree.Node
202
205
  /** The call itself, for the argument that may name the type it runs against. */
203
206
  node: ESTree.CallExpression
204
207
  /** The class the call sits in, resolved to a root once the whole file is known. */
@@ -325,7 +328,6 @@ const noInvalidExpressions: Rule.RuleModule = {
325
328
  policy,
326
329
  name,
327
330
  receiverRoot: receiverRoot(callee),
328
- argument,
329
331
  node,
330
332
  enclosing: policy.rootFromClass === true ? enclosingClass(ancestors) : undefined,
331
333
  })
@@ -359,14 +361,15 @@ const noInvalidExpressions: Rule.RuleModule = {
359
361
  if (!isCheckedCall(call.policy, call.name, call.receiverRoot, bindings)) {
360
362
  continue
361
363
  }
362
- const site = siteContext<ESTree.Node>(
364
+ for (const candidate of callExpressionCandidates<ESTree.Node>(
363
365
  call.policy,
364
366
  index => call.node.arguments[index],
365
367
  rootOf(call.enclosing, dtoRoots),
366
368
  estreeAst
367
- )
368
- for (const entry of expressionEntries(call.argument, call.policy.shape, estreeAst)) {
369
- checkAt(entry.node, entry.expression, site)
369
+ )) {
370
+ if (candidate.expression !== undefined) {
371
+ checkAt(candidate.node, candidate.expression, candidate.context)
372
+ }
370
373
  }
371
374
  }
372
375
  },