pyreon

Library API-Shape Mistakes

Generated from .claude/rules/anti-patterns.md (the same source as MCP get_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter / pyreon doctor / MCP validate catches it automatically.

A field added to a shared struct is silently dropped by every consumer that PROJECTS it into its own narrower shape

(@pyreon/charts values2, FIVE instances in one branch, 2026-09). Series gained values2 for a two-channel band, and the mark rendered correctly — so the feature looked done. But four downstream layers each declare their OWN narrower type and copy field by field: showValues (a kind === list that never named band), A11ySeries ({label, values, kind}), TooltipSeries ({label, values, color}), and hideHiddenSeries (a field-by-field rebuild). Three of the four dropped it — and a fifth turned up in the bubble mark, where Series DID carry the channel, as radii, already mapped to PIXELS: the consumers were not omitting a field they had, they were holding a measurement of the DRAWING where the reader needs the datum. Every one compiled, every one rendered, and every one reported HALF A BAND — a value label on the high edge, a screen-reader description reading "rising from 3 to 6" about a confidence interval, a tooltip showing Range: 6. The fourth was correct by accident (it only rebuilds HIDDEN series, which draw nothing). The rule: when you add a field to a struct that crosses layers, grep for every type that RESTATES that struct's shape — a projection into a narrower local interface is invisible to the type checker, because the narrower type is complete on its own terms. A field-by-field map is the tell; pass-by-reference (tooltipAt(f.spec.series)) carries new fields for free, which is why the tooltip's own plumbing was fine while its TooltipSeries type was not. Detection: a totality spec over the union (show-values.test.ts "covers every kind the Series union declares") catches the render half — but only if it has no EXEMPTIONS: this one carried "band is a region, its two bounds are already the drawing", which was a rationale for a branch nobody had written. A documented limit and an unwritten branch look identical from inside the codebase. Ask what the limit would COST to remove before writing it down. PMTC corollary (these modules cross to native): the projections' fixes each hit a different lowering limit — if (x !== undefined) narrowing does NOT carry (use ?? [] / ?? NaN), a TS const lowers to a Swift let so a struct property cannot be assigned through it (rebuild the object), and an index parameter must be number not Double (a Double parameter against an Int loop counter fails both toolchains). The structural close, after the fifth: classify every field of the shared struct in a Readonly<Record<keyof Series, 'data' | 'presentation'>>, so a new field does NOT COMPILE until someone decides which it is, and assert at runtime that every data field reaches the reader-facing surface. Same shape as themeDefaults — a total Record, never an allowlist array, because an array's omission is silent. It found a real gap on its first run (a series carrying BOTH a second bound and a size channel lost the size column to an else if), and its own control is a TYPE-level assertion that the pixel lookalike (radii) cannot be handed to the a11y layer at all. Reference: packages/fundamentals/charts/src/engine/{render,a11y,tooltip}.ts; locks series-channels.test.ts (the totality guard) + show-values.test.ts + band-a11y.test.tsx, all bisect-verified in both directions.


An issue-count window across an await on a SHARED accumulator misattributes concurrent siblings' entries

(fuzz-found, @pyreon/validate .catch, 2026-07). .catch decided "did MY schema fail?" by snapshotting ctx.issues.length at entry and comparing after settle — under parseAsync, a SIBLING field's issue landed inside that await window, so the catch both misfired its fallback AND truncated the sibling's legitimate issue → an invalid object parsed ok: true. Rule: on a shared mutable accumulator, a begin/end length-window is only valid across SYNCHRONOUS code; any variant that spans an await must give the questioner a PRIVATE child accumulator (merged up on success) — position/count-based attribution cannot survive interleaving (same family as the position-based-pop Class A entry). Fix: compiledWithCatch runs the whole pipeline against a child ctx sharing only path/context. Reference: packages/fundamentals/validate/src/core/schema.ts:finishCatch; caught ONLY by the async differential fuzzer (jit-async-differential.test.ts) — the sync fuzzers structurally cannot interleave, so async-capable grammar in differential fuzzers is load-bearing.


A member of a public discriminated union (OutputFormat/kind/type) with NO registered runtime handler — a silent typed-but-unimplemented gap audit-types can't see

(@pyreon/document json/jsonl, 2026-07). OutputFormat listed 'json' | 'jsonl' (and README + CLAUDE.md both claimed "20 output formats") but neither was registered in the renderer registry, so render(doc, 'json') threw No renderer registered for format 'json' — the type + docs promised a capability the runtime didn't have. audit-types flags interface FIELDS with zero non-type refs; it does NOT see union MEMBERS, so this class slips the gate entirely. Rule: every member of a public "kind"/"format"/"type" union must have a runtime handler (or be removed from the union) — a union member with no dispatch entry is the exact typed-but-unimplemented shape, just invisible to the field-based audit. Two sibling lessons from the same package: (a) each format's renderer has its OWN switch (node.type), and a switch with a missing case + no default SILENTLY DROPS a documented primitive — docx's processNode had no case 'page-break', so <PageBreak/> vanished in Word (the one paginated format where it matters most) while HTML/PDF/md all honored it; a multi-target dispatch needs a per-target completeness check (or a default that at least recurses/warns), not N independent switches trusted by eye. (b) a renderer that emits PARSED structured text (a GFM pipe-table) MUST escape that format's delimiter in cell content — markdown table cells passed a raw | straight through, so x | y split one cell into two and a newline broke the row (N pipes ⇒ N+1 apparent columns; the separator row then mismatches ⇒ corrupt table). Escape \\\ then |\| (backslash first) and collapse newlines to <br>. Reference: packages/fundamentals/document/src/{render.ts (json/jsonl registration), renderers/json.ts, renderers/markdown.ts:mdTableCell, renderers/docx.ts (page-break case)}; all three bisect-verified (tests/json-jsonl-md-table.test.ts + tests/integration.test.ts docx page-break binary check).


A rest-args factory that diverges from the array form the REST of the library + the market use → cryptic runtime crash

(fuzz-found, @pyreon/validate s.union, 2026-07). s.tuple([...]), s.enum([...]), and Zod / Valibot / ArkType's union([...]) all take an ARRAY, but s.union was rest-args-only (s.union(a, b)). A user writing the natural s.union([a, b]) by analogy hit a type error at build — but if the array reached the constructor at runtime (dynamic construction, as never cast, plain-JS caller), it was stored as a single "member" and crashed at PARSE time with a cryptic member['~standard'] is undefined deep in _compileType, not a clear message. Rule: a composition factory should accept the SAME shape its siblings + the market use; when a signature is unavoidably rest-args, still (a) accept the array form too — cheap: args.length === 1 && Array.isArray(args[0]) ? args[0] : args is unambiguous because a schema is never an array — and (b) guard non-schema / too-few members at CONSTRUCTION with a [Pyreon]-prefixed message so a bad call never surfaces as a cryptic deep-in-parse crash. Found by a differential fuzzer (JIT↔interpreter) whose schema grammar was extended to the fallback field types the in-repo differential test never generated; the fuzzer used the natural array call form and crashed instantly. Reference: packages/fundamentals/validate/src/composition/union.ts (dual-form factory + constructor guard) + tests/union-call-forms.test.ts (bisect-verified). Detection lesson: a differential fuzzer's SCHEMA generator is as important as its input generator — the existing JIT↔interp test only built string/number/boolean/object/array fields (all fully inlined), so the partial-inline fallback seam AND the union call-form footgun were both invisible until the grammar was widened.


Detecting a validator by a VENDOR-specific method silently no-ops every OTHER Standard Schema

(@pyreon/feature, 2026-07). createValidator gated on Zod's safeParseAsync, so a Valibot / ArkType schema (neither exposes it) received NO form validation despite the documented "Zod / Valibot / ArkType" support — the form reported VALID while the schema rejected (silent-schema-drop class). Rule: detect a validator by the CROSS-LIBRARY contract (~standard.validate), not one vendor's method — and accept CALLABLE schemas: ArkType's type(...) is a FUNCTION carrying ~standard, so a typeof === 'object' guard (exactly what @pyreon/validation's isStandardSchema uses — a cross-package bug feature works around locally) silently rejects it. Honest companion limit: field INTROSPECTION (auto form fields / table columns / create defaults via extractFields) is inherently Zod-only — no cross-library shape-introspection standard exists — so a non-Zod feature dev-warns + needs explicit initialValues. Cross-package follow-up: widen isStandardSchema to accept callables (also unblocks raw ArkType in @pyreon/form/store/state-tree). Reference: packages/fundamentals/feature/src/define-feature.ts:hasStandardSchema + tests/schema-validators.test.tsx (bisect-verified: revert the ~standard branch → Valibot + ArkType "invalid field surfaces an error" fail expected true to be false).


