Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 9 additions & 163 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,172 +128,18 @@ a summary.

## Required checks on `main`

One ruleset protects `main`: **`required-checks`, id `19913330`**. **Read the live set back rather
than trusting any list written down** (`gh api "repos/cosyte/synth/rulesets?includes_parents=true"`):
a hardcoded list goes stale and it is prose no test can check. Snapshot + why every context is pinned
to the GitHub Actions app: `notes#the-one-ruleset-that-protects-main`.

- **Extend that one ruleset in place; never add a second for the next gate.** An unpinned required
context can be satisfied by any actor with write access posting a commit status of that name,
without the workflow ever running. `notes#extend-that-one-ruleset-in-place`.
- **Never require a context before its workflow has completed on `main`**: every future PR goes
PENDING and unmergeable with nothing saying why. `test-selection`, `smoke (22)`/`smoke (24)` and
`install (22)`/`install (24)` are deliberately not required yet; read real names off a live check
run, never off a workflow's `name:`. `notes#contexts-deliberately-not-required-yet`.
- **Never rename a required job, and never split a step out of one**: both silently un-require it
and leave PRs pending rather than red (why `build` and `smoke` are one job in `smoke.yml`).
`notes#things-that-silently-detach-or-hollow-out-a-required-check`.
- **Never narrow `include` in `vitest.config.ts`.** `pnpm test` takes no path arguments, so that one
glob selects everything `ci / verify` runs, and **coverage does not backstop it** (measured over
`src/**` only, so dropping every `synthetic-safety.property.test.ts` costs 0% and reds nothing).
**For a synthetic-data generator that is the whole safety story.** Gated by
`pnpm check:test-selection`. `notes#things-that-silently-detach-or-hollow-out-a-required-check`.
- **Never replace that gate's exact-match script rule with a parser, and never answer a hole in it
with one more spelling**: analysing a shell string is unbounded, and this is the half a refuter
broke **three times** in `ncpdp`, each time in the remedy for the last. Its four deliberate shapes,
and why **self-test C is NOT covered by self-test A**:
`notes#the-test-selection-gate-and-its-four-deliberate-shapes`.
- **READ THE COUNTS OFF THE GATE, NOT OFF PROSE, AND MOVE THEM WHEN YOU ADD A TEST FILE.**
`pnpm check:test-selection` prints watched / name-only / unwatched every run; they have gone stale
**twice**. The numbers, and what the gate does **not** reach:
`notes#know-the-denominator-and-what-the-gate-does-not-reach`.
- **Deleting a subpath from `exports` shrinks that gate's headline subject and it stays green**
(measured). Not a free escape: it is a breaking change, and `scripts/smoke.mjs` derives its subpath
set from the same map and **refuses** on disagreement. Two gates on one map, on purpose.
`notes#narrowing-the-published-surface`.
- **Never require a workflow with no `pull_request` trigger** (`fuzz`, `scorecard`, `release`): it
strands every PR forever. `notes#requiring-a-workflow-with-no-pull_request-trigger`.
- **RE-MEASURE EVERY PORTED SENTENCE AGAINST THIS REPO: the one failure that repeated.** Two of
three refuter passes found a false number or a false claim of reach carried over from `ncpdp`/`deid`
rather than a hole in the mechanism. **Where a number is asserted, name what produced it; where
reach is asserted, bound it to a route actually seeded.**
`notes#re-measure-every-ported-sentence-against-this-repo`.
- **Nothing inside this repository can observe its own ruleset.** Delete it and every test still
passes, every gate still prints OK, and this file still says `main` is protected. A ruleset makes a
red check block a merge; it does not make the check correct.
`notes#nothing-inside-this-repository-can-observe-its-own-ruleset`.
Moved whole and unchanged to
[documentation/required-checks-on-main.md](documentation/required-checks-on-main.md): read it
before you touch a workflow, a job name or a required context.

