fhirsmith 0.5.4 → 0.5.6

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.
package/CHANGELOG.md CHANGED
@@ -5,9 +5,31 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.5.6] - 2026-02-26
9
+
10
+ ### Changed
11
+ - Added content to TerminologyCapabilities.codeSystem
12
+ - fix LOINC list filter handling
13
+ - Improve Diagnostic Logging
14
+ - Add icd-9-cm parser
15
+
16
+ ### Tx Conformance Statement
17
+
18
+ FHIRsmith 0.5.5 passed all 1382 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.0, runner v6.8.1)
19
+
20
+ ## [v0.5.5] - 2026-02-26
21
+
22
+ ### Changed
23
+ - Fix loading problem for multiple versions of the same code system
24
+ - Fix url matching in search to be precise
25
+
26
+ ### Tx Conformance Statement
27
+
28
+ FHIRsmith 0.5.5 passed all 1382 HL7 terminology service tests (modes tx.fhir.org,omop,general,snomed, tests v1.9.0, runner v6.8.1)
29
+
8
30
  ## [v0.5.4] - 2026-02-25
9
31
 
10
- This version requires that you delete all package content from the terminology-cache directly
32
+ This version requires that you delete all package content from the terminology-cache directly
11
33
  by hand before running this version.
12
34
 
13
35
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
5
5
  "main": "server.js",