A typeof === 'object' type guard silently rejects a CALLABLE value that carries the brand it checks for

(@pyreon/validation isStandardSchema, 2026-07 — surfaced by the @pyreon/feature pass). isStandardSchema bailed with if (value == null || typeof value !== 'object') return false before reading ~standard — but ArkType schemas are FUNCTIONS (type("string")(input) validates) that ALSO carry ~standard. So a raw ArkType schema failed Standard-Schema detection, and EVERY consumer that routes "is this a Standard Schema? then validate through it" (@pyreon/store/state-tree via extractParseFn, the standardSchemaToValidator bridge, @pyreon/validate, @pyreon/feature) SKIPPED validation for it — a form/store declared with a raw ArkType schema reported VALID while the schema would REJECT (store/state-tree instead THREW at definition, so raw ArkType was unusable). Rule: a duck-type guard checking for a brand property must gate on typeof === 'object' \|\| 'function', never object alone — a library may expose the brand on a CALLABLE (ArkType, and any "call the schema to validate" library); keep the brand check itself strict (~standard.validate must be a function) so a plain function without the brand still returns false. The fix is purely additive (object schemas unchanged) and the sibling bridges (standardSchemaToValidator/wrapStandardSchema) already invoked schema['~standard'].validate (the Standard-Schema entrypoint, NOT a Zod-specific .safeParse), so ONLY detection was broken. Detection lesson: the existing isStandardSchema tests only passed OBJECT schemas (raw zod/valibot) — a callable-schema (real ArkType) case was never asserted, so the guard shipped broken from inception; use the REAL callable library, not a mock ~standard object. Residual (separate consumer bug, follow-up): @pyreon/form's resolveSchemaValidator short-circuits typeof === 'function' BEFORE isStandardSchema, so a raw ArkType schema in useForm({ schema }) is still mistreated as a SchemaValidateFn. Reference: packages/fundamentals/validation/src/schema.ts:isStandardSchema + tests/callable-standard-schema.test.ts (real arktype + @pyreon/store end-to-end, bisect-verified).


