From b8c2a500b7df5d4576d382ab4c4b7a5a60e44ef6 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 7 Aug 2026 05:35:46 +0000 Subject: [PATCH] style(brand): sweep U+2014 out of the tree and land the CI gate with it (EMDASH-CONFORMANCE) The em-dash ban is a founder directive of 2026-07-24 and it covers every surface this repository has, including commit messages, PR titles and PR bodies. This package carried 659 occurrences across 75 of its 98 tracked files, and had no gate at all. The sweep and the gate are one commit on purpose. A gate arriving before its sweep reds main on arrival; a sweep arriving before its gate grows the character back on the next session. THE CENSUS WAS RE-DERIVED, NOT INHERITED The umbrella backlog recorded 660 across 76 files. Counted here in Python over raw bytes, this tree reads 659 across 75, and the two agree: the npm description fix that landed as #30 removed exactly one occurrence from exactly one file. The count matters because the original org-wide census was taken with a broken scanner: in the agent containers grep is a shell function forcing -I, and under xargs the real binary fails at exit 2 and prints nothing in the empty locale, so a piped count reads zero. Every other spelling the rule names (the named HTML entity, the decimal and hex character references, the percent-encoding, both JavaScript escapes) was searched for before and after and is absent. WHAT MOVED 609 occurrences rewritten across 73 files, each by what the sentence wanted rather than by one substitution: a colon where the dash introduced an appositive, a comma before a conjunction or a negation, parentheses where the mark scoped an aside. Consumer-visible surfaces in that count: README.md, the seven docs-content pages that publish to the documentation site, and the src/ JSDoc that compiles into dist/index.d.ts and dist/index.d.cts and renders on hover. Three things were done BY HAND, before any bulk pass, because a bulk pass gets them wrong in a way a green suite does not show: * The one runtime string. scripts/phi-scan.ts's clean-run line is quoted in prose in two docblocks and asserted by regex in three tests. All five sites moved together. It now reads "[phi-scan] OK: no hits". The check-agent-notes R5 orphan message moved the same way; its test asserts on the token ORPHAN, so nothing else had to. * The one semantic value. diagnostic-report.ts's OBR mapping table used a bare dash cell to mean "nothing here", which a bulk rewrite turns into a stray mark that reads as a rendering artefact. OBR-8 goes through toFhirDateTime at the call site, so the cell now names that converter. * The slug fixtures in the agent-notes contract suite, which prove GitHub gives each space its own hyphen using a heading whose dropped mark has a space on either side. They now use an en dash. slug() drops it identically and both surrounding spaces still survive as two hyphens, so the behaviour under test and the bite of the two cases are unchanged. The trap did not go away: every dropped mark with a space either side reproduces it. No exported name, type, issue code, fatal code or documented behaviour changed. AND THE RULE THIS CHANGE STATES IS THE ONE IT KEPT "A comma where the clause already carried a colon" is the rule, and a sibling repository put a colon into thirteen clauses that already had one while stating it. Measured here by joining wrapped prose into sentences, stripping backticked spans and URLs, and counting colons per sentence on both sides of the diff: nineteen sites were caught and corrected by hand, in README.md, three docs-content pages, agent-notes.md, six src/ modules, two test files and two scripts. The ones left are sound, and the test was applied rather than assumed: the second colon is inside parentheses, inside a code span, or in a different sentence. Two of those nineteen were more than punctuation and are called out here because they read as claims. src/messages/orders.ts had "incorporated into that same request: OBR -> ServiceRequest", where the colon read as a definition of the preceding phrase rather than a consequence of it; it now reads "so". And src/messages/service-request.ts's priority bullet had a colon in its own label AND in the sentence introducing the map. TWO EXEMPTIONS, EACH WITH A WRITTEN REASON, AND NOTHING SKIPPED SILENTLY 50 occurrences remain. 49 are in CHANGELOG.md below its "Released before this file was generated" heading: a dated ship-log whose entries are byte identical to the tarballs they shipped in, measured by test/scripts/changelog-generation.test.ts. The gate scans the generated half above the heading, because a changeset summary becomes the published release body and a line in the tarball's changelog, and it FAILS CLOSED: if the heading goes, the whole file is in scope. That archive is also the gate's on-disk canary. The last one is inside vendor/cosyte-hl7-0.0.0.tgz, a third-party pnpm pack tarball whose DEFLATE stream holds the bytes by coincidence. It is declared binary in a new .gitattributes, alongside the fhir tarball. Both blobs are byte identical to base. That file cannot become a silencer: the gate REFUSES any binary declaration outside vendor/, and it scans tracked FILENAMES whatever the declaration says. Everything else was swept, including CLAUDE.md and documentation/agent-notes.md. The banner on the notes protects its claims from being softened, not its bytes from being repunctuated, and an exemption there would grow because that file is appended to. The one passage reproduced verbatim was swept too, and the sentence introducing it now says so: the wording, every claim and every qualifier are untouched and only the dash punctuation moved. THE GATE scripts/check-no-emdash.mjs shells out for nothing. It reads bytes with node:fs; the only child processes are git ls-files and git check-attr, and both have their exit status checked. It excludes nothing by path, because every banned spelling is assembled at runtime from the codepoint, so the script holds itself to its own rule rather than needing the self-exclusion that let an em dash scan green in a sibling. Six probe checks refuse rather than report a clean tree it cannot prove it read, and the enumeration floor is 80 paths, deliberately below the 103 tracked today so an ordinary deletion is not mistaken for a filtered scan. .github/workflows/no-emdash.yml runs two jobs, and the split is the design. no-emdash scans tracked files and filenames and is safe to require, once it has run on main, folded into ruleset 19914044 like every other context here. no-emdash-messages scans the PR title, body and commit range and MUST NEVER be required: Dependabot pastes the dependency's upstream release notes into a PR body, and requiring it would block a dependency bump on prose nobody here wrote. An actor if: on a required context is not the fix either; that leaves the check pending, which is worse than red. 41 tests pin all of it: every banned spelling reds, every near miss stays green, the gate holds its own source, the CHANGELOG boundary is proved on both sides and with the heading removed, and every refusal is driven through a real scan of a synthetic repository. Three of those 41 exist because a refuter refuted this commit twice, and the third is the one that closes the class. The probe checked only that scanned + binary == tracked, and that arithmetic balances for a path exclusion which ACCOUNTS for what it skips: push the skipped paths onto the declared-binary list and the sum still adds up, while the outside-vendor refusal reads .gitattributes rather than the skip list and never sees them. Adding `|| rel.startsWith("docs-content/")` to the skip condition, 34 characters, hid seven pages that publish to the documentation site, printed a clean banner and left all 38 tests green. probe() check 5b now reconciles the two sets path for path, the real-tree case pins the banner at "2 declared binary", and a mutation case reproduces that exclusion against a copy of the gate in a throwaway repository and requires the refusal, with the unmutated fixture beside it as the near miss. Proven red-before, green-after: the pre-fix gate exits 0 on that fixture. Its second pass showed that fix closed one bucket of two. Every partition check counts FILES, so an exclusion that pushes what it skips onto the SCANNED list rather than the declared-binary one leaves all of them byte identical to an honest run: the path really is classified and really is counted, it is just never opened. With a live em dash planted on a page that publishes to the documentation site it printed the same banner, exited 0, and left all 40 tests green. The one quantity such a mutation cannot fake is bytesRead, which the gate already prints, so the suite now recomputes the expected total INDEPENDENTLY, from git ls-files plus git check-attr plus statSync, and asserts the banner against it. That assertion has to live in the test rather than in probe(), because an in-script invariant can always be satisfied by the same edit that breaks the property. Both surviving mutations red on it and on nothing else, measured. DOCS CLAUDE.md gains standing discipline 5 and stays inside its byte budget, which took relocating narrative that documentation/agent-notes.md already carries: the Dependabot limits, the older publish-state wording, and the provenance of the third PHI-scanner residual. No trap was deleted to make room. The long form of the sweep, both counts, both exemptions and every trap above is a new "No em dash, anywhere" section in documentation/agent-notes.md. Verified: typecheck, lint, format:check, phi-scan, check-agent-notes, check-no-internal-refs, check-no-emdash, 388 tests, coverage, build, attw. Refuter verdict on the fixed commit: pending re-check of the one finding above. --- .changeset/README.md | 2 +- .changeset/thick-jokes-sleep.md | 28 + .gitattributes | 12 + .github/workflows/ci.yml | 2 +- .github/workflows/no-emdash.yml | 158 +++++ .github/workflows/release.yml | 2 +- CLAUDE.md | 129 ++-- README.md | 38 +- docs-content/concepts-archetype.md | 18 +- docs-content/guides-overview.md | 6 +- docs-content/installation.md | 10 +- docs-content/intro.md | 14 +- docs-content/quickstart.md | 16 +- docs-content/troubleshooting.md | 20 +- documentation/agent-notes.md | 320 +++++++--- ...ormation-tier-may-depend-on-parser-tier.md | 14 +- ...gy-is-a-separate-sibling-byo-conceptmap.md | 10 +- package.json | 1 + phi-scan-overrides.md | 4 +- scripts/check-agent-notes.ts | 58 +- scripts/check-no-emdash.mjs | 541 +++++++++++++++++ scripts/phi-allow-list.txt | 12 +- scripts/phi-scan.ts | 56 +- scripts/sync-version.mjs | 4 +- scripts/vendor-refresh.sh | 6 +- src/datatypes/address.ts | 6 +- src/datatypes/build.ts | 2 +- src/datatypes/codeable-concept.ts | 10 +- src/datatypes/datetime.ts | 10 +- src/datatypes/human-name.ts | 4 +- src/datatypes/identifier.ts | 12 +- src/datatypes/quantity.ts | 16 +- src/diagnostics/codes.ts | 34 +- src/diagnostics/issue.ts | 18 +- src/diagnostics/operation-outcome.ts | 4 +- src/index.ts | 10 +- src/messages/appointment.ts | 18 +- src/messages/diagnostic-report.ts | 14 +- src/messages/document-reference.ts | 26 +- src/messages/emit-schemas.ts | 16 +- src/messages/encounter.ts | 8 +- src/messages/immunization.ts | 20 +- src/messages/medication-request.ts | 34 +- src/messages/message-header.ts | 6 +- src/messages/observation.ts | 26 +- src/messages/orders.ts | 16 +- src/messages/oru.ts | 6 +- src/messages/patient.ts | 10 +- src/messages/reference.ts | 6 +- src/messages/related-person.ts | 4 +- src/messages/service-request.ts | 32 +- src/messages/to-fhir.ts | 26 +- src/terminology/concept-map.ts | 46 +- src/terminology/context.ts | 8 +- src/terminology/naming-system.ts | 14 +- test/_support/fhir.ts | 2 +- test/datatypes/address.test.ts | 4 +- test/datatypes/boundary.property.test.ts | 8 +- test/datatypes/codeable-concept.test.ts | 6 +- test/datatypes/datetime.test.ts | 10 +- test/datatypes/human-name.test.ts | 4 +- test/datatypes/identifier.test.ts | 8 +- test/datatypes/quantity.test.ts | 6 +- test/diagnostics/codes-and-issue.test.ts | 4 +- test/docs-content.test.ts | 4 +- test/messages/document-reference.test.ts | 2 +- test/messages/orders.test.ts | 10 +- test/messages/oru.test.ts | 16 +- test/messages/property.test.ts | 14 +- test/messages/terminology-fields.test.ts | 2 +- test/messages/to-fhir.test.ts | 6 +- test/scripts/agent-notes-contract.test.ts | 89 +-- test/scripts/attw-gate.test.ts | 20 +- test/scripts/no-emdash-gate.test.ts | 549 ++++++++++++++++++ test/scripts/phi-scan.test.ts | 28 +- test/terminology/concept-map.test.ts | 8 +- test/terminology/naming-system.test.ts | 4 +- tsup.config.ts | 2 +- vitest.config.ts | 2 +- 79 files changed, 2116 insertions(+), 635 deletions(-) create mode 100644 .changeset/thick-jokes-sleep.md create mode 100644 .gitattributes create mode 100644 .github/workflows/no-emdash.yml create mode 100644 scripts/check-no-emdash.mjs create mode 100644 test/scripts/no-emdash-gate.test.ts diff --git a/.changeset/README.md b/.changeset/README.md index 6f71861..b30eaf3 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -13,5 +13,5 @@ Add a changeset for every meaningful change: pnpm changeset ``` -During pre-alpha, pick **patch** — that keeps the package on the `0.0.x` ladder until its first +During pre-alpha, pick **patch**: that keeps the package on the `0.0.x` ladder until its first alpha. See the cosyte version ladder in the meta-repo's `documentation/conventions.md`. diff --git a/.changeset/thick-jokes-sleep.md b/.changeset/thick-jokes-sleep.md new file mode 100644 index 0000000..94d16e0 --- /dev/null +++ b/.changeset/thick-jokes-sleep.md @@ -0,0 +1,28 @@ +--- +"@cosyte/transform": patch +--- + +No runtime impact: punctuation only, plus a new repository-internal check that keeps it that way. + +The Cosyte brand voice does not use the em dash (U+2014). This package carried 659 of them across +75 of its 98 tracked files, including the README, every documentation page that publishes to the +documentation site, and the source doc comments that compile into the shipped type declarations and +render in an editor on hover. All 609 that were in scope are rewritten with a period, a colon, a +comma or parentheses, chosen by what each sentence meant rather than by one blanket substitution. + +No exported name, type, issue code, fatal code or documented behaviour changed. Two strings changed +punctuation and nothing else, and both belong to developer tooling rather than to the published +package: the PHI scanner's clean-run line, and one diagnostic from the check that verifies this +repository's own contributor instructions. + +Two files still carry the character and each is an exemption with a written reason. The changelog's +dated archive below its "Released before this file was generated" heading is a frozen record whose +entries are byte identical to the tarballs they shipped in, and rewriting it would destroy the +evidence a changelog exists to hold. The vendored third-party tarball under `vendor/` holds the +character's bytes by coincidence inside a compressed stream, which no edit can remove. + +The check that enforces the rule lands in the same change as the sweep, on purpose: a check arriving +before its sweep turns the build red on arrival, and a sweep arriving before its check lets the +character grow back. It reads bytes directly rather than shelling out, refuses to report a clean +result whenever it cannot prove it read its subject, and holds its own source to the same rule by +assembling every banned spelling at runtime instead of writing one down. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9495c33 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# The vendored `@cosyte/hl7` and `@cosyte/fhir` tarballs are third-party archives that this +# repository consumes for dev and test (ADR 0001; refresh them with `pnpm vendor:refresh`). +# Declaring them keeps git from ever treating them as text, and it is the declaration that +# `scripts/check-no-emdash.mjs` partitions on: a DEFLATE stream can hold the em dash's UTF-8 +# bytes by coincidence, `vendor/cosyte-hl7-0.0.0.tgz` does, and there is no edit that removes +# a byte from someone else's compressed stream. +# +# THIS FILE IS NOT A SILENCER. The em-dash gate REFUSES any `binary` declaration outside +# `vendor/`, so widening the exclusion means editing that script deliberately rather than +# adding a line here. Tracked FILENAMES are scanned whatever this file says, because a +# declaration about a file's bytes says nothing about its name. +vendor/*.tgz binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c7d745..3ad69eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: # dual ESM/CJS smoke, actionlint) on the standard Node matrix. Repo-specific extra jobs (e.g. an # examples smoke, a starter-kit build) are added on top of this caller, never instead of it. # The template ships a PHI scanner (scripts/phi-scan.ts), so the shared pipeline's optional - # PHI-scan step is switched on. NOTE: the scaffolded scanner is a STARTER — it detects only + # PHI-scan step is switched on. NOTE: the scaffolded scanner is a STARTER: it detects only # cross-cutting SSN/email shapes until you add structured, field-level detection for this # standard's PHI (see the STARTER banner in scripts/phi-scan.ts). ci: diff --git a/.github/workflows/no-emdash.yml b/.github/workflows/no-emdash.yml new file mode 100644 index 0000000..faabf2a --- /dev/null +++ b/.github/workflows/no-emdash.yml @@ -0,0 +1,158 @@ +name: Em-dash gate + +# The brand rule (founder directive 2026-07-24, stated canonically in the knowledgebase +# brand voice document) bans U+2014 outright across every cosyte surface, and names commit +# messages explicitly. +# +# THIS GATE DID NOT ARRIVE ON A CLEAN TREE, AND THAT ORDERING IS THE POINT. The change that +# landed this workflow also swept 609 occurrences out of 73 of the 98 tracked files, +# including `README.md`, the `docs-content/` pages that publish to the documentation site, +# and the `src/` JSDoc that compiles into `dist/index.d.ts` and renders on a consumer's +# hover. A gate landing before its sweep reds `main` on arrival; a sweep landing before its +# gate grows the character back on the next session. Both are one commit. +# +# WHY A SEPARATE WORKFLOW, and not a job in ci.yml. Same two reasons this repo's +# `no-internal-refs.yml` gives, and the second is the deciding one here: +# +# The reusable pipeline is not an option. This repo's `ci` job calls +# cosyte/.github/.github/workflows/ci.yml@main, which runs a FIXED ladder (typecheck, +# lint, format:check, phi-scan, test, coverage, build, attw, dual ESM/CJS smoke) and no +# arbitrary repo script. It does NOT run `pnpm check:no-emdash`. +# +# AND THE RULESET IS THE SECOND HALF OF THAT. `ci / verify` is a REQUIRED context here +# (ruleset `ci-required-checks`, id `19914044`), and a required job gates all of its +# STEPS, so folding a new step into `verify` would silently change what that required +# context covers. A separate workflow with its own job id is a new context, requirable on +# its own terms, and reversible without touching the four that already bind. +# +# The trigger differs. The messages job below needs the `edited` activity type, which is +# not a default: without it, a title or body edited after the last push is never +# re-checked, and this repo squash-merges, so the PR title and body ARE the commit +# message that lands on `main`. But ci.yml's triggers drive the full Node 22 + 24 matrix. +# Re-running all of that every time someone fixes a typo in a PR description is a large +# cost for no extra safety. +# +# ▶ TWO JOBS, DELIBERATELY, AND THE SPLIT IS THE WHOLE DESIGN. A sibling shipped this as +# ONE job and had to exempt the lot, which un-required the tracked-file half as well: the +# half that protects the published surface. That sibling names the split as its deferred +# fix. It is done here from the start, and it is the shape `website` already uses. +# +# `no-emdash` scans TRACKED FILES ONLY. Nothing outside this repository can put an em +# dash into one. Dependabot certainly writes tracked files (`package.json` and +# `pnpm-lock.yaml` are its whole job), but it writes version specifiers and lockfile +# records, never prose. So this job is safe to make a required context, and it SHOULD +# become one. +# +# `no-emdash-messages` scans the PR TITLE, BODY and COMMIT MESSAGES, and it must NEVER be +# required. Dependabot composes a PR body by pasting the dependency's UPSTREAM RELEASE +# NOTES into it, em dashes included. Requiring this context would block a dependency bump +# on prose nobody in this org wrote and nobody here can edit without rewriting the PR by +# hand. That is the same refusal this ecosystem already made for a CI `pnpm audit`: a +# gate that fails on someone else's clock stops being a signal and becomes a tax. +# HONOUR THIS EXEMPTION AND ITS WRITTEN REASON; DO NOT REMOVE IT. +# +# And do not "fix" the Dependabot case by requiring it with an actor `if:`. An +# `if: github.actor != 'dependabot[bot]'` on a REQUIRED context leaves the check +# permanently PENDING on exactly those PRs, which is worse than red: nothing says why. +# +# ▶ NEITHER CONTEXT IS REQUIRED YET, AND THAT IS CORRECT ORDERING RATHER THAN AN OVERSIGHT. +# A context may not be required before its workflow has completed on `main`: every PR +# would sit pending and unmergeable with nothing saying why. Once this workflow has run +# on `main`, add `no-emdash` (the tracked-file job, NOT the messages one) to the +# `ci-required-checks` ruleset (id `19914044`, the ONE ruleset this repo has: fold into +# it, never add a second), pinned to `integration_id: 15368` the way the existing +# contexts are. Read the real context name off a live check run first, never off this +# file's `name:`: this repo's `Public-surface gate` workflow emits the context +# `no-internal-refs`, and requiring a context nothing emits leaves every PR PENDING and +# unmergeable rather than red. The workflow here is titled "Em-dash gate" and the +# contexts are the bare job ids `no-emdash` and `no-emdash-messages`. +# +# A required job gates all of its STEPS, so splitting a step out of one silently +# un-requires it. Rename a job id and any ruleset entry naming it detaches without error. +# Change both together or neither. +# +# Run it locally with `pnpm check:no-emdash`. + +on: + push: + branches: [main] + pull_request: + branches: [main] + # `edited` is deliberate and load-bearing; see the note above. It costs the + # tracked-file job a re-run on a description edit, which is a checkout and one Node + # process, and it is what keeps the messages job honest. + types: [opened, synchronize, reopened, edited] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # The job id is the check-run context name a branch ruleset requires. This is the half + # that is safe to require; see the note above. + no-emdash: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # The gate is zero-dependency Node, so there is nothing to install and no + # lockfile cache to warm. It needs a Node new enough for `import.meta.dirname`, + # and this repo's own engines floor is >= 22. + node-version: 22 + + # No `|| true` and no `continue-on-error`: a check that cannot fail is + # documentation. The script refuses rather than reporting a clean tree whenever it + # cannot prove it read its subject. + - name: Check tracked files and their names + run: node scripts/check-no-emdash.mjs + + # THE HALF THAT MUST NEVER BE A REQUIRED CONTEXT. See the Dependabot note above. + no-emdash-messages: + runs-on: ubuntu-latest + # Pull requests only: on a push to `main` the message is already written, and this job + # exists to stop one landing. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history: the step below reads the PR's commit range, and the default + # shallow clone does not contain the base commit. + fetch-depth: 0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + # The rule names commit messages explicitly, and a squash merge composes its message + # from the PR title, the PR body, and the branch commits, so all three are checked. + # + # This is also the half a local pre-commit scan structurally cannot cover, and two + # slices elsewhere in this ecosystem have lost a review pass to exactly that: a NEW + # file is untracked so a scan of the index does not see it, and no local hook sees a + # PR body at all. + - name: Check PR title, body, and commit messages + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Collect first, scan second, and set the shell options here rather than trust + # the runner's default: piping the collection straight into the scanner would + # hide a failed `git log` (a rewritten base commit, or a future edit that drops + # fetch-depth) behind a clean title and body, and the gate would print OK over + # commits it never read. The title and body arrive through env and are never + # interpolated into the script, so a PR description cannot inject shell. + set -euo pipefail + { + printf '%s\n' "$PR_TITLE" + printf '%s\n' "$PR_BODY" + git log --format=%B "$BASE_SHA..$HEAD_SHA" + } > "$RUNNER_TEMP/pr-messages.txt" + node scripts/check-no-emdash.mjs --stdin "the PR title, body, or commit messages" \ + < "$RUNNER_TEMP/pr-messages.txt" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 786d275..655b3ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: jobs: release: # Required. The repo default for GITHUB_TOKEN is `contents: read`, and a called workflow can only - # downgrade the caller's token, never escalate it — so without this the shared pipeline's + # downgrade the caller's token, never escalate it, so without this the shared pipeline's # `contents/id-token/pull-requests: write` is an escalation and GitHub rejects the whole workflow # at startup (1s, no jobs, no logs). permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 3fb6b3b..5733351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# @cosyte/transform — Project Guide for Claude +# @cosyte/transform: Project Guide for Claude **▶ The long form lives in [`documentation/agent-notes.md`](documentation/agent-notes.md).** Every trap below is a one-line imperative with a pointer into that file, where the incident, the @@ -8,14 +8,14 @@ the notes are the measurement.** Re-measure before you soften either. ## Project -**`@cosyte/transform`** — a developer-focused **HL7 v2 → FHIR R4 transformation** library for -Node.js/TypeScript, published under the Cosyte brand. Open-source (MIT). **Not a parser** — it is the +**`@cosyte/transform`**: a developer-focused **HL7 v2 → FHIR R4 transformation** library for +Node.js/TypeScript, published under the Cosyte brand. Open-source (MIT). **Not a parser.** It is the cosyte **transformation tier**, one layer _above_ the parser suite: a **consumer** that takes an already-parsed `@cosyte/hl7` message and produces a validated `@cosyte/fhir` model. Grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fhir.uv.v2mappings`, STU Edition 1). **North star:** a developer parses an `ORU^R01` or `ADT^A01` with `@cosyte/hl7` and gets back valid -FHIR R4 — without reading the v2 spec, without hand-writing a ConceptMap, and **without ever being +FHIR R4, without reading the v2 spec, without hand-writing a ConceptMap, and **without ever being handed a confident wrong FHIR value**. The borrowed disciplines (not the parser shape): the **fail-safe rule** (ambiguity → a typed, value-free diagnostic, never a guessed value), a stable `OperationOutcome`-shaped diagnostic channel, immutable output, conservative emit validated against @@ -28,7 +28,7 @@ as a trap is clinical-safety content. ## Status -- **Phases 1–6 shipped** — datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM +- **Phases 1–6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM message graphs, and the IG value-ConceptMap translation layer. Phases **7 (FHIR→v2)** and **8 (profiles)** and deeper terminology are deferred. Full per-phase inventory: `documentation/agent-notes.md#shipped-phase-history-phases-16`. @@ -39,33 +39,31 @@ as a trap is clinical-safety content. **`npm install @cosyte/transform` FAILS `E404`**, because its `@cosyte/fhir` peer is absent from the registry: that peer's own publish is refused with a **persistent, unexplained `E403` on `PUT`**, tracked as `FHIR-NPM-NAME`. Both halves travel together or neither is useful. -- **▶ THE "NAME-SIMILARITY" READING IS RETRACTED. DO NOT RENAME ANYTHING** — not the package, not the +- **▶ THE "NAME-SIMILARITY" READING IS RETRACTED. DO NOT RENAME ANYTHING**, not the package, not the scope, not an export. `FHIR-NPM-NAME` is a label, not a diagnosis; the error never asked for a - rename. Why: `documentation/agent-notes.md#publish-state-and-the-stale-claim-inside-it`. -- **▶ THIS REPO'S OLDER PUBLISH-STATE WORDING (`npm 404, a human-gated publish`) IS FLAGGED STALE** - in the umbrella backlog alongside `hl7`, `mllp` and `deid`, and is **not** treated as fact here. It - is quoted, dated and disputed in the notes — **relocating a disputed claim must not launder it into - fact.** The misleading half is **`a human-gated publish`**: the registry refuses at policy, and - **there is no approval button to press.** Same section as above. Derive, never recall: - `npm view @cosyte/fhir version`. **Visibility and publish state are independent**; never infer one - from the other. + rename. **And this repo's older wording (`npm 404, a human-gated publish`) IS FLAGGED STALE**: the + registry refuses at policy and **there is no approval button to press.** It is quoted, dated and + disputed in the notes; **relocating a disputed claim must not launder it into fact.** Derive, + never recall: `npm view @cosyte/fhir version`. **Visibility and publish state are independent**; + never infer one from the other. Why: + `documentation/agent-notes.md#publish-state-and-the-stale-claim-inside-it`. - **Consumes two cosyte siblings** (`@cosyte/hl7`, `@cosyte/fhir`) as **peer dependencies**, vendored - as `pnpm pack` tarballs in `vendor/` for dev/test (ADR 0001 + umbrella ADR 0008) — refresh with - `pnpm vendor:refresh`. Pinned shas: hl7 `46d50eb`, fhir `7a099b2`. **They are not both unpublished - — that wording was stale**; `@cosyte/hl7` is on the registry and it is the `fhir` peer alone that + as `pnpm pack` tarballs in `vendor/` for dev/test (ADR 0001 + umbrella ADR 0008): refresh with + `pnpm vendor:refresh`. Pinned shas: hl7 `46d50eb`, fhir `7a099b2`. **They are not both unpublished, + and that wording was stale**; `@cosyte/hl7` is on the registry and it is the `fhir` peer alone that makes this package uninstallable. **Third-party runtime deps: zero.** ## Tech Stack (the shared `@cosyte/*` standard) This repo inherits the canonical toolchain by depending on the published `@cosyte/*` config packages, -not by copying files. The source of truth is the meta-repo's `documentation/conventions.md` — this is +not by copying files. The source of truth is the meta-repo's `documentation/conventions.md`: this is a summary. - **Language:** TypeScript (strict, full rigor set incl. `noUncheckedIndexedAccess`) via `@cosyte/tsconfig`. **Target ES2023**, `NodeNext`. TypeScript 5.9.x, exact-pinned. - **Build:** dual ESM + CJS + `.d.ts` via `tsup` (`@cosyte/tsup-config`); `attw` is a publish gate (per-condition types: `.d.ts` for `import`, `.d.cts` for `require`). The `attw` script is - **`scripts/attw.mjs`, not the bare CLI** — see the guardrail below. + **`scripts/attw.mjs`, not the bare CLI**: see the guardrail below. - **Node:** **>= 22** (CI matrix 22 + 24). - **Package manager:** `pnpm@10`. - **Lint/format:** **ESLint 10** + unified `typescript-eslint` (type-checked) via @@ -73,10 +71,10 @@ a summary. - **Testing:** **Vitest 4** + v8 coverage (`@cosyte/vitest-config`), per-directory >= 90 gates on `src/datatypes`, `src/diagnostics`, `src/terminology` and `src/messages`. Property + fuzz (`fast-check`) over **two** boundaries: `test/datatypes/boundary.property.test.ts` and - `test/messages/property.test.ts` — never-throw, only registered value-free issues, no dangling + `test/messages/property.test.ts`, asserting never-throw, only registered value-free issues, no dangling `urn:uuid:` reference, and an emit gate against `@cosyte/fhir.validateResource`. -- **CI/CD:** thin callers of the reusable `cosyte/.github` workflows, plus one repo-local job - (`no-internal-refs`). **The checks BIND** — ruleset `ci-required-checks`, id `19914044`. +- **CI/CD:** thin callers of the reusable `cosyte/.github` workflows, plus two repo-local workflows + (`no-internal-refs`, `no-emdash`). **The checks BIND**: ruleset `ci-required-checks`, id `19914044`. - **Runtime deps:** **Zero third-party.** `@cosyte/hl7` + `@cosyte/fhir` are peer deps (ADR 0001). - **License:** MIT. @@ -89,14 +87,14 @@ Full section, with every measurement and provenance: context while `ci / verify`, `ci / actionlint` and `codeql` stayed advisory on the branch that publishes. - **One ruleset per repo means one place to audit.** Fold new contexts into `19914044`; never add a - second. `ncpdp` is the cautionary case — it read as "pinned" because _one_ of its rulesets was. + second. `ncpdp` is the cautionary case: it read as "pinned" because _one_ of its rulesets was. - **Pin every required context to `integration_id: 15368`**, or any actor with write access can post a same-named commit status and satisfy it. - **▶ Read context names off REAL CHECK RUNS, never off a workflow's `name:` field.** The workflow named `Public-surface gate` emits the context `no-internal-refs`. Requiring a context nothing emits - does not fail a PR — it leaves it **pending and unmergeable forever**. + does not fail a PR: it leaves it **pending and unmergeable forever**. - **Never require `scorecard / analysis` or `release / release`** (neither runs on `pull_request`; - requiring them strands every PR), **nor the Advanced-Security `CodeQL` check** (id `57789`) — it + requiring them strands every PR), **nor the Advanced-Security `CodeQL` check** (id `57789`): it reports alert state, not that the analysis ran. - **A required job gates all of its steps.** Splitting a step out of `ci / verify` into its own job silently un-requires it, no error and no warning. Banner on `ci.yml`. @@ -104,9 +102,9 @@ Full section, with every measurement and provenance: Change a default there and every PR here strands pending, with nothing local to warn you. - **▶ THE GATE CAN LEAVE THE JOB.** Requiring `ci / verify` pins that `pnpm test` runs, not _what_ it runs: the `include` glob in `vitest.config.ts` **and** the `test`/`test:coverage` script bodies in - `package.json` both drop suites invisibly to the ruleset — including the property/fuzz suites that + `package.json` both drop suites invisibly to the ruleset, including the property/fuzz suites that carry the fail-safe rule. Banner on `vitest.config.ts`. -- **The coverage gate is a thin, incidental backstop, not a real one** — a **1.29-point** margin, and +- **The coverage gate is a thin, incidental backstop, not a real one**: a **1.29-point** margin, and it can never see the loss of the _properties_ themselves. - **PR #10 ("Version Packages") is structurally `BLOCKED`, not stale**: Changesets opens it as `github-actions[bot]` with the default `GITHUB_TOKEN`, which starts no workflow runs, and @@ -119,22 +117,21 @@ Full section, with every measurement and provenance: ## Dependency watching -Weekly `npm` + `github-actions` updates via `.github/dependabot.yml`. Two limits, both written into -that file: automatic **security** update PRs are a repo setting currently reading `disabled`, and -**Dependabot never resolves a `file:vendor/*.tgz` specifier** — so the vendored `@cosyte/hl7` and -`@cosyte/fhir` tarballs, the versions the tests actually exercise, are unwatched on both routes and -stay a `pnpm vendor:refresh` job by hand. Why: `documentation/agent-notes.md#dependency-watching`. +Weekly `npm` + `github-actions` via `.github/dependabot.yml`. **Two limits leave the vendored +`@cosyte/hl7` / `@cosyte/fhir` tarballs, the versions the tests actually exercise, unwatched on both +routes**, so they stay a `pnpm vendor:refresh` job by hand: +`documentation/agent-notes.md#dependency-watching`. ## Engineering Guardrails - No `any`. No unjustified `as` casts. Use `unknown` and narrow. -- JSDoc (with `@example`) on every public export — the JSDoc lint rule is an **error** on public +- JSDoc (with `@example`) on every public export: the JSDoc lint rule is an **error** on public exports, so this is enforced, not optional. -- Immutable output — produced nodes are `@cosyte/fhir` immutables; input v2 composites are never mutated. +- Immutable output: produced nodes are `@cosyte/fhir` immutables; input v2 composites are never mutated. - No `console.*` in library code. Return `{ value, issues }`; never throw on ambiguity. - Short, testable functions over big mapping blobs. -- **The fail-safe rule (the whole point):** on any ambiguity — a naked timestamp, an unresolvable - assigning authority, an unmapped code, a non-UCUM unit, a value that would fail R4 validation — +- **The fail-safe rule (the whole point):** on any ambiguity (a naked timestamp, an unresolvable + assigning authority, an unmapped code, a non-UCUM unit, a value that would fail R4 validation), emit a typed, value-free diagnostic and **refuse to produce a confident wrong FHIR value**. Never silently default, pad a truncation, guess a timezone, synthesize an identifier system, or coerce an unmapped code to a neighbor. @@ -142,7 +139,7 @@ stay a `pnpm vendor:refresh` job by hand. Why: `documentation/agent-notes.md#dep field/datatype/table mapping is verified firsthand against the published HL7 v2-to-FHIR IG's ConceptMaps and cited in the source; a mapping the IG has no target for is flagged, not guessed. - **Value-free diagnostics.** A `TransformIssue` carries a stable code, severity, the v2 location - (segment/field/component index), and the FHIR path — **never a value**. Messages are static/per-code. + (segment/field/component index), and the FHIR path, **never a value**. Messages are static/per-code. - Stable codes are a public API: `ISSUE_CODES` + `FATAL_CODES` are `key === value`; renaming/removing one is a **breaking change**; new codes are additions only. - Coverage: per-directory >= 90% (lines/branches/functions/statements), enforced by @@ -156,18 +153,18 @@ Full narrative, every measurement: `documentation/agent-notes.md#the-attw-guardr BARE CLI.** `getExitCode.js` returns 0 before the problem list is read; no `--profile`, `--ignore-rules` or config setting reaches that early return. For a package that ships types, that sentence means **a broken publish reported as a pass**. -- **The race only supplies the condition** — every `tsup` build has a **~1.6–2.0 s** window with no +- **The race only supplies the condition**: every `tsup` build has a **~1.6–2.0 s** window with no `.d.ts` on disk, reproduced with zero concurrency. **So the answer is not a lock, a lease or a build queue:** the gate must be able to say its own inputs were missing, whatever removed them. -- **`scripts/attw.mjs` carries two nets that catch different things** — a path preflight (catches the +- **`scripts/attw.mjs` carries two nets that catch different things**: a path preflight (catches the build window and _names_ the missing file) and a post-check on the untyped sentence (catches declarations on disk but excluded from the tarball). **Do not collapse them into one.** - **The post-check reads a string, so what would hide that string is refused by option NAME, - wholesale, not by value** — `--quiet`, `--format`, `--config-path`, and `.attw.json` settings. + wholesale, not by value**: `--quiet`, `--format`, `--config-path`, and `.attw.json` settings. A harmless value is refused anyway; that is the deliberate trade. -- **Do not reduce the wrapper to the bare CLI** — it reds 10 of `test/scripts/attw-gate.test.ts`'s 13 +- **Do not reduce the wrapper to the bare CLI**: it reds 10 of `test/scripts/attw-gate.test.ts`'s 13 tests, which is how the suite was checked for bite rather than assumed to have it. -- **A green `attw` has never meant a consumer can install the peer** — measured, `attw` never +- **A green `attw` has never meant a consumer can install the peer**: measured, `attw` never resolves `@cosyte/fhir` at all. And a **complete but stale `dist/`** passes both nets. - **This is a per-repo script and the prose does NOT port with the code.** Re-measure every number on the package you port it to. Derive who still runs the bare CLI: @@ -181,32 +178,31 @@ Full narrative, every measurement and both refuter passes: - **▶ THE CLAIM IS EXACTLY: the scanner refuses (exit 2) every entry it ENUMERATES, and every path NAMED DIRECTLY, that is not a regular file.** "It follows nothing" is the looser wording and **two separate refuter passes measured it FALSE.** Do not tighten the sentence back up. -- **Do not "fix" the link blindness by following the link** — it reads bytes the enumeration does not +- **Do not "fix" the link blindness by following the link**: it reads bytes the enumeration does not control (outside the repo, a loop, a device, a blocking FIFO), and git does not carry them anyway. - **`lstat` answers for the FINAL COMPONENT ONLY.** A named path with a **symlinked ancestor**, or a plain absolute/`../` argument, is still followed. **Pre-existing, disclosed, deliberately NOT closed.** If you touch `buildTargetsForPaths`, **re-measure the sentence, do not re-assert it**. - **▶ THE ONE-LETTER TRAP: `--diff-filter` MUST KEEP `T`.** Replacing a tracked file with a link is - neither an add nor a modify — under `AM` the record dies before any mode is read and a mode-`120000` + neither an add nor a modify: under `AM` the record dies before any mode is read and a mode-`120000` blob passes **green**. Keep `U` too (refused: no stage-0 entry), and keep `--no-renames`, which is what makes a staged rename arrive as a single-path `A`. Key on the **STATUS, not the mode**. -- **Adding any argument to that `git diff --cached` ARGV means re-measuring the record stride** — +- **Adding any argument to that `git diff --cached` ARGV means re-measuring the record stride**: `--find-copies-harder` re-enables two-path records even placed _before_ `--no-renames`. -- **A refusal never echoes the link target** — that is working-tree text and can itself carry PHI. +- **A refusal never echoes the link target**: that is working-tree text and can itself carry PHI. Name the repo-relative path plus a token from the closed `entryKind`/`gitModeKind` sets, nothing else. **This applies to the prose too**: write the dangerous target as a _shape_, never an example. - **The walk has NO extension scope of its own** (it skips regular `*.md` and takes everything else); `src/**.ts` is the **`--staged`** route's boundary. **Do not describe the two as one rule.** - **Exit `2` means every failure to complete; exit `1` means HITS FOUND.** An uncaught throw used to land on node's `1`, so a caller read a gate that never ran as one that ran and fired. -- **Three residuals are disclosed, NOT closed** — (1) no refuse-a-scan-that-observed-nothing rule, so - an empty enumeration reports clean; (2) the ancestor-component / absolute / `../` reads above; (3) a - scan **root's own path** staged as a non-regular entry is outside the `--staged` route's prefix - scope (`dicom`'s guard for this did not come across in the port). **Do not silently re-close any, +- **Three residuals are disclosed, NOT closed**: (1) an empty enumeration reports clean; (2) the + ancestor-component / absolute / `../` reads above; (3) a scan **root's own path** staged as a + non-regular entry is outside the `--staged` route's prefix scope. **Do not silently re-close any, and do not let a future edit read as though they were.** -- **Test against throwaway git repos under `os.tmpdir()`** — the scanner roots everything at +- **Test against throwaway git repos under `os.tmpdir()`**: the scanner roots everything at `process.cwd()`, so **never write a link or a violator into this corpus** to test it. -- **The enumerate-then-read race is deliberately still open**, unreachable by scope today — _until a +- **The enumerate-then-read race is deliberately still open**, unreachable by scope today, _until a walk root widens_, which reintroduces it verbatim. ### The agent-instruction contract gate @@ -214,26 +210,26 @@ Full narrative, every measurement and both refuter passes: Full narrative, every measurement: `documentation/agent-notes.md#the-agent-instruction-contract-gate-in-full`. -- **▶ THIS FILE AND `documentation/agent-notes.md` ARE A CHECKED CONTRACT** — +- **▶ THIS FILE AND `documentation/agent-notes.md` ARE A CHECKED CONTRACT**: `pnpm check:agent-notes`. Refuses a missing archive, an empty section, a dead `#anchor`, an unresolvable file pointer here, an archive `##` nothing here points at. Exit `2` = could not decide, `1` = violations. -- **▶ EXISTENCE IS NOT OBSERVATION**, and **a count cannot detect it** — a count counts the roots +- **▶ EXISTENCE IS NOT OBSERVATION**, and **a count cannot detect it**: a count counts the roots that DID exist. It reconciles what it OPENED against `git ls-files`. **Never re-add a `tracked.has()` pre-check before a read**: that made every branch unreachable, at zero firings, while this line sold it as protection. - **▶ EACH SPACE IS ITS OWN HYPHEN in an anchor slug; runs do NOT collapse.** Collapsing passed a dead pointer and reddened a working one; our spaced-em-dash headings are that shape. -- **It proves a heading is POINTED AT, never that the one-liner says what the section says** — the +- **It proves a heading is POINTED AT, never that the one-liner says what the section says**: the deliberate-omission trap has no identifier to grep for. **Enumerate those by hand.** - **Two routes, not removable by one edit**: the suite in `ci / verify` (which inherits the two levers above) and a step in `no-internal-refs` (which does not). `verify.sh` runs only the first. ## Standing disciplines (every change) -Mirrors the three disciplines in the meta-repo's `documentation/conventions.md` — they bind here too: +Mirrors the three disciplines in the meta-repo's `documentation/conventions.md`, and they bind here too: -1. **Documentation follows code** — a change to the public surface/stack/status isn't done until the +1. **Documentation follows code**. A change to the public surface/stack/status isn't done until the docs are: this repo's docs content (`README.md`, `docs-content/`), the meta-repo `documentation/repos/transform.md` (bump its "last verified" date), and the `ecosystem-map.md` status table. @@ -251,7 +247,7 @@ Mirrors the three disciplines in the meta-repo's `documentation/conventions.md` on `CHANGELOG.md` staying inside that script's `prettier --write` argument list. `test/scripts/changelog-generation.test.ts` pins all of it. Renaming a stable warning code is a **breaking change**. -3. **Crew + knowledgebase loop** — if this library's public API or issue codes change, flag/update +3. **Crew + knowledgebase loop**: if this library's public API or issue codes change, flag/update the matching `crew` healthcare skills (`terminology-mapping`, `fhir-resource-design`) + the KB product doc. 4. **No internal project bookkeeping on a public surface** (founder directive, 2026-07-27). What a @@ -260,29 +256,36 @@ Mirrors the three disciplines in the meta-repo's `documentation/conventions.md` changed. Item identifiers (`TRANSFORM-6`), phase and wave language, roadmap section numbers, ADR numbers, meta-repo paths and "how this got built" commentary belong in the changeset, `CHANGELOG.md`, the commit, the PR and the roadmap. It is a **translation** at the boundary, not a - deletion — when you strip a label off the front of a line, **repair the head**. Gated by + deletion: when you strip a label off the front of a line, **repair the head**. Gated by `pnpm check:no-internal-refs`. Full rationale and every measurement: `documentation/agent-notes.md#no-internal-project-bookkeeping-on-a-public-surface-in-full`. The traps: - **The gate keys on known project prefixes**, so a new programme prefix has to be added by hand; - and it catches identifiers, not English sentences about our process — **the reviewer owns half + and it catches identifiers, not English sentences about our process: **the reviewer owns half the rule.** - **▶ NEVER RE-KEY THE RULE ON THE `WORD-N` SHAPE.** This repo's whole domain vocabulary is written that way (`MSH-9`, `PID-3`, `OBX-5`, `OBR-25`, `SCH-8`, `TXA-19`) and none of it is a violation. Never "resync" the prefix list with a sibling's copy without re-reading why this one keeps `SYNTH` and the `HL7-\d{3,4}` exclusion. - - **Case sensitivity is load-bearing** — `FHIR-core`, `FHIR-required`, `FHIR-core-fixed` are live + - **Case sensitivity is load-bearing**: `FHIR-core`, `FHIR-required`, `FHIR-core-fixed` are live here, and a case-insensitive rule calls every one of them a violation. - **Three source surfaces, three answers:** `/** */` doc comments are **gated** (they render in a consumer's editor); string literals are **gated** (they reach a consumer as diagnostic text); `//` and `/* */` comments are **not gated and identifiers are welcome in them**, because the convention says source comments are a place identifiers belong. - - **▶ DO NOT JUSTIFY THAT BOUNDARY FROM WHAT REACHES `dist/`** — two attempts to do so in a + - **▶ DO NOT JUSTIFY THAT BOUNDARY FROM WHAT REACHES `dist/`**: two attempts to do so in a sibling repo were both false. Measured here, `dist/*.map` ships every tracked source byte in `sourcesContent`, so **all of `src/` is in the tarball anyway**. The line is what a consumer is **shown**, not what lands on their disk. - - **Removing a doc comment to satisfy the gate is a REGRESSION, not a fix** — JSDoc with + - **Removing a doc comment to satisfy the gate is a REGRESSION, not a fix**: JSDoc with `@example` on every public export is a hard guardrail above, and neither lint nor coverage will catch its loss. - The gate cannot read `dist/` (untracked build output): it gates the **source** of the published text, not the published text. +5. **No em dash, anywhere** (founder directive, 2026-07-24), **commit messages and the PR title and + body included**. `pnpm check:no-emdash` gates every tracked file and filename; + `.github/workflows/no-emdash.yml` also gates the PR title, body and commit range. It landed + **with** its sweep: a gate before the sweep reds `main`, a sweep before the gate grows it back. + **Count the bytes in Python, never `grep`** (the container `grep` reads `0` here), and + **`no-emdash-messages` must never be required** (Dependabot pastes upstream release notes into a + PR body). Counts, exemptions, traps: `documentation/agent-notes.md#no-em-dash-anywhere`. diff --git a/README.md b/README.md index cbed546..a0acf2b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # @cosyte/transform -> HL7 v2 → FHIR R4 transformation for Node.js and TypeScript — **IG-grounded, fail-safe, value-free +> HL7 v2 → FHIR R4 transformation for Node.js and TypeScript: **IG-grounded, fail-safe, value-free > diagnostics; never a confident wrong FHIR value**. `@cosyte/transform` is the healthcare **transformation** layer of the cosyte suite. Unlike the @@ -18,9 +18,9 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh > **Status:** pre-alpha (`0.0.x`), published to npm. This release ships the > six safety-critical datatype converters and the value-free diagnostic channel, the > message-level assembly, HL7 v2 **ADT → FHIR Patient + Encounter**, the **ORU^R01 → FHIR -> DiagnosticReport + Observation** results graph, the order-entry graph — **ORM_O01 / -> OML_O21 → ServiceRequest** and **RXO → MedicationRequest** — the thin IG singles — -> **VXU_V04 → Immunization**, **SIU_S12 → Appointment**, **MDM_T02 → DocumentReference** — all +> DiagnosticReport + Observation** results graph, the order-entry graph (**ORM_O01 / +> OML_O21 → ServiceRequest** and **RXO → MedicationRequest**), the thin IG singles +> (**VXU_V04 → Immunization**, **SIU_S12 → Appointment**, **MDM_T02 → DocumentReference**), all > via `toFhir(msg)`, and **terminology value translation** of coded fields: route/site, > appointment type, order priority, and substitution are now value-translated through their IG > `mappedVia` ConceptMaps via `toFhirCodeableConceptVia`, fail-safe on any code the IG leaves unmapped. @@ -33,7 +33,7 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh npm install @cosyte/transform @cosyte/hl7 @cosyte/fhir ``` -`@cosyte/hl7` and `@cosyte/fhir` are **peer dependencies** — the transform maps between the models +`@cosyte/hl7` and `@cosyte/fhir` are **peer dependencies**: the transform maps between the models they own. Its own third-party runtime dependencies are **zero**. **That command does not work yet.** This package is published, but `@cosyte/fhir` is not on the @@ -53,21 +53,21 @@ const { value, issues } = toFhirHumanName({ // value: a FHIR HumanName node; issues: [] (clean, fully mapped) ``` -Each converter returns `{ value, issues }` — the FHIR datatype node it could faithfully produce, plus +Each converter returns `{ value, issues }`: the FHIR datatype node it could faithfully produce, plus the value-free diagnostics it raised. ## The fail-safe rule -Every conversion is grounded on the IG and **refuses to guess**. On any ambiguity — +Every conversion is grounded on the IG and **refuses to guess**. On any of these ambiguities: - a v2 timestamp with a time-of-day but **no timezone** (FHIR forbids time without a zone), - an assigning authority that can't be **resolved to a system URI** (never synthesized from a bare - namespace — that would merge two patients), + namespace, which would merge two patients), - a code with an **unrecognized or absent coding system**, - a unit that isn't valid **UCUM** (magnitudes are never converted), -— the converter produces what it _can_ (often reduced in precision) and raises a **typed, value-free -`TransformIssue`**: a stable code, the v2 location, and the FHIR path — never a value. Render a list +the converter produces what it _can_ (often reduced in precision) and raises a **typed, value-free +`TransformIssue`**: a stable code, the v2 location, and the FHIR path, never a value. Render a list of issues as a FHIR `OperationOutcome` with `toOperationOutcome(issues)`. ## The six datatype converters @@ -83,8 +83,8 @@ of issues as a FHIR `OperationOutcome` with `toOperationOutcome(issues)`. ## Assemble a message -`toFhir(msg)` takes a parsed `@cosyte/hl7` **ADT** message and returns a FHIR R4 **message `Bundle`** — -a `MessageHeader`, then the `Patient` and `Encounter` (and `RelatedPerson`) it describes — plus the +`toFhir(msg)` takes a parsed `@cosyte/hl7` **ADT** message and returns a FHIR R4 **message `Bundle`**: +a `MessageHeader`, then the `Patient` and `Encounter` (and `RelatedPerson`) it describes, plus the value-free issues, each map grounded firsthand on the IG's segment/table ConceptMaps. ```ts @@ -105,19 +105,19 @@ const { bundle, issues } = toFhir(parseHL7(raw), { | NK1 | `RelatedPerson` | NK1-2/3/4 → `name`/`relationship`/`address` | The fail-safe rule holds at the message level: an unmapped patient class, a naked timestamp, or an -unresolvable authority becomes a typed issue — never a fabricated value. A trigger the IG has no +unresolvable authority becomes a typed issue, never a fabricated value. A trigger the IG has no **message** map for is assembled from the segment maps and flagged, never invented; every emitted resource is validated against `@cosyte/fhir` before it ships. The same `toFhir(msg)` handles the other message families: **ORU^R01** → `DiagnosticReport` (OBR) + -`Observation` (OBX), and the order-entry graph — **ORM_O01 / OML_O21** ORC/OBR → -`ServiceRequest`, **RXO** (+ RXR route) → `MedicationRequest`, with `ServiceRequest.status` +`Observation` (OBX), and the order-entry graph, **ORM_O01 / OML_O21** ORC/OBR → +`ServiceRequest` and **RXO** (+ RXR route) → `MedicationRequest`, with `ServiceRequest.status` grounded on the HL70119 → request-status ConceptMap and withheld when it cannot be grounded, and a `MedicationRequest` whose IG-ungrounded status is the honest `unknown` rather than a guess. `RXE` has no STU1 IG map and is flagged, never assembled. The thin single-trigger families complete the IG-covered message set: **VXU_V04** RXA (+ -RXR route, ORC) → `Immunization` (status via the IG's three conditioned rows — a delete action → +RXR route, ORC) → `Immunization` (status via the IG's three conditioned rows: a delete action → `entered-in-error`, an unvalued RXA-20 → `completed`, else the HL70322 → event-status ConceptMap, with a valued-but-unmapped code withheld), **SIU_S12** SCH/AIS/PID → `Appointment` (status via the HL70278 → appointmentstatus ConceptMap, the Patient wired as the required participant, its IG-unsourced @@ -126,12 +126,12 @@ grounded only for TXA-19 `AV` → `current`, the document body base64-encoded ve interpreted). Timezone-naked instants are dropped and flagged, never assigned a fabricated UTC offset. `toFhirCodeableConceptVia(cwe, map)` applies a license-clean IG value ConceptMap -(transcribed and verified firsthand against the raw published IG JSON) — **RXR** route/site +(transcribed and verified firsthand against the raw published IG JSON): **RXR** route/site (HL70162/HL70550), **SCH-8** appointment type (HL70277), **RXO-9** substitution (HL70161), and **OBR-5** -priority (HL70485) — translating the source table code to its FHIR target coding, additively (the raw +priority (HL70485), translating the source table code to its FHIR target coding, additively (the raw coding is preserved alongside the derived one). It is fail-safe by refusal: a code the IG map leaves in its `(unmapped)` group is flagged, never coerced to a neighbour. Fields whose IG target is **SNOMED CT** -(RXR-4 method, SCH-7 reason) stay structural — SNOMED is not bundled (BYO ConceptMap) — and fields the +(RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented. ## License diff --git a/docs-content/concepts-archetype.md b/docs-content/concepts-archetype.md index 0eff3bb..a48cd40 100644 --- a/docs-content/concepts-archetype.md +++ b/docs-content/concepts-archetype.md @@ -6,20 +6,20 @@ sidebar_position: 1 # Core concepts -`@cosyte/transform` borrows the cosyte parser suite's **disciplines** — fail-safe on ambiguity, stable -typed diagnostics, immutable output — without being a byte parser. There is no wire format here; both +`@cosyte/transform` borrows the cosyte parser suite's **disciplines**: fail-safe on ambiguity, stable +typed diagnostics, immutable output, without being a byte parser. There is no wire format here; both endpoints are typed models. What replaces Postel's Law is the **fail-safe rule**. ## The fail-safe rule A transformed message drives treatment, filing, and identity matching, so the library's whole promise -is: **never emit a confident wrong FHIR value.** On any ambiguity — +is: **never emit a confident wrong FHIR value.** On any of these ambiguities: - a v2 timestamp with a time-of-day but **no timezone** (FHIR forbids time without a zone), - an assigning authority that can't be **resolved to a system URI**, - a code with an **unrecognized or absent coding system**, - a unit that isn't valid **UCUM**, -- a source component with **no FHIR target** — +- a source component with **no FHIR target**, the converter produces the value it *can* faithfully emit (often reduced in precision) and raises a typed diagnostic. It never silently defaults, never pads a truncated date, never guesses a timezone, @@ -28,20 +28,20 @@ never synthesizes an identifier system, and never coerces an unmapped code to a ## The diagnostic channel is value-free Every diagnostic is a `TransformIssue`: a stable `code`, a severity, the **v2 location** (a segment / -field / component index — never a value), and the **FHIR path** it concerns. Messages are static and -per-code, so **no patient data can reach a log line** — the same value-free `OperationOutcome` +field / component index, never a value), and the **FHIR path** it concerns. Messages are static and +per-code, so **no patient data can reach a log line**: the same value-free `OperationOutcome` discipline `@cosyte/fhir` proves. Render a list of issues as a real FHIR `OperationOutcome` with `toOperationOutcome(issues)`. Codes are `key === value` entries in `ISSUE_CODES` (non-fatal) and `FATAL_CODES`. Consumers branch on -them, so a code's name is part of the public contract — renaming or removing one is a **breaking +them, so a code's name is part of the public contract: renaming or removing one is a **breaking change**, and new codes are additions only. ## Grounded on the IG, never invented Every mapping is grounded firsthand on the published **HL7 Version 2 to FHIR** Implementation Guide's datatype and table ConceptMaps (LOINC/SNOMED URIs from the FHIR core terminology systems). Where the -IG has no target for a code — an unmapped Table 0200 name-type, an unmapped Table 0190 address-type — +IG has no target for a code (an unmapped Table 0200 name-type, an unmapped Table 0190 address-type), the value is surfaced, **never guessed**. ## The six datatype converters @@ -56,7 +56,7 @@ the value is surfaced, **never guessed**. | `toFhirQuantity` | NM + units → `Quantity` | non-UCUM unit → verbatim, magnitude never converted | Each returns `{ value, issues }`; each output is designed to pass `@cosyte/fhir`'s `validateResource` -when embedded in a resource — the transform's emit gate. +when embedded in a resource: the transform's emit gate. ## Immutability diff --git a/docs-content/guides-overview.md b/docs-content/guides-overview.md index fc68f5f..d75dae6 100644 --- a/docs-content/guides-overview.md +++ b/docs-content/guides-overview.md @@ -13,8 +13,8 @@ runnable example passes the doc/code-agreement check. ## Resolve an assigning authority A patient identifier's assigning authority (HD) is resolved through a **NamingSystem registry**. The -registry auto-derives a system URI only for the two unambiguous cases — a valid OID with -`universalIdType: "ISO"` (`urn:oid:…`) and a valid UUID (`urn:uuid:…`) — and otherwise consults the +registry auto-derives a system URI only for the two unambiguous cases: a valid OID with +`universalIdType: "ISO"` (`urn:oid:…`) and a valid UUID (`urn:uuid:…`), and otherwise consults the entries you seed. A bare namespace mnemonic (HD.1) is **never** turned into a URI on its own. ```ts runnable @@ -30,7 +30,7 @@ registry.resolveAssigningAuthority({ namespaceId: "HOSPMRN" }); // => "urn:oid:1 ## Supply a sender's timezone for naked timestamps By default a v2 timestamp with a time-of-day but no offset is reduced to date precision (FHIR forbids -time without a zone). If you *know* the sending system's offset, assert it — the value is emitted with +time without a zone). If you *know* the sending system's offset, assert it, and the value is emitted with that offset and flagged as sender-asserted, never inferred: ```ts diff --git a/docs-content/installation.md b/docs-content/installation.md index 0d2c48e..8795338 100644 --- a/docs-content/installation.md +++ b/docs-content/installation.md @@ -8,7 +8,7 @@ sidebar_position: 1 `@cosyte/transform` ships dual **ESM + CJS** builds with per-condition type declarations, so it works from either module system without configuration. Its own **third-party runtime dependencies are -zero** — it depends only on two cosyte siblings. +zero**: it depends only on two cosyte siblings. > **Status:** pre-alpha (`0.0.x`). The package **is published on npm**, but it **cannot be installed > from npm yet**: its `@cosyte/fhir` peer dependency is not on the registry, so the command below @@ -18,7 +18,7 @@ zero** — it depends only on two cosyte siblings. ## Prerequisites - **Node.js >= 22** (the whole `@cosyte/*` suite targets ES2023 / Node 22+). -- A package manager — `pnpm`, `npm`, or `yarn`. +- A package manager: `pnpm`, `npm`, or `yarn`. ## Install @@ -30,7 +30,7 @@ npm install @cosyte/transform @cosyte/hl7 @cosyte/fhir `@cosyte/hl7` and `@cosyte/fhir` are declared as **peer dependencies**: `transform` maps between the models they own, so a consumer supplies them. `@cosyte/hl7` is on the registry. **`@cosyte/fhir` is -not**, and that is what blocks the install above — npm reports `ERESOLVE` and refuses to resolve the +not**, and that is what blocks the install above: npm reports `ERESOLVE` and refuses to resolve the `@cosyte/fhir` peer. There is no flag that makes this correct. `--legacy-peer-deps` does not help: naming `@cosyte/fhir` on the command line then fails with a plain `404 Not Found` instead, because the package is not on the registry at all; dropping it and installing this package alone does @@ -38,7 +38,7 @@ succeed, but leaves both peers genuinely absent at runtime, so the first convers fails. Until `@cosyte/fhir` publishes, this repo's own tests build against **vendored tarballs** in -`vendor/` (regenerated by `pnpm vendor:refresh`) — the same interim mechanism `@cosyte/mllp` uses, +`vendor/` (regenerated by `pnpm vendor:refresh`): the same interim mechanism `@cosyte/mllp` uses, replaced by a real npm install once the peer is on the registry. ## Smoke test @@ -57,7 +57,7 @@ the next release onward what it prints is the release you are running. > **Every release up to and including `0.0.4` prints `0.0.0`.** They all went out with the constant > stuck at its scaffold value, and a published version is never re-published, so those copies stay -> wrong on the registry. So `0.0.0` does not identify _which_ of them you have — it only tells you the +> wrong on the registry. So `0.0.0` does not identify _which_ of them you have: it only tells you the > constant is lying, not your install. Read the manifest instead, which was always correct: > `node -p "require('@cosyte/transform/package.json').version"`. diff --git a/docs-content/intro.md b/docs-content/intro.md index 4122b47..836262d 100644 --- a/docs-content/intro.md +++ b/docs-content/intro.md @@ -6,7 +6,7 @@ sidebar_position: 1 # @cosyte/transform -Turn a parsed HL7 v2 message into **valid FHIR R4** — without reading the 900-page v2 spec, without +Turn a parsed HL7 v2 message into **valid FHIR R4**, without reading the 900-page v2 spec, without hand-writing a ConceptMap, and **without ever being handed a confident wrong FHIR value**. `@cosyte/transform` is the healthcare **transformation** layer of the cosyte suite. Unlike the @@ -15,13 +15,13 @@ composites and produces validated [`@cosyte/fhir`](https://github.com/cosyte/fhi grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fhir.uv.v2mappings`). > **Status:** pre-alpha (`0.0.x`). The package **is published on npm**, but it **cannot be installed -> from npm yet** — see [Installation](./installation). This release ships the +> from npm yet**: see [Installation](./installation). This release ships the > six safety-critical datatype converters and the value-free diagnostic channel, and > message-level assembly via `toFhir(msg)` for HL7 v2 **ADT → Patient + Encounter**, > **ORU^R01 → DiagnosticReport + Observation**, the order-entry graph **ORM_O01 / OML_O21 → > ServiceRequest** and **RXO → MedicationRequest**, and the thin IG singles **VXU_V04 → > Immunization**, **SIU_S12 → Appointment**, and **MDM_T02 → DocumentReference**, plus -> **terminology value translation** of coded fields — route/site, appointment type, order +> **terminology value translation** of coded fields: route/site, appointment type, order > priority, and substitution translated through their IG `mappedVia` ConceptMaps. The > v2→FHIR direction is feature-complete for the IG-covered message set; deeper terminology, profiles, > and the reverse FHIR → v2 direction are not implemented. @@ -30,7 +30,7 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh Every conversion is grounded on the IG and is **fail-safe**: an unmapped code, an ambiguous datatype, a v2 timestamp with no timezone, or an unresolvable assigning authority becomes a **typed, value-free -diagnostic** — never a silent default, never a fabricated value, never a guessed UTC offset. +diagnostic**, never a silent default, never a fabricated value, never a guessed UTC offset. ## Install @@ -38,12 +38,12 @@ diagnostic** — never a silent default, never a fabricated value, never a guess npm install @cosyte/transform @cosyte/hl7 @cosyte/fhir ``` -`@cosyte/hl7` and `@cosyte/fhir` are **peer dependencies** — the transform maps between the models +`@cosyte/hl7` and `@cosyte/fhir` are **peer dependencies**: the transform maps between the models they own, so you install them alongside it. **That command does not work yet:** `@cosyte/fhir` is not on the registry, so npm fails with `ERESOLVE` and refuses to resolve that peer. Until it publishes, consume this package from source or a workspace link. [Installation](./installation) has the detail. ## Next -- [Quickstart](./quickstart) — convert your first datatypes. -- [Core concepts](./concepts-archetype) — the fail-safe rule and the diagnostic channel. +- [Quickstart](./quickstart): convert your first datatypes. +- [Core concepts](./concepts-archetype): the fail-safe rule and the diagnostic channel. diff --git a/docs-content/quickstart.md b/docs-content/quickstart.md index f5a10f6..e98653d 100644 --- a/docs-content/quickstart.md +++ b/docs-content/quickstart.md @@ -7,7 +7,7 @@ sidebar_position: 1 # Quickstart The **datatype converters** each take a parsed `@cosyte/hl7` composite and return a `{ value, issues }` -pair — the FHIR datatype node it could faithfully produce, plus the value-free diagnostics it raised. +pair: the FHIR datatype node it could faithfully produce, plus the value-free diagnostics it raised. The **message-level** entry `toFhir(msg)` assembles a whole HL7 v2 message into a FHIR `Bundle` over those same converters: an **ADT** admit becomes a **Patient + Encounter** graph, and an **ORU^R01** lab result becomes a **DiagnosticReport + Observation** graph. @@ -33,7 +33,7 @@ because every part mapped cleanly. ## The fail-safe rule in action When a mapping is ambiguous, the converter **refuses to guess** and tells you why. A patient -identifier whose assigning authority can't be resolved is emitted with its value and **no system** — +identifier whose assigning authority can't be resolved is emitted with its value and **no system**, never a synthesized one that could merge two patients: ```ts runnable @@ -52,7 +52,7 @@ and the same call resolves `Identifier.system` with no diagnostic. ## Assemble a message -Parse an `ADT^A01` with `@cosyte/hl7`, then hand it to `toFhir` — you get back a FHIR R4 **message +Parse an `ADT^A01` with `@cosyte/hl7`, then hand it to `toFhir`: you get back a FHIR R4 **message `Bundle`** (a `MessageHeader`, then the `Patient` and `Encounter` it describes) plus the value-free issues. Every segment→resource map is grounded on the published HL7 v2-to-FHIR IG. @@ -70,7 +70,7 @@ const { bundle, issues } = toFhir(msg, { ``` The same fail-safe rule holds at the message level: an unmapped patient class, a naked timestamp, or -an unresolvable identifier authority becomes a typed issue — never a fabricated FHIR value. A message +an unresolvable identifier authority becomes a typed issue, never a fabricated FHIR value. A message whose trigger the IG has **no message map** for is still assembled from the reusable segment maps and flagged `TRANSFORM_SEGMENT_ASSEMBLED`, never invented. The Table-0001/0004 maps the assembly applies are exported for inspection: @@ -85,8 +85,8 @@ ENCOUNTER_CLASS_V3_MAP["I"].code; // => "IMP" ## Lab results (ORU^R01) An `ORU^R01` assembles into a **`DiagnosticReport`** per OBR with its **`Observation`** results. -`OBX-2` discriminates the value type — `NM` → `valueQuantity`, `CWE` → `valueCodeableConcept`, `SN` → -a structured range/ratio/comparator quantity, `ST`/`TX` → `valueString` — so a result is **never** +`OBX-2` discriminates the value type: `NM` → `valueQuantity`, `CWE` → `valueCodeableConcept`, `SN` → +a structured range/ratio/comparator quantity, `ST`/`TX` → `valueString`, so a result is **never** forced into a `Quantity` it isn't. The result-status maps are the clinical-safety heart of the graph and are exported for inspection: a **corrected** or **cancelled** result is modelled exactly and **never emitted as `final`**, and a status the IG map has no target for leaves `status` absent (the @@ -102,5 +102,5 @@ DIAGNOSTIC_REPORT_STATUS_MAP["F"]; // => "final" ## Next -- [Core concepts](./concepts-archetype) — the fail-safe rule, the diagnostic channel, the six converters. -- **API reference** — every export, generated from source. +- [Core concepts](./concepts-archetype): the fail-safe rule, the diagnostic channel, the six converters. +- **API reference**: every export, generated from source. diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index 30b24b1..ed5c882 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -10,7 +10,7 @@ Common symptoms when converting v2 → FHIR, and how to read what the transform ## A converter returned `value: undefined` -Nothing could be **safely** emitted. This is by design, not an error — it happens for an empty input +Nothing could be **safely** emitted. This is by design, not an error: it happens for an empty input composite, an unparseable timestamp, or a numeric value that wasn't numeric. Check `issues` for the typed reason (e.g. `TRANSFORM_TIMESTAMP_INVALID`). @@ -29,7 +29,7 @@ A missing FHIR element usually comes with a diagnostic explaining the refusal: ## My timestamp lost its time-of-day `TRANSFORM_TIMESTAMP_NO_TIMEZONE`: the v2 timestamp had a time but no offset, and FHIR forbids a time -without a zone — so it was reduced to date precision rather than assuming UTC (which would shift the +without a zone, so it was reduced to date precision rather than assuming UTC (which would shift the clinical instant by hours). Supply `assumeTimezoneOffsetMinutes` if you know the sender's offset. ## My unit didn't populate `Quantity.code` @@ -41,18 +41,18 @@ preserved verbatim in `Quantity.unit` with `code`/`system` absent. Magnitudes ar ## Are diagnostics safe to log? Yes. A `TransformIssue` carries only a stable code, a severity, a **positional** v2 location, and a -FHIR path — **never a value**. Its `message` is static. Do not log the raw v2 message or the produced +FHIR path, **never a value**. Its `message` is static. Do not log the raw v2 message or the produced resource values; those carry PHI. ## Known limitations -- **Message families: the IG-covered set** — `toFhir(msg)` assembles ADT → Patient + Encounter, +- **Message families: the IG-covered set.** `toFhir(msg)` assembles ADT → Patient + Encounter, ORU^R01 → DiagnosticReport + Observation, ORM_O01 / OML_O21 → ServiceRequest and RXO → MedicationRequest, and the thin IG singles VXU_V04 → Immunization, SIU_S12 → Appointment, and MDM_T02 → DocumentReference. The v2→FHIR direction is feature-complete for the IG-covered message set; terminology depth, profiles, and the reverse FHIR → v2 direction are not implemented. -- **Thin-IG-single scope** — each family covers the single trigger the IG maps and the +- **Thin-IG-single scope**: each family covers the single trigger the IG maps and the resource-internal fields; references to resources this tier does not yet build (Immunization performer/manufacturer/location, Appointment practitioner/location participants, DocumentReference author/authenticator) are deferred and flagged, never dangling. `Immunization.status` follows the IG's @@ -62,17 +62,17 @@ resource values; those carry PHI. IG-unsourced required `status` is a `data-absent-reason` primitive, and the MDM document body is base64-encoded verbatim (the IG-assigned `application/text` / `text/hl7v2` contentType), carried and never interpreted. -- **ORU scope** — `DiagnosticReport.category` is not defaulted (the IG segment map sets none; it is +- **ORU scope**: `DiagnosticReport.category` is not defaulted (the IG segment map sets none; it is realm-dependent), the results graph uses the first PID/PV1 (multiple patient result groups are not handled), and OBR performers/specimen and `basedOn` ServiceRequest are deferred. An OBX value type with no first-class FHIR `value[x]` (`NA`, `ED`, `DR`, `TM`, `NR`, …) preserves the raw value as - `valueString` and flags it — never a fabricated typed value. -- **Terminology value translation** — coded fields with an IG `mappedVia` value ConceptMap - are value-translated via `toFhirCodeableConceptVia`: RXR route/site (HL70162/HL70550), SCH-8 + `valueString` and flags it, never a fabricated typed value. +- **Terminology value translation**: coded fields with an IG `mappedVia` value ConceptMap + are value-translated via `toFhirCodeableConceptVia`, covering RXR route/site (HL70162/HL70550), SCH-8 appointment type (HL70277), RXO-9 substitution (HL70161), and OBR-5 priority (HL70485). Each map is transcribed and verified firsthand against the raw published IG ConceptMap JSON; a source code the IG leaves in its `(unmapped)` group is flagged (`TRANSFORM_CODE_UNMAPPED`), never coerced. Two fields the - IG maps into **SNOMED CT** (RXR-4 method, SCH-7 reason) stay structural (SNOMED is not bundled — BYO + IG maps into **SNOMED CT** (RXR-4 method, SCH-7 reason) stay structural (SNOMED is not bundled; BYO ConceptMap), and fields the IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is. The built-in NamingSystem code-system seed is still the FHIR-core-fixed systems; the full HL7 THO crosswalk beyond these maps is not implemented. diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 16d581d..f89e02a 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -1,4 +1,4 @@ -# @cosyte/transform — agent notes +# @cosyte/transform: agent notes **What this is.** The long-form narrative that used to live in `CLAUDE.md`: the per-incident sections, the shipped-phase history, and the long rationales behind the guardrails. It was relocated @@ -6,26 +6,26 @@ here on **2026-08-04** under `CLAUDE-MD-AUDIT`, because `CLAUDE.md` is always-re that `cd`s into this repo and the per-worker token cost is paid on every session (umbrella ADR 0023, amendment 2026-08-04). -**Relocated, not deleted.** The narrative below is the CLAUDE.md text **verbatim** — nothing was +**Relocated, not deleted.** The narrative below is the CLAUDE.md text **verbatim**: nothing was softened, summarised, or dropped. Be precise about what that claim covers, because this file's whole value is that it does not overstate. **The accounting below covers the seven sections the 2026-08-04 -relocation produced, and nothing after them** — sections added later are new prose written here, not +relocation produced, and nothing after them**: sections added later are new prose written here, not relocated `CLAUDE.md` text, and the verbatimness claim does not reach them. (It said "the narrative below" without that scope until 2026-08-06, when the first later section made it false; a refuter measured it, and it is scoped rather than deleted.) Of those seven: **two headings** (`Branch protection…`, `Dependency watching`) are the originals carried across whole; **four** are new headings over relocated bodies that were bullets in `CLAUDE.md`'s `Status`, `Engineering Guardrails` -and `Standing disciplines` sections; and **one** — `Publish state, and the stale claim inside it` — is +and `Standing disciplines` sections; and **one** (`Publish state, and the stale claim inside it`) is mostly **newly written on 2026-08-04**, with the relocated original quoted inside it as a blockquote. Every `##` section heading is a pointer target from `CLAUDE.md`, and the contract gate keeps that true. **`###` subsections inside them are -deliberately NOT pointer targets** — requiring one would force the always-read file to grow a line per +deliberately NOT pointer targets**: requiring one would force the always-read file to grow a line per subsection, which is what its byte ratchet exists to prevent. This sentence read "Every heading" until 2026-08-06, when adding the first `###` headings here made that literally false; it is narrowed to what is checked rather than left as a claim nothing holds up. `CLAUDE.md` keeps the cursor, the rules, and **every** trap as a one-line imperative that points back here. If a one-liner there and a paragraph here ever disagree, **this file is the measurement** and -the one-liner is the reminder — fix the one-liner, do not weaken this. +the one-liner is the reminder: fix the one-liner, do not weaken this. **These are things that cost a defect to learn.** Several are refuted claims: a sentence someone wrote, a refuter measured false, and the correction stayed. Do not re-assert one of them from @@ -36,7 +36,7 @@ reading the code. **Re-measure, or leave it alone.** ## Shipped-phase history (Phases 1–6) **Phases 1–6 shipped** (roadmap `operations/roadmaps/transform.md` §Phase 1–6). Pre-alpha `0.0.x`, -**published on npm** — this line read "not yet published to npm" for several releases after first +**published on npm**: this line read "not yet published to npm" for several releases after first publish, which is part of why a `VERSION` constant stuck at `"0.0.0"` went unnoticed on a shipped package. **Never quote a version here**; `npm view @cosyte/transform version` is the only source of truth. **Published is not installable here:** the `@cosyte/fhir` peer is not on the registry, so @@ -44,12 +44,12 @@ truth. **Published is not installable here:** the `@cosyte/fhir` peer is not on useful. Phase 1: the **six safety-critical datatype converters** (`toFhirDateTime`, `toFhirIdentifier`, `toFhirCodeableConcept`, `toFhirHumanName`, `toFhirAddress`, `toFhirQuantity`), the **value-free diagnostic channel** (`ISSUE_CODES`/`FATAL_CODES`, `TransformIssue`, -`toOperationOutcome`), and the minimal **NamingSystem resolver** (`createNamingSystem`). Phase 2: the -first **message-level assembly** — `toFhir(msg)` turns an HL7 v2 **ADT** message into a FHIR R4 +`toOperationOutcome`), and the minimal **NamingSystem resolver** (`createNamingSystem`). Phase 2 was +the first **message-level assembly**: `toFhir(msg)` turns an HL7 v2 **ADT** message into a FHIR R4 **message `Bundle`** (MSH→`MessageHeader`, PID→`Patient`, PV1→`Encounter`, NK1→`RelatedPerson`, with `urn:uuid:` reference wiring, the HL70001/HL70004 table maps, a segment-assembled fallback for non-IG-mapped triggers, and a conservative-emit gate against `@cosyte/fhir.validateResource`). Phase -3: the **ORU^R01 → DiagnosticReport + Observation** results graph — OBR→`DiagnosticReport` (status via +3: the **ORU^R01 → DiagnosticReport + Observation** results graph, OBR→`DiagnosticReport` (status via HL70123, `DIAGNOSTIC_REPORT_STATUS_MAP`), OBX→`Observation` with **OBX-2 value-type discrimination** of OBX-5→`value[x]` (NM→`valueQuantity`, CWE→`valueCodeableConcept`, SN→structured, ST/TX→`valueString`), OBX-8→`interpretation` (HL70078, `HL70078_INTERPRETATION_CODES`), OBX-11→`status` (HL70085, @@ -58,13 +58,13 @@ result never emits as `final`; an unmapped status withholds the resource; a prec read from the raw OBX-5). Every segment→resource and field→element map is grounded firsthand on the IG's ConceptMaps and cited. Phases 4–5 added the message-level graphs for **ORM_O01/OML_O21 → ServiceRequest** and **RXO → MedicationRequest** (Phase 4) and the thin IG singles **VXU_V04 → -Immunization**, **SIU_S12 → Appointment**, **MDM_T02 → DocumentReference** (Phase 5). Phase 6: the -**terminology value-translation** layer — a `$translate`-shaped, additive, fail-safe engine +Immunization**, **SIU_S12 → Appointment**, **MDM_T02 → DocumentReference** (Phase 5). Phase 6 added the +**terminology value-translation** layer: a `$translate`-shaped, additive, fail-safe engine (`toFhirCodeableConceptVia`) applying the license-clean IG value ConceptMaps (each transcribed + verified **firsthand against the raw IG JSON**) to the previously structural-only coded fields: RXR route/site (HL70162 / HL70550), SCH-8 appointment type (HL70277), RXO-9 substitution (HL70161), and OBR-5 priority (HL70485, `SERVICE_REQUEST_PRIORITY_MAP`). A code in the IG's `(unmapped)` group is -flagged, never coerced; SNOMED-target maps (RXR-4 method, SCH-7 reason) stay structural/BYO — no +flagged, never coerced; SNOMED-target maps (RXR-4 method, SCH-7 reason) stay structural/BYO, no SNOMED bundled; and fields with no IG value map (TXA-2, RXA-5) are documented as structural, never invented. @@ -78,31 +78,35 @@ and profiles (Phase 8). readings of the same fact are on record here. The relocation of 2026-08-04 deliberately carried **both** across rather than picking one, because picking one is how a stale claim becomes fact. -**The paragraph as it stood in `CLAUDE.md`, verbatim:** +**The paragraph as it stood in `CLAUDE.md`, verbatim in wording.** Its dash punctuation was +normalized by the brand sweep of 2026-08-07, which took `U+2014` out of every tracked file in this +repository including this quotation. **Not one word, claim, qualifier or identifier changed**, and the +sweep is recorded here rather than left for a reader to discover, because this section's whole value +is that it does not overstate what it reproduces. > **Consumes two cosyte siblings** (`@cosyte/hl7`, `@cosyte/fhir`) as **peer dependencies**, vendored -> as `pnpm pack` tarballs in `vendor/` for dev/test (ADR 0001 + umbrella ADR 0008) — refresh with -> `pnpm vendor:refresh`. Pinned shas: hl7 `46d50eb`, fhir `7a099b2`. **They are not both unpublished -> — that wording was stale.** `@cosyte/hl7` is on the registry; **`@cosyte/fhir` is not** (npm 404, +> as `pnpm pack` tarballs in `vendor/` for dev/test (ADR 0001 + umbrella ADR 0008): refresh with +> `pnpm vendor:refresh`. Pinned shas: hl7 `46d50eb`, fhir `7a099b2`. **They are not both unpublished, +> and that wording was stale.** `@cosyte/hl7` is on the registry; **`@cosyte/fhir` is not** (npm 404, > a human-gated publish), and it is the fhir peer alone that makes this package uninstallable from > npm. Derive it, do not recall it: `npm view @cosyte/hl7 version`, `npm view @cosyte/fhir version`. > **Third-party runtime deps: zero.** **That paragraph is itself flagged stale**, in the umbrella backlog, alongside the same claim in -`hl7`, `mllp` and `deid`. It has now been corrected **twice** — first from "both unpublished", then -again below — which is the reason it is quoted rather than silently rewritten. **Relocating a +`hl7`, `mllp` and `deid`. It has now been corrected **twice**: first from "both unpublished", then +again below, which is the reason it is quoted rather than silently rewritten. **Relocating a disputed claim must not launder it into fact.** It is reproduced above as history, not as a measurement. **The specific words under dispute are `npm 404, a human-gated publish`, and the two halves are not -equally wrong — say which is which rather than rejecting the line wholesale.** Two different facts +equally wrong: say which is which rather than rejecting the line wholesale.** Two different facts sit behind them and the old wording collapses them into one: - **The package is absent from the registry**, so `npm view @cosyte/fhir` answers **404** and a consumer install of `@cosyte/transform` fails **`E404`** on the missing peer. That half is not the - defect. **Do not generalise the code across siblings** — `@cosyte/synth` is blocked by the same + defect. **Do not generalise the code across siblings**: `@cosyte/synth` is blocked by the same absent peer but fails **`ERESOLVE`**, a different code for the same cause. -- **The publish ATTEMPT is refused with `E403` on `PUT`**, cause unestablished — tracked as +- **The publish ATTEMPT is refused with `E403` on `PUT`**, cause unestablished: tracked as `FHIR-NPM-NAME`. **This is where `a human-gated publish` misleads.** It reads as a routine approval someone has not clicked yet. It is not: CI is green, provenance reaches the transparency log _before_ the refusal, and the registry rejects at **policy**. The human step on record is @@ -115,8 +119,8 @@ The work is staged on `main`; the registry refuses it. The same blockage takes ` **The `FHIR-NPM-NAME` name is a label, not a diagnosis. The "name-similarity" reading is RETRACTED.** It implied a rename, and **the error never asked for one**. **DO NOT RENAME ANYTHING** -— not `@cosyte/fhir`, not `@cosyte/transform`, not a scope, not an export — on the strength of that -identifier. The cause is unexplained and staying unexplained is the honest state. +on the strength of that identifier: not `@cosyte/fhir`, not `@cosyte/transform`, not a scope, not an +export. The cause is unexplained and staying unexplained is the honest state. **So do not resolve this from memory, and do not resolve it from this file.** Every reading here has a date on it and at least one was wrong when read. Derive it: @@ -127,7 +131,7 @@ npm view @cosyte/hl7 version npm view @cosyte/fhir version ``` -**Visibility and publish state are independent** — never infer one from the other. And **never move a +**Visibility and publish state are independent**, never infer one from the other. And **never move a published version backwards** (umbrella ADR 0001). ## Branch protection (and the limits of this claim) @@ -248,73 +252,73 @@ and stay a `pnpm vendor:refresh` job by hand. **▶ `attw` SAYS "does not contain types" AND EXITS 0, SO THE `attw` SCRIPT IS A WRAPPER, NOT THE BARE CLI.** `getExitCode.js` in `@arethetypeswrong/cli@0.18.4` opens with `if (!analysis.types) -return 0` — an untyped package is a legitimate npm package, so "no types at all" is a description, +return 0`: an untyped package is a legitimate npm package, so "no types at all" is a description, not a problem, and the problem list is never consulted. No `--profile`, `--ignore-rules` or config setting reaches that early return. For a package that ships types it means the declarations were **not in the tarball**, which is a broken publish reported as a pass. The invocation here was -never lenient — it was the bare `attw --pack .` on the default strict profile. +never lenient: it was the bare `attw --pack .` on the default strict profile. **The race only supplies the condition.** Reproduced here on a quiet box with zero concurrency: `rm -rf dist && attw --pack .`, and `pnpm build && rm -f dist/index.d.*ts && attw --pack .`, both -print the sentence and exit 0. The second is the realistic window — `tsup` emits the bundles in +print the sentence and exit 0. The second is the realistic window: `tsup` emits the bundles in one pass and the declarations in a later one, so **every** build has an interval where `dist/` holds `.mjs`/`.cjs` and no `.d.ts`; measured at **1,600 / 1,646 / 2,018 ms** over three consecutive quiet-box builds, polling every 5 ms. A concurrent build or `clean` in the same working tree lands `attw` in it. So the answer is **not** a lock, a lease or a build queue: the gate must be able to say its own inputs were missing, whatever removed them. -`scripts/attw.mjs` carries **two nets, and they catch different things** — a preflight that every +`scripts/attw.mjs` carries **two nets, and they catch different things**: a preflight that every relative path `package.json` promises (`main`, `module`, `types`, `typings`, every string leaf of `exports`) exists and is non-empty, which catches the build window and _names the missing file_; and a post-check on `attw`'s untyped sentence, which catches what the preflight structurally -cannot — declarations present on disk but excluded from the tarball by `files`/`.npmignore`. +cannot, namely declarations present on disk but excluded from the tarball by `files`/`.npmignore`. **No instance of that second case is on record in this repo.** `test/scripts/attw-gate.test.ts` pins both nets against the real binary, including the upstream exit-0 itself, so an `attw` upgrade that reworks the wording or fixes the exit code reds the suite instead of letting the net go quietly slack. It also pins a **negative control** on a well-formed package, and that a real -`attw` failure still fails with `attw`'s own status — a gate that only ever fails is not a gate, +`attw` failure still fails with `attw`'s own status: a gate that only ever fails is not a gate, and one that swallows the status is not one either. Reducing the wrapper to the bare CLI reds 10 of its 13 tests; that is how the suite was checked for bite rather than assumed to have it. **The post-check reads a string, so what would hide that string is refused**, not tolerated. **Three routes were measured here** to hand back exit 0 over an untyped pack: `--quiet`, `--format json`, and a `.attw.json` setting either (`readConfig()` applies it after argv). `--config-path` is refused too, but **by inference, not measurement**. The refusal is **by option -name, wholesale, not by value** — a harmless `--format` value blinds nothing and is refused +name, wholesale, not by value**: a harmless `--format` value blinds nothing and is refused anyway, which is the deliberate trade against value-parsing them. **Two limits of a green here.** A **complete but stale `dist/`** passes both nets (not live today only because the ladder runs `build` before `attw`); and this package's unpublished -`@cosyte/fhir` peer is **not** something `attw` speaks to — measured, a good pack reports "No +`@cosyte/fhir` peer is **not** something `attw` speaks to: measured, a good pack reports "No problems found" and exits 0, identically with `node_modules/@cosyte/fhir` moved aside, so `attw` never resolves that peer. A green `attw` has never meant a consumer can install the peer. **This is a per-repo script.** It was ported here from `terminology`'s graded fix (terminology#28, `bf153cb`); siblings that still invoke the CLI directly still carry the defect, and the prose does -**not** port with the code — every number above was re-measured on this package. Derive who is +**not** port with the code: every number above was re-measured on this package. Derive who is left rather than writing a count down: `rg -l --glob '**/package.json' '"attw":' /workspace`. ## The PHI scanner guardrail, in full **▶ THE PHI SCANNER REFUSES (exit 2) EVERY ENTRY IT ENUMERATES, AND EVERY PATH NAMED DIRECTLY, THAT -IS NOT A REGULAR FILE. THAT IS THE WHOLE CLAIM — "it follows nothing" IS THE LOOSER WORDING, AND +IS NOT A REGULAR FILE. THAT IS THE WHOLE CLAIM: "it follows nothing" IS THE LOOSER WORDING, AND TWO SEPARATE REFUTER PASSES MEASURED IT FALSE.** See the ancestor-component residual below before you tighten this sentence back up. Before `PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES` (ported from `terminology#37`, `5f81640`) a symbolic link was clean on **both** enumerating routes, measured on this repo's own scanner over a link under -`src/` pointing at a name-bearing synthetic payload: all-mode printed `OK — no hits` / exit **0**, +`src/` pointing at a name-bearing synthetic payload: all-mode printed `OK: no hits` / exit **0**, and so did `--staged`. The walk enumerates `Dirent.isFile()`, an **lstat** answer, so a link is -neither a file nor a directory — and a linked _directory_ takes its whole subtree with it. +neither a file nor a directory, and a linked _directory_ takes its whole subtree with it. `--staged` reads `git show :`, and **git stores a link as its TARGET PATH under mode `120000`**, so that route gets the path text, never the target's bytes. **Do not "fix" this by following the link.** Following reads bytes the enumeration does not control (outside the repo, a loop, a device, a FIFO that blocks the gate forever), and git does not carry them anyway, so a hit on them would be a claim about something no commit contains. -**▶ AND THE THIRD MODE IS THE ONE A DRAFT OF THIS GUARDRAIL GOT WRONG — IT SAID "FOLLOWS NOTHING" +**▶ AND THE THIRD MODE IS THE ONE A DRAFT OF THIS GUARDRAIL GOT WRONG: IT SAID "FOLLOWS NOTHING" WHILE ONE ROUTE STILL FOLLOWED.** A refuter measured it: the named-`` mode classified with `statSync`, which **dereferences**, so `pnpm phi-scan src/link.ts` read the TARGET's bytes and -reported hits from them — including a target **outside the repository**, the first hazard the +reported hits from them, including a target **outside the repository**, the first hazard the sentence above says the scanner does not incur. It was never a false clean, which is exactly why reading the code did not catch it. It lstats now; **if you touch `buildTargetsForPaths`, re-measure the sentence, do not re-assert it.** A dangling link is reported as the link it is, because `existsSync` follows and would call it a missing file. -**▶ AND `lstat` ANSWERS FOR THE FINAL COMPONENT ONLY — a second refuter pass measured that too, +**▶ AND `lstat` ANSWERS FOR THE FINAL COMPONENT ONLY: a second refuter pass measured that too, after the first fix.** A named path whose **ancestor** is a symlink (`src/linkdir/payload.txt`) is still followed and still read from wherever that ancestor lands, as is a plain absolute or `../` argument. The all-mode walk over the same tree **does** refuse that ancestor, so the two routes @@ -323,16 +327,16 @@ realpath or containment logic, which is a guard growing past the defect it fixes commit-gating route (the `--staged` pre-commit hook, the all-mode walk CI runs) reaches it. **▶ THE ONE-LETTER TRAP: `--diff-filter` MUST KEEP `T`.** Replacing a **tracked** file with a link is neither an add nor a modify. Measured here, `git diff --cached --raw --diff-filter=AM` printed -**nothing** for that change while the unfiltered `--raw` printed `:100644 120000 T` — +**nothing** for that change while the unfiltered `--raw` printed `:100644 120000 T`, so under `AM` the record dies before any mode is read and the hook passes a mode-`120000` blob **green** while the changelog claims it refuses one. `T` also buys the reverse typechange (link → real file bearing PHI), which must be _scanned_, not refused. The route reads `--raw -z` purely so the destination mode is visible; `--name-only` cannot see it. -**A refusal never echoes the link target** — that is working-tree text and can itself carry PHI. +**A refusal never echoes the link target**: that is working-tree text and can itself carry PHI. Name the entry's own repo-relative path plus a token from the closed `entryKind`/`gitModeKind` sets, nothing else. **This applies to the prose too**: a diagnostic about a PHI leak is itself a PHI surface, which is why the docblock writes the dangerous target as a _shape_ and not an example. -**The walk has NO extension scope of its own** — it skips regular `*.md` as documentation and takes +**The walk has NO extension scope of its own**: it skips regular `*.md` as documentation and takes everything else, so a link at `src/leak.json` and a linked directory are refused there too. `src/**.ts` is the **`--staged`** route's boundary; do not describe the two as one rule. **▶ THE RENAME RESIDUAL IS CLOSED, AND THE FRAMING IT WAS FILED UNDER WAS FALSE.** It was @@ -380,11 +384,11 @@ process on node's uncaught-exception code, **1**, which is this scanner's code f caller keying on the code read a gate that never ran as one that ran and fired. `run()` at the foot of the file is the outermost net and `walk` names an unreadable directory itself; an unexpected throw still prints its stack, because a gate that swallows its own bugs is harder to fix. -Pinned in `test/scripts/phi-scan.test.ts` against **throwaway git repos under `os.tmpdir()`** — the +Pinned in `test/scripts/phi-scan.test.ts` against **throwaway git repos under `os.tmpdir()`**: the scanner roots everything at `process.cwd()`, so never write a link or a violator into this corpus to test it. **The enumerate-then-read race is deliberately still open here** and is a separate item: measured on this tree, a real `pnpm build` puts **no** transient under either walk root, and -both temp-using suites `mkdtemp` into `os.tmpdir()`, so it is unreachable by scope — _until a walk +both temp-using suites `mkdtemp` into `os.tmpdir()`, so it is unreachable by scope, _until a walk root widens_, which reintroduces it verbatim. ## No internal project bookkeeping on a public surface, in full @@ -429,7 +433,7 @@ the published text, not on the published text. **What it is.** `scripts/check-agent-notes.ts`, run as `pnpm check:agent-notes`, plus `test/scripts/agent-notes-contract.test.ts`. It checks the two-file agent-instruction split that -landed across the fleet on **2026-08-04** — an always-read `CLAUDE.md` carrying the cursor, the +landed across the fleet on **2026-08-04**: an always-read `CLAUDE.md` carrying the cursor, the rules and every trap as a one-line imperative with a pointer, and this on-demand file carrying the narrative those imperatives point at. The relocation took the tree from **1,327,773 to 527,428 bytes**, roughly **200K tokens off every worker**. Nothing checked that the contract held. This @@ -445,27 +449,27 @@ reader lands on prose and does not notice it is the wrong prose. ### The seven rules -- **R1 existence** — both contract files are tracked in `git ls-files` and non-empty. -- **R2 identity, the negative control against the WRONG package** — the level-1 title of each +- **R1 existence**: both contract files are tracked in `git ls-files` and non-empty. +- **R2 identity, the negative control against the WRONG package**: the level-1 title of each contract file must contain the `name` from `package.json`. Not hypothetical here: a `transform` worker's file was once rewritten out-of-band to attribute its measurements to a different package, carrying an instruction to treat that as intentional and not mention it. The worker refused, corrected the file and reported it. A gate should not need a worker to be vigilant. -- **R3 declared sections are non-empty** — every heading below level 1, in **both** files, has a +- **R3 declared sections are non-empty**: every heading below level 1, in **both** files, has a body. Fenced code is skipped, so a `# comment` inside a ```bash block is not read as an empty heading. -- **R4 anchor pointers resolve** — every `.md#` in either file, written as a markdown +- **R4 anchor pointers resolve**: every `.md#` in either file, written as a markdown link, inside backticks, or bare in a sentence (all three shapes are live in `CLAUDE.md`, so keying on markdown links alone would see one of eight). Slugging follows GitHub's algorithm, and the detail that matters is that the **en dash is dropped, not converted**: `## Shipped-phase history (Phases 1–6)` slugs to `shipped-phase-history-phases-16`. Getting that wrong would red a pointer that works, which is worse than having no gate. -- **R5 no orphan sections** — every `##` here is the target of at least one pointer from +- **R5 no orphan sections**: every `##` here is the target of at least one pointer from `CLAUDE.md`. This file's own preamble makes that claim; R5 is what keeps it true. -- **R6 file pointers resolve** — every in-repo path token in `CLAUDE.md` resolves against the +- **R6 file pointers resolve**: every in-repo path token in `CLAUDE.md` resolves against the **index**, as an exact file, a directory, or a prefix of exactly one tracked path (that last one exists for `documentation/decisions/0001`, which `CLAUDE.md` names by ADR number). -- **R7 the external allowlist cannot rot** — `documentation/conventions.md` and +- **R7 the external allowlist cannot rot**: `documentation/conventions.md` and `documentation/repos/transform.md` are declared external because they live in the meta-repo. If either ever resolves in-repo the gate **refuses**: an ambiguous exemption is one that has begun hiding a real broken pointer. @@ -474,16 +478,16 @@ reader lands on prose and does not notice it is the wrong prose. A check of this shape has one specific, repeatedly-hit failure mode here: **it prints green over a corpus it never opened.** The worst recorded instance was a scanner whose declared root **had never -existed**, so it reported clean on every run it ever made. **A count does not detect that** — a +existed**, so it reported clean on every run it ever made. **A count does not detect that**: a sibling's counterpart reported `71` against a healthy `122` and read as fine, because a count counts the roots that DID exist. So every read goes through `readObserved()`, which records the path, and `reconcile()` compares -what was opened against `git ls-files` — the index, not the directory entries, so the two cannot +what was opened against `git ls-files`: the index, not the directory entries, so the two cannot fail the same way. It refuses on: nothing opened at all; a contract file tracked but never opened; a file opened that git does not carry; and `git ls-files` answering **emptily**, which counts as no answer rather than as an empty repository. **Exit `2` means the gate could not decide; exit `1` -means violations were found** — the PHI scanner's split, load-bearing for the same reason it is +means violations were found**: the PHI scanner's split, load-bearing for the same reason it is there, because an uncaught throw lands on node's `1` and a caller would read a gate that never ran as one that ran and fired. @@ -491,7 +495,7 @@ as one that ran and fired. - **42 of the suite's 43 tests are red on the parent** (`7f4d59b`), measured by running the suite with `scripts/check-agent-notes.ts` moved aside, not by reading it. The single green one is the - corpus-only identity control, which reads the two titles directly and never invokes the gate — + corpus-only identity control, which reads the two titles directly and never invokes the gate, and it holding on the parent is the correct result, not a gap. - **Red before, green after, one edit apart.** The R4 test rewords a heading (the corpus is RED), then repairs the pointer in `CLAUDE.md` (GREEN). R5 does the same with an orphan section. @@ -514,13 +518,18 @@ of thing that reads correct and is not: - **The slug was not GitHub's, and the gate was one heading away from both harms.** It collapsed runs of spaces to a single hyphen; GitHub gives **each space its own hyphen**, so - `## Branch protection — and the limits` anchors as `branch-protection--and-the-limits`, with a + `## Branch protection – and the limits` anchors as `branch-protection--and-the-limits`, with a DOUBLE hyphen. Verified against GitHub's own renderer (`POST /markdown`) and `github-slugger`. Measured consequence: a **dead** pointer passed green, and a pointer that **works** was reddened. - This repo's house punctuation is exactly that spaced-em-dash style. Fixed and pinned by two tests. + The mark shown above was a spaced **em** dash when this was written, and that was the house + punctuation at the time; the brand sweep of 2026-08-07 removed the character from every tracked + file, so the example and the two pinning tests now use a spaced **en** dash instead. `slug()` + drops both alike and keeps both surrounding spaces, so the behaviour is unchanged and so is the + bite of the tests. **Any dropped mark with a space either side reproduces it**, so do not read the + sweep as having retired the trap. Fixed and pinned by two tests. - **A check that cannot fail is documentation.** `reconcile()` had every read guarded by a `tracked.has()` pre-check, which made all three of its refusal branches unreachable BY - CONSTRUCTION — instrumented and measured at **zero firings across the whole suite** — while + CONSTRUCTION, instrumented and measured at **zero firings across the whole suite**, while `CLAUDE.md` and the changeset both sold the reconciliation as the protection. The pre-checks were removed, not the function: a contract file present on disk but untracked now reads fine and is refused by the reconciliation, which is a branch that genuinely fires and has three fixtures. @@ -535,7 +544,7 @@ of thing that reads correct and is not: prefix of exactly one tracked path and each 404s on GitHub. The prefix arm now requires the remainder to break at a `-`, `/` or `.`. - **A section gutted to a bare `---` counted as having a body.** -- **The `verify.sh` claim in `CLAUDE.md` was false in the safe direction** — it said the ladder ran +- **The `verify.sh` claim in `CLAUDE.md` was false in the safe direction**: it said the ladder ran neither route. It runs `test:coverage`, which runs the suite, which runs the gate. Corrected. - **This diff falsified this file's own preamble.** Adding the first `###` headings here made "Every heading is a pointer target" literally false, in a shape R5 cannot see (it covers `##`). @@ -546,25 +555,25 @@ of thing that reads correct and is not: founder's stated position on gates of this class. Verified by running `prepare`, not by reading the classifier. -**Pass 2 was NOT REFUTED**, with all nine re-derived by running — including the slug, checked against +**Pass 2 was NOT REFUTED**, with all nine re-derived by running, including the slug, checked against GitHub's own renderer over all 26 headings and all 8 pointers, and `reconcile()` re-instrumented (branch (c) fires 5 times across the suite, (d) once, (a) and (b) zero, which is exactly what the code claims). It raised **four minor prose-accuracy findings, and every one was answered by CORRECTING THE -CLAIM rather than growing the guard** — the standing rule in this repo, applied a third time: +CLAIM rather than growing the guard**, the standing rule in this repo, applied a third time: - The relocation's "2 + 4 + 1 = seven sections, verbatim" accounting was falsified by this diff's own new eighth section. Scoped to the relocation rather than re-counted. -- R6 sees backticks, link targets and bare prose — **not** a bare path wrapped in emphasis. The +- R6 sees backticks, link targets and bare prose, **not** a bare path wrapped in emphasis. The charset that rejects the emphasis markers is the same one that keeps `@cosyte/*` out, so the narrowing is deliberate. Disclosed, not widened. -- Two of three `UNTRACKED_BY_DESIGN` entries were measured **dead** — emptying the map reds on `dist` +- Two of three `UNTRACKED_BY_DESIGN` entries were measured **dead**: emptying the map reds on `dist` alone. Deleted. An exemption nothing exercises is a claim nobody checks. - R3's thematic-break rule covers `---`, `***`, `___` and not CommonMark's spaced `- - -`. Widening it towards "dashes and spaces" starts competing with list syntax, and a gate that eats a real bullet is worse than one that misses a gutted section. **A fifth correction came from CI, after both passes, and is recorded because it is the same -shape.** CodeQL flagged the `slug()` chain HIGH for an **incomplete multi-character sanitisation** — +shape.** CodeQL flagged the `slug()` chain HIGH for an **incomplete multi-character sanitisation**: a single-pass `<[^>]*>` strip, which is genuinely incomplete. It was **removed rather than hardened**: it guarded no HTML sink, and `github-slugger`, the algorithm this function is checked against, strips no tags at all, so the line was a deviation from the thing it was imitating. @@ -584,13 +593,13 @@ is to keep the disclosed-limits list accurate, not to chase the class. trap phrased as a **deliberate omission** ("is deliberately left alone", "is never the default"), which carries no identifier to grep for. **Enumerate those by hand.** - **R6 is `CLAUDE.md`-only, deliberately.** This file is narrative and quotes **illustrative** paths - that must never resolve — `src/leak.ts`, `src/linkdir/payload.txt`, `test/fixtures/` are written + that must never resolve: `src/leak.ts`, `src/linkdir/payload.txt`, `test/fixtures/` are written into throwaway repos under `os.tmpdir()` by the PHI-scanner suite. Requiring them to resolve would push a worker to create them, which is the opposite of what the narrative says. Anchor pointers (R4) are scanned in both files, because `.md#` is unambiguous. - **It reads the source of the instructions and says nothing about whether an agent followed them.** - **It does not gate `CLAUDE.md`'s byte budget.** That ratchet lives in the umbrella's - `.claude/hooks/doc-budget.mjs`, and nothing inside this repository can observe it — the same + `.claude/hooks/doc-budget.mjs`, and nothing inside this repository can observe it: the same limit already recorded here for the branch ruleset. **Never quote the number in a repo file.** - **It does not verify the 2026-08-04 relocation was verbatim.** That was a one-time property of the move; there is no pre-move text here to diff against. @@ -607,7 +616,7 @@ needed. That test skips below Node 24 and says so; the CI matrix runs 22 **and** always takes it. It is enforced **two independent ways on purpose**. The suite runs inside `ci / verify`, which is -required — but that route inherits this repo's documented **"the gate can leave the job"** hole +required, but that route inherits this repo's documented **"the gate can leave the job"** hole verbatim: the `include` glob in `vitest.config.ts` and the `test`/`test:coverage` script bodies in `package.json` can each drop the suite with the job still green and the ruleset still satisfied. The `no-internal-refs` step depends on neither, so **the two routes cannot be removed by the same @@ -618,8 +627,177 @@ window in which open PRs strand pending on a context nothing has emitted yet. **What the umbrella's `scripts/verify.sh` ladder does and does not reach, measured rather than assumed.** The ladder is a fixed script-name list. It runs `test:coverage`, whose vitest `include` glob picks up `test/scripts/agent-notes-contract.test.ts`, which spawns this gate against the real -repository root and asserts exit 0 — **so route 1 does run locally.** What it does not run is the +repository root and asserts exit 0, **so route 1 does run locally.** What it does not run is the standalone `check:agent-notes` script, which the ladder has never heard of, and its own -unladdered-script detector fires and says so. **A worker in a submodule cannot fix that** — the fix +unladdered-script detector fires and says so. **A worker in a submodule cannot fix that**: the fix is one name in an umbrella file. An earlier version of this section said `verify.sh` ran neither; a refuter measured that false, and the correction stands rather than the claim. + +## No em dash, anywhere + +**The rule.** Founder directive of 2026-07-24, stated canonically in the knowledgebase brand-voice +document: cosyte never uses the em dash. Not in a file, not in a filename, not in a commit message, +not in a PR title or body. Rewrite with a period, a colon, a comma or parentheses. **Never +re-encode the character**: the HTML entity, both numeric character references, the percent-encoding +and both JavaScript escapes are banned on the same footing as the literal, and each has its own arm +in the gate. + +**The census, re-derived in Python over raw bytes, 2026-08-07.** 659 occurrences across 75 of the +98 tracked files. **The umbrella's figure of 660 across 76 was taken before the npm `description` +fix landed**, which removed exactly one occurrence from exactly one file, so the two agree. Every +other spelling the rule names (the named entity, the decimal and hex references, the +percent-encoding, both JavaScript escapes) was searched for and is **absent**: the literal +character is the only spelling this repository has ever carried. + +**609 occurrences across 73 files were rewritten**, each by what the sentence wanted rather than by +one substitution: a colon where the dash introduced an appositive, a comma before a conjunction, a +negation or a relative pronoun, a comma where the clause already carried a colon, parentheses where +the mark scoped an aside. Consumer-visible surfaces in that count: `README.md`, the seven +`docs-content/` pages that publish to the documentation site, and the `src/` JSDoc that compiles +into `dist/index.d.ts` / `dist/index.d.cts` and renders on a consumer's hover. **No issue code, no +fatal code, no type and no documented behaviour changed.** + +**COUNT THE BYTES IN PYTHON, NEVER WITH `grep`.** The org-wide census that scoped this work was +taken with a broken scanner and was low in every repository it touched. In the agent containers +`grep` is a shell **function** that forces `-I` and `--ignore-files`, and under `xargs` it is +bypassed for `/usr/bin/grep`, which in the container's empty locale **fails at exit 2 and prints +nothing** for `-P '\x{2014}'`. Piped to `wc -l` that reads `0`. Re-derive every figure here before +acting on it. + +### The one runtime string, and why it moved by hand first + +`scripts/phi-scan.ts`'s clean-run line carried the character. It is **quoted in prose in two +docblocks and asserted by regex in three tests**, so all five sites moved together, by hand, +**before** any bulk pass ran. A bulk pass that touches a string literal and not its assertion +desyncs a suite silently, which is how a sibling repository lost 13 assertions in one commit. The +line now reads `[phi-scan] OK: no hits`. The `check-agent-notes.ts` R5 orphan message changed the +same way; its test asserts on the token `ORPHAN`, so it did not move. + +### The semantic value, converted before the bulk pass + +`src/messages/diagnostic-report.ts`'s OBR mapping table had a bare `|` cell in the `via` column for +OBR-8, meaning "nothing here". A bulk rewrite turns that into a stray mark that reads as a +rendering artefact rather than an absent value, which is a defect a sibling shipped. It was +converted by hand first, and to the **true** value rather than to a word: OBR-8 goes through +`toFhirDateTime` at the call site, so the cell now names that converter. + +### The slug fixtures moved to an en dash, and the trap did not go away + +`test/scripts/agent-notes-contract.test.ts` proves that GitHub gives **each space its own hyphen**, +using a heading whose dropped mark has a space on either side. That fixture was a spaced em dash. +`slug()` keeps only letters, numbers, spaces, `_` and `-`, so an **en** dash is dropped in exactly +the same way and the two surrounding spaces still survive as two hyphens: the behaviour under test +is unchanged, and so is the bite of the two cases that pin it. **Do not read the sweep as having +retired the trap.** Every dropped mark with a space either side reproduces it, and the en dash is +punctuation this repo does still write (`Phases 1–6`). + +### The exemptions, both of them, with their reasons + +Nothing was skipped silently. Two files still carry the character and each is an exemption with a +written reason, not a remainder. + +**`CHANGELOG.md` (49), and the exemption is a BOUNDARY rather than a file.** The gate scans +everything **above** `## Released before this file was generated` and nothing below it. Above that +heading is generated by the release from a changeset summary, and a changeset summary becomes the +published release body **and** a line in the tarball's changelog, so an occurrence there is a +public-surface instance. Below it is the hand-maintained history that predates changelog +generation, preserved verbatim when it was relocated, and `test/scripts/changelog-generation.test.ts` +measures that a release passes the archive through unchanged. **It fails closed**: if the boundary +heading disappears, the whole file is in scope and the gate reds loudly, because the alternative is +an exemption that silently grows to cover the generated half. The archive is also the gate's +**on-disk canary**: a scan that reports it clean has gone blind rather than found good news, and +the gate refuses rather than reporting that as a pass. + +**`vendor/cosyte-hl7-0.0.0.tgz` (1), declared `binary` in `.gitattributes`.** A DEFLATE stream can +hold `E2 80 94` by coincidence and this one does; there is no edit that removes a byte from someone +else's compressed archive, and the tarball is third-party content this repo consumes rather than +authors. `vendor/cosyte-fhir-0.0.0.tgz` is declared alongside it and carries none today. +**`.gitattributes` is not a silencer**: the gate REFUSES any `binary` declaration outside +`vendor/`, so widening the exclusion means editing the gate deliberately. And a declaration about a +file's BYTES says nothing about its NAME, so tracked filenames are scanned whatever that file says. + +**Nothing else was skipped.** `CLAUDE.md` (47) and this file (68) were swept like any other tracked +file. The banner at the top of this file protects its **claims** from being softened; it is not a +banner protecting its **bytes** from being repunctuated, and an exemption here would grow, because +this file is appended to. The one quotation that is reproduced verbatim, in +`Publish state, and the stale claim inside it`, was swept too, and the section now says so in the +sentence that introduces it: the wording, every claim and every qualifier are untouched, and only +the dash punctuation moved. + +### The gate, and why it is Node rather than shell + +`scripts/check-no-emdash.mjs` shells out for nothing. It reads bytes with `node:fs`; the only child +processes are `git ls-files` and `git check-attr`, and both have their exit status checked rather +than assumed. That closes two classes at once: the container `grep` shim above, and the lost exit +status of a pipeline (`grep` exits 1 on no-match, `xargs` reports that as 123, so "clean" and "died +part way through" are indistinguishable in the shell form). + +**IT EXCLUDES NOTHING BY PATH, AND THAT IS THE POINT.** A scanner that spells the forms it bans has +to exclude itself, and a self-exclusion is a demonstrated false green in a sibling: an em dash +appended to that gate scanned OK, because the gate was the one file nobody checked. Here every +spelling is **assembled at runtime from the codepoint `0x2014`** and the prose names the forms +rather than writing them out, so the file contains none of them as text and is scanned by the same +code path as every other tracked file. **Assemble a new arm; never paste a literal in, and never +answer a red by adding an exclusion.** The test file does the same thing for the same reason. + +**▶ THE PARTITION IS RECONCILED AGAINST THE DECLARATION, NOT MERELY BALANCED, AND A REFUTER IS WHY.** +The first version of this gate checked only that `scanned + binary === tracked`. That balances for a +path exclusion which **accounts** for what it skips: push the skipped paths onto the declared-binary +list and the arithmetic still adds up, while the outside-`vendor/` refusal reads `.gitattributes` +rather than the skip list and so never sees them. A refuter added `|| rel.startsWith("docs-content/")` +to the skip condition, 34 characters, planted an em dash on a page that publishes to the +documentation site, and got a clean banner with **all 38 tests green**. The only tell was a count +nothing asserted. `probe()` check 5b now reconciles the two sets **path for path**, so the only thing +that may skip a file's content is a `.gitattributes` declaration; the real-tree case pins the banner +at `2 declared binary`; and a **mutation** case reproduces the refuter's exclusion against a copy of +the gate in a throwaway repository and requires the refusal, with the unmutated fixture beside it as +the near miss. Proven red-before, green-after: the pre-fix gate exits **0** on that fixture. + +**▶ AND THE PARTITION CHECKS ALL COUNT FILES, WHICH IS WHY THE SUITE ASSERTS BYTES.** The refuter's +second pass showed check 5b closes one bucket of two. An exclusion that pushes what it skips onto the +**scanned** list rather than the declared-binary one keeps every file count byte identical to an +honest run, because the path really is classified and really is counted; it is simply never opened. +Measured with a live em dash planted on a page that publishes to the documentation site: the same +`101 tracked file(s) scanned, 2 declared binary, 103 filename(s) checked` banner, exit **0**, 40 of +40 tests green. The extension variant behaved the same. **The one quantity a skip-the-read mutation +cannot fake is `bytesRead`**, which the gate already prints, so the suite now recomputes the expected +total **independently**, from `git ls-files` plus `git check-attr` plus `statSync`, and asserts the +banner against it. **It has to live in the test, not in `probe()`**: an in-script invariant can +always be satisfied by the same edit that breaks the property, because both sit in the file being +mutated. Both surviving mutations red on that one assertion and nothing else. + +**It refuses rather than reporting a clean tree it cannot prove.** Seven checks, each closing a way +the gate could return "no findings" having examined nothing: the character it scans for really is +`E2 80 94`; every pattern matches a specimen built to contain exactly its spelling; `findEmDashes` +finds something in every one of those specimens (checks 1 and 2 pass even if that function is +blind); the CHANGELOG archive canary above; the enumeration returned a tree; every tracked path is +classified exactly once **and every skipped one is declared**; and four known-text files are in the +scanned set. **The floor on the +enumeration is 80 paths, deliberately not the 103 tracked when this landed** so an ordinary +deletion does not read as a filtered enumeration. + +### Two jobs, and one of them must never be required + +`.github/workflows/no-emdash.yml` runs `no-emdash` over tracked files and filenames, and +`no-emdash-messages` over the PR title, body and commit range. The split is the whole design: a +sibling shipped this as ONE job and had to exempt the lot, which un-required the tracked-file half +as well. + +**▶ `no-emdash-messages` MUST NEVER BE A REQUIRED CONTEXT, AND THE REASON IS WRITTEN DOWN RATHER +THAN INFERRED.** Dependabot composes a PR body by pasting the dependency's **upstream release +notes** into it, em dashes included. Requiring that context would block a dependency bump on prose +nobody in this org wrote and nobody here can edit without rewriting the PR by hand: the same +refusal this ecosystem already made for a CI `pnpm audit`, a gate that fails on someone else's +clock. **Do not "fix" it with an actor `if:` on a required context either**: that leaves the check +permanently **pending** on exactly those PRs, which is worse than red, because nothing says why. + +**`no-emdash` (tracked files) IS safe to require**, once it has run on `main`. Nothing outside this +repository can put an em dash into a tracked file: Dependabot writes `package.json` and +`pnpm-lock.yaml`, which are version specifiers and lockfile records, never prose. Fold it into +ruleset `19914044` like every other context here, never into a second ruleset, and read the context +name off a live check run rather than off the workflow's `name:`. + +**THE JOB SCANS SURFACES A LOCAL PRE-COMMIT SCAN STRUCTURALLY CANNOT SEE**, and two slices +elsewhere in this ecosystem have lost a review pass to exactly that: a NEW file is untracked, so a +scan of the index does not see it, and no local hook sees a PR body at all. On a squash merge the +PR title and body **become** the commit message, so they are the same surface as the tree. diff --git a/documentation/decisions/0001-transformation-tier-may-depend-on-parser-tier.md b/documentation/decisions/0001-transformation-tier-may-depend-on-parser-tier.md index 097d210..4271ce5 100644 --- a/documentation/decisions/0001-transformation-tier-may-depend-on-parser-tier.md +++ b/documentation/decisions/0001-transformation-tier-may-depend-on-parser-tier.md @@ -1,4 +1,4 @@ -# 0001 — The transformation tier may depend on the cosyte parser tier; third-party runtime deps stay zero +# 0001: The transformation tier may depend on the cosyte parser tier; third-party runtime deps stay zero - **Status:** Accepted (2026-07-21) - **Scope:** `@cosyte/transform` @@ -8,15 +8,15 @@ ## Context -The cosyte parsers are **siblings that mirror each other's API and do not import one another** — -`@cosyte/hl7` is the reference the others copy — and each ships **zero third-party runtime +The cosyte parsers are **siblings that mirror each other's API and do not import one another**: +`@cosyte/hl7` is the reference the others copy, and each ships **zero third-party runtime dependencies**. That zero-dep rule is a **supply-chain gate**: healthcare integrators vet every dependency, so a parser ships Node-stdlib-only. `@cosyte/transform` is **not a parser**. It is the first cosyte package one tier *above* the parsers: a **consumer** whose entire reason to exist is to bridge two of them. It takes an already-parsed `@cosyte/hl7` composite and produces an `@cosyte/fhir` model node (validated against -`validateResource`). It therefore **must** depend on `@cosyte/hl7` and `@cosyte/fhir` at runtime — +`validateResource`). It therefore **must** depend on `@cosyte/hl7` and `@cosyte/fhir` at runtime: a one-way, acyclic dependency (`transform → {hl7, fhir}`; neither ever depends back). Two questions follow: (a) does depending on the parser tier violate the zero-dep supply-chain rule? @@ -33,10 +33,10 @@ and (b) how do we consume two **unpublished** (`0.0.0`) siblings before PUB-FLIP 2. **Third-party runtime `dependencies` stay at exactly zero.** The zero-dep rule governs **third-party** supply-chain surface. `@cosyte/hl7`/`@cosyte/fhir` are **first-party cosyte code we - already vet, build, and ship** — categorically different from pulling a random npm package. So + already vet, build, and ship**: categorically different from pulling a random npm package. So `package.json#dependencies` is `{}` and `scripts/verify.sh transform` enforces the count at 0; the two cosyte deps live under `peerDependencies` (+ vendored `devDependencies`), where the cap does - not count them. A higher layer consuming the lower layers is the point of having layers — and this + not count them. A higher layer consuming the lower layers is the point of having layers, and this posture must **not** leak back into the parser tier. 3. **Before PUB-FLIP, the unpublished siblings are consumed as vendored `pnpm pack` tarballs at @@ -52,7 +52,7 @@ and (b) how do we consume two **unpublished** (`0.0.0`) siblings before PUB-FLIP is explicit and acyclic; the peer-dep model gives the consumer one copy of each sibling and a visible version; the vendored-tarball mechanism is a proven precedent, not a new invention. - **Negative / cost.** The vendored tarballs are committed binary artifacts that must be refreshed - when a consumed sibling surface changes (a deliberate, gated act — a sibling API change can break a + when a consumed sibling surface changes (a deliberate, gated act: a sibling API change can break a mapping, so a refresh re-runs the conformance gate). The `.npmrc`/`.tgz` files trip a filename secret-guard on commit and are committed with `--no-verify` after confirming they contain no secrets (dist + package.json + LICENSE only), exactly like `@cosyte/mllp`'s vendored tarball. diff --git a/documentation/decisions/0002-terminology-is-a-separate-sibling-byo-conceptmap.md b/documentation/decisions/0002-terminology-is-a-separate-sibling-byo-conceptmap.md index 92a2b1e..f498b36 100644 --- a/documentation/decisions/0002-terminology-is-a-separate-sibling-byo-conceptmap.md +++ b/documentation/decisions/0002-terminology-is-a-separate-sibling-byo-conceptmap.md @@ -1,4 +1,4 @@ -# 0002 — Terminology is a separate `@cosyte/terminology` sibling; translation is BYO-ConceptMap +# 0002: Terminology is a separate `@cosyte/terminology` sibling; translation is BYO-ConceptMap - **Status:** Accepted (2026-07-21) - **Scope:** `@cosyte/transform` @@ -20,8 +20,8 @@ licensing and architecture questions bite: - **FHIR already isolates terminology as a swappable service** (`$translate`/`$validate-code`/ `$expand` over a `ConceptMap`), separate from transformation. -The open question (roadmap §10 Q7): does the terminology **machinery** — the NamingSystem resolver, -the `$translate`-shaped ConceptMap engine, the UCUM validator — live inside `transform`, or in its own +The open question (roadmap §10 Q7): does the terminology **machinery** (the NamingSystem resolver, +the `$translate`-shaped ConceptMap engine, the UCUM validator) live inside `transform`, or in its own package? And whichever way, what do we ship as **content**? ## Decision @@ -32,9 +32,9 @@ package? And whichever way, what do we ship as **content**? FHIR `ConceptMap`. We ship only **license-clean** maps (HL7's own v2→FHIR tables, THO NamingSystems, LOINC/RxNorm-core/ICD-10-CM/UCUM under their terms); the **encumbered** maps (into SNOMED/CPT, or a site's local codes) are supplied by the consumer, who holds the licenses. On an unmapped code the - fail-safe holds — preserved + flagged, never coerced (roadmap §4.3). + fail-safe holds: preserved + flagged, never coerced (roadmap §4.3). -2. **The terminology *machinery* is a separate sibling, `@cosyte/terminology`** — a planned package +2. **The terminology *machinery* is a separate sibling, `@cosyte/terminology`**: a planned package that will own the NamingSystem resolver, the `$translate`-shaped ConceptMap engine, and UCUM validation, mirroring the FHIR architecture that keeps terminology a swappable service. `transform` **consumes** it as a sibling (Phase 6), and **does not build it here.** Whether `@cosyte/terminology` diff --git a/package.json b/package.json index 1044aaa..19e33f1 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "phi-scan": "tsx scripts/phi-scan.ts", "check:no-internal-refs": "bash scripts/check-no-internal-refs.sh", "check:agent-notes": "tsx scripts/check-agent-notes.ts", + "check:no-emdash": "node scripts/check-no-emdash.mjs", "vendor:refresh": "bash scripts/vendor-refresh.sh", "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --max-warnings=0 --no-error-on-unmatched-pattern", "lint:fix": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --fix --no-error-on-unmatched-pattern", diff --git a/phi-scan-overrides.md b/phi-scan-overrides.md index f434c35..5b5804d 100644 --- a/phi-scan-overrides.md +++ b/phi-scan-overrides.md @@ -3,7 +3,7 @@ This file logs every `--allow-fixture ` bypass invocation of `scripts/phi-scan.ts`. The scanner refuses to honor a `--allow-fixture ` flag UNLESS this file contains a `### ` subsection referencing the same -path. The committed log is intentionally annoying — it discourages bypass and +path. The committed log is intentionally annoying: it discourages bypass and creates an audit trail. Prefer extending `scripts/phi-allow-list.txt` (a token-level, reviewed declaration) over a whole-file bypass, which silences _every_ check for that file. @@ -12,7 +12,7 @@ _every_ check for that file. > machinery and a cross-cutting SSN/email floor ONLY. Before you rely on > `pnpm phi-scan` as a real PHI gate for this standard, add structured, > field-level detection (names, DOB, MRN / member id, address, phone) in the -> fenced TODO section of `scripts/phi-scan.ts` — see the sibling parsers +> fenced TODO section of `scripts/phi-scan.ts`: see the sibling parsers > (`hl7` / `dicom` / `x12` / `ccda` / `ncpdp`) for worked examples. ## Format diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts index c8615e6..12933bb 100644 --- a/scripts/check-agent-notes.ts +++ b/scripts/check-agent-notes.ts @@ -21,7 +21,7 @@ * (1) `documentation/agent-notes.md` is deleted or renamed. `CLAUDE.md` keeps eight * pointers into a file that is not there. Lint, coverage, `attw`, the PHI scan and * the public-surface gate are all unaffected. - * (2) A section is emptied — its body edited away while the heading stays. Every pointer + * (2) A section is emptied: its body edited away while the heading stays. Every pointer * still resolves; the measurement it pointed at is gone. * (3) A heading is reworded. The anchor in `CLAUDE.md` silently stops resolving. GitHub * renders a dead fragment as the top of the file, so a reader lands on prose and does @@ -39,7 +39,7 @@ * healthy `122` and read as fine, because a count counts the roots that DID exist. The * remedy that works is the one landed in `ncpdp`'s scanner: RECONCILE THE PATHS THIS GATE * ACTUALLY OPENED AGAINST AN INDEPENDENT STATEMENT OF THE CORPUS. `git ls-files` is that - * statement — it reads the index, not the directory entries this gate walks, so the two + * statement: it reads the index, not the directory entries this gate walks, so the two * cannot fail the same way. See `reconcile()` at the bottom of this file. Every read in * this gate goes through `readObserved()`, which records the path, so "what we opened" is * structural rather than a promise. @@ -52,8 +52,8 @@ * =========================================================================== * * 0 the contract holds - * 1 CONTRACT VIOLATIONS FOUND — listed on stderr - * 2 THE GATE COULD NOT COMPLETE OR COULD NOT OBSERVE — a missing contract file, an + * 1 CONTRACT VIOLATIONS FOUND: listed on stderr + * 2 THE GATE COULD NOT COMPLETE OR COULD NOT OBSERVE: a missing contract file, an * unreadable one, a broken `git ls-files`, or a reconciliation mismatch * * The 1/2 split is `scripts/phi-scan.ts`'s convention and it is load-bearing for the same @@ -61,7 +61,7 @@ * that NEVER RAN as one that ran and fired. `main()` funnels every throw to `2`. * * =========================================================================== - * ██ WHAT THIS GATE DOES NOT COVER — READ BEFORE YOU TRUST IT █████████████ + * ██ WHAT THIS GATE DOES NOT COVER: READ BEFORE YOU TRUST IT █████████████ * =========================================================================== * * * It proves a heading is POINTED AT. It can never prove the one-line imperative in @@ -71,7 +71,7 @@ * the trap phrased as a DELIBERATE OMISSION ("is deliberately left alone", "is never * the default"), which carries no identifier to grep for. Enumerate those BY HAND. * * FILE-POINTER RESOLUTION (rule R6) IS SCANNED IN `CLAUDE.md` ONLY, DELIBERATELY. - * `agent-notes.md` is narrative and quotes ILLUSTRATIVE paths that must never resolve — + * `agent-notes.md` is narrative and quotes ILLUSTRATIVE paths that must never resolve: * `src/leak.ts`, `src/linkdir/payload.txt`, `test/fixtures/` are written into throwaway * git repos under `os.tmpdir()` by the PHI-scanner suite and deliberately do not exist * here. Requiring them to resolve would be inventing a contract this repo does not @@ -80,7 +80,7 @@ * * It reads the SOURCE of the instructions. It says nothing about whether an agent * followed them. * * It does not gate `CLAUDE.md`'s byte budget. That ratchet lives in the umbrella's - * `.claude/hooks/doc-budget.mjs` and nothing inside this repository can observe it — + * `.claude/hooks/doc-budget.mjs` and nothing inside this repository can observe it: * the same limit `CLAUDE.md` already records for the branch ruleset. * * It does not verify the relocation was VERBATIM. That was a one-time property of the * 2026-08-04 move; there is no pre-move text here to diff against. @@ -126,12 +126,12 @@ const MANIFEST = "package.json"; * * Each is written in `CLAUDE.md` qualified as the meta-repo's ("The source of truth is the * meta-repo's `documentation/conventions.md`"), and there is no copy in this repo on - * purpose — a second copy is what the meta-repo exists to prevent. + * purpose: a second copy is what the meta-repo exists to prevent. * * THIS LIST CANNOT SILENTLY ROT INTO A HIDING PLACE. R7 refuses if an entry EVER resolves * in-repo: at that moment the path is ambiguous, the exemption is wrong, and the gate says * so instead of quietly exempting a real pointer. That is the self-correcting shape the - * meta-repo's rule 2 asks for — an exemption without its reason cannot correct itself. + * meta-repo's rule 2 asks for: an exemption without its reason cannot correct itself. */ const EXTERNAL_PATHS = new Map([ [ @@ -175,7 +175,7 @@ function readObserved(path: string): string { /** * `git ls-files -z`, the independent statement of the corpus. It reads git's INDEX; the * rest of this gate reads the working tree by name. Two different sources is the whole - * point — a mistake in one does not reproduce in the other. + * point: a mistake in one does not reproduce in the other. * * An EMPTY answer counts as NO answer. `git ls-files` exits 0 outside a repo and in an * empty one, so a zero-length result is indistinguishable from "the corpus is gone" and @@ -211,24 +211,26 @@ function trackedPaths(): Set { * * Lowercase; drop everything that is not a letter, a number, a space, `_` or `-`; then * spaces to `-`. Note what that does and does not keep: backticks, commas and parentheses - * are DROPPED, and so is the EN DASH — `## Shipped-phase history (Phases 1–6)` slugs to + * are DROPPED, and so is the EN DASH, so `## Shipped-phase history (Phases 1–6)` slugs to * `shipped-phase-history-phases-16`, not `...phases-1-6`. Getting that wrong would make * this gate red on a pointer that works, which is worse than not having the gate. * * ▶ EACH SPACE BECOMES ITS OWN HYPHEN. RUNS ARE NOT COLLAPSED, AND THAT IS THE WHOLE - * DIFFERENCE BETWEEN THIS AND A PLAUSIBLE-LOOKING SLUGGER. `## Branch protection — and the - * limits` has a space either side of an em dash; the dash is dropped and TWO spaces remain, + * DIFFERENCE BETWEEN THIS AND A PLAUSIBLE-LOOKING SLUGGER. `## Branch protection – and the + * limits` has a space either side of a dropped mark; the mark goes and TWO spaces remain, * so GitHub emits `branch-protection--and-the-limits` with a DOUBLE hyphen. A first version * of this function collapsed the run, and a refuter measured both harms against GitHub's own * renderer (`POST /markdown`) and against `github-slugger`: it passed a pointer that is DEAD - * when clicked, and it reddened a pointer that WORKS. This repo's house punctuation is - * exactly that em-dash-with-spaces style, so it was one heading away. Do not "tidy" the run. + * when clicked, and it reddened a pointer that WORKS. This was one heading away when the + * house punctuation was a spaced em dash; the brand sweep retired that character, and it is + * still one heading away, because EVERY dropped mark with a space either side does the same + * thing. Do not "tidy" the run. * * ▶ THERE IS DELIBERATELY NO HTML-TAG STRIP HERE, AND ITS REMOVAL WAS NOT COSMETIC. A * `.replace(/<[^>]*>/g, "")` stood in this chain and CodeQL flagged it HIGH as an incomplete * multi-character sanitisation, correctly: a single pass leaves `ipt>` behind. - * Two reasons it went rather than grew a loop. It was never a sanitiser — nothing here - * reaches an HTML sink — so hardening it would have been defending a sink that does not + * Two reasons it went rather than grew a loop. It was never a sanitiser, nothing here + * reaches an HTML sink, so hardening it would have been defending a sink that does not * exist. And `github-slugger`, the algorithm this function is checked against, strips no tags * at all, so the line was a DEVIATION from the thing it was imitating. Measured before * removal: no heading in either contract file contains `<` or `>`, so every anchor is @@ -319,7 +321,7 @@ function parseHeadings(text: string): Heading[] { } /** - * Every `.md#` pointer in a document, wherever it is written — inside + * Every `.md#` pointer in a document, wherever it is written: inside * backticks, inside a markdown link target, or bare in a sentence. All three shapes are * live in `CLAUDE.md` today, so keying on markdown links alone would see one of eight. */ @@ -362,7 +364,7 @@ const UNTRACKED_BY_DESIGN = new Map([ * seen, because the strict charset below rejects the marker characters and trimming them * would re-admit the `@cosyte/*` glob that the charset exists to keep out. Measured: no path * in this repo's `CLAUDE.md` is emphasised without also being backticked, so there is no live - * false green — but do not read this paragraph as "any punctuation". + * false green, but do not read this paragraph as "any punctuation". * * THE RECOGNISER IS MOSTLY DERIVED FROM `git ls-files`, NOT INVENTED. A token counts as an * in-repo path if it is a tracked TOP-LEVEL FILENAME (`package.json`, `vitest.config.ts`) or @@ -449,7 +451,7 @@ function pathPointers(text: string, tracked: Set): string[] { } /** - * Resolve a path token against the INDEX, never against the filesystem — an untracked file on + * Resolve a path token against the INDEX, never against the filesystem: an untracked file on * one worker's disk is not a pointer another clone can follow. * * Three ways to resolve, narrowest first: an exact tracked file; a tracked directory; or a @@ -612,13 +614,13 @@ function checkContract(tracked: Set): Violation[] { rule: "R5", where: `${AGENT_NOTES}:${String(h.line)}`, detail: - `section "${h.text}" (#${h.anchor}) is an ORPHAN — nothing in ${CLAUDE_MD} points at ` + + `section "${h.text}" (#${h.anchor}) is an ORPHAN: nothing in ${CLAUDE_MD} points at ` + `it, so a worker who only reads the always-read file never learns it exists.`, }); } } - // ── R6 FILE POINTERS RESOLVE. `CLAUDE.md` ONLY — see the header for why `agent-notes.md` + // ── R6 FILE POINTERS RESOLVE. `CLAUDE.md` ONLY: see the header for why `agent-notes.md` // is deliberately excluded (it quotes illustrative paths that must never resolve). for (const p of pathPointers(claude, tracked)) { if (EXTERNAL_PATHS.has(p) || UNTRACKED_BY_DESIGN.has(p)) continue; @@ -631,7 +633,7 @@ function checkContract(tracked: Set): Violation[] { } } - // ── R7 NEITHER R6 EXEMPTION LIST IS STALE — the meta-repo paths and the by-design-untracked + // ── R7 NEITHER R6 EXEMPTION LIST IS STALE: the meta-repo paths and the by-design-untracked // build outputs. An exemption that starts resolving in-repo is an exemption that has begun // hiding a real pointer. Refuse instead of exempting. for (const [p, reason] of [...EXTERNAL_PATHS, ...UNTRACKED_BY_DESIGN]) { @@ -650,7 +652,7 @@ function checkContract(tracked: Set): Violation[] { } /** - * OBSERVATION, NOT EXISTENCE. Runs after the checks and can only ever REFUSE — it never + * OBSERVATION, NOT EXISTENCE. Runs after the checks and can only ever REFUSE: it never * turns a red run green. * * ▶ BE PRECISE ABOUT WHICH OF THESE FIRES TODAY, BECAUSE AN EARLIER VERSION OF THIS FUNCTION @@ -659,16 +661,16 @@ function checkContract(tracked: Set): Violation[] { * suite, and measured ZERO firings while the always-read `CLAUDE.md` sold this as the * protection. The pre-checks were removed rather than the function. Now: * - * (a) `observed` is empty — a TRIPWIRE FOR A FUTURE EDIT, not a live check. It becomes + * (a) `observed` is empty: a TRIPWIRE FOR A FUTURE EDIT, not a live check. It becomes * reachable the moment someone makes a read conditional. Kept for that, and labelled * as that rather than counted as protection. - * (b) a contract file is tracked but was never opened — same: a tripwire, reachable only + * (b) a contract file is tracked but was never opened. Same: a tripwire, reachable only * once a read is skipped or short-circuited. - * (c) something was opened that git does not carry — THIS ONE FIRES TODAY. An untracked + * (c) something was opened that git does not carry: THIS ONE FIRES TODAY. An untracked * `CLAUDE.md`, `agent-notes.md` or `package.json` sitting on one worker's disk reads * perfectly well and is refused here, because a pointer no other clone can follow is * not a contract. Three fixtures in the suite land on it. - * (d) `git ls-files` answered emptily — handled in `trackedPaths()`, which refuses there, + * (d) `git ls-files` answered emptily: handled in `trackedPaths()`, which refuses there, * and that is what makes an empty or non-git directory exit 2 rather than green. */ function reconcile(tracked: Set): void { diff --git a/scripts/check-no-emdash.mjs b/scripts/check-no-emdash.mjs new file mode 100644 index 0000000..c91ffc4 --- /dev/null +++ b/scripts/check-no-emdash.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env node +/** + * scripts/check-no-emdash.mjs: the em-dash gate, over every tracked file. + * + * Run: `pnpm check:no-emdash`, or `pnpm check:no-emdash --stdin