## 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
exports, so this is enforced, not optional.
- Immutable by default. Mutation only via explicit methods.
- No `console.*` in library code. Throw typed errors or return results.
- Short, testable functions over big parsing blobs.
- Postel's Law: parser is liberal (lenient default + warnings), serializer is conservative (always
emits spec-clean output).
- Fatal errors only for unrecoverable structural corruption (Tier-3 codes). Everything else is a
warning with a stable code + positional context.
- **No diagnostic takes a value parameter.** `SynthError(code)` is the only error `src/` constructs
and its message is a fixed entry in the frozen `SYNTH_FATAL_MESSAGES` table, so a caller string has
no position through which to reach `message`, `stack` or a thrown field. **Adding a fatal means
adding a registry entry, never a template string.** `notes#no-diagnostic-takes-a-value-parameter`.
- **Resolve every caller-supplied selector against its closed set** (`resolveKind`/`resolveMix`,
`src/select.ts`) at the entry point, before anything is generated. **Not a style rule**: an
unresolved selector reached an `err.message`/`err.stack` via a peer builder's `TypeError`, became an
`Artifact.kind` and a `manifest.counts` key, and fell out of an exhaustive `switch` as `undefined`.
All three were live. `notes#resolve-every-caller-supplied-selector-against-its-closed-set`.
- **Never claim `SynthError` being the only constructor is the whole guarantee**: `src/` can still
surface a runtime `TypeError` or a peer parser's fatal on forwarded input. Harnesses keep
`String(w.code)`, never a `message` or snippet; `test/phi/diagnostic-surface.test.ts` carries the
proof. `notes#syntherror-being-the-only-constructor-is-not-the-whole-guarantee`.
- **Read the meta-repo's `documentation/repos/phi-audit.md` before touching that.** **A new safety
sentence is worth nothing without a slot in that table behind it.**
`notes#the-phi-free-diagnostics-claim`.
- **▶ `attw` SAYS "does not contain types" AND EXITS 0, SO THE `attw` SCRIPT IS A WRAPPER
(`node scripts/attw.mjs --profile node16`), NOT THE BARE CLI.** For a package that ships types that
sentence means the declarations were **not in the tarball** (a broken publish reported as a pass),
and no `--profile`, `--ignore-rules` or config setting reaches that early return. **The race only
supplies the condition**, 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. `notes#the-attw-false-green-and-why-the-script-is-a-wrapper`.
- **Do NOT re-derive the exit-0 condition from the shape of the code: three refuter passes corrected
it, each in the same direction, and "attw misses subpaths" is the plausible, wrong story.** The
residual is a **known limit, filed rather than fixed; weaken the sentence rather than adding a
fifth arm.** All three corrections, each with its measurement, and why a correct red explained by a
falsehood is worse than it looks: `notes#the-false-green-needs-every-entry-point-untyped-at-once`,
`notes#the-first-correction-was-also-wrong-in-the-same-direction`,
`notes#missing-is-a-proxy-not-the-key-a-known-limit`.
- **`scripts/attw.mjs` carries TWO nets that catch different things** (a preflight over the relative
paths `package.json` promises, and a post-check on the untyped sentence), both pinned by
`test/scripts/attw-gate.test.ts` against the real binary plus a negative control.
`notes#the-two-nets-in-scriptsattwmjs`.
- **The post-check reads a string, so the argument guard is an ALLOW-LIST, NOT A DENY-LIST**:
`--profile` and `--no-definitely-typed` forwarded, everything else refused, "harmless" included.
**Do not answer a hole in it with a seventh spelling.** The six measured routes, why a deny-list
could not hold, and why the `.attw.json` refusal stays separate:
`notes#the-argument-guard-is-an-allow-list-not-a-deny-list`.
- **The seven `file:vendor/*.tgz` devDeps are NOT part of the `attw` story**, `npm pack` emits no
`vendor/` and attw does not resolve bare external specifiers. A **stale** vendored tarball makes
this gate neither red nor green; a **missing** one is a different thing this sentence does not
cover. `notes#the-vendored-tarballs-are-not-part-of-the-attw-story`.
- Coverage: per-directory >= 90% (lines/branches/functions/statements), enforced by
`pnpm test:coverage`.
Moved whole and unchanged to
[documentation/engineering-guardrails.md](documentation/engineering-guardrails.md): read it before
you touch the thing a guardrail guards.