Discriminating a Standard Schema RESULT on 'value' in r instead of on issues — valibot's FAILURE result carries BOTH

(@pyreon/validation wrapStandardSchema, 2026-07). wrapStandardSchema (behind extractParseFn, the dispatcher @pyreon/store/@pyreon/state-tree call verbatim) decided SUCCESS with if ('value' in r) return { ok: true, value: r.value }. But the Standard Schema spec's discriminant is issues ("if issues is undefined, validation succeeded"), and valibot's failure result is { typed: false, value: <raw input>, issues: [...] } — it carries value on failure too. So a RAW valibot schema (Tier A.2 — passed directly, no valibotSchema() adapter) driving schema-mode defineStore({ schema }) / model({ schema }) was a SILENT validation no-op: extractParseFn(v.object({ age: v.number() }))({ age: 'nope' }) returned { ok: true, value: { age: 'nope' } }, so an invalid set/patch did NOT throw and wrote the raw invalid value INTO state (data corruption). Raw zod / arktype were unaffected only by accident (their failure results carry no value key); @pyreon/form/@pyreon/feature were unaffected because they route through standardSchemaToValidator, which already checked issues first — the proven-correct sibling at the top of the SAME file. Rule: discriminate a Standard Schema (or any lib whose validate returns a typed result) on issues — failure iff issues is a non-empty array, success otherwise — NEVER on the presence of value; a validator may return the raw input alongside the issues on failure, so 'value' in r is not a success signal. When a package has two functions consuming the same result shape and one is correct (standardSchemaToValidator, issues-first), the other should MIRROR it, not re-derive its own discriminant. The "real library, one lib short" test lesson (reinforced): store/state-tree schema suites exercised raw ARKTYPE + the valibot ADAPTER but never RAW valibot, so the bug hid from package inception (#910). A raw Standard Schema consumer test must run the FULL raw-library matrix (zod + valibot + arktype) — each library's success/failure result shape differs (valibot's both-keys failure is the trap the others don't have); one representative lib is not enough. Bisect-verified: revert the discriminant → 4 raw-valibot specs fail (expected true to be false on the bridge/matrix ok, expected [Function] to throw on the store e2e); the zod/arktype matrix + valid-input + async-schema specs stay green. Reference: packages/fundamentals/validation/src/schema.ts:wrapStandardSchema + tests/standard-schema-result-discriminant.test.ts (real valibot/zod/arktype + @pyreon/store end-to-end).


Reading an observable property inside an @Observable class's init — SwiftUI rebuilds the view, builds a SECOND instance, and the discarded one's teardown takes a process-wide slot with it

(the PMTC PyreonRouter cold-deep-link gate, 2026-09). A guard added to PyreonRouter.init expressed itself against self (self.path.last, and resolveChainIn(routes, …) where the bare routes resolves to the observable self.routes). PyreonRouter is @Observable, so the macro rewrites those reads into _$observationRegistrar.access(self, …) — and init runs inside ContentView.init, which SwiftUI evaluates inside its own tracking context. The half-built router got registered as a dependency, SwiftUI rebuilt ContentView, a SECOND PyreonRouter() was constructed (registering itself as the live deep-link listener) and then discarded because @State keeps the first value — and the discarded router's deinit released the listener slot. Net effect: the COLD deep link worked (the kept router consumed the pending path in its own init) and every WARM link afterwards was silently dropped. The symptom points at the deep-link channel, which is the one place the bug is not. Rule: an @Observable class's init must compute from its LOCAL PARAMETERS only — never read a stored observable property, directly or through an instance method that does. Make such a helper static and pass the value in. The gate is now self.path = Self.initialPathAllowed(routes, initialPath.last) ? initialPath : [], reading the init parameter rather than the property. Detection lesson: no unit test can see this — XCTest has no SwiftUI observation context, so the self-reading and self-free forms are indistinguishable there (97/97 router-swift tests pass against BOTH). Only the on-device XCUITest separates them, and only because it asserts the WARM arrival as well as the cold one; a cold-only deep-link test passes against the broken build. Bisect-verified on a real simulator across four arms: gate reading self → fails 3/3 iterations; deep-link change reverted with that gate kept → still fails 3/3 (exonerating the channel); both reverted → passes; gate made self-free → passes. Reference: packages/native/router-swift/Sources/PyreonRouter/PyreonRouter.swift (initialPathAllowed + resolveChainInStatic) + examples/native-router-demo-ios/iosUITests/PyreonRouterDemoUITests.swift:test_deepLinkOpensTheRouteColdAndWarm.


A verifier that samples SENTINELS reports on the sample, not on the artifact — and a failure path with no RESUME turns a transient error into a month-long outage

(the 0.51.0 partial release, found 2026-09-08). check-published-state compared three JS sentinels plus one native binary against npm and printed OK 4/4 daily, while a full sweep showed six of 76 packages (@pyreon/native-cli, native-compiler, both runtimes, both routers) at 0.50.0 against a 0.51.0 cut. The publish run had hit npm E422 Error verifying sigstore provenance bundle on four of them — transient, npm-side, gone on a second PUT — and native-cli was correctly held back behind an unpublished dependency; publish.ts had no retry; release.yml publishes only when a Version PR merges; heal-release-chain judged "npm has the version" by the @pyreon/core anchor. Every layer was individually reasonable and the composition shipped a stale native compiler to every fresh multiplatform scaffold for a month. Three rules. (1) When the full sweep is affordable (76 registry reads), do the full sweep — a sentinel is a heuristic standing in for a measurement, and the repo already forbids that shape ("a list-vs-reality gate checks BOTH directions"). (2) A publish step must retry ONLY what it has EVIDENCE is transient — 5xx, dropped sockets — never a 404 (no Trusted Publisher: the same PUT fails forever), a 403, the cannot-publish-over conflict (that is success), or E422 Error verifying sigstore provenance bundle. That last one is the trap: it reads as an npm-side hiccup and is deterministic. Its full message names the cause — Failed to validate repository information: package.json: "repository.url" is "" — i.e. provenance compares the tarball's manifest against the building repo, and those six manifests had no repository field at that tag. The first live resume run, a month later, reproduced it byte-for-byte on the same tag. Classifying it as transient (which this entry originally did) buys three identical failures and a wrong root cause. (3) A resumable operation needs an actual RESUME trigger, not just idempotency — publish.ts was idempotent all along and nothing ever called it again. The resume must build from the release TAG, never from main, or a month of unreleased work ships under a released version number. And a tag-replay remediation cannot repair a release broken BY its own tag (here: manifests missing repository), so it must be BOUNDED — attempt once per version, then warn and escalate — or it turns a stale-release condition into a red Release run on every push to main. The alarm belongs in the daily gate that can stay red without blocking anything; the remediation belongs where a failure is informative, not blocking. Reference: scripts/check-published-state.ts:classifyLag, scripts/publish-retry.ts, release.yml resume-detect/resume-publish; locks check-published-state.test.ts + publish-retry.test.ts; live bisect: main's script printed OK against the six-package lag, the change names all six.


A timeout gate that scans ONE workflow certifies the others by omission.

check-ci-job-timeouts read only ci.yml, so four jobs in other workflows (CodeQL, dependency-audit, scorecard, the nightly notifier) ran on GitHub's 6-hour default — on a 20-slot org pool, a hung one holds a slot for six hours. The parser also had to learn two shapes it had never seen: on: nests 2-space keys (push:, schedule:) that are not jobs, and a timeout-minutes: ${{ … }} expression is a declared budget. Now every workflow, every job. Same family as the per-file Kotlin gate and the native decide regex: a gate's INPUT SET is a claim, and a narrow one is quietly false for everything outside it.


An own-key COUNT is not a MEMBERSHIP test — the field checks that "proved" the keys read through the PROTOTYPE CHAIN

(@pyreon/validate .strict() JIT, 2026-09). Both .strict() emitters short-circuited on Object.keys(x).length === N, reasoning that the field checks above had already proven all N declared keys present. They had not: a field check reads x.name, which walks the prototype chain, so Object.create({name,age}) — or a class instance with prototype getters — passes every field check with ZERO own keys. Two divergences from the interpreter (which scans Object.keys + Object.hasOwn), and they are ONE bug: verdict mode returned false for a prototype-carried VALID object while parse().ok was true, breaking the locked is() ⇔ parse().ok invariant; and parse mode SKIPPED its unknown-key scan for { nmae: 'Ada', age: 36 }, because a typo'd key IN PLACE OF a real one keeps the count at N — so Unrecognized key "nmae" was never reported, in the one feature that exists to report it. Rule: a short-circuit that stands in for a set-membership predicate must PROVE membership in the SAME SET the cheap signal came from, not merely count that set. |Object.keys(x)| === N plus every declared key is in Object.keys(x) is a proof; a count alone is an inference from a premise the reads never established. The general form: whenever an optimisation replaces a predicate with a cheaper one, write down what the cheap one assumes and ask which code ESTABLISHES that assumption — here the answer was "nothing does", and the assumption was about the prototype chain, which is invisible at the point the count is taken. The FIRST fix for this got the second half wrong and is the more instructive half of the entry: it proved membership with Object.hasOwn, which is true for an own NON-ENUMERABLE property — a superset of what Object.keys returns. So { a, zzz } with a non-enumerable own b counted N, proved both declared keys "own", skipped the scan, and never reported zzz: the identical defect one shape over, shipped by the fix for it, with a docblock stating the wrong proof as fact. The predicate must be Object.prototype.propertyIsEnumerable.call (own AND enumerable = exactly Object.keys membership; called off Object.prototype so a declared field named propertyIsEnumerable cannot shadow it). Corollary for bisecting a fix that REPLACES an earlier fix: run three legs, not two — original / first attempt / shipped. A two-leg bisect certifies the first attempt as correct, because it does fix the shape it was written for. Two detection lessons, and the second is the one that matters. (1) The differential fuzzer generated schemas and inputs INDEPENDENTLY, so the divergent pairing (a strict schema meeting an object whose every FIELD check passes) was reachable only by coincidence — and measured against the broken build, it never happened. Pairing each generated schema with a value it ACCEPTS, then deriving the count-breaking twins FROM that value, makes the same 2000 iterations fail at iteration 25. (2) That fuzz went into the WRONG HARNESS, and the sharing that fixes the drift is exactly what blinds it. Its oracle is is() === parse().ok — both JIT — so once the two emitters share one predicate they agree BY CONSTRUCTION, and a wrong shared predicate keeps both equally wrong. That is why the hasOwn hole above passed it. A shape served by two emitters needs its differential against the INTERPRETER (the independent implementation), not against the other emitter; consolidating two code paths onto one predicate silently retires any suite whose oracle was that they agree. Reference: packages/fundamentals/validate/src/core/jit.ts:strictShortCircuitMiss; locks tests/strict-prototype-keys.test.ts, the schema-paired fuzz in tests/jit-check-differential.test.ts, and — load-bearing — the .strict() block in tests/jit-differential.test.ts (JIT vs interpreter), three-leg bisect-verified: count-only fails the typo and nested inputs, hasOwn fails the non-enumerable input, propertyIsEnumerable passes 46/46.


Library API-Shape Mistakes