From 4c7bc5877215c3a3f602faeb9f87c3f11c903ec8 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 3 Aug 2026 15:41:45 +0000 Subject: [PATCH 1/3] fix: make the published manifest installable, and stop VERSION lying Two defects that together made @cosyte/cli unusable, plus a third found while verifying the first. 1. CLI-UNINSTALLABLE-MANIFEST. 0.0.1 and 0.0.2 both published with all ten @cosyte/* siblings declared as file:vendor/*.tgz local paths. vendor/ is not in files and there is no bundledDependencies, so the tarball shipped none of them and every install route died with ENOENT on node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz. The siblings are now real registry ranges: @cosyte/hl7 ^0.0.7 and @cosyte/terminology ^0.0.9 as hard dependencies, the six breadth parsers plus @cosyte/transform ^0.0.4 as optionalDependencies. Verified by packing the tarball and installing it in a clean directory outside the repo (exit 0), then running both bins and importing the . subpath under ESM and CJS. Negative control: the published 0.0.2 still ENOENTs. @cosyte/fhir could NOT be made real and is not declared at all. It is not on the registry, and declaring it in any form alongside @cosyte/transform (whose @cosyte/fhir peer is mandatory) fails the whole install with ERESOLVE, as an optional dep and as an optional peer alike. Either alone installs clean; the pair does not. Do not attribute that to a missing peerDependenciesMeta.optional flag: measured across the suite that flag does not decide the outcome, and the mechanism is unexplained. It is retained as a devDependency on the vendored tarball so this repo's own FHIR and convert tests run. Consequence, stated on every consumer surface rather than left to be discovered: an installed copy has no FHIR support. FHIR parse/inspect/fmt/ validate and convert now degrade to a value-free CLI_PARSER_UNAVAILABLE (69) instead of crashing on a bare await import(). Adds loadOptionalPackage(detail, load) beneath loadOptional (which takes a CosyteFormat and hardcodes the word "parser", wrong for both cases) and loadFhir(), whose diagnostic says the package is not on the registry rather than "install it". test/absent-sibling.test.ts pins the mapping and adds a static guard that reds if a new bare import("@cosyte/fhir") or import("@cosyte/transform") appears in src/. 2. CLI-VERSION-DRIFT. src/core/version.ts exported "0.0.0" against a 0.0.2 manifest, confirmed in the published tarball, and reached two user-visible surfaces: cosyte --version and the MCP server's advertised serverInfo.version. The constant's own doc comment already claimed a sync step that did not exist. scripts/sync-version.mjs (ported from @cosyte/transform) now runs inside the version script, and test/sanity.test.ts compares the export against package.json. The assertions are fixed, not just the value, because those are what let the same defect through five sibling releases: docs-content/installation.md asserted typeof VERSION, true of every wrong value, and now asserts the exact version, which the sync script keeps in step; and the declaration's ": string" annotation that the script's pattern keys on is pinned by its own test. Verified that dropping the annotation reds only that assertion and makes the script exit 1 rather than silently no-op. 3. Pre-existing, and NOT fixed by either of the above: npx @cosyte/cli fails with "could not determine executable to run", because npx runs the bin matching the package name's last segment (cli) and this package ships cosyte and cosyte-mcp. Reproduced on the published 0.0.2 and on the fixed tarball, whose bin block is byte-identical. The docs now use the measured working form, npx --package @cosyte/cli cosyte. A cli bin alias would fix the short form and is deliberately not added, because npm install -g would then claim the name cli on the user's PATH; that trade is a founder call. --- .changeset/lucky-donkeys-invent.md | 33 ++++++ .github/dependabot.yml | 19 +-- CHANGELOG.md | 55 +++++++++ CLAUDE.md | 51 +++++++- README.md | 81 +++++++------ RELEASING.md | 180 ++++++++++++++++++----------- docs-content/installation.md | 79 +++++++++---- docs-content/limitations.md | 12 ++ docs-content/mcp.md | 13 ++- docs-content/troubleshooting.md | 14 ++- package.json | 22 ++-- pnpm-lock.yaml | 126 ++++++++++---------- scripts/sync-version.mjs | 98 ++++++++++++++++ scripts/vendor-refresh.sh | 19 ++- src/commands/convert.ts | 14 ++- src/core/parsers.ts | 78 +++++++++++-- src/core/version.ts | 17 ++- src/index.ts | 1 + test/absent-sibling.test.ts | 169 +++++++++++++++++++++++++++ test/sanity.test.ts | 39 ++++++- 20 files changed, 882 insertions(+), 238 deletions(-) create mode 100644 .changeset/lucky-donkeys-invent.md create mode 100644 scripts/sync-version.mjs create mode 100644 test/absent-sibling.test.ts diff --git a/.changeset/lucky-donkeys-invent.md b/.changeset/lucky-donkeys-invent.md new file mode 100644 index 0000000..83fdf5b --- /dev/null +++ b/.changeset/lucky-donkeys-invent.md @@ -0,0 +1,33 @@ +--- +"@cosyte/cli": patch +--- + +Make the published package installable, and stop `VERSION` lying about the release +(CLI-UNINSTALLABLE-MANIFEST, CLI-VERSION-DRIFT). + +`0.0.1` and `0.0.2` both published with all ten `@cosyte/*` siblings declared as `file:vendor/*.tgz` +local paths. `vendor/` is not in `files`, so the tarball shipped none of them and every install route +died with `ENOENT` on `node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz`. The siblings are now +real registry ranges: `@cosyte/hl7` and `@cosyte/terminology` as hard `dependencies`, the six breadth +parsers plus `@cosyte/transform` as `optionalDependencies`. Proven by packing the tarball and +installing it in a clean directory outside the repo, then running both bins and importing the `.` +subpath under ESM and CJS: the check a `npm publish --dry-run` cannot perform. + +`src/core/version.ts` exported `"0.0.0"` while `package.json` said `0.0.2`, so `cosyte --version` and +the MCP server's advertised `serverInfo.version` both lied; confirmed in the published tarball. +`scripts/sync-version.mjs` now runs inside the `version` script, and the test suite compares the +export against `package.json`. The two assertions that let the same defect through five sibling +releases are fixed too, not just the value: the docs smoke test asserted `typeof VERSION` and now +asserts the exact version, and the declaration's `: string` annotation that the sync script keys on is +pinned by its own test. + +One dependency could not be made real. `@cosyte/fhir` is not on the npm registry, and declaring it +alongside `@cosyte/transform` (which requires it) fails the whole install with `ERESOLVE`, so it is +not declared at all and `@cosyte/transform` is skipped by npm. FHIR `parse`/`inspect`/`fmt`/`validate` +and `convert` therefore degrade to a value-free `CLI_PARSER_UNAVAILABLE` (exit `69`) instead of +crashing on a bare `await import()`, with a diagnostic that says the package is not on the registry +rather than "install it". HL7 v2, `map-codes` and the six breadth formats work from a plain install. + +The docs also stop telling people to run `npx @cosyte/cli …`, which never worked and is not fixed by +any of the above: `npx` picks the executable matching the package name's last segment (`cli`), and +this package ships `cosyte` and `cosyte-mcp`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3965d8c..ae7e0aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,13 +18,18 @@ updates: # WHAT DEPENDABOT CAN ACTUALLY SEE HERE, stated plainly so this entry is not mistaken for # broader cover than it gives: # - # * The four hard `dependencies` (`@cosyte/hl7`, `@cosyte/fhir`, `@cosyte/transform`, - # `@cosyte/terminology`) and six of the seven `optionalDependencies` are - # `file:vendor/*.tgz` vendored tarballs. Dependabot does NOT resolve `file:` specs, so it - # will never propose a bump for any of them. They are refreshed by hand with - # `pnpm vendor:refresh`, and they become real `@cosyte/*` npm ranges at the publish flip - # (a published package cannot ship a `file:` dep). Until then, keeping them current is a - # human job and this config does not pretend otherwise. + # * The `@cosyte/*` siblings are now REAL npm ranges, not `file:vendor/*.tgz` tarballs, so + # Dependabot resolves and bumps them like any other dependency. That is a change: it + # previously could not see ANY of them. Covered now: the two hard `dependencies` + # (`@cosyte/hl7`, `@cosyte/terminology`) and seven of the eight `optionalDependencies` + # (`@cosyte/astm`, `ccda`, `dicom`, `mllp`, `ncpdp`, `x12`, `transform`). + # * `@cosyte/fhir` is the ONE that is still invisible, and it is invisible because it is not + # DECLARED, not because of a `file:` spec. It is not on the npm registry, so it cannot be a + # dependency of this package at all; it is carried as a `file:vendor/*.tgz` DEV dependency so + # this repo's own FHIR tests still run. Dependabot does not resolve `file:` specs, so that one + # stays a hand-refresh (`pnpm vendor:refresh`). An installed copy simply has no FHIR library, + # and the CLI reports `CLI_PARSER_UNAVAILABLE` rather than pretending. This config does not + # imply cover it does not give. # * `@modelcontextprotocol/sdk` (exact-pinned) is the one third-party RUNTIME dependency # Dependabot can see and bump. It is kept OUT of the group below by an explicit # `exclude-patterns`, so a runtime bump on the MCP agent surface lands as its own diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d92e0..e90980f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ this file is maintained by hand (Changesets handles the version bump and publish ### Fixed +- **`@cosyte/cli` can be installed from npm again (CLI-UNINSTALLABLE-MANIFEST).** `0.0.1` and `0.0.2` + both published with all ten `@cosyte/*` sibling packages declared as `file:vendor/*.tgz` local + paths. `vendor/` is not in `files` and there is no `bundledDependencies`, so the tarball shipped + none of them and every install route died on the first: `ENOENT ... +node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz`. The siblings are now real registry ranges: + `@cosyte/hl7` (`^0.0.7`) and `@cosyte/terminology` (`^0.0.9`) as hard `dependencies`, and the six + breadth parsers plus `@cosyte/transform` (`^0.0.4`) as `optionalDependencies`. Verified the way a + dry-run cannot: the packed tarball was installed in a clean directory outside the repo (exit `0`), + then both bins were run and the `.` subpath imported under ESM and CJS. + +- **`VERSION` and `cosyte --version` now report the release you are running (CLI-VERSION-DRIFT).** + `src/core/version.ts` exported `"0.0.0"` while `package.json` said `0.0.2`, and the constant's own + doc comment claimed it was "synced with `package.json#version` on release by the Changesets + `version` script" when no such step existed. Confirmed in the published tarball: `@cosyte/cli@0.0.2` + ships `VERSION = "0.0.0"` in `dist/index.mjs` and `dist/index.cjs`. It reached two user-visible + surfaces, `cosyte --version` and the MCP server's advertised `serverInfo.version`. `scripts/sync-version.mjs` + (ported from `@cosyte/transform`) now runs inside the `version` script, and `test/sanity.test.ts` + compares the export against `package.json` so a skipped sync goes red rather than shipping. + **The two assertions that let five bad releases through the sibling packages are fixed here, not + just the value:** `docs-content/installation.md` asserted `typeof VERSION` (true of every wrong + value) and now asserts the exact version, which the sync script keeps in step; and the declaration's + `: string` annotation, which the sync script's pattern keys on, is pinned by its own test, so + dropping it fails at `pnpm test` instead of silently at release time. + - **The `attw` publish gate no longer exits 0 on an untyped pack (ATTW-FALSE-GREEN-PORT).** The `attw` script was the bare CLI (`attw --pack . --profile node16`), and `@arethetypeswrong/cli@0.18.4`'s `getExitCode.js` opens with `if (!analysis.types) return 0`, @@ -90,6 +114,37 @@ this file is maintained by hand (Changesets handles the version bump and publish routed through a guarded loader to degrade to `CLI_PARSER_UNAVAILABLE` (exit `69`) rather than crash. `loadOptional()` cannot be reused unchanged: it takes a `CosyteFormat`, and `"transform"` is not one, and its diagnostic hardcodes the word "parser". Not undertaken here. + - **Superseded within this same unreleased set**, by the two entries at the top of this section: + the swap and the loader change were both carried out. Two claims that entry made did not survive + contact: the "name similarity" reading of the `@cosyte/fhir` `E403` was **retracted across the + ecosystem on 2026-08-03** (the cause is unexplained; do not assert one), and "npm tolerates an + `optionalDependency` that fails to resolve" is true only in isolation. Declaring **both** + `@cosyte/fhir` and `@cosyte/transform` optional fails the install outright with `ERESOLVE`, which + is why `@cosyte/fhir` ended up not declared at all rather than declared optional. + +### Changed + +- **FHIR support is unavailable in an npm-installed copy, and now says so instead of crashing.** + `@cosyte/fhir` is not on the npm registry, so it cannot be declared as a dependency at all; + measured, declaring it alongside `@cosyte/transform` (which requires it) fails the whole install + with `ERESOLVE`. `@cosyte/transform` is therefore skipped by npm as an unresolvable optional + dependency. Both were previously loaded with a bare `await import()`, which in an installed copy + would have surfaced a raw resolver error and a stack frame, so FHIR `parse`/`inspect`/`fmt`/`validate` + and `convert` now degrade to a value-free `CLI_PARSER_UNAVAILABLE` (exit `69`) with a diagnostic that + says the package is not on the registry rather than "install it". New `loadOptionalPackage(detail, load)` + under `loadOptional`, exported on the `.` subpath. `@cosyte/fhir` is retained as a `devDependency` + on the vendored tarball so this repo's own FHIR and `convert` tests still run. HL7 v2, `map-codes` + and the six breadth formats are unaffected and work from a plain install. + +- **The docs no longer tell you to run `npx @cosyte/cli …`, which never worked.** Separate from the + packaging defect and not fixed by it: `npx` runs the executable whose name matches the package + name's last segment, which would be `cli`, and this package ships `cosyte` and `cosyte-mcp`, so the + short form fails with `could not determine executable to run`. Reproduced on the published `0.0.2` + and on the fixed tarball, whose `bin` block is byte-identical. `README.md`, `docs-content/installation.md` + and both MCP registration snippets now use `npx --package @cosyte/cli cosyte …` / + `npx -y --package @cosyte/cli cosyte-mcp`, each measured working. A `cli` bin alias would fix the + short form and is deliberately not added, because `npm install -g` would then claim a command named + `cli` on the user's `PATH`. ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 523ca70..06c2844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,41 @@ subpath still exports a small programmatic `core` API (`detectFormat`, `EXIT`, ` **Pre-existing and correctly not retroactive:** commit subjects already on `main` may carry `U+2014`. The message half only runs on `pull_request`. History is not rewritten. +- **The published manifest is installable again, and `VERSION` no longer lies.** `0.0.1` and `0.0.2` + are permanently broken on npm (`file:vendor/*.tgz` specifiers in the published manifest, `ENOENT` on + every install route) and both shipped `VERSION = "0.0.0"` against a `0.0.2` manifest, which + `cosyte --version` and the MCP `serverInfo.version` both printed. Fixed together: + **`scripts/sync-version.mjs`** now runs inside the `version` script and rewrites **two** targets, + `src/core/version.ts` and the asserted literal in `docs-content/installation.md`; `test/sanity.test.ts` + compares the export against `package.json` **and** pins the declaration's `: string` shape, which the + script's pattern keys on. **Fix the ASSERTION, not just the value:** the docs block asserted + `typeof VERSION`, which is true of every wrong value and green-lit both bad releases. + **The dep swap is done except for one dependency, and the exception is the interesting part.** + `@cosyte/hl7` (`^0.0.7`) and `@cosyte/terminology` (`^0.0.9`) are hard deps at real ranges; the six + breadth parsers and `@cosyte/transform` (`^0.0.4`) are `optionalDependencies` at real ranges. + **`@cosyte/fhir` is not declared at all**, because it is not on the registry and, measured here, + declaring it in ANY form alongside `@cosyte/transform` (whose `@cosyte/fhir` peer is mandatory) + fails the whole install with `ERESOLVE`: optional dep and optional _peer_ both. Either one alone + installs clean; the pair does not. **Do not explain this with a missing + `peerDependenciesMeta.optional` flag** - measured across the suite, that flag does not decide the + outcome, and the mechanism is unexplained. `@cosyte/fhir` is kept as a **`devDependency`** on the + vendored tarball so this repo's own FHIR/`convert` tests run and so `transform`'s peer resolves in + the dev tree. Consequence, stated on every consumer surface rather than discovered: **an installed + copy has no FHIR support**, and FHIR `parse`/`inspect`/`fmt`/`validate` plus `convert` degrade to a + value-free `CLI_PARSER_UNAVAILABLE` (69). That required `loadOptionalPackage(detail, load)` beneath + `loadOptional` (which takes a `CosyteFormat` and hardcodes the word "parser", wrong for both cases) + plus `loadFhir()`; `test/absent-sibling.test.ts` includes a **static guard** that reds if a new bare + `import("@cosyte/fhir")` or `import("@cosyte/transform")` appears in `src/`. + **Verified by installing, which a `--dry-run` cannot do**: pack, `npm install` the tarball in a clean + directory outside the repo (exit 0), run both bins, import `.` under ESM and CJS. Negative control: + the published `0.0.2` still `ENOENT`s. Keep that step; it is checklist step 6 in `RELEASING.md`. + **A THIRD fault is real, pre-existing, and NOT fixed by any of this: `npx @cosyte/cli …` fails with + `could not determine executable to run`.** `npx` runs the bin matching the package name's last + segment (`cli`); this package ships `cosyte` and `cosyte-mcp`. The `bin` block is byte-identical to + the published `0.0.2`, so the swap cannot have changed it. Docs now say + `npx --package @cosyte/cli cosyte …` (measured working). **A `cli` bin alias would fix it and is + deliberately not added** - `npm install -g` would then claim the name `cli` on the user's `PATH`. + That trade is a founder call. - **Phase 7 shipped (CLI-7): release hardening: the final roadmap phase. The CLI is feature-complete.** No new runtime command surface; this phase is publish-readiness. **Fuzz** over the CLI's two input boundaries: the terminal (`run`, over argv plus stdin bytes) and the agent surface (`dispatchTool`, @@ -186,12 +221,16 @@ subpath still exports a small programmatic `core` API (`detectFormat`, `EXIT`, ` - **Phase 1 shipped** (§Phase 1). `cosyte parse ` for **HL7 v2** + **FHIR R4**, **content format autodetection** (conservative, fail-safe, never a guessed parser), the documented **exit-code contract**, and the **value-free diagnostic** channel with stable `CLI_*` codes. -- **Hard runtime deps (ADR 0021 + 0023):** `@cosyte/hl7` + `@cosyte/fhir` (parsers) and - `@cosyte/transform` + `@cosyte/terminology` (the higher-layer libs `convert`/`map-codes` wrap) are - **real `dependencies`** (an `npx` bin can't peer-depend), vendored as `pnpm pack` tarballs in - `vendor/` until PUB-FLIP: refresh with `pnpm vendor:refresh`. Pinned shas: hl7 `46d50eb`, fhir - `7a099b2`, transform `e6c4531`, terminology `e5ed368`. **Lazy-loaded per command.** Umbrella - `verify-policy.json` caps `cli` runtime deps at **4** (raised 2 → 4 for CLI-4, ADR 0023). +- **Hard runtime deps (ADR 0021 + 0023), as they stand AFTER the vendor → npm swap:** only + **`@cosyte/hl7` (`^0.0.7`) + `@cosyte/terminology` (`^0.0.9`)** are hard `dependencies` now, both + real registry ranges (an `npx` bin can't peer-depend). `@cosyte/transform` moved to + `optionalDependencies` and **`@cosyte/fhir` is undeclared** - see the swap note above for why, and + do not "restore" either without reading it. That is **2** hard runtime deps against an umbrella + `verify-policy.json` cap of **4**, so it is under the cap, not at it. **Lazy-loaded per command.** + On the `0.0.x` ladder `^0.0.7` permits no other version, so these are effectively exact pins, and + Dependabot now sees them (it never could while they were `file:` specs). `vendor/` survives only to + supply `@cosyte/fhir` as a **`devDependency`**; the other nine tarballs are refreshed by + `pnpm vendor:refresh` but wired to nothing, and removing them is a deliberate separate cleanup. Third-party CLI-core runtime deps: **zero**. The MCP server's **`@modelcontextprotocol/sdk`** is the CLI's only third-party runtime dep: declared in **`optionalDependencies`** (not `dependencies`), isolated behind `./mcp`, so it is outside the hard-closure cap (ADR 0024). diff --git a/README.md b/README.md index 6ab76fc..7e01a40 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,9 @@ It is a thin, honest skin over libraries that already own correctness ([`@cosyte and owns two disciplines of its own: a documented **exit-code contract** and a **value-free diagnostic** posture. -## Known issue: `0.0.1` is published but cannot be installed +## Known issue: `0.0.1` and `0.0.2` are published but cannot be installed -**`@cosyte/cli@0.0.1` is on npm and `npm install @cosyte/cli` fails.** It ends in an `ENOENT`, like -this: +**`npm install @cosyte/cli@0.0.1` and `@0.0.2` both fail.** They end in an `ENOENT`, like this: ``` npm error code ENOENT @@ -33,26 +32,33 @@ npm error path node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz npm error enoent ENOENT: no such file or directory ``` -`npx @cosyte/cli …` and `npm install -g @cosyte/cli` fail the same way, and so does the `npx`-based -MCP server registration below. There is no workaround from the consumer side. **Nothing is wrong with -your setup, and re-running it will not help.** +`npx` and `npm install -g` fail the same way on those two versions, and so does the `npx`-based MCP +server registration below. There is no workaround from the consumer side. **Nothing is wrong with your +setup, and re-running it will not help.** -**Why.** The published manifest declares its ten `@cosyte/*` sibling packages as local file paths +**Why.** Those manifests declared the ten `@cosyte/*` sibling packages as local file paths (`file:vendor/*.tgz`) rather than as npm version ranges. Those tarballs live in the git repository and -are deliberately not part of the published package, so npm resolves the paths against a directory that -does not exist in your `node_modules` and stops at the first one. It is a packaging defect in that -release, not a fault in any command. - -**A published version is immutable, so `0.0.1` will stay broken.** The fix has to arrive as a later -version, and it is blocked on something outside this package: `@cosyte/fhir` is not on npm yet (its -name was rejected by the registry as too similar to an existing package, which is a naming problem -rather than missing work), and `@cosyte/transform` is on npm but currently fails to install for the -same reason, because it peer-depends on `@cosyte/fhir`. `@cosyte/hl7` and `@cosyte/terminology` are -published and would swap over today. Until the naming issue is resolved, run the CLI from a source -checkout (`pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`). - -> **Status:** pre-alpha (`0.0.x`), published to npm at `0.0.1`, which **cannot be installed** (see -> above). The `cosyte` command wraps **all eight +are deliberately not part of the published package, so npm resolved the paths against a directory that +does not exist in your `node_modules` and stopped at the first one. It was a packaging defect in those +releases, not a fault in any command. + +**A published version is immutable, so `0.0.1` and `0.0.2` stay broken: install a later version.** The +sibling packages are now declared as real npm ranges, and the resulting tarball has been installed from +outside this repository and exercised, which is the check a `npm publish --dry-run` cannot perform. + +**One dependency could not be made real, and it costs you FHIR support.** `@cosyte/fhir` is not on the +npm registry, so it cannot be a dependency of this package at all; `@cosyte/transform` is on npm but +requires it, so npm skips that too. In an installed copy: + +- FHIR `parse` / `inspect` / `fmt` / `validate` and the `convert` command report a value-free + `CLI_PARSER_UNAVAILABLE` and exit `69`. They never guess, and they never blame your input. +- HL7 v2, `map-codes`, and the six breadth formats (X12, C-CDA, DICOM, NCPDP, ASTM, MLLP) all work. + +To use the FHIR commands today, run the CLI from a source checkout (`pnpm install && pnpm build`, then +invoke `dist/bin/cosyte.mjs`), where the FHIR library is supplied locally. + +> **Status:** pre-alpha (`0.0.x`). `0.0.1` and `0.0.2` are on npm and **cannot be installed** (see +> above); a later version fixes that. The `cosyte` command wraps **all eight > cosyte formats** (**HL7 v2**, **FHIR R4**, **X12**, **ASTM**, **NCPDP SCRIPT**, **C-CDA**, **DICOM**, > and **MLLP**) plus the `@cosyte/transform` and `@cosyte/terminology` higher-layer libraries, with > conservative content-format autodetection and a documented exit-code contract: @@ -90,17 +96,25 @@ checkout (`pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`). ## Run it -> **None of the commands in this section work today.** `@cosyte/cli` is on npm, but every install -> route fails with the `ENOENT` described in [the known issue](#known-issue-001-is-published-but-cannot-be-installed) -> above. They are recorded here as the shape install will take once a fixed version can ship. Until -> then, run it from a local checkout (`pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`). +```bash +npm install -g @cosyte/cli # put `cosyte` on your PATH +cosyte parse message.hl7 # format autodetected → HL7 v2 +``` + +Or without installing, using `npx`: ```bash -npx @cosyte/cli parse message.hl7 # no install; format autodetected → HL7 v2 -npm install -g @cosyte/cli # or put `cosyte` on your PATH -cosyte --help +npx --package @cosyte/cli cosyte parse message.hl7 ``` +> **`npx @cosyte/cli …` (the short form) does not work, and this is not the packaging defect above.** +> It fails with `could not determine executable to run`. When a package ships more than one +> executable, `npx` runs the one whose name matches the package name's last segment; that would be +> `cli`, and this package ships `cosyte` and `cosyte-mcp`. Naming the executable explicitly with +> `--package` is the supported form, and it works. A `cli` executable is deliberately **not** added as +> an alias: `npm install -g` would then put a command called `cli` on every user's `PATH`, which is far +> too generic a name to claim. + ## `cosyte parse` Read a file (or stdin via `-`), autodetect the format by content, and print the parsed model as typed @@ -274,15 +288,16 @@ subprocess**, not a hosted endpoint. Register it in an MCP client's config: ```json { "mcpServers": { - "cosyte": { "command": "npx", "args": ["-y", "@cosyte/cli", "mcp"] } + "cosyte": { "command": "npx", "args": ["-y", "--package", "@cosyte/cli", "cosyte-mcp"] } } } ``` -> This registration **does not work yet**: `npx` has to install the package first, and that install -> fails with the `ENOENT` described in -> [the known issue](#known-issue-001-is-published-but-cannot-be-installed) above. To run the MCP -> server today, point `command` at a built local checkout's `dist/bin/cosyte-mcp.mjs` instead. +> **`--package` is required, and the shorter `["-y", "@cosyte/cli", "mcp"]` does not work**: it fails +> with `could not determine executable to run`, for the `npx` executable-selection reason described +> under [Run it](#run-it) above. This also needs a version that can be installed at all, so not +> `0.0.1` or `0.0.2`. The `convert` tool reports `CLI_PARSER_UNAVAILABLE` from an npm install, because +> the FHIR library is not on the registry. `cosyte mcp` and the standalone `cosyte-mcp` bin both start the stdio server. It exposes four tools (`parse`, `validate`, `inspect`, `convert`), each calling the same command the terminal runs, so the CLI diff --git a/RELEASING.md b/RELEASING.md index 1134182..c8d949b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,79 +12,109 @@ gotchas worth not rediscovering. The suite-wide mechanics live in the umbrella Both are standing human gates. Everything up to them (the changeset, the version PR, the publish **dry-run**, provenance/OIDC config, this doc) is agent-shippable. The publish itself is not. -## Before the first publish is even possible: the vendor → npm dep swap +## The vendor → npm dep swap (done, with one dep that could not follow) `@cosyte/cli` is the only package in the suite that **hard-depends on its siblings**: an `npx`-invoked -`bin` cannot peer-depend on something the user pre-installed. Until `PUB-FLIP`, those deps are -**vendored `pnpm pack` tarballs** (`file:vendor/*.tgz`, ADR 0021/0023): +`bin` cannot peer-depend on something the user pre-installed. Those deps used to be **vendored +`pnpm pack` tarballs** (`file:vendor/*.tgz`, ADR 0021/0023). **A published package cannot ship a +`file:vendor/…tgz` dependency**, and two releases went out doing exactly that. -- **Hard `dependencies`** (capped at 4): `@cosyte/hl7`, `@cosyte/fhir`, `@cosyte/transform`, - `@cosyte/terminology`. -- **`optionalDependencies`** (lazy, outside the cap): the six breadth parsers - (`dicom`/`x12`/`ccda`/`ncpdp`/`astm`/`mllp`, ADR 0025) and `@modelcontextprotocol/sdk` (ADR 0024). +They are now real npm ranges, with the single exception of `@cosyte/fhir`, which is not on the +registry and therefore is not declared at all. `vendor/` survives only to supply `@cosyte/fhir` to +this repo's own test run, as a `devDependency`; `pnpm vendor:refresh` still refreshes the tarballs, +and the other nine are no longer wired to anything. -Refresh them with `pnpm vendor:refresh`. **At `PUB-FLIP` these `file:` specifiers must become real -`@cosyte/*` npm ranges**: a published package cannot ship a `file:vendor/…tgz` dependency. This swap -is a deliberate release step, not an automated one. +### ▶ THIS STEP WAS SKIPPED TWICE, AND `0.0.1` + `0.0.2` ARE BROKEN ON npm BECAUSE OF IT -### ▶ THIS STEP WAS SKIPPED, AND `0.0.1` IS BROKEN ON npm BECAUSE OF IT - -**`@cosyte/cli@0.0.1` published on 2026-07-29 with all ten `file:vendor/*.tgz` specifiers intact.** -`vendor/` is not in `files`, and there is no `bundledDependencies`, so the tarball ships none of -them. Every install route (`npm i`, `npm i -g`, `npx`) dies on the first one: +**Both published with all ten `file:vendor/*.tgz` specifiers intact.** `vendor/` is not in `files`, +and there is no `bundledDependencies`, so those tarballs ship none of them. Every install route +(`npm i`, `npm i -g`, `npx`) dies on the first one: ``` npm error code ENOENT npm error path node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz ``` -A published version is immutable (ADR 0001), so `0.0.1` cannot be repaired; the fix must be a later -version. **Two lessons, both cheap to act on:** - -1. **`npm publish --dry-run` cannot catch this, and the checklist implied it could.** A dry-run packs - a tarball; it never resolves that tarball's dependencies from a registry. The gate that would have - caught it is `npm install` of the packed tarball **from a directory outside this repo**. Add that - before the next publish, and do not treat a green dry-run as install-proof. -2. **The swap is now blocked, not merely pending.** `@cosyte/fhir` is unpublished (`E403`, an npm - name-similarity rejection, tracked as `FHIR-NPM-NAME`), and `@cosyte/transform@0.0.2` is published - but fails `E404` on its `@cosyte/fhir` peer, so neither can become a real range today. - `@cosyte/hl7` (`0.0.3`) and `@cosyte/terminology` (`0.0.4`) would swap over now, as would all six - breadth parsers. See "The route to an installable release" below. - -### The route to an installable release - -Verified against the live registry on 2026-07-30, not assumed: - -| dep | real range today? | note | -| --------------------- | ------------------------------- | ------------------------------ | -| `@cosyte/hl7` | yes (`0.0.3`) | hard dep | -| `@cosyte/terminology` | yes (`0.0.4`) | hard dep | -| six breadth parsers | yes (all published) | already `optionalDependencies` | -| `@cosyte/fhir` | **no** (`404`, `FHIR-NPM-NAME`) | hard dep | -| `@cosyte/transform` | **no** (`E404` on the peer) | hard dep | - -An installable release **is reachable before `FHIR-NPM-NAME` is resolved**, because npm tolerates an -`optionalDependency` that fails to resolve (measured: a `404` optional dep installs clean, exit `0`). -Moving `@cosyte/fhir` and `@cosyte/transform` to `optionalDependencies` with real ranges lets the -install succeed with those two simply absent. - -**It needs a code change first, and shipping without it would be worse than the current break.** -`@cosyte/hl7` and `@cosyte/fhir` are imported with a raw `await import()` in `src/core/parsers.ts` -(`@cosyte/fhir` at 327/412/542/602, `@cosyte/hl7` at 319/397/537/612) and `src/commands/convert.ts` -(179-180); only the six breadth parsers go -through `loadOptional()`, which is what turns an absent package into the value-free -`CLI_PARSER_UNAVAILABLE` (exit `69`). An absent `@cosyte/fhir` or `@cosyte/transform` would therefore -crash rather than degrade honestly. - -**`loadOptional()` cannot be reused as-is, so scope this as more than a one-line change.** Its -signature is `loadOptional(format: CosyteFormat, …)` and `"transform"` is **not** a `CosyteFormat` -(`src/core/format.ts:33` lists the eight wire formats only). Its diagnostic also hardcodes the word -"parser" (`the @cosyte/${format} parser is not installed`), which is wrong for `transform`, a -conversion library. So the work is: widen the helper (or add a sibling that takes a package name and -a diagnostic), route the `fhir` and `transform` imports through it, and give `transform` a diagnostic -that names it accurately. `@cosyte/hl7` stays a hard dep and needs no treatment. -`src/commands/map-codes.ts:177` also raw-imports `@cosyte/terminology`, which is harmless while -`terminology` publishes cleanly at `0.0.4`, but it is the same shape if that ever changes. +A published version is immutable (ADR 0001), so neither can be repaired; the fix ships as a later +version. **The lesson, and it is now a checklist step:** + +**`npm publish --dry-run` cannot catch this, and the checklist implied it could.** A dry-run packs a +tarball; it never resolves that tarball's dependencies from a registry. The gate that catches it is +`npm install` of the packed tarball **from a directory outside this repo**. That is step 6 below, and +a green dry-run is not install-proof. + +### The dependency swap, as actually done + +Verified against the live registry on 2026-08-03, and by installing the packed tarball in a clean +directory, not assumed: + +| dep | shipped as | resolves? | +| --------------------------- | ----------------------------------- | --------- | +| `@cosyte/hl7` | `dependencies`, `^0.0.7` | yes | +| `@cosyte/terminology` | `dependencies`, `^0.0.9` | yes | +| six breadth parsers | `optionalDependencies`, real ranges | yes | +| `@modelcontextprotocol/sdk` | `optionalDependencies`, `1.29.0` | yes | +| `@cosyte/transform` | `optionalDependencies`, `^0.0.4` | **no** | +| `@cosyte/fhir` | **not declared at all** | **no** | + +**On the `0.0.x` ladder `^0.0.7` is an exact pin** (caret on a `0.0.z` version allows no other +version), which is what we want pre-alpha: the CLI is tested against exactly those sibling releases, +and Dependabot now proposes each bump as its own reviewable PR. + +**Why `@cosyte/fhir` is not declared, rather than declared optional.** It is not on the registry +(`FHIR-NPM-NAME`, a persistent npm `E403`; the earlier "name similarity" reading was retracted +ecosystem-wide on 2026-08-03, so treat the cause as **unexplained** and do not assert one). Declaring +it at all breaks the install, which was measured here rather than reasoned about: + +| root manifest declares | `npm install` | +| --------------------------------------------------------- | ------------------------ | +| optional `@cosyte/fhir` alone | exit `0` | +| optional `@cosyte/transform` alone | exit `0` | +| **both** | **`ERESOLVE`, exit `1`** | +| optional `@cosyte/fhir` as an _optional peer_ + transform | **`ERESOLVE`, exit `1`** | + +So the two cannot both be named. `@cosyte/transform` is declared (it is real, and it starts +installing by itself the day `@cosyte/fhir` publishes, with no release needed here) and `@cosyte/fhir` +is not. **Do not attribute this to a missing `peerDependenciesMeta.optional` flag**: measured across +the suite, that flag does not decide the outcome (`synth` marks all seven peers optional and still +fails `ERESOLVE`; `deid` declares the same optional `@cosyte/fhir` peer and installs cleanly). The +mechanism is not yet explained. Record measurements, not theories. + +`@cosyte/fhir` is kept as a **`devDependency`** on the vendored tarball, so this repo's own FHIR and +`convert` tests still run, and so it satisfies `@cosyte/transform`'s peer in the dev tree. It is not +published, because `devDependencies` are not. + +### What an installed copy cannot do, and why that is not a crash + +With `@cosyte/fhir` and `@cosyte/transform` both absent, every code path that reaches for them +degrades to a value-free `CLI_PARSER_UNAVAILABLE` (exit `69`). That required a code change, and +shipping the manifest swap without it would have been worse than the install break: both were +imported with a bare `await import()`, so an install without them raised a raw resolver error and a +stack frame, which the CLI's value-free posture forbids. + +`loadOptional()` could not be reused as-is: its signature is `loadOptional(format: CosyteFormat, …)` +and `"transform"` is not a `CosyteFormat`, and its diagnostic hardcodes the word "parser", which is +wrong for a conversion library. So `src/core/parsers.ts` now has `loadOptionalPackage(detail, load)` +underneath it, plus a `loadFhir()` whose diagnostic says the package is not on the registry (rather +than `loadOptional`'s "install it", which would be false). `@cosyte/hl7` and `@cosyte/terminology` +stay hard deps and keep their bare imports, correctly. `test/absent-sibling.test.ts` holds this shut, +including a static guard that fails if any new bare `import("@cosyte/fhir")` or +`import("@cosyte/transform")` call site appears in `src/`. + +### The `npx @cosyte/cli` short form does not work, and the swap does not fix it + +Separate, pre-existing, and measured on both the published `0.0.2` and the fixed tarball: + +``` +$ npx @cosyte/cli --help +npm error could not determine executable to run +``` + +`npx` runs the executable whose name matches the package name's last segment, which would be `cli`; +this package ships `cosyte` and `cosyte-mcp`. `npx --package @cosyte/cli cosyte --help` works and is +what the docs tell people to run. **A `cli` bin alias would fix the short form and is deliberately not +added**, because `npm install -g` would then put a command named `cli` on every user's `PATH`. If that +trade is ever revisited, it is a founder call, not a packaging tidy-up. ## The pipeline @@ -134,17 +164,29 @@ would fail. ## The publish checklist (for the human at the gate) -Steps 1 and 3 are **already done**: the repo is public, and both bin names were taken by `0.0.1`. -Step 2 is the one that was skipped, and it is why this checklist now has a step 6. +Steps 1, 2 and 3 are **already done**. Step 2 is the one that was skipped twice, and it is why this +checklist has a step 6. 1. ~~`PUB-FLIP` the repo public (founder stop 1).~~ Done; the repo is public. -2. Swap the vendored `file:` deps for real `@cosyte/*` npm ranges; `pnpm install`; re-run - `scripts/verify.sh cli`. **Blocked today** on two of the four hard deps: see "The route to an - installable release" above for what is reachable without waiting. +2. ~~Swap the vendored `file:` deps for real `@cosyte/*` npm ranges.~~ Done; see "The dependency swap, + as actually done" above for what could and could not be swapped, and for the measurements behind + it. `@cosyte/fhir` stays undeclared until it is on the registry; when it publishes, declare it and + `@cosyte/transform` starts resolving too. 3. ~~Confirm the `cosyte` / `cosyte-mcp` bin names are free on npm.~~ Done; `0.0.1` owns both. 4. Land the release changeset; approve the **"Version Packages"** PR. 5. Approve the protected `release` environment to publish (founder stop 2). Provenance attaches automatically. 6. **Install the published version from outside this repo before calling it shipped**, in a clean - temp directory: `npm install @cosyte/cli@`. A green `--dry-run` does not prove this, and - `0.0.1` is the proof that it does not. + temp directory. A green `--dry-run` does not prove this, and `0.0.1` and `0.0.2` are the proof that + it does not. Run the binary too, because installing is not the same as working: + + ```bash + cd "$(mktemp -d)" && npm init -y >/dev/null + npm install @cosyte/cli@ # must exit 0 + node_modules/.bin/cosyte --version # must print , NOT 0.0.0 + node_modules/.bin/cosyte parse some.hl7 # must exit 0 + node -e 'import("@cosyte/cli").then(m=>console.log(m.VERSION))' + ``` + + `cosyte --version` is the check that catches a skipped `scripts/sync-version.mjs`: `0.0.1` and + `0.0.2` both printed `0.0.0`. diff --git a/docs-content/installation.md b/docs-content/installation.md index e5fae15..4784a71 100644 --- a/docs-content/installation.md +++ b/docs-content/installation.md @@ -6,17 +6,17 @@ sidebar_position: 1 # Installation -`@cosyte/cli` ships the `cosyte` command as a Node.js executable. The lowest-friction path is `npx`, -no global install required. +`@cosyte/cli` ships the `cosyte` command as a Node.js executable, alongside `cosyte-mcp`. A global +install is the simplest route; `npx` works too, with one flag noted under [Run it](#run-it). -> **Status:** pre-alpha (`0.0.x`), published to npm at `0.0.1`. **That release cannot be installed**, -> so none of the commands below work yet: see [Installing fails today](#installing-fails-today). They -> are the shape install will take once a fixed version can ship; until then, consume the CLI from -> source. +> **Status:** pre-alpha (`0.0.x`). **`0.0.1` and `0.0.2` cannot be installed at all**: see +> [If you are on 0.0.1 or 0.0.2](#if-you-are-on-001-or-002). The next release fixes that. FHIR support +> is unavailable in an installed copy for a separate reason, described under +> [What is not available from npm](#what-is-not-available-from-npm). -## Installing fails today +## If you are on 0.0.1 or 0.0.2 -`@cosyte/cli@0.0.1` is on npm, and `npm install @cosyte/cli` fails with an `ENOENT`: +Both are on npm, and `npm install @cosyte/cli` fails on either with an `ENOENT`: ``` npm error code ENOENT @@ -27,18 +27,31 @@ npm error enoent ENOENT: no such file or directory `npx` and `npm install -g` fail identically. There is no consumer-side workaround, and nothing is wrong with your environment. -The published manifest declares its ten `@cosyte/*` sibling packages as local file paths -(`file:vendor/*.tgz`) instead of npm version ranges. Those tarballs are not part of the published -package, so npm resolves the paths against a directory that is not there. It is a packaging defect in -that release; the commands themselves are unaffected. +Those manifests declared the ten `@cosyte/*` sibling packages as local file paths +(`file:vendor/*.tgz`) instead of npm version ranges. The tarballs are not part of the published +package, so npm resolved the paths against a directory that is not there. A published version is +immutable, so both stay broken; **install a later version.** -A published version is immutable, so `0.0.1` stays broken and the fix must ship as a later version. -That is currently blocked outside this package: `@cosyte/fhir` is not on npm (the registry rejected -the name as too similar to an existing package), and `@cosyte/transform` peer-depends on it, so it -fails to install for the same reason. `@cosyte/hl7` and `@cosyte/terminology` are published and would -swap over today. +## What is not available from npm -**Run it from source in the meantime:** +The siblings the CLI wraps are now real npm ranges, with one exception that is worth stating plainly +rather than discovering at runtime. + +**`@cosyte/fhir` is not on the npm registry.** It cannot be a dependency of this package, so an +installed `@cosyte/cli` has no FHIR library and: + +- `parse`, `inspect`, `fmt` and `validate` on FHIR input report `CLI_PARSER_UNAVAILABLE` and exit + `69`. They do not guess, and they do not fail as though your input were bad. +- `convert` reports the same, because it needs both `@cosyte/fhir` and `@cosyte/transform`, and + `@cosyte/transform` in turn requires `@cosyte/fhir`, so npm skips it as an unresolvable optional + dependency. + +Everything else works from a plain install: HL7 v2 (`@cosyte/hl7`), `map-codes` +(`@cosyte/terminology`), and the six breadth formats X12, C-CDA, DICOM, NCPDP, ASTM and MLLP, each an +optional dependency that reports `CLI_PARSER_UNAVAILABLE` if you deselect it. + +**To use the FHIR commands today, run the CLI from source**, where the FHIR library is supplied +locally: ```bash git clone https://github.com/cosyte/cli && cd cli @@ -53,17 +66,26 @@ node dist/bin/cosyte.mjs --help ## Run it +Install globally to put `cosyte` on your `PATH`: + ```bash -npx @cosyte/cli parse message.hl7 # no install: npx caches the package +npm install -g @cosyte/cli +cosyte --help ``` -Or install globally to put `cosyte` on your `PATH`: +Or run it without installing, naming the executable explicitly: ```bash -npm install -g @cosyte/cli -cosyte --help +npx --package @cosyte/cli cosyte parse message.hl7 ``` +> **The short form `npx @cosyte/cli …` does not work**, and it is a separate matter from the +> packaging defect above: it fails with `could not determine executable to run`. When a package ships +> more than one executable, `npx` runs the one whose name matches the package name's last segment, +> which would be `cli`; this package ships `cosyte` and `cosyte-mcp`. Use `--package` as above. A +> `cli` alias is deliberately not added, because `npm install -g` would then claim a command called +> `cli` on your `PATH`. + ## Programmatic API The same `core` the CLI uses is available as a small library (the `.` subpath): the format @@ -72,7 +94,16 @@ autodetector, the exit-code contract, and the value-free diagnostic types: ```ts runnable import { VERSION } from "@cosyte/cli"; -typeof VERSION; // => "string" +VERSION; // => "0.0.2" ``` -If that resolves, the install is good: head to the [Quickstart](./quickstart). +If that resolves and prints the release you installed, the install is good: head to the +[Quickstart](./quickstart). + +> **`VERSION` was wrong in `0.0.1` and `0.0.2`.** Both shipped exporting `0.0.0`, and `cosyte +> --version` printed that too. This page asserted only `typeof VERSION` back then, which is true of +> every wrong value, so it stayed green across both. The constant is now kept in lockstep with the +> manifest by the release tooling and compared against it in the test suite, and the literal above is +> rewritten by that same step. A published version is never re-published, so those two copies stay +> wrong on the registry; if you have one, read the manifest instead, which was always correct: +> `node -p "require('@cosyte/cli/package.json').version"`. diff --git a/docs-content/limitations.md b/docs-content/limitations.md index b395f34..c0f0071 100644 --- a/docs-content/limitations.md +++ b/docs-content/limitations.md @@ -74,6 +74,18 @@ These are **non-goals**, not missing features: named so nothing over-trusts the | `dicom` | not supported (binary model) | ✓ | not supported | ✓ | | `mllp` | ✓ (de-framed to HL7) | ✓ | not supported | not supported | + **The matrix says what the CLI implements. It is not a promise about what your install can reach**, + and for one format those differ today: + +- **FHIR is implemented but not available from an npm install.** `@cosyte/fhir` is not on the npm + registry, so it cannot be a dependency of this package. In an installed copy every FHIR cell above + reports a value-free `CLI_PARSER_UNAVAILABLE` and exits `69`, and so does **`convert`**, which needs + both `@cosyte/fhir` and `@cosyte/transform` (the latter requires the former, so npm skips it as an + unresolvable optional dependency). This is stated rather than discovered: the commands do not guess, + and they do not report your input as bad. Run the CLI from a source checkout to use them. The six + breadth formats are also optional dependencies, but they do resolve, so they are present unless you + install with `--omit=optional`. + - **No clinical interpretation, no unit conversion, no terminology content.** The CLI inherits these non-goals from the libraries it wraps. It never decides criticality, rescales a magnitude, or bundles SNOMED/LOINC/CPT. diff --git a/docs-content/mcp.md b/docs-content/mcp.md index 84437ab..2db7052 100644 --- a/docs-content/mcp.md +++ b/docs-content/mcp.md @@ -22,14 +22,19 @@ Add the server to your MCP client's configuration (Claude Desktop, an IDE agent, ```json { "mcpServers": { - "cosyte": { "command": "npx", "args": ["-y", "@cosyte/cli", "mcp"] } + "cosyte": { "command": "npx", "args": ["-y", "--package", "@cosyte/cli", "cosyte-mcp"] } } } ``` -> **This registration does not work yet.** `npx` installs the package first, and that install fails: -> see [Installing fails today](./installation#installing-fails-today). Point `command` at a built -> local checkout's `dist/bin/cosyte-mcp.mjs` instead. +> **`--package` is required here, and the shorter `["-y", "@cosyte/cli", "mcp"]` does not work.** It +> fails with `could not determine executable to run`, because `npx` picks the executable whose name +> matches the package name's last segment (`cli`) and this package ships `cosyte` and `cosyte-mcp`. +> Naming `cosyte-mcp` explicitly is the supported form. +> +> This registration also requires a version that can be installed at all: `0.0.1` and `0.0.2` cannot +> be. See [If you are on 0.0.1 or 0.0.2](./installation#if-you-are-on-001-or-002). FHIR tools are +> unavailable from an npm install for a further reason described alongside it. `cosyte mcp` starts the stdio server; the standalone `cosyte-mcp` bin is equivalent. diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index 13adb26..6a08109 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -38,10 +38,16 @@ names which formats _do_ support the operation. The command is never faked to a ## `CLI_PARSER_UNAVAILABLE` (exit 69) -The optional parser package for a recognised format is not installed. The six breadth parsers -(`dicom`/`x12`/`ccda`/`ncpdp`/`astm`/`mllp`) are `optionalDependencies`: installed by default, but if -one is absent (e.g. you installed with `--omit=optional`) the CLI degrades to this value-free signal -rather than crashing. Install the named `@cosyte/` package to use that format. +The library for a recognised format is not installed. The CLI degrades to this value-free signal +rather than crashing, and it never falls back to a guess. Two different causes: + +- **The six breadth parsers** (`dicom`/`x12`/`ccda`/`ncpdp`/`astm`/`mllp`) are `optionalDependencies`: + installed by default, but absent if you installed with `--omit=optional`. Install the named + `@cosyte/` package to use that format. +- **FHIR is always unavailable from an npm install**, and no reinstall changes that: `@cosyte/fhir` is + not on the npm registry, so it is not a dependency of this package. This also takes out `convert`, + which additionally needs `@cosyte/transform` (itself skipped, because it requires `@cosyte/fhir`). + The diagnostic says so. To use the FHIR commands, run the CLI from a source checkout. ## `CLI_NO_INPUT` (exit 66) diff --git a/package.json b/package.json index 930be9e..7049f64 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "vendor:refresh": "bash scripts/vendor-refresh.sh", "pack:docs": "bash scripts/build-docs-artifacts.sh", "attw": "node scripts/attw.mjs --profile node16", - "version": "changeset version && prettier --write package.json CHANGELOG.md", + "version": "changeset version && node scripts/sync-version.mjs && prettier --write package.json CHANGELOG.md src/core/version.ts", "release": "changeset publish", "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw", "prepare": "command -v simple-git-hooks >/dev/null 2>&1 && simple-git-hooks || true" @@ -105,6 +105,7 @@ "@arethetypeswrong/cli": "0.18.4", "@changesets/cli": "2.31.0", "@cosyte/eslint-config": "^0.0.4", + "@cosyte/fhir": "file:vendor/cosyte-fhir-0.0.0.tgz", "@cosyte/prettier-config": "^0.0.2", "@cosyte/test-utils": "^0.0.1", "@cosyte/tsconfig": "^0.0.2", @@ -123,18 +124,17 @@ "vitest": "4.1.4" }, "dependencies": { - "@cosyte/fhir": "file:vendor/cosyte-fhir-0.0.0.tgz", - "@cosyte/hl7": "file:vendor/cosyte-hl7-0.0.0.tgz", - "@cosyte/terminology": "file:vendor/cosyte-terminology-0.0.1.tgz", - "@cosyte/transform": "file:vendor/cosyte-transform-0.0.0.tgz" + "@cosyte/hl7": "^0.0.7", + "@cosyte/terminology": "^0.0.9" }, "optionalDependencies": { - "@cosyte/astm": "file:vendor/cosyte-astm-0.0.1.tgz", - "@cosyte/ccda": "file:vendor/cosyte-ccda-0.0.1.tgz", - "@cosyte/dicom": "file:vendor/cosyte-dicom-0.0.1.tgz", - "@cosyte/mllp": "file:vendor/cosyte-mllp-0.0.1.tgz", - "@cosyte/ncpdp": "file:vendor/cosyte-ncpdp-0.0.1.tgz", - "@cosyte/x12": "file:vendor/cosyte-x12-0.0.1.tgz", + "@cosyte/astm": "^0.0.11", + "@cosyte/ccda": "^0.0.8", + "@cosyte/dicom": "^0.0.8", + "@cosyte/mllp": "^0.0.8", + "@cosyte/ncpdp": "^0.0.8", + "@cosyte/transform": "^0.0.4", + "@cosyte/x12": "^0.0.8", "@modelcontextprotocol/sdk": "1.29.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56367c4..1003fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,18 +12,12 @@ importers: .: dependencies: - '@cosyte/fhir': - specifier: file:vendor/cosyte-fhir-0.0.0.tgz - version: file:vendor/cosyte-fhir-0.0.0.tgz '@cosyte/hl7': - specifier: file:vendor/cosyte-hl7-0.0.0.tgz - version: file:vendor/cosyte-hl7-0.0.0.tgz + specifier: ^0.0.7 + version: 0.0.7 '@cosyte/terminology': - specifier: file:vendor/cosyte-terminology-0.0.1.tgz - version: file:vendor/cosyte-terminology-0.0.1.tgz - '@cosyte/transform': - specifier: file:vendor/cosyte-transform-0.0.0.tgz - version: file:vendor/cosyte-transform-0.0.0.tgz(@cosyte/fhir@file:vendor/cosyte-fhir-0.0.0.tgz)(@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz) + specifier: ^0.0.9 + version: 0.0.9 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.4 @@ -34,6 +28,9 @@ importers: '@cosyte/eslint-config': specifier: ^0.0.4 version: 0.0.4(eslint@10.5.0)(typescript@5.9.3) + '@cosyte/fhir': + specifier: file:vendor/cosyte-fhir-0.0.0.tgz + version: file:vendor/cosyte-fhir-0.0.0.tgz '@cosyte/prettier-config': specifier: ^0.0.2 version: 0.0.2 @@ -84,23 +81,26 @@ importers: version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.4)(vite@7.3.6(@types/node@22.20.1)(tsx@4.21.0)) optionalDependencies: '@cosyte/astm': - specifier: file:vendor/cosyte-astm-0.0.1.tgz - version: file:vendor/cosyte-astm-0.0.1.tgz + specifier: ^0.0.11 + version: 0.0.11 '@cosyte/ccda': - specifier: file:vendor/cosyte-ccda-0.0.1.tgz - version: file:vendor/cosyte-ccda-0.0.1.tgz + specifier: ^0.0.8 + version: 0.0.8 '@cosyte/dicom': - specifier: file:vendor/cosyte-dicom-0.0.1.tgz - version: file:vendor/cosyte-dicom-0.0.1.tgz + specifier: ^0.0.8 + version: 0.0.8 '@cosyte/mllp': - specifier: file:vendor/cosyte-mllp-0.0.1.tgz - version: file:vendor/cosyte-mllp-0.0.1.tgz(@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz) + specifier: ^0.0.8 + version: 0.0.8(@cosyte/hl7@0.0.7) '@cosyte/ncpdp': - specifier: file:vendor/cosyte-ncpdp-0.0.1.tgz - version: file:vendor/cosyte-ncpdp-0.0.1.tgz + specifier: ^0.0.8 + version: 0.0.8 + '@cosyte/transform': + specifier: ^0.0.4 + version: 0.0.4(@cosyte/fhir@file:vendor/cosyte-fhir-0.0.0.tgz)(@cosyte/hl7@0.0.7) '@cosyte/x12': - specifier: file:vendor/cosyte-x12-0.0.1.tgz - version: file:vendor/cosyte-x12-0.0.1.tgz + specifier: ^0.0.8 + version: 0.0.8 '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.4.3) @@ -206,19 +206,16 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@cosyte/astm@file:vendor/cosyte-astm-0.0.1.tgz': - resolution: {integrity: sha512-MfB1X7QpiQyI5vV2KufjnpYYIrqcdDu2jmCxPICnBdYtk5Tt4OVazK13lBygOO4PIMt0LVyB0jESJ3bQo3hs8A==, tarball: file:vendor/cosyte-astm-0.0.1.tgz} - version: 0.0.1 + '@cosyte/astm@0.0.11': + resolution: {integrity: sha512-PywYU8Jq85n450mxARE+uqICwGIjOAR3Ffy2WhXDofcP6KQNV6bLWfpN9tdf9mEsARpqEzDwXIAi2Rm8u5Q4AA==} engines: {node: '>=22.0.0'} - '@cosyte/ccda@file:vendor/cosyte-ccda-0.0.1.tgz': - resolution: {integrity: sha512-PUb/BL2qiJ+Nqgqe5Xm7eff4gZDHxbvHeEbGgJkrjjZGcecNmMsiSJvnXVHRQ5/vGUMbAqhF1kyuYtxxcs3H1A==, tarball: file:vendor/cosyte-ccda-0.0.1.tgz} - version: 0.0.1 + '@cosyte/ccda@0.0.8': + resolution: {integrity: sha512-R5nh3wiAKhKxQ1/0NN5oC9KgD0k+iggWTmi/pTymSi3LNJL6MU4JtQhmqSCrG1b4GP+LK7UkDnnRkLnyhVrlEg==} engines: {node: '>=22.0.0'} - '@cosyte/dicom@file:vendor/cosyte-dicom-0.0.1.tgz': - resolution: {integrity: sha512-S2XMtFHCCFRwOvYgDjtY7FLiEpaG0Jc9hbZeU2qfp3gtk/uAN7lKTCXymXguvZKdR3R6ezo3Rl+GGxd5pHU4UQ==, tarball: file:vendor/cosyte-dicom-0.0.1.tgz} - version: 0.0.1 + '@cosyte/dicom@0.0.8': + resolution: {integrity: sha512-RIYIWhjhQsTmwbwGzMuFJ4Fj4RUJPzHESi+CjPQtQtRvVcN1zASEgXodMjEQFClTHEf8x3e3CaE3NOdtd68w6A==} engines: {node: '>=22.0.0'} '@cosyte/eslint-config@0.0.4': @@ -232,14 +229,12 @@ packages: version: 0.0.0 engines: {node: '>=22.0.0'} - '@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz': - resolution: {integrity: sha512-ZiHGyeIsVID2Y/DF/ZFKgYbFf0EcwaZzmDk3W3EuajExdIZ1Od/tkFmdTC6OI4Lw8gdzDyP1vVLzeim6cW3NMA==, tarball: file:vendor/cosyte-hl7-0.0.0.tgz} - version: 0.0.1 + '@cosyte/hl7@0.0.7': + resolution: {integrity: sha512-gv13ciAmPGpr3udGbKTFai3KkwQ585GCl9uu19oy0KzFJnHH7OoXzjbbL9OX1BkZhwIona20z04vU+RD646r9g==} engines: {node: '>=22.0.0'} - '@cosyte/mllp@file:vendor/cosyte-mllp-0.0.1.tgz': - resolution: {integrity: sha512-ii3g/YA4AcE2DxNrDLoKoWGnXn+aD4ReAFX6808MLhNZW9i8GplZJRjtogulTOUh8G13UIL/OluFZheNNFtvDw==, tarball: file:vendor/cosyte-mllp-0.0.1.tgz} - version: 0.0.1 + '@cosyte/mllp@0.0.8': + resolution: {integrity: sha512-9foRvjLUnpQtgWKxR5Vf46qjHnTHcxrzzW+ZkElmHsKGkwYmvscxj5kI5Rb95s58wASWZSLmUBKXXD1jV8dDYg==} engines: {node: '>=22.0.0'} peerDependencies: '@cosyte/hl7': '>=0.0.0' @@ -247,17 +242,15 @@ packages: '@cosyte/hl7': optional: true - '@cosyte/ncpdp@file:vendor/cosyte-ncpdp-0.0.1.tgz': - resolution: {integrity: sha512-7nM7HWE1XGq7ivnVeSN/VjjvJKAmufUqGUI8VOyx7U0VM9ynyqorXlSqVDZ4E2CEehiUVkqFe9gwYq1KBqwMdA==, tarball: file:vendor/cosyte-ncpdp-0.0.1.tgz} - version: 0.0.1 + '@cosyte/ncpdp@0.0.8': + resolution: {integrity: sha512-sczZGYcWwLR4uzANCTomclq/32cTrTj7rjR81OB9FbKNOPvIAXeDa91pgNojvRPJeU0KRywmXvtlQ9pwN67tGA==} engines: {node: '>=22.0.0'} '@cosyte/prettier-config@0.0.2': resolution: {integrity: sha512-nmplXQKNq5DZ+Su/tCUF6m9UJ9MOArhw5NG0X9yqi6B5zvxmVXtKMAX1N2bKkqpKhZqyQOR9ALGYVpTx022R/g==} - '@cosyte/terminology@file:vendor/cosyte-terminology-0.0.1.tgz': - resolution: {integrity: sha512-LztV7MsD36hHJKCLmOlSPdnyP3YYe84O01k4G1tXOpw5smQTpB70sujcet8SdgEw1R1vOok4kUi2IMb6j2QQpg==, tarball: file:vendor/cosyte-terminology-0.0.1.tgz} - version: 0.0.1 + '@cosyte/terminology@0.0.9': + resolution: {integrity: sha512-tztqLojYIsNY1HxY2CgsiCK+XX9ajzwfUecR+sY6dt0kfFGJNrGSisI7BrYcQSYZ4fjpsL+w44gkIfZmTnR2WA==} engines: {node: '>=22.0.0'} '@cosyte/test-utils@0.0.1': @@ -266,9 +259,9 @@ packages: peerDependencies: fast-check: ^3.0.0 - '@cosyte/transform@file:vendor/cosyte-transform-0.0.0.tgz': - resolution: {integrity: sha512-Vfx0cRA4nc9X3Bn5rCVarFZ2waGBjI+NCLbV31helXJ96V6gIrDGR/cyfsdRPfsUk/wUdcBqsS2Yv3EgJPOPfA==, tarball: file:vendor/cosyte-transform-0.0.0.tgz} - version: 0.0.0 + '@cosyte/transform@0.0.4': + resolution: {integrity: sha512-W6sTJjHppgurTMk1mcne/MDihmjjdncBIWyCqpqh1jozDZX9KkLknj5X3KiYVSVqSgxgkAtCPkzbl7AhK/2EpQ==} + version: 0.0.4 engines: {node: '>=22.0.0'} peerDependencies: '@cosyte/fhir': '>=0.0.0' @@ -289,9 +282,8 @@ packages: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 vitest: ^4.0.0 - '@cosyte/x12@file:vendor/cosyte-x12-0.0.1.tgz': - resolution: {integrity: sha512-VBuZhmMjn3jZjhKNShsbGyFG91iqv48X2VjO1W4dg0FCpEB7lYyOf/cyQSSZJx6FO7Bt2Dc+r276J2yfoqo3VQ==, tarball: file:vendor/cosyte-x12-0.0.1.tgz} - version: 0.0.1 + '@cosyte/x12@0.0.8': + resolution: {integrity: sha512-fABaspavxT4K8sMHsyPLjqVRzYVvs+LozedTXyITtKzXaUPvLqf9VhmNPmn7GPo7FxuaL7sLG3+r2qqKKp8Jqw==} engines: {node: '>=22.0.0'} '@es-joy/jsdoccomment@0.88.0': @@ -613,66 +605,79 @@ packages: resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.2': resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.2': resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.2': resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.2': resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.2': resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.2': resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.2': resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.2': resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.2': resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.2': resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.2': resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.2': resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.2': resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} @@ -2420,15 +2425,15 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@cosyte/astm@file:vendor/cosyte-astm-0.0.1.tgz': + '@cosyte/astm@0.0.11': optional: true - '@cosyte/ccda@file:vendor/cosyte-ccda-0.0.1.tgz': + '@cosyte/ccda@0.0.8': dependencies: '@xmldom/xmldom': 0.9.10 optional: true - '@cosyte/dicom@file:vendor/cosyte-dicom-0.0.1.tgz': + '@cosyte/dicom@0.0.8': optional: true '@cosyte/eslint-config@0.0.4(eslint@10.5.0)(typescript@5.9.3)': @@ -2444,30 +2449,31 @@ snapshots: '@cosyte/fhir@file:vendor/cosyte-fhir-0.0.0.tgz': {} - '@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz': {} + '@cosyte/hl7@0.0.7': {} - '@cosyte/mllp@file:vendor/cosyte-mllp-0.0.1.tgz(@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz)': + '@cosyte/mllp@0.0.8(@cosyte/hl7@0.0.7)': optionalDependencies: - '@cosyte/hl7': file:vendor/cosyte-hl7-0.0.0.tgz + '@cosyte/hl7': 0.0.7 optional: true - '@cosyte/ncpdp@file:vendor/cosyte-ncpdp-0.0.1.tgz': + '@cosyte/ncpdp@0.0.8': dependencies: fast-xml-parser: 5.10.1 optional: true '@cosyte/prettier-config@0.0.2': {} - '@cosyte/terminology@file:vendor/cosyte-terminology-0.0.1.tgz': {} + '@cosyte/terminology@0.0.9': {} '@cosyte/test-utils@0.0.1(fast-check@3.23.2)': dependencies: fast-check: 3.23.2 - '@cosyte/transform@file:vendor/cosyte-transform-0.0.0.tgz(@cosyte/fhir@file:vendor/cosyte-fhir-0.0.0.tgz)(@cosyte/hl7@file:vendor/cosyte-hl7-0.0.0.tgz)': + '@cosyte/transform@0.0.4(@cosyte/fhir@file:vendor/cosyte-fhir-0.0.0.tgz)(@cosyte/hl7@0.0.7)': dependencies: '@cosyte/fhir': file:vendor/cosyte-fhir-0.0.0.tgz - '@cosyte/hl7': file:vendor/cosyte-hl7-0.0.0.tgz + '@cosyte/hl7': 0.0.7 + optional: true '@cosyte/tsconfig@0.0.2': {} @@ -2481,7 +2487,7 @@ snapshots: vite: 7.3.6(@types/node@22.20.1)(tsx@4.21.0) vitest: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.4)(vite@7.3.6(@types/node@22.20.1)(tsx@4.21.0)) - '@cosyte/x12@file:vendor/cosyte-x12-0.0.1.tgz': + '@cosyte/x12@0.0.8': optional: true '@es-joy/jsdoccomment@0.88.0': diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs new file mode 100644 index 0000000..5fa3fda --- /dev/null +++ b/scripts/sync-version.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Sync the `VERSION` constant in `src/core/version.ts` with `package.json`'s `version`, and the + * asserted literal in the `docs-content/installation.md` smoke test along with it. + * + * Why this exists: `VERSION` is a public export AND the value `cosyte --version` prints AND the + * version the MCP server advertises in `SERVER_INFO`, but the bump is owned by Changesets, which + * only rewrites `package.json`. Without this step the package publishes a `VERSION` that *lies*. + * That is not hypothetical here: `@cosyte/cli@0.0.1` and `@cosyte/cli@0.0.2` are on the registry + * today exporting `VERSION === "0.0.0"`, verified in the published tarball. The constant's own doc + * comment already claimed it was "synced with `package.json#version` on release by the Changesets + * `version` script" while no such step existed. The `version` script (which the shared release + * workflow invokes as `pnpm run version`) runs `changeset version` and then this, so the bump and + * the constant always land in the same "Version Packages" commit. + * + * The guard against drift is `test/sanity.test.ts`, which compares the export against `package.json` + * at test time and separately pins the declaration's shape. Skipping this script makes that test go + * red: deliberately. + * + * TWO targets, because a type-only smoke test asserts nothing. `docs-content/installation.md`'s + * runnable block previously read `typeof VERSION; // => "string"`, which is true of every wrong + * value and green-lit both bad releases. It now asserts the exact version, so the doc/code agreement + * gate (`test/docs-content.test.ts`, which executes those blocks against the built `dist/`) is a real + * check rather than a tautology. That only stays true if this script keeps both in step. + * + * Idempotent; exits non-zero if either declaration is missing or ambiguous: a rename must not + * silently no-op, and a decoy declaration must not be rewritten ahead of the real one. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const root = new URL("..", import.meta.url); + +const { version } = JSON.parse(readFileSync(new URL("package.json", root), "utf8")); +if (typeof version !== "string" || version.length === 0) { + console.error("sync-version: package.json has no usable `version`"); + process.exit(1); +} + +/** + * One rewrite target: a file, the pattern locating its single version-bearing line, and a builder + * producing the replacement. `hint` is what a human is told when the pattern stops matching. + */ +const targets = [ + { + path: "src/core/version.ts", + // Column-anchored, and the `: string` annotation is part of the match on purpose: it is the + // shape test/sanity.test.ts pins, so the two cannot drift apart silently. + pattern: /^export const VERSION: string = "[^"]*";$/gm, + build: () => `export const VERSION: string = "${version}";`, + hint: 'could not find `export const VERSION: string = "...";`', + }, + { + path: "docs-content/installation.md", + // The asserted literal inside the runnable smoke-test block. + pattern: /^VERSION; \/\/ => "[^"]*"$/gm, + build: () => `VERSION; // => "${version}"`, + hint: 'could not find the runnable assertion `VERSION; // => "..."`', + }, +]; + +let wrote = false; + +for (const { path, pattern, build, hint } of targets) { + const url = new URL(path, root); + const source = readFileSync(url, "utf8"); + const matches = source.match(pattern); + + if (matches === null) { + console.error( + `sync-version: ${hint} in ${path}.\n` + + "The declaration was renamed or reformatted: update this script alongside it.", + ); + process.exit(1); + } + + if (matches.length !== 1) { + console.error( + `sync-version: found ${matches.length} version declarations in ${path}; expected exactly one.\n` + + "A column-0 decoy (e.g. in a comment) is ambiguous: remove it so the real one is unmistakable.", + ); + process.exit(1); + } + + // Pass a replacer *function*, not a replacement string: `String.prototype.replace` interprets + // `$&`, `$1`, `` $` ``, etc. in a replacement string, so a version like `1.2.3-$&x` would inject + // the matched text and corrupt the constant. A function's return value is inserted literally. + const updated = source.replace(pattern, build); + + if (updated === source) { + console.log(`sync-version: ${path} already ${version}`); + } else { + writeFileSync(url, updated); + console.log(`sync-version: ${path} -> ${version}`); + wrote = true; + } +} + +if (!wrote) console.log(`sync-version: nothing to do; everything already ${version}`); diff --git a/scripts/vendor-refresh.sh b/scripts/vendor-refresh.sh index a1c6fad..72a6eec 100644 --- a/scripts/vendor-refresh.sh +++ b/scripts/vendor-refresh.sh @@ -1,6 +1,23 @@ #!/usr/bin/env bash # -# vendor-refresh.sh: regenerate the vendored @cosyte sibling tarballs @cosyte/cli depends on. +# vendor-refresh.sh: regenerate the vendored @cosyte sibling tarballs. +# +# ▶ READ THIS FIRST: ONLY ONE OF THESE TARBALLS IS STILL WIRED TO ANYTHING. +# +# The vendor -> npm dependency swap has happened. package.json now declares real registry ranges for +# every sibling EXCEPT @cosyte/fhir, which is not on the npm registry and therefore cannot be a +# dependency of a published package at all. So: +# +# @cosyte/fhir file:vendor/cosyte-fhir-0.0.0.tgz, and it is a devDependency, NOT a runtime one. +# It exists so this repo's own FHIR + convert tests can run, and so that +# @cosyte/transform's mandatory @cosyte/fhir peer resolves in the dev tree. It is +# never published: devDependencies are not. +# everything else a real "^0.0.x" range from npm. The nine other tarballs below are refreshed by +# this script but referenced by nothing. They are kept because @cosyte/fhir's +# refresh shares this machinery, and removing them is a separate cleanup. +# +# When @cosyte/fhir publishes: declare it as a real range alongside the others, drop the +# devDependency, and this script can go. See RELEASING.md. # # @cosyte/cli is a `bin` package (the `cosyte` command), not a library. An `npx`-invoked bin CANNOT # rely on the user having pre-installed anything, so, unlike @cosyte/mllp (peer + optional on diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 8f027b7..1cd3ad2 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -28,6 +28,7 @@ import { EXIT } from "../core/exit-codes.js"; import type { Finding } from "../core/findings.js"; import { resolveInput } from "../core/input.js"; import type { RunDeps } from "../core/io.js"; +import { loadFhir, loadOptionalPackage } from "../core/parsers.js"; import { VALUE_FREE, type PhiPosture } from "../core/phi.js"; import type { RunResult } from "../core/result.js"; import { parseFailureResult } from "../core/wrap.js"; @@ -172,12 +173,21 @@ type ConvertOutcome = * `--unsafe-show-values`) flowing through the shared core/wrap chokepoint. The `toFhir` + serialize * step is deliberately outside it: `toFhir` never throws for a well-formed message, so any throw there * is an unexpected bug the dispatcher maps to `CLI_INTERNAL` (70), never mislabelled as a rejection. + * + * Two of the three libraries are **not guaranteed present in an installed copy**, so both loads go + * through the optional-package boundary and an absent one becomes a value-free + * `CLI_PARSER_UNAVAILABLE` (69) rather than an unhandled resolver error. `@cosyte/hl7` is a hard + * dependency and needs no such treatment. */ async function runConvert(bytes: Uint8Array, posture: PhiPosture): Promise { const [{ parseHL7 }, { toFhir }, { serializeResource }] = await Promise.all([ import("@cosyte/hl7"), - import("@cosyte/transform"), - import("@cosyte/fhir"), + loadOptionalPackage( + "the @cosyte/transform conversion library is not installed; install it to use convert " + + "(it is an optional dependency, and it in turn requires @cosyte/fhir)", + () => import("@cosyte/transform"), + ), + loadFhir(), ]); let msg: ReturnType; diff --git a/src/core/parsers.ts b/src/core/parsers.ts index adfc65d..037fc52 100644 --- a/src/core/parsers.ts +++ b/src/core/parsers.ts @@ -18,6 +18,8 @@ * @packageDocumentation */ +import type * as CosyteFhir from "@cosyte/fhir"; + import { CLI_CODES, CliError } from "./diagnostics.js"; import { EXIT } from "./exit-codes.js"; import { formatHl7Position, type Finding } from "./findings.js"; @@ -270,21 +272,77 @@ function anyError(findings: readonly Finding[]): boolean { * ``` */ export async function loadOptional(format: CosyteFormat, load: () => Promise): Promise { + return loadOptionalPackage( + `the @cosyte/${format} parser is not installed; install it to use this format ` + + `(it is an optional dependency)`, + load, + ); +} + +/** + * The same graceful-degradation path as {@link loadOptional}, keyed on a **package name** rather than + * a {@link CosyteFormat}, with the diagnostic supplied by the caller. + * + * Two of the packages the CLI lazy-loads are not wire-format parsers and so cannot go through + * {@link loadOptional}: `@cosyte/transform` (a conversion library, not one of the eight formats, so + * calling it a "parser" in the diagnostic would be wrong) and `@cosyte/fhir` (a format, but absent for + * a reason no reinstall fixes, which the message has to say). Both are reachable from a published + * install only if the user already has them, so both must degrade to a value-free + * `CLI_PARSER_UNAVAILABLE` (exit `69`) instead of surfacing a raw resolver error and a stack frame. + * + * @template T - The imported module's type. + * @param detail - The value-free diagnostic detail shown when the package is absent. + * @param load - A thunk performing the dynamic import. + * @returns The imported module. + * @throws {CliError} `CLI_PARSER_UNAVAILABLE` (exit `69`) when the package is absent; any other error + * propagates unchanged. + * @example + * ```ts + * import { loadOptionalPackage } from "@cosyte/cli"; + * + * await loadOptionalPackage("the conversion library is not installed", () => + * import("@cosyte/transform"), + * ); + * ``` + */ +export async function loadOptionalPackage(detail: string, load: () => Promise): Promise { try { return await load(); } catch (e) { if (isModuleNotFound(e)) { - throw new CliError( - CLI_CODES.CLI_PARSER_UNAVAILABLE, - EXIT.UNAVAILABLE, - `the @cosyte/${format} parser is not installed; install it to use this format ` + - `(it is an optional dependency)`, - ); + throw new CliError(CLI_CODES.CLI_PARSER_UNAVAILABLE, EXIT.UNAVAILABLE, detail); } throw e; } } +/** + * Why `@cosyte/fhir` is absent, in the words a user can act on. It is not on the npm registry, so it + * cannot be a declared dependency of this package at all, and no reinstall of `@cosyte/cli` will + * produce it. Value-free by construction: a package name and a statement of fact, no input echoed. + */ +const FHIR_UNAVAILABLE = + "the @cosyte/fhir parser is not installed, and it is not currently on the npm registry, " + + "so it cannot be installed from there; FHIR support is unavailable in this install"; + +/** + * Load `@cosyte/fhir`, degrading to a value-free `CLI_PARSER_UNAVAILABLE` when it is absent. + * + * Every FHIR branch in this file and the `convert` command go through here, so there is exactly one + * place that decides what an absent FHIR library does. It is deliberately not re-exported from the + * package entry point: consumers get {@link loadOptionalPackage}, not this repo's own wiring. + * + * @returns The `@cosyte/fhir` module. + * @throws {CliError} `CLI_PARSER_UNAVAILABLE` (exit `69`) when the package is absent. + * @example + * ```ts + * const { parseResource } = await loadFhir(); + * ``` + */ +export async function loadFhir(): Promise { + return loadOptionalPackage(FHIR_UNAVAILABLE, () => import("@cosyte/fhir")); +} + /** True iff `e` is a "module not found" failure, by Node's `code`, or the standard resolver message. */ function isModuleNotFound(e: unknown): boolean { if (typeof e !== "object" || e === null) return false; @@ -324,7 +382,7 @@ export async function parseFormat(format: CosyteFormat, bytes: Uint8Array): Prom }; } case "fhir": { - const { parseResource, serializeResource } = await import("@cosyte/fhir"); + const { parseResource, serializeResource } = await loadFhir(); const text = decode(bytes); const { resource, issues } = parseResource(text); const model: unknown = JSON.parse(serializeResource(resource)); @@ -409,7 +467,7 @@ export async function inspectFormat( }; } case "fhir": { - const { parseResource, resourceType, readBundle } = await import("@cosyte/fhir"); + const { parseResource, resourceType, readBundle } = await loadFhir(); const { resource, issues } = parseResource(decode(bytes)); const rt = resourceType(resource) ?? null; if (rt === "Bundle") { @@ -539,7 +597,7 @@ export async function fmtFormat(format: CosyteFormat, bytes: Uint8Array): Promis return { output: msg.toString(), warningCount: msg.warnings.length }; } case "fhir": { - const { parseResource, serializeResource } = await import("@cosyte/fhir"); + const { parseResource, serializeResource } = await loadFhir(); const { resource, issues } = parseResource(decode(bytes)); return { output: serializeResource(resource), warningCount: issues.length }; } @@ -599,7 +657,7 @@ export async function fmtFormat(format: CosyteFormat, bytes: Uint8Array): Promis export async function validateFormat(format: CosyteFormat, bytes: Uint8Array): Promise { switch (format) { case "fhir": { - const { parseResource, validateResource } = await import("@cosyte/fhir"); + const { parseResource, validateResource } = await loadFhir(); const { resource, issues } = parseResource(decode(bytes)); const validation = validateResource(resource); const findings: Finding[] = [ diff --git a/src/core/version.ts b/src/core/version.ts index 3c778a9..c525510 100644 --- a/src/core/version.ts +++ b/src/core/version.ts @@ -1,7 +1,11 @@ /** - * The CLI version string, synced with `package.json#version` on release by the Changesets `version` - * script. Kept in its own module so both the programmatic entry point and the dispatcher share one - * source of truth. + * The CLI version string, synced with `package.json#version` on release by `scripts/sync-version.mjs`, + * which the `version` script runs immediately after `changeset version`. Kept in its own module so + * both the programmatic entry point and the dispatcher share one source of truth. + * + * The declaration below is rewritten by that script and is matched at column 0, annotation included. + * Renaming it or reflowing it makes the script exit non-zero rather than silently no-op; the shape is + * pinned by `test/sanity.test.ts`, and the value is compared against `package.json` there too. * * @packageDocumentation */ @@ -9,11 +13,14 @@ /** * The `@cosyte/cli` version. On the uniform `v0.0.x-until-first-alpha` ladder. * + * Read by `cosyte --version` and by the MCP server's advertised `SERVER_INFO`, so it is a + * user-visible surface on both front doors, not merely an export. + * * @example * ```ts * import { VERSION } from "@cosyte/cli"; * - * typeof VERSION; // => "string" + * console.log(`cosyte ${VERSION}`); * ``` */ -export const VERSION = "0.0.0"; +export const VERSION: string = "0.0.2"; diff --git a/src/index.ts b/src/index.ts index 56366bd..6160111 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export { validateFormat, deframeMllp, loadOptional, + loadOptionalPackage, valueFreeLocator, type Op, type ParseResult, diff --git a/test/absent-sibling.test.ts b/test/absent-sibling.test.ts new file mode 100644 index 0000000..74accb5 --- /dev/null +++ b/test/absent-sibling.test.ts @@ -0,0 +1,169 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { CliError, errorResult } from "../src/core/diagnostics.js"; +import { EXIT } from "../src/core/exit-codes.js"; +import { loadFhir, loadOptionalPackage } from "../src/core/parsers.js"; + +/** + * The published manifest cannot declare `@cosyte/fhir` at all (it is not on the npm registry), and + * `@cosyte/transform` is an `optionalDependency` npm skips today because its own `@cosyte/fhir` peer + * cannot resolve. So in a real `npm install @cosyte/cli` **both are absent**, and every code path that + * reaches for them has to degrade to a value-free `CLI_PARSER_UNAVAILABLE` (exit `69`) instead of + * surfacing a raw module-resolution error and a stack frame. + * + * Before this suite existed those two packages were loaded with a bare `await import(...)`, so an + * install without them crashed. Two complementary guards, because neither alone is enough: + * + * 1. The **mapping**: a realistic resolver error becomes the value-free CLI error, with wording that + * tells the truth about *why* each package is missing. + * 2. The **static guard**: no bare `import("@cosyte/fhir")` or `import("@cosyte/transform")` survives + * anywhere in `src/` outside the one loader that catches it. This is the regression that matters, + * and it is the one a behavioural test cannot see: a newly added call site is simply a code path + * no test happens to exercise. + * + * The end-to-end proof is the clean-room install in `RELEASING.md`: pack the tarball, install it in a + * directory outside this repo (where neither package resolves), and run both bins. + */ + +/** The error Node's ESM resolver actually raises for a package that is not installed. */ +function moduleNotFound(specifier: string): Error { + const e = new Error(`Cannot find package '${specifier}' imported from /app/dist/index.mjs`); + (e as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND"; + return e; +} + +describe("an absent @cosyte/fhir degrades to a value-free CLI_PARSER_UNAVAILABLE", () => { + it("maps the resolver error to CLI_PARSER_UNAVAILABLE / 69", async () => { + const mapped = await loadOptionalPackage("detail", () => + Promise.reject(moduleNotFound("@cosyte/fhir")), + ).catch((e: unknown) => e); + expect(mapped).toBeInstanceOf(CliError); + expect((mapped as CliError).code).toBe("CLI_PARSER_UNAVAILABLE"); + expect((mapped as CliError).exit).toBe(EXIT.UNAVAILABLE); + }); + + it("resolves the real package when it IS present (the dev tree vendors it)", async () => { + const mod = await loadFhir(); + expect(mod.parseResource).toBeTypeOf("function"); + }); + + it("renders as exit 69 with no stack frame and no input value on stderr", () => { + const r = errorResult( + new CliError("CLI_PARSER_UNAVAILABLE", EXIT.UNAVAILABLE, "the @cosyte/fhir parser is absent"), + ); + expect(r.exit).toBe(EXIT.UNAVAILABLE); + expect(r.stdout).toBe(""); + expect(r.stderr).toContain("CLI_PARSER_UNAVAILABLE"); + expect(r.stderr).not.toMatch(/\s+at\s+\S+:\d+/); + }); + + it("says @cosyte/fhir is not on the registry, so the message is actionable", async () => { + // `loadOptional("fhir", ...)` would say "install it (it is an optional dependency)", which is + // false: it cannot be installed from npm at all. The dedicated wording is the point. + const err = (await loadFhirWithAbsentPackage().catch((e: unknown) => e)) as CliError; + expect(err.message).toContain("not currently on the npm registry"); + expect(err.message).not.toContain("it is an optional dependency"); + }); + + it("propagates an unrelated error unchanged (not every failure is 'unavailable')", async () => { + await expect( + loadOptionalPackage("detail", () => Promise.reject(new Error("a real library bug"))), + ).rejects.toThrow("a real library bug"); + }); + + it("passes a successful import straight through", async () => { + expect(await loadOptionalPackage("detail", () => Promise.resolve({ ok: true }))).toStrictEqual({ + ok: true, + }); + }); +}); + +/** Re-run `loadFhir`'s own diagnostic through the loader, with the import forced to fail. */ +function loadFhirWithAbsentPackage(): Promise { + const detail = fhirDetailFromSource(); + return loadOptionalPackage(detail, () => Promise.reject(moduleNotFound("@cosyte/fhir"))); +} + +/** + * Read the exact diagnostic `loadFhir` uses out of the source, so this test asserts the shipped + * wording rather than a copy of it that could drift. + */ +function fhirDetailFromSource(): string { + const source = readFileSync(new URL("../src/core/parsers.ts", import.meta.url), "utf8"); + const match = /const FHIR_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?\n)+)/.exec(source); + if (match?.[1] === undefined) throw new Error("could not read FHIR_UNAVAILABLE from parsers.ts"); + return [...match[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]).join(""); +} + +describe("an absent @cosyte/transform degrades, and is named as a conversion library", () => { + it("does not call transform a 'parser': loadOptional's wording would be wrong for it", () => { + const source = readFileSync(new URL("../src/commands/convert.ts", import.meta.url), "utf8"); + expect(source).toContain("conversion library is not installed"); + }); +}); + +describe("no sibling that can be absent is imported outside the optional-loader boundary", () => { + /** + * `@cosyte/hl7` and `@cosyte/terminology` are hard `dependencies` at real registry ranges, so a bare + * dynamic import of those is correct. `@cosyte/fhir` and `@cosyte/transform` cannot be relied on in + * an installed copy, so every one of their import sites must sit inside a loader that catches the + * resolver error. `src/core/parsers.ts` holds the only two permitted call sites. + */ + const MUST_BE_GUARDED = ["@cosyte/fhir", "@cosyte/transform"]; + + /** True iff the line is inside a comment (a JSDoc `@example` is documentation, not a call site). */ + function isComment(line: string): boolean { + const t = line.trim(); + return t.startsWith("*") || t.startsWith("//") || t.startsWith("/*"); + } + + /** True iff the import on this line is wrapped by one of the loaders that catches an absent package. */ + function isGuarded(line: string): boolean { + return ( + /loadFhir\(\)|loadOptionalPackage\(|loadOptional\(/.test(line) || + line.trim().startsWith("() =>") || + line.includes("() => import(") + ); + } + + /** Every tracked `.ts` file under `src/`, walked so a new command cannot slip past this. */ + function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.isFile() && entry.name.endsWith(".ts") ? [full] : []; + }); + } + + const SRC = join(import.meta.dirname, "..", "src"); + + for (const pkg of MUST_BE_GUARDED) { + it(`every dynamic import of ${pkg} goes through the optional-loader boundary`, () => { + const offenders: string[] = []; + for (const file of sourceFiles(SRC)) { + const source = readFileSync(file, "utf8"); + // A bare `await import("")` - i.e. one NOT preceded on the same line by a loader call. + for (const line of source.split("\n")) { + if (!line.includes(`import("${pkg}")`)) continue; + if (isComment(line)) continue; // a JSDoc @example is documentation, not a call site + if (!isGuarded(line)) offenders.push(`${file}: ${line.trim()}`); + } + } + expect(offenders).toStrictEqual([]); + }); + } + + it("the guard has teeth: a bare import of a guarded package is detected", () => { + // Negative controls on the detector itself, so a rule that matches nothing cannot pass silently. + expect(isGuarded(' const { parseResource } = await import("@cosyte/fhir");')).toBe(false); + expect(isGuarded(' loadFhir(), // () => import("@cosyte/fhir")')).toBe(true); + expect(isGuarded(' loadOptionalPackage(DETAIL, () => import("@cosyte/transform")),')).toBe( + true, + ); + expect(isComment(' * import("@cosyte/transform"),')).toBe(true); + expect(isComment(' await import("@cosyte/transform");')).toBe(false); + }); +}); diff --git a/test/sanity.test.ts b/test/sanity.test.ts index b743cb8..90ace7d 100644 --- a/test/sanity.test.ts +++ b/test/sanity.test.ts @@ -1,16 +1,51 @@ +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; import { VERSION } from "../src/index.js"; +const pkg: unknown = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + +/** Narrow the parsed manifest without an `as` cast: the sanity test must not lie about its input. */ +function manifestVersion(manifest: unknown): string { + if (typeof manifest !== "object" || manifest === null || !("version" in manifest)) { + throw new Error("package.json did not parse to an object with a `version` field"); + } + const { version } = manifest; + if (typeof version !== "string") throw new Error("package.json `version` is not a string"); + return version; +} + describe("toolchain sanity", () => { it("resolves the public entry point and exports VERSION as a string", () => { expect(typeof VERSION).toBe("string"); expect(VERSION.length).toBeGreaterThan(0); }); + it("package exports VERSION matching package.json", () => { + // Compared against package.json, never a hardcoded literal. `changeset version` bumps + // package.json alone, so a release that skipped `scripts/sync-version.mjs` (wired into the + // `version` script) would publish a VERSION export that lies about the release. That is exactly + // how 0.0.1 and 0.0.2 shipped exporting "0.0.0" - verified in the published tarball - while a + // shape-only assertion here and a `typeof` smoke test in docs-content/installation.md both + // stayed green throughout. `cosyte --version` and the MCP server's advertised SERVER_INFO + // version both read this constant, so the lie reached two user-visible surfaces. + expect(VERSION).toBe(manifestVersion(pkg)); + }); + it("exposes VERSION as a semver-looking string", () => { - // At this stage VERSION is "0.0.0"; the regex only asserts the shape, not the exact value, so - // future phases can bump it without breaking. + // Shape only, so a bump needs no edit here: the value itself is pinned to package.json above. expect(VERSION).toMatch(/^\d+\.\d+\.\d+(?:[.-].+)?$/); }); + + it("declares VERSION in the exact shape scripts/sync-version.mjs rewrites", () => { + // The sync script matches `export const VERSION: string = "...";` at column 0. Dropping the + // `: string` annotation, renaming the constant, or reflowing the declaration makes the script + // exit non-zero at release time rather than silently no-op, but nothing else in the suite + // notices the shape, so it is pinned here where a normal `pnpm test` run sees it. + const source = readFileSync(new URL("../src/core/version.ts", import.meta.url), "utf8"); + const declarations = source.match(/^export const VERSION: string = "[^"]*";$/gm); + expect(declarations).not.toBeNull(); + expect(declarations).toHaveLength(1); + }); }); From 7b2b59685b5c3437a72510115f68f7b4de578f2b Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 3 Aug 2026 16:10:11 +0000 Subject: [PATCH 2/3] fix: correct four honesty defects the gate found in the install fix Remedy for gate-refuter pass 1 (VERDICT: REFUTED, two majors, both documentation-honesty rather than code). No loader or guard was grown. 1. The convert diagnostic gave advice that cannot be followed. It said "install it to use convert (it is an optional dependency)", but `npm install @cosyte/transform` fails E404 on its own @cosyte/fhir peer, so it pointed at a command that always fails. That is the exact defect the fhir diagnostic exists to avoid, and the same commit's test asserted the fhir message must NOT say it. In a plain install the transform branch is the only one convert can reach, so this was the shipped behaviour, on the terminal and the MCP surface alike. The message now names the real cause. CHANGELOG, the changeset and troubleshooting.md claimed it already did; that claim is now true. test/absent-sibling.test.ts pins both diagnostics against the "install it" wording. 2. README.md still said "The swap is blocked, not scheduled, and it stays blocked until @cosyte/fhir can be published under a name npm accepts", forty-four lines below new text saying the swap was done. It also restated the name-similarity diagnosis that was retracted ecosystem-wide on 2026-08-03 and that this same commit retracted in RELEASING.md and CHANGELOG.md. Corrected. 3. "It is not published, because devDependencies are not" is false. devDependencies ARE published (npm view @cosyte/hl7@0.0.7 devDependencies returns a full list), so the fix release's manifest does still carry one file:vendor/*.tgz specifier. It is harmless because a consumer never installs a dependency's devDependencies, which is what the text now says. RELEASING.md and vendor-refresh.sh. 4. New docs presented --omit=optional as a supported way to slim the install. Measured: it installs clean, then every invocation including --version dies with ERR_MODULE_NOT_FOUND on @modelcontextprotocol/sdk and a raw stack trace, because dist/bin/cosyte.mjs imports the SDK statically at top level. That code defect is PRE-EXISTING (identical on c858cb0) and is NOT fixed here; it is now documented rather than implied away, and recorded as needing its own change. Also: the static guard's reach was overstated as catching "any" new call site. A refuter falsified that by assigning the thunk to a variable, leaving the suite green. RELEASING.md and CLAUDE.md now state what it does and does not catch, including that it cannot see the repo's first static `import type` of @cosyte/fhir, one token from a runtime import. And README/installation no longer imply an installable release exists: 0.0.2 is still the newest on npm. --- CHANGELOG.md | 24 ++++++++++++++++++++---- CLAUDE.md | 13 +++++++++++-- README.md | 17 ++++++++++++----- RELEASING.md | 24 ++++++++++++++++++++---- docs-content/installation.md | 20 ++++++++++++++------ docs-content/limitations.md | 9 +++++++-- docs-content/troubleshooting.md | 8 +++++--- scripts/vendor-refresh.sh | 7 +++++-- src/commands/convert.ts | 19 ++++++++++++++----- test/absent-sibling.test.ts | 29 +++++++++++++++++++++++++++-- 10 files changed, 135 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e90980f..ee19694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,10 +131,26 @@ node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz`. The siblings are now rea dependency. Both were previously loaded with a bare `await import()`, which in an installed copy would have surfaced a raw resolver error and a stack frame, so FHIR `parse`/`inspect`/`fmt`/`validate` and `convert` now degrade to a value-free `CLI_PARSER_UNAVAILABLE` (exit `69`) with a diagnostic that - says the package is not on the registry rather than "install it". New `loadOptionalPackage(detail, load)` - under `loadOptional`, exported on the `.` subpath. `@cosyte/fhir` is retained as a `devDependency` - on the vendored tarball so this repo's own FHIR and `convert` tests still run. HL7 v2, `map-codes` - and the six breadth formats are unaffected and work from a plain install. + says the package is not on the registry rather than "install it". **Neither diagnostic says "install + it", and that is deliberate**: `npm install @cosyte/transform` fails `E404` on its own + `@cosyte/fhir` peer, so `loadOptional()`'s stock wording ("install it to use this format, it is an + optional dependency") would point a user at a command that cannot succeed. New + `loadOptionalPackage(detail, load)` under `loadOptional`, exported on the `.` subpath. + `@cosyte/fhir` is retained as a `devDependency` on the vendored tarball so this repo's own FHIR and + `convert` tests still run; note that `devDependencies` **are** published, so that one + `file:vendor/*.tgz` specifier does remain in the manifest, harmlessly, because a consumer never + installs a dependency's `devDependencies` (verified: the install exits `0`). HL7 v2, `map-codes` and + the six breadth formats are unaffected and work from a plain install. + +- **Documented a pre-existing defect that this release makes reachable for the first time: + `--omit=optional` produces an install in which the `cosyte` command does not run at all.** The + install exits `0`, then every invocation, `--version` included, fails with `ERR_MODULE_NOT_FOUND` on + `@modelcontextprotocol/sdk` and a raw stack trace, because the built `dist/bin/cosyte.mjs` imports + the SDK statically at the top level rather than only on the `mcp` path. Verified identical on the + base commit, so it is not introduced here; it simply could not be hit before, because the package + could not be installed at all. `docs-content/` now says not to use that flag instead of implying it + is a supported way to slim the install. **The code defect is not fixed here** and needs its own + change: it also falsifies the "a plain `cosyte parse` never pulls it" claim in `src/bin/cosyte.ts`. - **The docs no longer tell you to run `npx @cosyte/cli …`, which never worked.** Separate from the packaging defect and not fixed by it: `npx` runs the executable whose name matches the package diff --git a/CLAUDE.md b/CLAUDE.md index 06c2844..fa52edc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,8 +130,17 @@ subpath still exports a small programmatic `core` API (`detectFormat`, `EXIT`, ` copy has no FHIR support**, and FHIR `parse`/`inspect`/`fmt`/`validate` plus `convert` degrade to a value-free `CLI_PARSER_UNAVAILABLE` (69). That required `loadOptionalPackage(detail, load)` beneath `loadOptional` (which takes a `CosyteFormat` and hardcodes the word "parser", wrong for both cases) - plus `loadFhir()`; `test/absent-sibling.test.ts` includes a **static guard** that reds if a new bare - `import("@cosyte/fhir")` or `import("@cosyte/transform")` appears in `src/`. + plus `loadFhir()`; `test/absent-sibling.test.ts` includes a **static guard** that reds on a new + **single-line, unwrapped** `import("@cosyte/fhir")` / `import("@cosyte/transform")` in `src/`, which + is the shape the defect took. **Do not write "any new call site": a refuter falsified that wording + by adding a thunk assigned to a variable, and the suite stayed 10/10 green.** It also misses a + multi-line import and a **static** `import … from "@cosyte/fhir"` - and this repo now HAS the first + static reference to that package (`src/core/parsers.ts`, `import type`, erased at build, verified + absent from `dist/`). Dropping the word `type` loads it eagerly and breaks every command in an + installed copy, unseen by the guard. Also note the two diagnostics deliberately do NOT say + "install it": `npm install @cosyte/transform` fails `E404` on its own `fhir` peer, so that advice + would send a user at a command that cannot succeed. `loadOptional()`'s stock wording says exactly + that, which is why neither goes through it. **Verified by installing, which a `--dry-run` cannot do**: pack, `npm install` the tarball in a clean directory outside the repo (exit 0), run both bins, import `.` under ESM and CJS. Negative control: the published `0.0.2` still `ENOENT`s. Keep that step; it is checklist step 6 in `RELEASING.md`. diff --git a/README.md b/README.md index 7e01a40..d1d36e8 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,21 @@ invoke `dist/bin/cosyte.mjs`), where the FHIR library is supplied locally. > > The CLI is **feature-complete**: an argv+stdin+MCP fuzz gate, an exit-code golden matrix, a > built-package smoke of both bins, and a clean `npm publish` dry-run. Those gates cover the code, and -> the code is not what is broken. The one release step they do not cover is the dependency swap -> described above, which is why `0.0.1` published green and still cannot be installed: a dry-run -> builds the tarball but never resolves it from a registry. **The swap is blocked, not scheduled**, -> and it stays blocked until `@cosyte/fhir` can be published under a name npm accepts, which also -> unblocks `@cosyte/transform`. See [RELEASING.md](./RELEASING.md). +> the code was never what was broken. The one release step they do not cover is the dependency swap +> described above, which is why `0.0.1` and `0.0.2` published green and still cannot be installed: a +> dry-run builds the tarball but never resolves it from a registry. **That swap has now been made**, +> and installing the packed tarball from outside the repository is a release step in its own right. +> `@cosyte/fhir` is the one dependency it could not cover, which is why FHIR support is absent from an +> installed copy rather than merely deferred. See [RELEASING.md](./RELEASING.md). ## Run it +> **These commands do not work yet, and the reason is now only that no fixed version has shipped.** +> The newest version on npm is `0.0.2`, and it is one of the two that cannot be installed. The +> packaging defect is fixed in this repository and proven by installing the packed tarball outside it, +> but a published version is immutable, so the fix reaches you only in the next release. Until then, +> run it from a source checkout: `pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`. + ```bash npm install -g @cosyte/cli # put `cosyte` on your PATH cosyte parse message.hl7 # format autodetected → HL7 v2 diff --git a/RELEASING.md b/RELEASING.md index c8d949b..dc0fd11 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -81,8 +81,15 @@ fails `ERESOLVE`; `deid` declares the same optional `@cosyte/fhir` peer and inst mechanism is not yet explained. Record measurements, not theories. `@cosyte/fhir` is kept as a **`devDependency`** on the vendored tarball, so this repo's own FHIR and -`convert` tests still run, and so it satisfies `@cosyte/transform`'s peer in the dev tree. It is not -published, because `devDependencies` are not. +`convert` tests still run, and so it satisfies `@cosyte/transform`'s peer in the dev tree. + +**Say this precisely, because the obvious shorter sentence is false.** `devDependencies` **are** +published: they stay in the published `package.json` (`npm view @cosyte/hl7@0.0.7 devDependencies` +returns a full list), and so the fix release's manifest still carries one `file:vendor/*.tgz` +specifier. What makes that harmless is that **a consumer never installs a dependency's +`devDependencies`**, so npm never resolves the path. That was verified, not assumed: installing the +packed tarball in a clean directory exits `0` with the `file:` devDependency present in the manifest. +The runtime closure is what had to be clean, and it is. ### What an installed copy cannot do, and why that is not a crash @@ -98,8 +105,17 @@ wrong for a conversion library. So `src/core/parsers.ts` now has `loadOptionalPa underneath it, plus a `loadFhir()` whose diagnostic says the package is not on the registry (rather than `loadOptional`'s "install it", which would be false). `@cosyte/hl7` and `@cosyte/terminology` stay hard deps and keep their bare imports, correctly. `test/absent-sibling.test.ts` holds this shut, -including a static guard that fails if any new bare `import("@cosyte/fhir")` or -`import("@cosyte/transform")` call site appears in `src/`. +including a static guard over `src/`. + +**What that guard does and does not catch, because "any new call site" would overstate it.** It flags +a single-line `await import("@cosyte/fhir")` or `import("@cosyte/transform")` that is not wrapped by +one of the loaders, which is the shape the defect actually took, and it carries negative controls so +it cannot pass by matching nothing. It does **not** catch a thunk assigned to a variable and awaited +elsewhere, an import split across lines, or a **static** `import … from "@cosyte/fhir"`. That last one +matters: this repo now has its first static reference to that package (`src/core/parsers.ts`, as +`import type`, which is erased at build time and emits no runtime load, verified in `dist/`). Dropping +the word `type` would load it eagerly and break every command in an installed copy, and the guard +would not see it. ### The `npx @cosyte/cli` short form does not work, and the swap does not fix it diff --git a/docs-content/installation.md b/docs-content/installation.md index 4784a71..d726b96 100644 --- a/docs-content/installation.md +++ b/docs-content/installation.md @@ -9,9 +9,12 @@ sidebar_position: 1 `@cosyte/cli` ships the `cosyte` command as a Node.js executable, alongside `cosyte-mcp`. A global install is the simplest route; `npx` works too, with one flag noted under [Run it](#run-it). -> **Status:** pre-alpha (`0.0.x`). **`0.0.1` and `0.0.2` cannot be installed at all**: see -> [If you are on 0.0.1 or 0.0.2](#if-you-are-on-001-or-002). The next release fixes that. FHIR support -> is unavailable in an installed copy for a separate reason, described under +> **Status:** pre-alpha (`0.0.x`), and **there is no installable release yet.** The newest version on +> npm is `0.0.2`, and `0.0.1` and `0.0.2` are both uninstallable: see +> [If you are on 0.0.1 or 0.0.2](#if-you-are-on-001-or-002). The packaging defect is fixed in the +> repository and proven by installing the packed tarball, but a published version is immutable, so the +> fix arrives with the next release. Until then, run the CLI from a source checkout. FHIR support is +> unavailable in an installed copy for a separate reason, described under > [What is not available from npm](#what-is-not-available-from-npm). ## If you are on 0.0.1 or 0.0.2 @@ -30,7 +33,8 @@ wrong with your environment. Those manifests declared the ten `@cosyte/*` sibling packages as local file paths (`file:vendor/*.tgz`) instead of npm version ranges. The tarballs are not part of the published package, so npm resolved the paths against a directory that is not there. A published version is -immutable, so both stay broken; **install a later version.** +immutable, so both stay broken. **The fix ships as a later version, which does not exist yet**; run +the CLI from a source checkout in the meantime. ## What is not available from npm @@ -47,8 +51,12 @@ installed `@cosyte/cli` has no FHIR library and: dependency. Everything else works from a plain install: HL7 v2 (`@cosyte/hl7`), `map-codes` -(`@cosyte/terminology`), and the six breadth formats X12, C-CDA, DICOM, NCPDP, ASTM and MLLP, each an -optional dependency that reports `CLI_PARSER_UNAVAILABLE` if you deselect it. +(`@cosyte/terminology`), and the six breadth formats X12, C-CDA, DICOM, NCPDP, ASTM and MLLP, which +are optional dependencies that do resolve, so a default install has all six. + +> **Do not install with `--omit=optional`.** It succeeds, but the `cosyte` command then fails to start +> at all on a missing `@modelcontextprotocol/sdk`, before it reaches any command. Known defect, +> tracked separately. **To use the FHIR commands today, run the CLI from source**, where the FHIR library is supplied locally: diff --git a/docs-content/limitations.md b/docs-content/limitations.md index c0f0071..4fadfde 100644 --- a/docs-content/limitations.md +++ b/docs-content/limitations.md @@ -83,8 +83,13 @@ These are **non-goals**, not missing features: named so nothing over-trusts the both `@cosyte/fhir` and `@cosyte/transform` (the latter requires the former, so npm skips it as an unresolvable optional dependency). This is stated rather than discovered: the commands do not guess, and they do not report your input as bad. Run the CLI from a source checkout to use them. The six - breadth formats are also optional dependencies, but they do resolve, so they are present unless you - install with `--omit=optional`. + breadth formats are also optional dependencies, but they do resolve, so a default install has all + six; removing one degrades that format to the same value-free `CLI_PARSER_UNAVAILABLE`. + +- **Do not install with `--omit=optional`.** The install succeeds, but the `cosyte` command then does + not run at all: it fails on a missing `@modelcontextprotocol/sdk` before reaching any command, even + `--version`. That is a known defect and is tracked separately; it is recorded here rather than left + for you to hit. - **No clinical interpretation, no unit conversion, no terminology content.** The CLI inherits these non-goals from the libraries it wraps. It never decides criticality, rescales a magnitude, or bundles diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index 6a08109..fd1a74d 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -41,9 +41,11 @@ names which formats _do_ support the operation. The command is never faked to a The library for a recognised format is not installed. The CLI degrades to this value-free signal rather than crashing, and it never falls back to a guess. Two different causes: -- **The six breadth parsers** (`dicom`/`x12`/`ccda`/`ncpdp`/`astm`/`mllp`) are `optionalDependencies`: - installed by default, but absent if you installed with `--omit=optional`. Install the named - `@cosyte/` package to use that format. +- **The six breadth parsers** (`dicom`/`x12`/`ccda`/`ncpdp`/`astm`/`mllp`) are `optionalDependencies` + that resolve, so a default install has all six; you see this only if one was removed. Install the + named `@cosyte/` package to get it back. Note that `--omit=optional` is **not** a supported + way to slim the install: it also removes `@modelcontextprotocol/sdk`, and the `cosyte` command then + fails to start at all rather than reaching this diagnostic. Known defect, tracked separately. - **FHIR is always unavailable from an npm install**, and no reinstall changes that: `@cosyte/fhir` is not on the npm registry, so it is not a dependency of this package. This also takes out `convert`, which additionally needs `@cosyte/transform` (itself skipped, because it requires `@cosyte/fhir`). diff --git a/scripts/vendor-refresh.sh b/scripts/vendor-refresh.sh index 72a6eec..75ef8a1 100644 --- a/scripts/vendor-refresh.sh +++ b/scripts/vendor-refresh.sh @@ -10,8 +10,11 @@ # # @cosyte/fhir file:vendor/cosyte-fhir-0.0.0.tgz, and it is a devDependency, NOT a runtime one. # It exists so this repo's own FHIR + convert tests can run, and so that -# @cosyte/transform's mandatory @cosyte/fhir peer resolves in the dev tree. It is -# never published: devDependencies are not. +# @cosyte/transform's mandatory @cosyte/fhir peer resolves in the dev tree. +# NOTE, because the shorter sentence is false: devDependencies ARE published, so +# this file: specifier does ship in the manifest. It is harmless only because a +# consumer never installs a dependency's devDependencies, so npm never resolves +# the path. Verified by installing the packed tarball in a clean directory. # everything else a real "^0.0.x" range from npm. The nine other tarballs below are refreshed by # this script but referenced by nothing. They are kept because @cosyte/fhir's # refresh shares this machinery, and removing them is a separate cleanup. diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 1cd3ad2..4e2f604 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -166,6 +166,19 @@ type ConvertOutcome = | { readonly ok: true; readonly conversion: Conversion } | { readonly ok: false; readonly result: RunResult }; +/** + * Why `@cosyte/transform` is absent, in words a user can act on. **It deliberately does not say + * "install it"**: `npm install @cosyte/transform` fails too (`E404` on its own `@cosyte/fhir` peer), + * so telling someone to install it would send them at a command that cannot succeed. That is the same + * defect the `@cosyte/fhir` diagnostic exists to avoid, and it is why neither goes through + * `loadOptional()`, whose stock wording is exactly "install it (it is an optional dependency)". + * Value-free by construction: package names and a statement of fact, no input echoed. + */ +const TRANSFORM_UNAVAILABLE = + "the @cosyte/transform conversion library is not installed, so convert is unavailable; it " + + "requires @cosyte/fhir, which is not currently on the npm registry, so npm skips transform as " + + "an unresolvable optional dependency and installing it directly fails for the same reason"; + /** * Parse the HL7 v2 bytes and convert to FHIR: every library **lazy-loaded** so this code loads only * when `convert` runs. Only the `parseHL7` call is inside the failure boundary: a genuine parser @@ -182,11 +195,7 @@ type ConvertOutcome = async function runConvert(bytes: Uint8Array, posture: PhiPosture): Promise { const [{ parseHL7 }, { toFhir }, { serializeResource }] = await Promise.all([ import("@cosyte/hl7"), - loadOptionalPackage( - "the @cosyte/transform conversion library is not installed; install it to use convert " + - "(it is an optional dependency, and it in turn requires @cosyte/fhir)", - () => import("@cosyte/transform"), - ), + loadOptionalPackage(TRANSFORM_UNAVAILABLE, () => import("@cosyte/transform")), loadFhir(), ]); diff --git a/test/absent-sibling.test.ts b/test/absent-sibling.test.ts index 74accb5..64e1039 100644 --- a/test/absent-sibling.test.ts +++ b/test/absent-sibling.test.ts @@ -99,9 +99,34 @@ function fhirDetailFromSource(): string { } describe("an absent @cosyte/transform degrades, and is named as a conversion library", () => { - it("does not call transform a 'parser': loadOptional's wording would be wrong for it", () => { + /** The shipped diagnostic, read out of the source so this asserts the real text, not a copy. */ + function transformDetail(): string { const source = readFileSync(new URL("../src/commands/convert.ts", import.meta.url), "utf8"); - expect(source).toContain("conversion library is not installed"); + const match = /const TRANSFORM_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?\n)+)/.exec(source); + if (match?.[1] === undefined) throw new Error("could not read TRANSFORM_UNAVAILABLE"); + return [...match[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]).join(""); + } + + it("does not call transform a 'parser': loadOptional's wording would be wrong for it", () => { + expect(transformDetail()).toContain("conversion library is not installed"); + expect(transformDetail()).not.toContain("parser is not installed"); + }); + + it("does NOT tell the user to install it, because that command cannot succeed", () => { + // `npm install @cosyte/transform` fails E404 on its own @cosyte/fhir peer, so "install it to use + // convert (it is an optional dependency)" - loadOptional's stock wording, and what this + // diagnostic said in an earlier draft - sends a user at a command that always fails. A refuter + // caught exactly that. The message must name the real cause instead. + const detail = transformDetail(); + expect(detail).not.toMatch(/install it to use/); + expect(detail).not.toContain("it is an optional dependency)"); + expect(detail).toContain("not currently on the npm registry"); + }); + + it("both unavailable-sibling diagnostics name the registry as the cause", () => { + for (const detail of [transformDetail(), fhirDetailFromSource()]) { + expect(detail).toContain("not currently on the npm registry"); + } }); }); From 477891996c5fadec1aefe99488c3933a221b4e26 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 3 Aug 2026 16:21:43 +0000 Subject: [PATCH 3/3] test: stop the diagnostic extraction silently truncating its last line Pass-2 finding, minor. Both source-extraction helpers in absent-sibling.test.ts matched `"..."` segments followed by ` +` or a newline, but the LAST line of each concatenation ends `";`, so the final segment never matched. Measured: the transform detail came out 182 of 270 characters, the fhir detail likewise short. The helpers' own comment said they read the shipped text "not a copy", while asserting about 62% of it, and the blind spot sat exactly where a re-introduced "install it" tail would land, so the regression the test was written for could have walked back in unseen. One token (`;?`) in each regex, plus a test pinning the last words of both strings so a truncation of any length reds. Verified as a negative control: restoring the old regex reds that one assertion and nothing else. --- test/absent-sibling.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/absent-sibling.test.ts b/test/absent-sibling.test.ts index 64e1039..df4194e 100644 --- a/test/absent-sibling.test.ts +++ b/test/absent-sibling.test.ts @@ -93,7 +93,11 @@ function loadFhirWithAbsentPackage(): Promise { */ function fhirDetailFromSource(): string { const source = readFileSync(new URL("../src/core/parsers.ts", import.meta.url), "utf8"); - const match = /const FHIR_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?\n)+)/.exec(source); + // The `;?` is load-bearing: the LAST line of the concatenation ends `";`, so without it the final + // segment never matches and this silently returns a truncated string while still reading like the + // whole thing. A refuter caught exactly that: the negative assertions below were blind to the last + // line, which is where an appended sentence would land. The `endsWith` guards below pin it. + const match = /const FHIR_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?;?\n)+)/.exec(source); if (match?.[1] === undefined) throw new Error("could not read FHIR_UNAVAILABLE from parsers.ts"); return [...match[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]).join(""); } @@ -102,7 +106,8 @@ describe("an absent @cosyte/transform degrades, and is named as a conversion lib /** The shipped diagnostic, read out of the source so this asserts the real text, not a copy. */ function transformDetail(): string { const source = readFileSync(new URL("../src/commands/convert.ts", import.meta.url), "utf8"); - const match = /const TRANSFORM_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?\n)+)/.exec(source); + // `;?` as in fhirDetailFromSource: without it the final segment is dropped silently. + const match = /const TRANSFORM_UNAVAILABLE =\n((?:\s+"[^"]*"(?: \+)?;?\n)+)/.exec(source); if (match?.[1] === undefined) throw new Error("could not read TRANSFORM_UNAVAILABLE"); return [...match[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]).join(""); } @@ -128,6 +133,15 @@ describe("an absent @cosyte/transform degrades, and is named as a conversion lib expect(detail).toContain("not currently on the npm registry"); } }); + + it("the source extraction reads the WHOLE constant, not a truncated prefix", () => { + // Without this the negative assertions above have a last-line blind spot: the extraction regex + // silently drops the final segment (the one ending `";`), which is where an appended sentence + // would land, so a re-introduced "install it" tail would pass unnoticed. Pinned by the last + // words of each shipped string, so a truncation of any length fails here. + expect(transformDetail()).toMatch(/installing it directly fails for the same reason$/); + expect(fhirDetailFromSource()).toMatch(/FHIR support is unavailable in this install$/); + }); }); describe("no sibling that can be absent is imported outside the optional-loader boundary", () => {