## Standing disciplines (every change)

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
docs are: this repo's docs content (`README.md`, `docs-content/`), the meta-repo
`documentation/repos/synth.md` (bump its "last verified" date), and the `ecosystem-map.md`
status table.
2. **Version + changelog**: a Changeset (`patch` on the `0.0.x` ladder) per meaningful change.
**The changeset summary IS the changelog entry**, and `CHANGELOG.md` is generated output above
`## Released before this file was generated`: `.changeset/config.json` names a `changelog`
generator, so the release writes the version heading and the entry itself. **Do not hand-edit
`CHANGELOG.md`, and never reintroduce a hand-maintained `[Unreleased]` heading**: one stood over
this package's whole published history and shipped a tarball calling its own contents unreleased;
with generation on, a release prepends ABOVE such a heading, which is worse. Only the H1 may sit
above the first heading, and Changesets' Prettier pass stays ON here (derived, not copied):
`notes#the-changelog-is-generated-by-the-release`. Gated by
`test/scripts/changelog-generation.test.ts`. Renaming a stable warning code is a **breaking
change**.
3. **Crew + knowledgebase loop**, if this parser's public API or warning codes change, flag/update
the matching `crew` healthcare skill + the KB product doc.
4. **No internal project bookkeeping on a public surface** (founder directive, 2026-07-27). What a
consumer reads (`README.md`, `docs-content/`, the npm `description`, a release body) says what the
software does and what changed; identifiers, phase/wave language, ADR numbers, meta-repo paths and
`roadmap §N` citations belong in the changeset, `CHANGELOG.md`, the commit, the PR and the
roadmap. It is a **translation** at the boundary, not a deletion: **repair the head of a line you
strip an identifier from**. Gated by `pnpm check:no-internal-refs`, which keys on known prefixes
(**a new programme prefix must be added by hand**) and catches identifiers, not English sentences
about our process, so the reviewer still owns half the rule.
`notes#no-internal-project-bookkeeping-on-a-public-surface`.

**`SYNTH` is BOTH our item prefix AND the marker this package writes into what it generates**
(`SYNTH-FAC`, `SYNTH-LIS`, `SYNTH-ANALYZER`, the `SYNTH-`+10-digit NCPDP message id). The
separation is the explicit `SYNTHETIC_FIXTURE_TOKEN` list in the gate, never a shape rule.
**Never re-key the identifier rule on the `WORD-N` shape**, and never "resync" the prefix list
with a sibling's copy without re-reading why `SYNTH` is PRESENT here and ABSENT from `ncpdp`'s.
`notes#synth-is-both-our-item-prefix-and-a-marker-in-generated-output`.

**Four surfaces, three different answers.** Browsable Markdown, npm metadata and `/** */` doc
comments are **gated**; string literals are **gated too, and here they matter most: they are the
bytes this package emits**; `//` and plain `/* */` comments are **not gated and identifiers are
welcome in them**, because the convention says source comments are a place identifiers belong.
**Do not justify this boundary from what reaches `dist/`**: effectively all of `src/` does, and
two attempts to argue it that way in a sibling repo were false and both caught by a refuter. The
line is not what reaches a consumer's disk but what a consumer is **shown**.
`notes#four-surfaces-three-different-answers`.

Two consequences: **removing a doc comment to satisfy the gate is a regression**, not a fix; and
**when a stale claim is what carries the phase number, cut the sentence rather than reword it**:
rewording leaves a falsehood standing in cleaner clothes. The gate cannot read `dist/`, so it
gates the source of the published text, not the published text.
`notes#two-consequences-doc-comments-and-stale-phase-claims`.

5. **No em dash, anywhere** (founder directive, 2026-07-24), **including commit messages**. Gated by
`pnpm check:no-emdash` over every tracked file, its name, the gate script, and on a PR the title,
body and commit messages. Rewrite with a comma, colon, period or **parentheses**; **never
re-encode it**. Landed 2026-08-06 with the sweep (1,167 rewrites over 135 files).
Full text and the sweep's own traps: `notes#no-em-dash-anywhere`. Two that gate what you change:
- **The job is deliberately NOT a required context**: it reads the PR body, and Dependabot pastes
the dependency's upstream release notes there. **Keep the exemption and its written reason, and
never answer it with an actor `if:` on a required context** (that leaves the check PENDING, not
red). **PR text is also the half no local hook can see, so check your own before you push.**
- **Do not simplify two deliberate shapes.** The binary partition is a **declaration**
(`git check-attr binary`, refused outside `vendor/`), never a NUL test and never `grep -I`,
because a real UTF-8 source here holds a NUL and carried 14. And `CHANGELOG.md` is scanned
**above** `## Released before this file was generated` rather than excluded, the archive below
being a dated ship-log (128 deliberate survivors, and it fails closed).
Moved whole and unchanged to
[documentation/standing-disciplines.md](documentation/standing-disciplines.md): read it before you
open a change.
59 changes: 59 additions & 0 deletions documentation/engineering-guardrails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
Split out of `CLAUDE.md` to keep that file inside its byte budget. The text below is unchanged.
A pointer written `notes#<section>` names a section of `agent-notes.md` beside this file.

