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