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..ee19694 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,53 @@ 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". **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 + 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..fa52edc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,50 @@ 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 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`. + **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 +230,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..d1d36e8 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: @@ -82,25 +88,40 @@ checkout (`pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`). > > 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 -> **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`). +> **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 -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 +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 --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 +295,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..dc0fd11 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,79 +12,125 @@ 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. + +**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 + +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 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 + +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 +180,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..d726b96 100644 --- a/docs-content/installation.md +++ b/docs-content/installation.md @@ -6,17 +6,20 @@ 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`), 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). -## 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 +30,36 @@ 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. **The fix ships as a later version, which does not exist yet**; run +the CLI from a source checkout in the meantime. -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, 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: ```bash git clone https://github.com/cosyte/cli && cd cli @@ -53,17 +74,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 +102,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..4fadfde 100644 --- a/docs-content/limitations.md +++ b/docs-content/limitations.md @@ -74,6 +74,23 @@ 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 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 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..fd1a74d 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -38,10 +38,18 @@ 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` + 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`). + 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..75ef8a1 100644 --- a/scripts/vendor-refresh.sh +++ b/scripts/vendor-refresh.sh @@ -1,6 +1,26 @@ #!/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. +# 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. +# +# 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..4e2f604 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"; @@ -165,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 @@ -172,12 +186,17 @@ 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(TRANSFORM_UNAVAILABLE, () => 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..df4194e --- /dev/null +++ b/test/absent-sibling.test.ts @@ -0,0 +1,208 @@ +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"); + // 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(""); +} + +describe("an absent @cosyte/transform degrades, and is named as a conversion library", () => { + /** 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"); + // `;?` 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(""); + } + + 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"); + } + }); + + 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", () => { + /** + * `@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); + }); });