## 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
exports, so this is enforced, not optional.
- Immutable by default. Mutation only via explicit methods.
- No `console.*` in library code. Throw typed errors or return results.
- Short, testable functions over big parsing blobs.
- Postel's Law: parser is liberal (lenient default + warnings), serializer is conservative (always
emits spec-clean output).
- Fatal errors only for unrecoverable structural corruption (Tier-3 codes). Everything else is a
warning with a stable code + positional context.
- **No diagnostic takes a value parameter.** `SynthError(code)` is the only error `src/` constructs
and its message is a fixed entry in the frozen `SYNTH_FATAL_MESSAGES` table, so a caller string has
no position through which to reach `message`, `stack` or a thrown field. **Adding a fatal means
adding a registry entry, never a template string.** `notes#no-diagnostic-takes-a-value-parameter`.
- **Resolve every caller-supplied selector against its closed set** (`resolveKind`/`resolveMix`,
`src/select.ts`) at the entry point, before anything is generated. **Not a style rule**: an
unresolved selector reached an `err.message`/`err.stack` via a peer builder's `TypeError`, became an
`Artifact.kind` and a `manifest.counts` key, and fell out of an exhaustive `switch` as `undefined`.
All three were live. `notes#resolve-every-caller-supplied-selector-against-its-closed-set`.
- **Never claim `SynthError` being the only constructor is the whole guarantee**: `src/` can still
surface a runtime `TypeError` or a peer parser's fatal on forwarded input. Harnesses keep
`String(w.code)`, never a `message` or snippet; `test/phi/diagnostic-surface.test.ts` carries the
proof. `notes#syntherror-being-the-only-constructor-is-not-the-whole-guarantee`.
- **Read the meta-repo's `documentation/repos/phi-audit.md` before touching that.** **A new safety
sentence is worth nothing without a slot in that table behind it.**
`notes#the-phi-free-diagnostics-claim`.
- **▶ `attw` SAYS "does not contain types" AND EXITS 0, SO THE `attw` SCRIPT IS A WRAPPER
(`node scripts/attw.mjs --profile node16`), NOT THE BARE CLI.** For a package that ships types that
sentence means the declarations were **not in the tarball** (a broken publish reported as a pass),
and no `--profile`, `--ignore-rules` or config setting reaches that early return. **The race only
supplies the condition**, 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. `notes#the-attw-false-green-and-why-the-script-is-a-wrapper`.
- **Do NOT re-derive the exit-0 condition from the shape of the code: three refuter passes corrected
it, each in the same direction, and "attw misses subpaths" is the plausible, wrong story.** The
residual is a **known limit, filed rather than fixed; weaken the sentence rather than adding a
fifth arm.** All three corrections, each with its measurement, and why a correct red explained by a
falsehood is worse than it looks: `notes#the-false-green-needs-every-entry-point-untyped-at-once`,
`notes#the-first-correction-was-also-wrong-in-the-same-direction`,
`notes#missing-is-a-proxy-not-the-key-a-known-limit`.
- **`scripts/attw.mjs` carries TWO nets that catch different things** (a preflight over the relative
paths `package.json` promises, and a post-check on the untyped sentence), both pinned by
`test/scripts/attw-gate.test.ts` against the real binary plus a negative control.
`notes#the-two-nets-in-scriptsattwmjs`.
- **The post-check reads a string, so the argument guard is an ALLOW-LIST, NOT A DENY-LIST**:
`--profile` and `--no-definitely-typed` forwarded, everything else refused, "harmless" included.
**Do not answer a hole in it with a seventh spelling.** The six measured routes, why a deny-list
could not hold, and why the `.attw.json` refusal stays separate:
`notes#the-argument-guard-is-an-allow-list-not-a-deny-list`.
- **The seven `file:vendor/*.tgz` devDeps are NOT part of the `attw` story**, `npm pack` emits no
`vendor/` and attw does not resolve bare external specifiers. A **stale** vendored tarball makes
this gate neither red nor green; a **missing** one is a different thing this sentence does not
cover. `notes#the-vendored-tarballs-are-not-part-of-the-attw-story`.
- Coverage: per-directory >= 90% (lines/branches/functions/statements), enforced by
`pnpm test:coverage`.
Loading
Loading