6
6
  "engines": {
package/tx/cs/cs-api.js CHANGED
@@ -785,6 +785,10 @@ class CodeSystemFactoryProvider {
785
785
  */
786
786
  version() { throw new Error("Must override"); }
787
787
 
788
+ content() {
789
+ return "complete";
790
+ }
791
+
788
792
  getPartialVersion() {
789
793
  let ver = this.version();
790
794
  if (ver && VersionUtilities.isSemVer(ver)) {
package/tx/cs/cs-loinc.js CHANGED
@@ -670,7 +670,7 @@ class LoincServices extends BaseCSServices {
670
670
  sql = `SELECT DISTINCT TargetKey as Key FROM Relationships
671
671
  WHERE RelationshipTypeKey = ${this.relationships.get('Answer')}
672
672
  AND SourceKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
673
- ORDER BY SourceKey ASC`;
673
+ ORDER BY TargetKey ASC`;
674
674
  lsql = `SELECT COUNT(DISTINCT TargetKey) FROM Relationships
675
675
  WHERE RelationshipTypeKey = ${this.relationships.get('Answer')}
676
676
  AND SourceKey IN (SELECT CodeKey FROM Codes WHERE Code = '${this.#sqlWrapString(value)}')
@@ -1,3 +1,4 @@
1
+ const {VersionUtilities, VersionPrecision} = require("../../library/version-utilities");
1
2
 
2
3
  /**
3
4
  * Base class for metadata resources to provide common interface
@@ -83,6 +84,68 @@ class CanonicalResource {
83
84
  }
84
85
  return this.jsonObj.versionAlgorithmString;
85
86
  }
87
+
88
+ guessVersionAlgorithmFromVersion(version) {
89
+ if (VersionUtilities.isSemVerWithWildcards(version)) {
90
+ return 'semver';
91
+ }
92
+ if (this.appearsToBeDate(version)) {
93
+ return 'date';
94
+ }
95
+ if (this.isAnInteger(version)) {
96
+ return 'integer';
97
+ }
98
+ return 'alpha';
99
+ }
100
+
101
+ /**
102
+ * returns true if this is more recent than other.
103
+ *
104
+ * Uses version if possible, otherwise uses date
105
+ *
106
+ * @param other
107
+ * @returns {boolean}
108
+ */
109
+ isMoreRecent(other) {
110
+ if (this.version && other.version && this.version != other.version) {
111
+ const fmt = this.versionAlgorithm() || other.versionAlgorithm() || this.guessVersionAlgorithmFromVersion(this.version);
112
+ switch (fmt) {
113
+ case 'semver':
114
+ return VersionUtilities.isThisOrLater(other.version, this.version, VersionPrecision.PATCH);
115
+ case 'date':
116
+ return this.dateIsMoreRecent(this.version, other.version);
117
+ case 'integer':
118
+ return parseInt(this.version, 10) > parseInt(other.version, 10);
119
+ case 'alpha': return this.version.localeCompare(other.version) > 0;
120
+ default: return this.version.localeCompare(other.version);
121
+ }
122
+ }
123
+ if (this.date && other.date && this.date != other.date) {
124
+ return this.dateIsMoreRecent(this.date, other.date);
125
+ }
126
+ return false;
127
+ }
128
+
129
+ appearsToBeDate(version) {
130
+ if (!version || typeof version !== 'string') return false;
131
+ // Strip optional time portion (T...) before checking
132
+ const datePart = version.split('T')[0];
133
+ return /^\d{4}-?\d{2}(-?\d{2})?$/.test(datePart);
134
+
135
+ }
136
+
137
+ dateIsMoreRecent(date, date2) {
138
+ return this.normaliseDateString(date) > this.normaliseDateString(date2);
139
+ }
140
+
141
+ normaliseDateString(date) {
142
+ // Strip time portion, then remove dashes so all formats compare uniformly as YYYYMMDD or YYYYMM
143
+ return date.split('T')[0].replace(/-/g, '');
144
+ }
145
+
146
+ isAnInteger(version) {
147
+ return /^\d+$/.test(version);
148
+ }
86
149
  }
87
150
 
88
151
  module.exports = { CanonicalResource };
@@ -22,18 +22,17 @@ class Renderer {
22
22
  displayCoded(...args) {
23
23
  if (args.length === 1) {
24
24
  const arg = args[0];
25
- if (arg.systemUri !== undefined && arg.version !== undefined && arg.code !== undefined && arg.display !== undefined) {
25
+ if (arg instanceof CodeSystemProvider) {
26
+ return arg.system() + "|" + arg.version();
27
+ } else if (arg.system !== undefined && arg.version !== undefined && arg.code !== undefined && arg.display !== undefined) {
26
28
  // It's a Coding
27
29
  return this.displayCodedCoding(arg);
28
30
  } else if (arg.coding !== undefined || arg.text) {
29
31
  // It's a CodeableConcept
30
32
  return this.displayCodedCodeableConcept(arg);
31
- } else if (arg.systemUri !== undefined && arg.version !== undefined) {
33
+ } else if (arg.system !== undefined && arg.version !== undefined) {
32
34
  // It's a CodeSystemProvider
33
35
  return this.displayCodedProvider(arg);
34
- } else if (arg instanceof CodeSystemProvider) {
35
- let cs = arg;
36
- return cs.system() + "|" + cs.version();
37
36
  }
38
37
  } else if (args.length === 2) {
39
38
  return this.displayCodedSystemVersion(args[0], args[1]);
@@ -46,7 +45,7 @@ class Renderer {
46
45
  }
47
46
 
48
47
  displayCodedProvider(system) {
49
- let result = system.systemUri + '|' + system.version;
48
+ let result = system.system + '|' + system.version;
50
49
  if (system.sourcePackage) {
51
50
  result = result + ' (from ' + system.sourcePackage + ')';
52
51
  }
@@ -70,7 +69,7 @@ class Renderer {
70
69
  }
71
70
 
72
71
  displayCodedCoding(code) {
73
- return this.displayCodedSystemVersionCodeDisplay(code.systemUri, code.version, code.code, code.display);
72
+ return this.displayCodedSystemVersionCodeDisplay(code.system, code.version, code.code, code.display);
74
73
  }
75
74
 
76
75
  displayCodedCodeableConcept(code) {
@@ -90,12 +89,12 @@ class Renderer {
90
89
 
91
90
  displayValueSetInclude(inc) {
92
91
  let result;
93
- if (inc.systemUri) {
94
- result = '(' + inc.systemUri + ')';
95
- if (inc.hasConcepts) {
92
+ if (inc.system) {
93
+ result = '(' + inc.system + ')';
94
+ if (inc.concept) {
96
95
  result = result + '(';
97
96
  let first = true;
98
- for (const cc of inc.concepts) {
97
+ for (const cc of inc.concept) {
99
98
  if (first) {
100
99
  first = false;
101
100
  } else {
@@ -105,23 +104,23 @@ class Renderer {
105
104
  }
106
105
  result = result + ')';
107
106
  }
108
- if (inc.hasFilters) {
107
+ if (inc.filter) {
109
108
  result = result + '(';
110
109
  let first = true;
111
- for (const ci of inc.filters) {
110
+ for (const ci of inc.filter) {
112
111
  if (first) {
113
112
  first = false;
114
113
  } else {
115
114
  result = result + ',';
116
115
  }
117
- result = result + ci.prop + ci.op + ci.value;
116
+ result = result + ci.property + ci.op + ci.value;
118
117
  }
119
118
  result = result + ')';
120
119
  }
121
120
  } else {
122
121
  result = '(';
123
122
  let first = true;
124
- for (const s of inc.valueSets || []) {
123
+ for (const s of inc.valueSet || []) {
125
124
  if (first) {
126
125
  first = false;
127
126
  } else {
@@ -1557,6 +1556,10 @@ class Renderer {
1557
1556
  // No versions specified
1558
1557
  await this.renderLink(li, cs.uri);
1559
1558
  }
1559
+ let content = cs.content || Extensions.readString(cs, "http://hl7.org/fhir/4.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content");
1560
+ if (content && content != "complete") {
1561
+ li.tx(" (" + content + ")");
1562
+ }
1560
1563
  }
1561
1564
  }
1562
1565
  }
package/tx/library.js CHANGED
@@ -449,8 +449,13 @@ class Library {
449
449
  for (const resource of resources) {
450
450
  const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
451
451
  cs.sourcePackage = contentLoader.pid();
452
- cp.codeSystems.set(cs.url, cs);
453
- cp.codeSystems.set(cs.vurl, cs);
452
+ const existing = cp.codeSystems.get(cs.url);
453
+ if (!existing || cs.isMoreRecent(existing)) {
454
+ cp.codeSystems.set(cs.url, cs);
455
+ }
456
+ if (cs.version) {
457
+ cp.codeSystems.set(cs.vurl, cs);
458
+ }
454
459
  csc++;
455
460
  }
456
461
  this.codeSystemProviders.push(cp);
package/tx/provider.js CHANGED
@@ -136,8 +136,13 @@ class Provider {
136
136
  for (const resource of resources) {
137
137
  const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
138
138
  cs.sourcePackage = contentLoader.pid();
139
- this.codeSystems.set(cs.url, cs);
140
- this.codeSystems.set(cs.vurl, cs);
139
+ const existing = this.codeSystems.get(cs.url);
140
+ if (!existing || cs.isMoreRecent(existing)) {
141
+ this.codeSystems.set(cs.url, cs);
142
+ }
143
+ if (cs.version) {
144
+ this.codeSystems.set(cs.vurl, cs);
145
+ }
141
146
  }
142
147
  const vs = new PackageValueSetProvider(contentLoader);
143
148
  await vs.initialize();
@@ -632,7 +632,7 @@ class ValueSetExpander {
632
632
  } else if (cs.contentMode() === 'supplement') {
633
633
  throw new Issue('error', 'business-rule', null, null, 'The code system definition for ' + cset.system + ' defines a supplement, so this expansion cannot be performed', 'invalid');
634
634
  } else {
635
- this.addParamUri(exp, cs.contentMode(), cs.system + '|' + cs.version);
635
+ this.addParamUri(exp, cs.contentMode(), cs.system() + '|' + cs.version());
636
636
  Extensions.addString(exp, "http://hl7.org/fhir/StructureDefinition/valueset-unclosed",
637
637
  "This extension is based on a fragment of the code system " + cset.system);
638
638
  }
@@ -325,9 +325,10 @@ class MetadataHandler {
325
325
  for (const cs of provider.codeSystems.values()) {
326
326
  const url = cs.url || (cs.jsonObj && cs.jsonObj.url);
327
327
  const version = cs.version || (cs.jsonObj && cs.jsonObj.version);
328
+ const content = cs.content || cs.jsonObj.content;
328
329
 
329
330
  if (url) {
330
- this.addCodeSystemEntry(seenSystems, url, version);
331
+ this.addCodeSystemEntry(seenSystems, url, version, content);
331
332
  }
332
333
  }
333
334
  }
@@ -339,7 +340,7 @@ class MetadataHandler {
339
340
  const version = factory.version();
340
341
 
341
342
  if (url) {
342
- this.addCodeSystemEntry(seenSystems, url, version);
343
+ this.addCodeSystemEntry(seenSystems, url, version, factory.content());
343
344
  }
344
345
  }
345
346
  }
@@ -357,13 +358,16 @@ class MetadataHandler {
357
358
  * @param {string} url - Code system URL
358
359
  * @param {string} version - Code system version (may be null)
359
360
  */
360
- addCodeSystemEntry(seenSystems, url, version) {
361
+ addCodeSystemEntry(seenSystems, url, version, content) {
361
362
  if (!seenSystems.has(url)) {
362
363
  // Create new entry
363
364
  const entry = { uri: url };
364
365
  if (version) {
365
366
  entry.version = [{ code: version }];
366
367
  }
368
+ if (content) {
369
+ entry.content = content;
370
+ }
367
371
  seenSystems.set(url, entry);
368
372
  } else if (version) {
369
373
  // Add version to existing entry
@@ -371,6 +375,9 @@ class MetadataHandler {
371
375
  if (!entry.version) {
372
376
  entry.version = [];
373
377
  }
378
+ if (content) {
379
+ entry.content = content;
380
+ }
374
381
  // Check if version already exists
375
382
  if (!entry.version.some(v => v.code === version)) {
376
383
  entry.version.push({ code: version });
@@ -177,6 +177,12 @@ class SearchWorker extends TerminologyWorker {
177
177
  isMatch = false;
178
178
  break;
179
179
  }
180
+ } else if (param === 'url') { // exact match
181
+ const propValue = json.url;
182
+ if (propValue != searchValue) {
183
+ isMatch = false;
184
+ break;
185
+ }
180
186
  } else {
181
187
  // Standard partial text match
182
188
  const propValue = json[jsonProp];
@@ -44,6 +44,7 @@ class ValueSetChecker {
44
44
  valueSet;
45
45
  params;
46
46
  others = new Map();
47
+ indentCount = 0;
47
48
 
48
49
  constructor(worker, valueSet, params) {
49
50
  validateParameter(worker, "worker", TerminologyWorker);
@@ -302,8 +303,8 @@ class ValueSetChecker {
302
303
  this.seeValueSet();
303
304
  this.worker.opContext.addNote(this.valueSet, 'Analysing ' + this.valueSet.vurl + ' for validation purposes', this.indentCount);
304
305
  if (this.indentCount === 0) {
305
- this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary, this.indentCount);
306
- let vrs = this.params.verSummary;
306
+ this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary(), this.indentCount);
307
+ let vrs = this.params.verSummary();
307
308
  if (vrs) {
308
309
  this.worker.opContext.addNote(this.valueSet, 'Version Rules: ' + vrs, this.indentCount);
309
310
  }
@@ -1731,7 +1732,7 @@ class ValueSetChecker {
1731
1732
  let list = [];
1732
1733
  for (let filter of cset.filter) {
1733
1734
  let s = cset.filter.length > 1 ? "(" : "";
1734
- s = filter.prop+" "+filter.op+" "+filter.value;
1735
+ s = filter.property+" "+filter.op+" "+filter.value;
1735
1736
  s = s + (cset.filter.length > 1 ? ")" : "");
1736
1737
  list.push(s)
1737
1738
  }
@@ -316,7 +316,7 @@ class TerminologyWorker {
316
316
  for (const ext of supplementExtensions) {
317
317
  const supplementUrl = ext.valueString || ext.valueUri;
318
318
  if (supplementUrl && !cs.hasSupplement(this.opContext, supplementUrl)) {
319
- throw new TerminologyError(`ValueSet depends on supplement '${supplementUrl}' on ${cs.systemUri} that is not known`);
319
+ throw new TerminologyError(`ValueSet depends on supplement '${supplementUrl}' on ${cs.system} that is not known`);
320
320
  }
321
321
  }
322
322
  }
@@ -1,4 +1,5 @@
1
1
  const {VersionUtilities} = require("../../library/version-utilities");
2
+ const {Extensions} = require("../library/extensions");
2
3
 
3
4
  /**
4
5
  * Converts input TerminologyCapabilities to R5 format (modifies input object for performance)
@@ -14,7 +15,17 @@ function terminologyCapabilitiesToR5(jsonObj, sourceVersion) {
14
15
  }
15
16
 
16
17
  if (VersionUtilities.isR4Ver(sourceVersion)) {
17
- // R4 to R5: No major structural changes needed for TerminologyCapabilities
18
+ for (const cs of jsonObj.codeSystem || []) {
19
+ if (cs.content) {
20
+ let cnt = Extensions.readString("http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content");
21
+ if (cnt) {
22
+ delete cs.extensions;
23
+ cs.content = cnt;
24
+ }
25
+ }
26
+ }
27
+
28
+
18
29
  return jsonObj;
19
30
  }
20
31
 
@@ -125,6 +136,15 @@ function terminologyCapabilitiesR5ToR4(r5Obj) {
125
136
  if (r5Obj.versionAlgorithmCoding) {
126
137
  delete r5Obj.versionAlgorithmCoding;
127
138
  }
139
+ for (const cs of r5Obj.codeSystem || []) {
140
+ if (cs.content) {
141
+ if (!cs.extension) {
142
+ cs.extension = [];
143
+ }
144
+ cs.extension.push({"url" : "http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content", valueCode : cs.content});
145
+ delete cs.content;
146
+ }
147
+ }
128
148
 
129
149
  return r5Obj;
130
150
  }
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * parse-icd9.js
4
+ * Parses an ICD-9-CM text file into a FHIR R4 CodeSystem resource.
5
+ *
6
+ * Usage:
7
+ * node parse-icd9.js <input-file> [output-file]
8
+ *
9
+ * File format observed:
10
+ *
11
+ * GROUP HEADERS (two forms):
12
+ * [Roman numeral. ]DISPLAY (NNN-NNN) <- top-level chapter
13
+ * DISPLAY (NNN-NNN) <- sub-chapter / block
14
+ *
15
+ * ICD-9 CONCEPT HEADERS:
16
+ * NNN[.D...]<whitespace>DISPLAY
17
+ * (code is left-aligned; display follows after one or more spaces/tabs)
18
+ *
19
+ * CONTINUATION / ANNOTATION LINES:
20
+ * Lines that are neither a group header nor a code header and are not
21
+ * blank. They belong to the most recently opened concept.
22
+ * Keyword prefixes Includes: Excludes: Note: start a named section.
23
+ * Subsequent indented lines continue that section.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ // ── helpers ──────────────────────────────────────────────────────────────────
32
+
33
+ function normalise(str) {
34
+ return str.replace(/[ \t]+/g, ' ').trim();
35
+ }
36
+
37
+ // Group header: optional roman-numeral prefix + display text + (NNN-NNN)
38
+ // Also handles V-code ranges like (V01-V91) and E-code ranges (E800-E999)
39
+ const GROUP_HDR = /^(?:[IVXLCDM]+\.\s+)?(.*?)\s+\(([A-Z]?\d{1,3}-[A-Z]?\d{1,3})\)\s*$/;
40
+
41
+ // ICD-9 / V-code / E-code concept header: code at column 0, then 1+ spaces, then display
42
+ const CODE_HDR = /^([A-Z]?\d{2,3}(?:\.\d+)?)\s+(\S.*)/;
43
+
44
+ function isGroupHeader(line) {
45
+ return GROUP_HDR.test(line.trim());
46
+ }
47
+
48
+ function isCodeHeader(line) {
49
+ // Must start at column 0 (no leading whitespace)
50
+ return /^[A-Z]?\d/.test(line) && CODE_HDR.test(line);
51
+ }
52
+
53
+ function parseGroupHeader(line) {
54
+ const m = line.trim().match(GROUP_HDR);
55
+ if (!m) return null;
56
+ return { code: m[2], display: normalise(m[1]) };
57
+ }
58
+
59
+ function parseCodeHeader(line) {
60
+ const m = line.match(CODE_HDR);
61
+ if (!m) return null;
62
+ return { code: m[1].trim(), display: normalise(m[2]) };
63
+ }
64
+
65
+ // ── block collection ──────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Walk lines and emit raw blocks.
69
+ * Each block = { type: 'group'|'icd9', lines: string[] }
70
+ * The first line is the header; subsequent lines are body lines (still raw).
71
+ */
72
+ function collectBlocks(lines) {
73
+ const blocks = [];
74
+ let current = null;
75
+
76
+ const flush = () => { if (current) { blocks.push(current); current = null; } };
77
+
78
+ for (const raw of lines) {
79
+ const trimmed = raw.trim();
80
+
81
+ if (!trimmed) {
82
+ // blank line - keep current open; annotations can span blank lines
83
+ continue;
84
+ }
85
+
86
+ if (isGroupHeader(trimmed)) {
87
+ flush();
88
+ current = { type: 'group', lines: [trimmed] };
89
+ } else if (isCodeHeader(raw)) {
90
+ flush();
91
+ current = { type: 'icd9', lines: [raw] };
92
+ } else {
93
+ // continuation / annotation
94
+ if (current) current.lines.push(raw);
95
+ // lines before the first recognised header are silently dropped
96
+ }
97
+ }
98
+ flush();
99
+ return blocks;
100
+ }
101
+
102
+ // ── block -> record ────────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Given a list of body lines (after the header), split into named sections.
106
+ * Returns { description, includes, excludes, note } - each a plain string or undefined.
107
+ */
108
+ function parseSections(bodyLines) {
109
+ const lines = bodyLines.map(normalise).filter(Boolean);
110
+
111
+ const sections = { description: [], includes: [], excludes: [], note: [] };
112
+ let cur = 'description';
113
+
114
+ for (const line of lines) {
115
+ if (/^includes?:/i.test(line)) {
116
+ cur = 'includes';
117
+ const rest = line.replace(/^includes?:\s*/i, '').trim();
118
+ if (rest) sections.includes.push(rest);
119
+ } else if (/^excludes?:/i.test(line)) {
120
+ cur = 'excludes';
121
+ const rest = line.replace(/^excludes?:\s*/i, '').trim();
122
+ if (rest) sections.excludes.push(rest);
123
+ } else if (/^note:/i.test(line)) {
124
+ cur = 'note';
125
+ const rest = line.replace(/^note:\s*/i, '').trim();
126
+ if (rest) sections.note.push(rest);
127
+ } else {
128
+ sections[cur].push(line);
129
+ }
130
+ }
131
+
132
+ const join = arr => arr.join(' ').replace(/\s+/g, ' ').trim() || undefined;
133
+ return {
134
+ description : join(sections.description),
135
+ includes : join(sections.includes),
136
+ excludes : join(sections.excludes),
137
+ note : join(sections.note),
138
+ };
139
+ }
140
+
141
+ function parseBlock(block) {
142
+ let header;
143
+ if (block.type === 'group') {
144
+ header = parseGroupHeader(block.lines[0]);
145
+ } else {
146
+ header = parseCodeHeader(block.lines[0]);
147
+ }
148
+ if (!header) return null;
149
+
150
+ const sections = parseSections(block.lines.slice(1));
151
+
152
+ return {
153
+ code : header.code,
154
+ display : header.display,
155
+ isGroup : block.type === 'group',
156
+ ...sections,
157
+ };
158
+ }
159
+
160
+ // ── hierarchy ─────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Find the best (narrowest) parent for a given code from codes already seen.
164
+ * Parents always precede children in the source file.
165
+ */
166
+ function findParent(code, seenCodes) {
167
+ const isRange = /^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(code);
168
+
169
+ // Helper: strip leading letter and parse int
170
+ const numOf = s => parseInt(s.replace(/^[A-Z]/, ''), 10);
171
+
172
+ if (isRange) {
173
+ const [loStr, hiStr] = code.split('-');
174
+ const lo = numOf(loStr), hi = numOf(hiStr);
175
+ let best = null, bestSpan = Infinity;
176
+ for (const c of seenCodes) {
177
+ if (!/^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(c) || c === code) continue;
178
+ const [pLo, pHi] = c.split('-').map(numOf);
179
+ if (pLo <= lo && pHi >= hi) {
180
+ const span = pHi - pLo;
181
+ if (span < bestSpan) { best = c; bestSpan = span; }
182
+ }
183
+ }
184
+ return best;
185
+ }
186
+
187
+ // Decimal code e.g. "002.1" -> try "002"
188
+ const dotIdx = code.indexOf('.');
189
+ if (dotIdx !== -1) {
190
+ for (let len = code.length - 1; len >= dotIdx; len--) {
191
+ const candidate = code.substring(0, len);
192
+ if (seenCodes.includes(candidate)) return candidate;
193
+ }
194
+ const base = code.substring(0, dotIdx);
195
+ if (seenCodes.includes(base)) return base;
196
+ }
197
+
198
+ // Integer code e.g. "002" -> find narrowest containing range
199
+ const num = numOf(code);
200
+ let best = null, bestSpan = Infinity;
201
+ for (const c of seenCodes) {
202
+ if (!/^[A-Z]?\d{1,3}-[A-Z]?\d{1,3}$/.test(c)) continue;
203
+ const [pLo, pHi] = c.split('-').map(numOf);
204
+ if (pLo <= num && pHi >= num) {
205
+ const span = pHi - pLo;
206
+ if (span < bestSpan) { best = c; bestSpan = span; }
207
+ }
208
+ }
209
+ return best;
210
+ }
211
+
212
+ // ── FHIR CodeSystem builder ───────────────────────────────────────────────────
213
+
214
+ function buildFhirCodeSystem(records) {
215
+ const byCode = new Map(records.map(r => [r.code, r]));
216
+ const parentOf = new Map();
217
+ const seenCodes = [];
218
+
219
+ for (const r of records) {
220
+ parentOf.set(r.code, findParent(r.code, seenCodes));
221
+ seenCodes.push(r.code);
222
+ }
223
+
224
+ const childrenOf = new Map();
225
+ for (const r of records) {
226
+ const p = parentOf.get(r.code);
227
+ if (p) {
228
+ if (!childrenOf.has(p)) childrenOf.set(p, []);
229
+ childrenOf.get(p).push(r.code);
230
+ }
231
+ }
232
+
233
+ function buildConcept(code) {
234
+ const r = byCode.get(code);
235
+ const concept = { code: r.code, display: r.display };
236
+
237
+ const props = [];
238
+ if (r.isGroup) props.push({ code: 'notSelectable', valueBoolean: true });
239
+ if (r.description) props.push({ code: 'description', valueString: r.description });
240
+ if (r.includes) props.push({ code: 'includes', valueString: r.includes });
241
+ if (r.excludes) props.push({ code: 'excludes', valueString: r.excludes });
242
+ if (r.note) props.push({ code: 'note', valueString: r.note });
243
+ if (props.length) concept.property = props;
244
+
245
+ const kids = childrenOf.get(code);
246
+ if (kids?.length) concept.concept = kids.map(buildConcept);
247
+
248
+ return concept;
249
+ }
250
+
251
+ const roots = records.filter(r => !parentOf.get(r.code));
252
+
253
+ return {
254
+ resourceType : 'CodeSystem',
255
+ id : 'icd-9-cm',
256
+ url : 'http://hl7.org/fhir/sid/icd-9-cm',
257
+ version : '2015',
258
+ name : 'ICD9CM',
259
+ title : 'International Classification of Diseases, 9th Revision, Clinical Modification',
260
+ status : 'active',
261
+ content : 'complete',
262
+ hierarchyMeaning : 'is-a',
263
+ property : [
264
+ {
265
+ code : 'notSelectable',
266
+ uri : 'http://hl7.org/fhir/concept-properties#notSelectable',
267
+ description : 'Grouping code not intended for direct coding',
268
+ type : 'boolean',
269
+ },
270
+ { code: 'description', description: 'Additional descriptive text', type: 'string' },
271
+ { code: 'includes', description: 'Inclusion notes', type: 'string' },
272
+ { code: 'excludes', description: 'Exclusion notes', type: 'string' },
273
+ { code: 'note', description: 'Additional notes', type: 'string' },
274
+ ],
275
+ concept: roots.map(r => buildConcept(r.code)),
276
+ };
277
+ }
278
+
279
+ // ── main ──────────────────────────────────────────────────────────────────────
280
+
281
+ function countAll(concepts) {
282
+ if (!concepts) return 0;
283
+ return concepts.reduce((n, c) => n + 1 + countAll(c.concept), 0);
284
+ }
285
+
286
+ function main() {
287
+ const [,, inputFile, outputFile] = process.argv;
288
+ if (!inputFile) {
289
+ console.error('Usage: node parse-icd9.js <input-file> [output-file]');
290
+ process.exit(1);
291
+ }
292
+
293
+ const outFile = outputFile || path.join(path.dirname(inputFile), 'icd9-cm.json');
294
+
295
+ const text = fs.readFileSync(inputFile, 'utf8');
296
+ const lines = text.split(/\r?\n/);
297
+ console.log(`Read ${lines.length} lines`);
298
+
299
+ const blocks = collectBlocks(lines);
300
+ console.log(`Collected ${blocks.length} blocks`);
301
+
302
+ const records = blocks.map(parseBlock).filter(Boolean);
303
+ const groups = records.filter(r => r.isGroup).length;
304
+ console.log(`Parsed ${records.length} records (${groups} groups, ${records.length - groups} codes)`);
305
+
306
+ const cs = buildFhirCodeSystem(records);
307
+ console.log(`CodeSystem has ${countAll(cs.concept)} concepts`);
308
+
309
+ fs.writeFileSync(outFile, JSON.stringify(cs, null, 2), 'utf8');
310
+ console.log(`Written -> ${outFile}`);
311
+ }
312
+
313
+ main();