@cosyte/terminology 0.0.6 → 0.0.8

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
@@ -25,6 +25,146 @@ this file is maintained by hand (Changesets handles the version bump and publish
25
25
 
26
26
  ### Fixed
27
27
 
28
+ - **A loaded GEM map, complex map and RxNorm drug graph froze their wrapper and handed out plain
29
+ `Map`s inside it, so whoever held one could delete a diagnosis mapping, add a target the steward's
30
+ file never authored, or empty a medication's ingredient edges** (`TERMINOLOGY-INNER-MAPS-UNSEALED`).
31
+ `Object.freeze` cannot reach a `Map` — its entries live in an internal slot rather than in
32
+ properties — so freezing the model left `set` / `delete` / `clear` working on every index it points
33
+ at, and the `readonly` on those fields was a compile-time claim only.
34
+
35
+ Measured on `dd8465b`, before any fix: one `push` into `loadGems(…).entries.get("0010")` made
36
+ `applyGem` answer with a target that is in no GEM file; `entries.delete("0010")` turned the same
37
+ source into a typed `TERM_CROSSWALK_UNMAPPED`; `graph.edges.clear()` left `ingredientsOf` answering
38
+ `targets: []` for every drug while `edgeCount` still reported the loaded figure; and
39
+ `graph.concepts.set(…)` put a concept the caller's release never shipped where navigation would
40
+ return it. The candidate and rule lists were nominally `readonly` in the same way — writing over
41
+ element `0` landed too.
42
+
43
+ A code system's `concepts`, a GEM's and a complex map's `entries`, and a drug graph's `concepts` /
44
+ `edges` / `ndcs` are each now a **read-only view** of a map rather than the map itself — those six
45
+ indexes, not every field this package types `ReadonlyMap` — and every list, entry and warning
46
+ reachable from one of those models is frozen. Reads are exactly a `Map`'s: `get`, `has`, `size`,
47
+ `keys`, `values`, `entries`, `forEach`, iteration, `new Map(view)`.
48
+
49
+ **Two behaviour changes to know before you write against one.** A view is not a `Map` instance, so
50
+ `instanceof Map` is now `false` on `CodeSystem.concepts`, a `GemMap`'s and a `ComplexMap`'s
51
+ `entries`, and a graph's `concepts` / `edges` / `ndcs`; it prints as a plain object, `Object.keys`
52
+ lists its own methods, and `JSON.stringify` renders it as `{"size":2}` where a `Map` rendered `{}`.
53
+ And a model carrying one therefore **cannot be structured-cloned** — `structuredClone` and
54
+ `worker.postMessage` raise a `DataCloneError`, `v8.serialize` a plain `Error`, where before each
55
+ produced a working copy. Clone what the other side needs instead: `new Map(cs.concepts)`. A loaded
56
+ `ConceptMap`, `ValueSet` or unit table carries no view and clones as it always did. That refusal is
57
+ deliberate rather than incidental. Hiding the view's methods would make the clone _succeed_ and
58
+ hand back a model
59
+ whose indexes were empty while `count` / `conceptCount` / `edgeCount` still reported the loaded
60
+ figures — this defect's own shape, arriving silently. It is pinned as a test, not left as prose.
61
+
62
+ Not being a `Map` is also what closes the second route rather than only the first: replacing a
63
+ `Map`'s three mutators leaves `Map.prototype.set.call(theMap, …)` reaching the internal slot
64
+ directly, and a view has no slot to reach. `CodeSystem.concepts` was sealed that weaker way — a
65
+ fabricated concept could be forced into a loaded release and `lookup` would return it — and is a
66
+ view now too.
67
+
68
+ **The bundled UCUM table is deliberately not part of this and is unchanged.** `atomByCode` and
69
+ `prefixByCode` stay real `Map`s whose `set` / `delete` / `clear` are replaced by a refusal, so an
70
+ entry forced through `Map.prototype` still lands there. That residual is bounded and stays
71
+ documented: `parseUcum` resolves an atom by scanning the frozen `atoms` array, never through a
72
+ lookup map, so a forced entry changes no validation, reduction or `ucumEqual` answer — only the
73
+ caller's own `get` is lied to.
74
+
75
+ The guarantee is that the loaded model does not change, not that a write throws: a named mutator
76
+ raises a `TypeError` in either mode, while a frozen list or entry throws in strict mode and is
77
+ silently ignored in sloppy mode, which the CJS build lets a caller observe. Every route is
78
+ therefore asserted by the **reading** it would have changed as well as by the refusal, and a
79
+ reachability sweep walks **all seven** `load*` models for anything still writable, so a field added
80
+ later is covered without anyone maintaining a list of routes. The UCUM table is pinned in that
81
+ sweep as the named exception rather than skipped, so converting it later has to be deliberate. The
82
+ sweep carries its own negative controls: a planted mutable object and a raw `Map` behind a frozen
83
+ wrapper both have to be reported.
84
+
85
+ - **`parseEssence`'s example imported it from `@cosyte/terminology/ucum/essence`, a subpath the
86
+ package's `exports` does not declare.** It is package-internal and not re-exported from the entry
87
+ point; the example says so and imports it the way this package's own code does. Consumers reach the
88
+ table through `loadUcumEssence()`, which is unchanged.
89
+
90
+ - **Three `/* v8 ignore */` hints in `src/ucum/essence.ts` were inert, and are now plain comments.**
91
+ The branches they claimed to exclude were reported uncovered either way — measured with and
92
+ without a `--` reason suffix and under `--coverage.experimentalAstAwareRemapping` — so the hints
93
+ stated a coverage exclusion that no run performed. The package's posture is the one
94
+ `vitest.config.ts` already records for `rxnorm/`: an unreachable `noUncheckedIndexedAccess`
95
+ fallback stays visibly uncovered under a directory floor of 90, rather than being hidden by an
96
+ assertion about reachability that the next reader inherits with no way to check it. No coverage
97
+ figure moved, which is what proves they were inert.
98
+
99
+ - **A GEM source with more than one combination scenario had no deterministic test**, so the
100
+ comparator that orders them was reached only when a `fast-check` draw happened to generate a second
101
+ scenario — `Array.prototype.sort` never calls one for a single-element array. Scenarios are
102
+ alternative valid representations of the same source, so their order is part of the answer. Covered
103
+ now by a fixture whose file order is deliberately scenario 2 first. Found while measuring this
104
+ slice; `vitest run --coverage --exclude 'test/property/**'` reports `crosswalk/` at 100% functions
105
+ with it and 95.45% without.
106
+
107
+ - **The UCUM unit table `loadUcumEssence()` hands out was `readonly` to TypeScript only, so a unit's
108
+ definition could be rewritten in place — and doing so fabricated a unit equivalence that outlived
109
+ putting the table back** (`TERMINOLOGY-MUTABLE-ESSENCE-TABLE`). One table is shared by every caller
110
+ and by the engine's own reducer, `essence.ts` froze nothing, and `reduce` memoizes each atom's
111
+ reduction against the atom object. So corrupting a loaded atom **before that atom's first
112
+ reduction** wrote the memo, and the reading survived restoring the table exactly as shipped.
113
+
114
+ Measured on `bf153cb`, and live on published `0.0.5` and `0.0.6` alike — `0.0.6` is that commit plus
115
+ a version bump. Defining the loaded litre as `1` with no prior
116
+ touch of `L`: `ucumEqual("L", "1")` and `ucumEqual("mg/L", "mg")` both answered `true` where the
117
+ same calls without it answered `false`, and `mmol/L` fell from `6.0221407599999985e+23 × m-3` to a
118
+ dimensionless `6.02214076e+20` — **a concentration comparing equal to a mass**. Two further routes
119
+ to the same readings on that build: writing the nested definition object in place
120
+ (`atom.value.unit = "1"`), and replacing an element of the `atoms` array, which is the one
121
+ `parseUcum` scans, so it hands the parser a forged atom without touching the memo at all.
122
+
123
+ This is the half that keying the memo on the atom **object** did not reach. That closed the route
124
+ where the atom was one you had _built_; this is the route where the atom is one you were _handed_,
125
+ and the consequence class is identical. Neither substitutes for the other.
126
+
127
+ The table is frozen when it is parsed, deeply: every atom, every atom's definition object, every
128
+ prefix, and both arrays. Shallow would have been worth nothing — freezing the atom alone leaves
129
+ `atom.value.unit`, which reproduces all three readings. `atomByCode` and `prefixByCode` refuse
130
+ `set`, `delete` and `clear`, which `Object.freeze` cannot do for a `Map`. `parseEssence` freezes
131
+ what it returns on the same rule.
132
+
133
+ **The guarantee is that the table does not change, not that a write throws.** `Object.freeze`
134
+ refuses a write by throwing in strict mode and ignoring it in sloppy mode, and this package ships a
135
+ CJS build a sloppy-mode caller can require; the table is the one that was parsed either way. The
136
+ tests assert the readings, and the `TypeError` only as how a strict-mode caller observes it.
137
+
138
+ **Two things this does not claim, both measured rather than reasoned.** `Map.prototype.set.call()`
139
+ can still force an entry into a lookup map — no `Map` can be made immutable in place — and what
140
+ that reaches is bounded and pinned in both directions: `parseUcum` resolves an atom by scanning
141
+ `atoms`, never through `atomByCode`, so a forced entry changes **no** reduction and no `ucumEqual`
142
+ answer, only what the caller's own `get` returns. And the values `parseUcum` and `reduce` hand back
143
+ are deliberately still per-call and mutable.
144
+
145
+ **No reduction changes.** 3,080 expressions built from the table — every atom bare, squared,
146
+ inverted and annotated; every prefix over rotating metric atoms; rotating ratios, products and
147
+ grouped powers; and the shapes the README and the tests name — plus 312 `ucumEqual` pairs, reduce
148
+ byte-identically on `bf153cb` and on this tree (3,393 output lines, one sha256).
149
+
150
+ One knock-on, stated because getting it wrong is easy: freezing the table removes **a** route to
151
+ `reduce`'s cyclic-definition guard, not the last one. Two routes that look like they should reach
152
+ it do not, and both are now pinned as tests — naming a table atom in a definition (it is resolved
153
+ against the loaded table, so your atom can _name_ one but never _be_ one; a self-referential `Pa`
154
+ taken off a table copy resolves to the shipped pascal and answers `1000 × g.m-1.s-2`), and
155
+ corrupting a loaded atom, which is what is now refused. The route that does reach it needs no table
156
+ at all: `UcumAtom.value` is `readonly` to TypeScript, which an **accessor** satisfies, and the
157
+ definition is read after the atom is marked in progress, so a getter can re-enter `reduce` for the
158
+ same atom. That is a supported public call, and the guard is pinned by it under a 100,000-byte atom
159
+ code — so all three of `reduce`'s messages are again asserted whole, against both `message` and
160
+ `err.stack`, with the caller's own code as the unbounded value in each.
161
+
162
+ Also here: the `/* v8 ignore next */` on `reduce`'s `atom.base` line is removed. It justified
163
+ itself with a property of the bundled table — every base atom there carries a `dim` — for a line
164
+ reachable through the exported surface with a caller-built `{ base: true }` atom that carries none.
165
+ That arm is answered on the atom's own code, is now covered by a test rather than excluded, and
166
+ changes nothing for the table's own base units.
167
+
28
168
  - **A `UnitNode` you assembled yourself could change how `reduce` and `ucumEqual` answer for a unit
29
169
  parsed out of the bundled UCUM table, for the rest of the process**
30
170
  (`TERMINOLOGY-ATOMMEMO-POISONING`). `reduce` is exported and takes a caller-built node, and
package/README.md CHANGED
@@ -208,7 +208,17 @@ _names_, listed under [What is bundled](#what-is-bundled).
208
208
  NDC you asked about, so log the `code` and the locus rather than the whole result — a code _in
209
209
  patient context_ can be PHI.
210
210
  - **Zero runtime dependencies. Dual ESM + CJS.** Node stdlib only; built with `tsup`, validated with
211
- `attw`. Immutable by construction (every returned value is deep-frozen).
211
+ `attw`. A release you load is frozen, its indexes included: a code system's concepts, a GEM's or a
212
+ complex map's entries and a drug graph's concepts, edges and NDCs are each a read-only **view** of a
213
+ map rather than the map itself, so nothing holding one can add, delete or clear an entry, whichever
214
+ way it tries. A view is not a `Map` instance, so a model carrying one cannot be `structuredClone`d
215
+ or posted to a worker — copy out what you need (`new Map(cs.concepts)`). The bundled unit table is frozen deeply, atom by atom and definition by
216
+ definition, because it is shared with the engine's own reducer; its two lookup maps refuse `set`,
217
+ `delete` and `clear` by name, and an entry forced past that through `Map.prototype` changes nothing
218
+ the engine reads, because `parseUcum` resolves an atom by scanning the frozen atom array rather than
219
+ through a lookup map. The AST and
220
+ the reduction that `parseUcum` and `reduce` hand back are per-call values you own, and mutating one
221
+ changes nothing for anyone else.
212
222
 
213
223
  ## What is bundled
214
224
 
package/dist/index.cjs CHANGED
@@ -567,6 +567,31 @@ function translate(sourceCoding, map) {
567
567
  );
568
568
  }
569
569
 
570
+ // src/common/frozen-map.ts
571
+ function frozenMap(source, what) {
572
+ const refuse = () => {
573
+ throw new TypeError(`Cannot mutate ${what} (it is immutable)`);
574
+ };
575
+ const view = {
576
+ get size() {
577
+ return source.size;
578
+ },
579
+ get: (key) => source.get(key),
580
+ has: (key) => source.has(key),
581
+ keys: () => source.keys(),
582
+ values: () => source.values(),
583
+ entries: () => source.entries(),
584
+ forEach: (callback, thisArg) => {
585
+ for (const [key, value] of source) callback.call(thisArg, value, key, view);
586
+ },
587
+ [Symbol.iterator]: () => source[Symbol.iterator](),
588
+ set: refuse,
589
+ delete: refuse,
590
+ clear: refuse
591
+ };
592
+ return Object.freeze(view);
593
+ }
594
+
570
595
  // src/codesystem/status.ts
571
596
  var defaultStatusMapper = (raw) => {
572
597
  switch (raw.trim().toUpperCase()) {
@@ -973,15 +998,6 @@ function parseRrf(source) {
973
998
  }
974
999
 
975
1000
  // src/codesystem/load.ts
976
- function sealConceptMap(map) {
977
- const guard = () => {
978
- throw new TypeError("Cannot mutate a loaded CodeSystem's concepts map (it is immutable)");
979
- };
980
- for (const method of ["set", "delete", "clear"]) {
981
- Object.defineProperty(map, method, { value: guard, writable: false, configurable: false });
982
- }
983
- return map;
984
- }
985
1001
  function loadCodeSystem(source) {
986
1002
  let concepts;
987
1003
  let warnings;
@@ -1019,7 +1035,7 @@ function loadCodeSystem(source) {
1019
1035
  }
1020
1036
  }
1021
1037
  out.count = concepts.size;
1022
- out.concepts = sealConceptMap(concepts);
1038
+ out.concepts = frozenMap(concepts, "a loaded CodeSystem's concepts map");
1023
1039
  out.warnings = Object.freeze(warnings);
1024
1040
  return Object.freeze(out);
1025
1041
  }
@@ -3733,7 +3749,29 @@ function parseEssence(xml) {
3733
3749
  atoms.sort((a, b) => b.code.length - a.code.length);
3734
3750
  const atomByCode = new Map(atoms.map((a) => [a.code, a]));
3735
3751
  const prefixByCode = new Map(prefixes.map((p) => [p.code, p]));
3736
- return { prefixes, atoms, atomByCode, prefixByCode };
3752
+ return freezeEssence({ prefixes, atoms, atomByCode, prefixByCode });
3753
+ }
3754
+ function refuseTableWrite() {
3755
+ throw new TypeError("the UCUM unit table is immutable");
3756
+ }
3757
+ function sealLookup(map) {
3758
+ return Object.defineProperties(map, {
3759
+ set: { value: refuseTableWrite },
3760
+ delete: { value: refuseTableWrite },
3761
+ clear: { value: refuseTableWrite }
3762
+ });
3763
+ }
3764
+ function freezeEssence(essence) {
3765
+ for (const atom of essence.atoms) {
3766
+ if (atom.value) Object.freeze(atom.value);
3767
+ Object.freeze(atom);
3768
+ }
3769
+ for (const prefix of essence.prefixes) Object.freeze(prefix);
3770
+ Object.freeze(essence.atoms);
3771
+ Object.freeze(essence.prefixes);
3772
+ sealLookup(essence.atomByCode);
3773
+ sealLookup(essence.prefixByCode);
3774
+ return Object.freeze(essence);
3737
3775
  }
3738
3776
  var cached;
3739
3777
  function loadUcumEssence() {
@@ -4047,21 +4085,25 @@ function loadGems(source) {
4047
4085
  if (trimmed === "") continue;
4048
4086
  const fields = trimmed.split(/\s+/);
4049
4087
  if (fields.length !== 3) {
4050
- warnings.push({
4051
- code: "TERM_GEM_MALFORMED_ROW",
4052
- line: i + 1,
4053
- detail: `expected 3 whitespace-delimited fields, found ${String(fields.length)}`
4054
- });
4088
+ warnings.push(
4089
+ Object.freeze({
4090
+ code: "TERM_GEM_MALFORMED_ROW",
4091
+ line: i + 1,
4092
+ detail: `expected 3 whitespace-delimited fields, found ${String(fields.length)}`
4093
+ })
4094
+ );
4055
4095
  continue;
4056
4096
  }
4057
4097
  const [src, tgt, flagStr] = fields;
4058
4098
  const flags = decodeFlags(flagStr);
4059
4099
  if (flags === void 0) {
4060
- warnings.push({
4061
- code: "TERM_GEM_MALFORMED_ROW",
4062
- line: i + 1,
4063
- detail: "flag field is not a 5-digit code"
4064
- });
4100
+ warnings.push(
4101
+ Object.freeze({
4102
+ code: "TERM_GEM_MALFORMED_ROW",
4103
+ line: i + 1,
4104
+ detail: "flag field is not a 5-digit code"
4105
+ })
4106
+ );
4065
4107
  continue;
4066
4108
  }
4067
4109
  const entry = { source: src, flags };
@@ -4072,11 +4114,12 @@ function loadGems(source) {
4072
4114
  else entries.set(src, [frozen]);
4073
4115
  count++;
4074
4116
  }
4117
+ for (const list of entries.values()) Object.freeze(list);
4075
4118
  return Object.freeze({
4076
4119
  direction: source.direction,
4077
4120
  ...source.version !== void 0 ? { version: source.version } : {},
4078
4121
  count,
4079
- entries,
4122
+ entries: frozenMap(entries, "a loaded GEM map's entries"),
4080
4123
  warnings: Object.freeze(warnings)
4081
4124
  });
4082
4125
  }
@@ -4224,11 +4267,13 @@ function parseRf2(content) {
4224
4267
  const groupN = Number(at(f, iGroup));
4225
4268
  const priorityN = Number(at(f, iPriority));
4226
4269
  if (source === "" || !Number.isFinite(groupN) || !Number.isFinite(priorityN)) {
4227
- warnings.push({
4228
- code: "TERM_COMPLEX_MAP_MALFORMED_ROW",
4229
- line: i + 1,
4230
- detail: "row is missing referencedComponentId / mapGroup / mapPriority"
4231
- });
4270
+ warnings.push(
4271
+ Object.freeze({
4272
+ code: "TERM_COMPLEX_MAP_MALFORMED_ROW",
4273
+ line: i + 1,
4274
+ detail: "row is missing referencedComponentId / mapGroup / mapPriority"
4275
+ })
4276
+ );
4232
4277
  continue;
4233
4278
  }
4234
4279
  rows.push({
@@ -4271,7 +4316,7 @@ function loadComplexMap(input) {
4271
4316
  }
4272
4317
  const out = {
4273
4318
  count,
4274
- entries,
4319
+ entries: frozenMap(entries, "a loaded complex map's entries"),
4275
4320
  warnings
4276
4321
  };
4277
4322
  if (input.version !== void 0) out.version = input.version;
@@ -4565,9 +4610,9 @@ function loadRxNormGraph(source) {
4565
4610
  const ndcs = source.sat !== void 0 ? parseNdcs(source.sat, warnings) : /* @__PURE__ */ new Map();
4566
4611
  for (const list of edges.values()) Object.freeze(list);
4567
4612
  const out = {
4568
- concepts,
4569
- edges,
4570
- ndcs,
4613
+ concepts: frozenMap(concepts, "a loaded RxNorm graph's concepts"),
4614
+ edges: frozenMap(edges, "a loaded RxNorm graph's edges"),
4615
+ ndcs: frozenMap(ndcs, "a loaded RxNorm graph's NDC index"),
4571
4616
  conceptCount: concepts.size,
4572
4617
  edgeCount,
4573
4618
  warnings: Object.freeze(warnings)
@@ -4731,7 +4776,7 @@ function approximateMatch(graph, query, options = {}) {
4731
4776
  }
4732
4777
 
4733
4778
  // src/index.ts
4734
- var VERSION = "0.0.6";
4779
+ var VERSION = "0.0.8";
4735
4780
 
4736
4781
  exports.COMPLEX_MAP_SOURCE_SYSTEM = COMPLEX_MAP_SOURCE_SYSTEM;
4737
4782
  exports.DIAGNOSTIC_CODES = DIAGNOSTIC_CODES;