@cosyte/terminology 0.0.7 → 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,85 @@ 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
+
28
107
  - **The UCUM unit table `loadUcumEssence()` hands out was `readonly` to TypeScript only, so a unit's
29
108
  definition could be rewritten in place — and doing so fabricated a unit equivalence that outlived
30
109
  putting the table back** (`TERMINOLOGY-MUTABLE-ESSENCE-TABLE`). One table is shared by every caller
package/README.md CHANGED
@@ -208,8 +208,15 @@ _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`. The models the engine loads for you are frozen — the bundled unit table deeply so, atom by
212
- atom and definition by definition, because it is shared with the engine's own reducer. The AST and
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
213
220
  the reduction that `parseUcum` and `reduce` hand back are per-call values you own, and mutating one
214
221
  changes nothing for anyone else.
215
222
 
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
  }
@@ -4069,21 +4085,25 @@ function loadGems(source) {
4069
4085
  if (trimmed === "") continue;
4070
4086
  const fields = trimmed.split(/\s+/);
4071
4087
  if (fields.length !== 3) {
4072
- warnings.push({
4073
- code: "TERM_GEM_MALFORMED_ROW",
4074
- line: i + 1,
4075
- detail: `expected 3 whitespace-delimited fields, found ${String(fields.length)}`
4076
- });
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
+ );
4077
4095
  continue;
4078
4096
  }
4079
4097
  const [src, tgt, flagStr] = fields;
4080
4098
  const flags = decodeFlags(flagStr);
4081
4099
  if (flags === void 0) {
4082
- warnings.push({
4083
- code: "TERM_GEM_MALFORMED_ROW",
4084
- line: i + 1,
4085
- detail: "flag field is not a 5-digit code"
4086
- });
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
+ );
4087
4107
  continue;
4088
4108
  }
4089
4109
  const entry = { source: src, flags };
@@ -4094,11 +4114,12 @@ function loadGems(source) {
4094
4114
  else entries.set(src, [frozen]);
4095
4115
  count++;
4096
4116
  }
4117
+ for (const list of entries.values()) Object.freeze(list);
4097
4118
  return Object.freeze({
4098
4119
  direction: source.direction,
4099
4120
  ...source.version !== void 0 ? { version: source.version } : {},
4100
4121
  count,
4101
- entries,
4122
+ entries: frozenMap(entries, "a loaded GEM map's entries"),
4102
4123
  warnings: Object.freeze(warnings)
4103
4124
  });
4104
4125
  }
@@ -4246,11 +4267,13 @@ function parseRf2(content) {
4246
4267
  const groupN = Number(at(f, iGroup));
4247
4268
  const priorityN = Number(at(f, iPriority));
4248
4269
  if (source === "" || !Number.isFinite(groupN) || !Number.isFinite(priorityN)) {
4249
- warnings.push({
4250
- code: "TERM_COMPLEX_MAP_MALFORMED_ROW",
4251
- line: i + 1,
4252
- detail: "row is missing referencedComponentId / mapGroup / mapPriority"
4253
- });
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
+ );
4254
4277
  continue;
4255
4278
  }
4256
4279
  rows.push({
@@ -4293,7 +4316,7 @@ function loadComplexMap(input) {
4293
4316
  }
4294
4317
  const out = {
4295
4318
  count,
4296
- entries,
4319
+ entries: frozenMap(entries, "a loaded complex map's entries"),
4297
4320
  warnings
4298
4321
  };
4299
4322
  if (input.version !== void 0) out.version = input.version;
@@ -4587,9 +4610,9 @@ function loadRxNormGraph(source) {
4587
4610
  const ndcs = source.sat !== void 0 ? parseNdcs(source.sat, warnings) : /* @__PURE__ */ new Map();
4588
4611
  for (const list of edges.values()) Object.freeze(list);
4589
4612
  const out = {
4590
- concepts,
4591
- edges,
4592
- 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"),
4593
4616
  conceptCount: concepts.size,
4594
4617
  edgeCount,
4595
4618
  warnings: Object.freeze(warnings)
@@ -4753,7 +4776,7 @@ function approximateMatch(graph, query, options = {}) {
4753
4776
  }
4754
4777
 
4755
4778
  // src/index.ts
4756
- var VERSION = "0.0.7";
4779
+ var VERSION = "0.0.8";
4757
4780
 
4758
4781
  exports.COMPLEX_MAP_SOURCE_SYSTEM = COMPLEX_MAP_SOURCE_SYSTEM;
4759
4782
  exports.DIAGNOSTIC_CODES = DIAGNOSTIC_CODES;