Documentation Mistakes
Generated from
.claude/rules/anti-patterns.md(the same source as MCPget_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter /pyreon doctor/ MCPvalidatecatches it automatically.
A renderer that reserves a markdown level for STRUCTURE must demote body headings, or the body can forge structure — and the forgery is invisible until the first body that uses a heading
(@pyreon/mcp get_changelog, 2026-09). ## <version> is the one boundary consumers split on. A changeset body may carry its own ## Title; changesets inlines it INDENTED under the bullet; the parser strips the indent (line.replace(/^ {2,4}/, '')); the heading resurfaces at column zero as a fake version. The 0.52.0 release had five such bodies, so formatChangelog(query, { limit: 1 }) — one version — rendered SIX ## lines and the release PR went red on a test that was right. Main stayed green only because its CHANGELOG had not been regenerated: the defect was latent in the formatter, armed by the first release to carry a heading. Two lessons. (1) When one markdown level is load-bearing in your output, demote body headings by construction (h2→h3, and floor at h3 so a body h1 cannot become a colliding h2) — do not rely on authors never writing one. (2) A spec that reads a REAL generated file discriminates only on the state that file happens to be in; the real-CHANGELOG spec stayed green with the fix neutered on main, so the load-bearing specs are the synthetic ones that plant the heading deliberately. Bisect: neutering the demotion fails exactly the 3 synthetic heading specs while the byte-identical-passthrough control stays green. Reference: packages/tools/mcp/src/changelog.ts:demoteBodyHeadings.
Forgetting to update all surfaces
CLAUDE.md, docs/, README, llms.txt, llms-full.txt, MCP api-reference must all stay in sync
Outdated examples
Examples must compile and run — no pseudocode in docs
A gate on a machine-readable value, with adjacent prose restating that value UNCHECKED
gating only the machine half relocates the rot instead of stopping it — the prose is what a human actually follows, so it drifts silently while the gate stays green. Shipped instance: contrib/krausest/pyreon-keyed's pin-freshness gate (#2398/#2400) asserted package.json pinned the current workspace version, while README-SUBMISSION.md one directory over kept saying @pyreon/*@^0.38.0 — twelve minors stale, and 0.38 predates the remove fast path (#2288) + the anchor-registry retained fix (#2003). A submitter trusting the README would have published an independent benchmark number for a Pyreon materially worse than shipped, under our own name — the exact outcome the gate existed to prevent. The same file's step 3 told them to run npm ci in a directory with no committed lockfile, which hard-fails (EUSAGE), so the first build command in the instructions could not run at all. Rule: when you gate a value, either (a) gate every surface that restates it, or (b) delete the restatement and point at the single source of truth. Prose beside a gated value is not documentation of the gate — it is an ungated second copy. Prefer gating the DOC when the doc is the human-facing artifact (a submission checklist, a runbook, an install guide): a stale doc there costs more than a stale constant. Design the doc-side check to police contradiction, not vocabulary — match only the spelled-out @pkg@^x.y.z form so historical narrative ("was staged at ^0.38.0") stays writable, and target the exact broken command rather than banning a string that is legitimate elsewhere (the fork-ROOT npm ci is correct — upstream commits a lock). Reference: packages/internals/test-utils/src/tests/krausest-pin-fresh.test.ts (the two README specs, bisect-verified).
Literal backslashes in manifest summary / mistakes / example string VALUES
(caught in the styler manifest migration, PR after #624; renderer FIXED in PR #1442): @pyreon/manifest's renderStringLiteral historically escaped ` → \` and ${ → \${ but NOT literal backslashes. A manifest string whose resolved value contained a literal \ (e.g. ```bash in a CodeGroup example body) serialized to \\\`` in the generated file = (escaped backslash)(RAW backtick) → premature template-literal close in api-reference.ts→ tsc parse failure. **The renderer now escapes` FIRST** (before backticks + ${), so the bug class is structurally closed. Reference: packages/internals/manifest/src/render.ts:renderStringLiteral (post-fix). Symptom guide for the historical shape (still relevant if someone reverts): after bun run gen-docs, bunx oxlint packages/tools/mcp/src/api-reference.ts — a parse error in the freshly-generated region (not the hand-written ones) is this bug.
<Playground code={ … }> in docs (deprecated)
iframe-sandboxed string-blob code with nested template-literal escape passes — the exact shape behind PR #1434's '\n' double-unescape SyntaxError. Migrated wholesale in PR #1448 (36 instances across 30 docs-zero pages → <Example file="./examples/<topic>/<slug>" />). For new docs always use <Example>. The legacy VitePress docs/ site retains <Playground> until it's fully cut over to docs-zero. Reusable migration tooling: scripts/migrate-playground-to-example.ts (parses + extracts + rewrites) + scripts/batch-fix-example-types.ts (iterative TS strict-mode fixup). Don't author new <Playground> calls — the value prop ("type-checked, refactor-safe, cross-mount signal-share") is structurally absent. Enforced by pyreon/no-playground-in-docs lint rule.
<Example> example components that hard-require props.shared
every example component MUST accept { shared?: Signal<T> } and fall back to a local signal — const count = props.shared ?? signal(0). Without that fallback, the example breaks when used WITHOUT share (i.e., as a standalone single demo). The contract is "bridgeable, not require-bridged." Reference: docs/src/examples/reactivity/signals-read-write-react.tsx for the canonical shape.
as never casts on accessor-form JSX attribute values
(fixed at the root in PR #1442): the canonical Pyreon pattern for reactive attributes is attr={() => sigCall() === target ? 'value' : undefined}. Several attrs in @pyreon/core's JSX types declared the static-value union but missed the function-accessor variant — aria-current, etc. Consumer code had to write (() => …) as never to silence the type error. Fix the root cause in packages/core/core/src/jsx-runtime.ts by adding | (() => UnionOfStaticTypes | undefined) to the attr's type, matching the shape used by aria-selected, aria-disabled, aria-hidden. Audit every accessor-supported attr to ensure the function variant is present.
A SwiftUI presentation modifier (.sheet/.alert/.popover) anchored to EmptyView() never presents — and it typechecks clean, so only a device catches it
(the PMTC <Modal> instance, 2026-07). PMTC lowered <Modal open onClose> to EmptyView().sheet(isPresented: …) { … }. EmptyView contributes NOTHING to the render tree, so there is no view in the hierarchy for SwiftUI to anchor the presentation to and the modifier is silently inert: tapping the open button produced no sheet, no dialog, and no modal body anywhere in the XCUITest accessibility dump. The emit was valid Swift and passed swiftc -typecheck throughout, so every gate below R4 (snapshot, parse, typecheck-against-stubs) was green while one of the 15 canonical primitives did not work at all on iOS. Fix: anchor to a REAL but layout-neutral view — Color.clear.frame(width: 0, height: 0).sheet(…). Color.clear is a genuine view (valid anchor); the zero frame keeps it from shifting the surrounding stack. General rule: a SwiftUI modifier whose effect is a PRESENTATION needs a host that actually renders; EmptyView() is not one. Any emitter that attaches behaviour to a synthesized host must attach it to something that participates in layout. The asymmetry is the lesson for multi-target emit: Compose reaches the same primitive by COMPOSING a node (if (open) { Dialog(onDismissRequest = …) { … } }), which has no anchoring requirement and was correct all along — so this bug existed on exactly one target. When two targets reach a primitive through different mechanisms (a modifier vs a composed node), a per-target DEVICE check is the only thing that settles it; the same family as "<Inline> is a non-wrapping Compose Row but a shrinking SwiftUI HStack". Bisect-verified on a real simulator (revert to EmptyView() → test_modalPresentsAndDismisses fails; restore → passes). Reference: packages/native/compiler/src/emit-swift.ts (Modal emit) + examples/native-counter-ios/iosUITests/PyreonCounterUITests.swift.
A SPECIAL-CASE emitter that returns before the generic modifier tail silently drops data-testid, making the element structurally UNASSERTABLE
(the PMTC <Link> instance, 2026-07). Most primitives flow through a generic emit path whose tail turns data-testid into .accessibilityIdentifier (Swift) / Modifier.testTag (Compose). <Link> had its own emitter (emitSwiftLink / emitKotlinLink) that built PyreonLink(to) { children } and returned EARLY — so the identifier was discarded on BOTH targets and the element could not be selected by XCUITest or onNodeWithTag at all. This is very likely why Link sat in the capability matrix's "not individually asserted" list for so long: you cannot write an assertion against an element you cannot select, so the doc recorded a symptom whose cause was an emit gap. General rule: whenever you add a special-case emitter for a tag, audit which generic-tail responsibilities it now skips — test identifiers, a11y props, layout modifiers — because each omission is invisible in the emit (the code looks right) and surfaces only as "we never asserted that one". Swift additionally needs .accessibilityElement(children: .contain) here, because PyreonLink WRAPS its label and SwiftUI flattens a plain wrapper out of the accessibility tree (same trap as VStack/ScrollView); .contain rather than .combine keeps the child label individually queryable. Device-read shape: Other identifier: 'home-link-about' containing Button label: 'About via Link'. Bisect-verified on a real simulator. Reference: emit-swift.ts:emitSwiftLink + emit-kotlin.ts:emitKotlinLink + the canonical-primitives.test.ts "carries the identifier" specs. RECURRED within the month (the <Toggle> Kotlin instance, 2026-07): emitKotlinToggle built Switch(checked, onCheckedChange, enabled) and returned before the generic tail, so data-testid on <Toggle> was dropped and the Switch was unselectable by onNodeWithTag — found the moment the Android device assertion for the Core-UI row was attempted (the Swift half chained its modifiers, so iOS was fine). The audit the general rule prescribes was not run against the OTHER special-case emitters when the Link fix landed; do that sweep when touching any of them. Companion stub trap: the kotlinc validate stub's Switch had NO modifier param (a SUBSET stub), so the corrected emit FAILED the stub gate until the stub was brought to fidelity — the mirror image of the superset-stub masking class. Reference: emit-kotlin.ts:emitKotlinToggle (modifier tail) + kotlin-stubs.ts Switch.
XCUITest element TYPE and tap POINT must be read off the device, not guessed — three of four new device assertions failed for query reasons, not product reasons
(2026-07). Writing device assertions against an assumed accessibility shape produces failures that look exactly like product bugs and bury the real one. Measured shapes from a live simulator dump: (1) a container carrying .accessibilityElement(children: .contain) surfaces as otherElements, NOT as the child's type — so a <Link>'s identifier is on an Other wrapping the Button, and app.buttons[id] misses it; (2) <Scroll> surfaces as scrollViews, not otherElements; (3) <Toggle> lowers to Toggle("", isOn:) whose OUTER element spans the full row (measured 402pt) while the real control occupies only the trailing ~63pt — so element.tap() hits the row centre, lands in dead label space, and silently does not flip (the state text stays put and the failure reads as "the binding never wrote the signal"); tap element.switches.firstMatch instead. General rule: before asserting, dump app.debugDescription once and read the element types, identifiers and frames. A device test built on a guessed shape is worse than none — it manufactures failures that mask genuine ones. Same family as "read the API before probing it".
A content-keyed conditional-import predicate written against ONE call shape misses the other — .foo( vs .foo { }
(the PMTC Kotlin clickable instance, 2026-07). packages/native/cli/src/build.ts:conditionalKotlinImports adds an import when the emitted Kotlin CONTAINS a symbol, because Kotlin star-imports are single-package and androidx symbols live in sub-packages the header doesn't cover. The clickable arm tested emitted.includes('.clickable(') — the shape <Press> emits. But <Link> emits a TRAILING LAMBDA, Modifier.clickable { navigate() }, which contains no .clickable( at all, so the import was never added and the first <Link> in an Android example failed gradle assembleDebug with Unresolved reference 'clickable'. Every pre-merge gate stayed green: the validate-kotlin loop CONCATENATES the stubs into the same compilation unit, so it resolves the symbol with or without an import and is structurally incapable of catching a missing one — only the real device build can. Rule: any predicate that matches emitted SYNTAX must cover every call shape the emitter can produce for that symbol. In Kotlin that means both foo( and foo { for anything callable with a trailing lambda — use /\.foo\s*[({]/, not includes('.foo('). Audit the sibling arms when you touch one: the emitter's trailing-lambda surface is enumerable (grep -ohE "\.\w+ \{" emit-kotlin.ts), and cross-checking it against the paren-keyed predicates is a 30-second check that bounds the whole class (it found .clickable was the ONLY gap — .alpha/.background/.border/.clip/.combinedClickable are paren-only in the emit, and .semantics {/.clearAndSetSemantics { were already brace-keyed). Watch over-matching in the other direction too: combinedClickable capitalises the C, so .clickable is not a substring of it and the widened regex must not start pulling a dead import — pinned by its own spec. Bisect-verified. Reference: packages/native/cli/src/build.ts + tests/build.test.ts ("TRAILING-LAMBDA form").
[FIXED, 2026-08] A reactive boundary that tears down and re-mounts on EVERY re-run destroys a MEMOIZED child — <Show> pinned its subtree permanently stale, with no warning.
mountReactive's effect ran currentCleanup() then mount(accessor()) unconditionally, so an accessor re-run with an UNCHANGED value still rebuilt. That is wasteful for most shapes and fatal for the one the compiler emits since the _lc change: a component's SOLE child is memoized (_lc builds once and caches), so <Show>'s accessor returns the SAME _tpl NativeItem — an object whose DOM node and bindings were constructed at _tpl time. The teardown disposed those bindings; the remount re-inserted the same element WITHOUT rebuilding them. One live node, permanently stale, zero warnings. The trigger is ordinary and the reason it hid is that the boundary's VERDICT does not change: when={() => selected() !== undefined} re-runs on every selected change while staying true, so nothing structural appears to happen — and a test that asserts a single settled value passes, because the FIRST update still works. It needs a SECOND update with the verdict held constant. Fix: skip the teardown when the accessor returns the value already mounted. Identity (===) is deliberately the test rather than a deep compare — every shape that builds a fresh value per run (a bare h() inside an accessor, a changed primitive, a new array) compares unequal and behaves exactly as before, so the skip can only ever fire for a value literally already in the DOM. Bookkeeping matters in two places: record the value only AFTER the teardown has happened (so a throwing accessor leaves the previous mount and its record intact), and reset the record when a re-entrant newer generation supersedes this run (or a later run matching the stale value would skip a mount that never happened). General rule: a reactive boundary must not assume its child can be rebuilt. Once ANY child value is memoized or carries construction-time state — a template clone, a bound DOM node, a component instance — "unmount and mount again" stops being idempotent, and the boundary has to compare before it destroys. Also a real churn reduction: a <Show> over a frequently-changing signal no longer rebuilds its branch on every change (runtime.mountReactive.identitySkip). Reference: packages/core/runtime-dom/src/nodes.ts:mountReactive; bisect-verified in tests/show-child-retrack.test.tsx (neutering the identity check fails "survives a when re-run that produces the SAME boolean" with expected 'b' to be 'c'), which compiles through the REAL transformJSX because vitest's own JSX transform never emits _lc/_tpl and so cannot reproduce it at all.
A code GENERATOR must typecheck its OUTPUT, not just emit it — six defects in one generator's first pass were invisible to every test of the generator itself
(the @pyreon/lathe instance, 2026-08). A generator's unit tests assert on emitted STRINGS, so they pass whenever the string matches what the author expected — which is exactly the thing under test. Six real bugs shipped past a 59-test suite and were caught only by running tsc over the generated files in a consumer, and one of those only by a real-browser e2e: (1) Infer imported into a native module — TypeScript erases import type, but PMTC's warn pass reads the import STATEMENT and reports the whole module un-lowerable; (2) the response generic on .query<T>() instead of useQuery<T>, which PMTC lowers to a decode of Any (it says so in a WARNING, not an error, so nothing failed); (3) no schema: standardSchema on the generated client — @pyreon/http keeps schema support opt-in so the core costs nothing unused, so an endpoint declared with { response } against a client that never enabled it REJECTS A 200 AT RUNTIME, which no static check can see; (4) a { response } clause emitted only for a bare $ref, leaving array-returning operations at TResponse = unknown so every generated hook failed to typecheck in the consumer's repo; (5) optional properties written x?: T where the schema infers x?: T | undefined — different types under exactOptionalPropertyTypes, so the emitted type did not match its own schema; (6) mock fixtures keyed on a response field MockRoute does not have, imported from the package root rather than the /mock subpath. Rules: (a) a generator's test suite MUST include a fixture consumer that typechecks and RUNS the output — asserting on emitted strings only proves the emitter agrees with itself; (b) when emitting against a library, read its EXPORTS MAP and its option types rather than the barrel (mock/MockRoute live on @pyreon/http/mock; standardSchema on @pyreon/http/schema); (c) an opt-in capability the emitted code depends on must be emitted TOO — the failure is a runtime rejection with a 200 on the wire, the least debuggable shape there is. Reference: packages/tools/lathe/src/tests/generate.test.ts (each bug has a named regression spec) + examples/lathe-bookshelf (the consumer that typechecks + runs the output, gated by test:e2e:lathe).
Emitted const declarations must be ordered by DEPENDENCY, not by name — generated output that reads perfectly can still throw at import
(the @pyreon/lathe instance, 2026-08). A generator emitting export const Alpha = s.object({ z: Zulu }) before export const Zulu produces a module that throws ReferenceError: Cannot access 'Zulu' before initialization the instant it is imported, because const is not hoisted. Models were emitted ALPHABETICALLY, which satisfies the constraint only by coincidence — and the coincidence held for the example that shipped first, because allOf flattening had removed its forward references. Three rules. (1) Order is a correctness property whenever the emitted binding form is not hoisted. Sort topologically over the reference graph; ties break by name so regeneration stays byte-identical. (2) A genuine cycle cannot be ordered, so the emitter must break it deliberately — s.lazy(() => X) (or the target language's equivalent) on the back edges only, never on every reference, or the common output stops being the plain literal a downstream compiler recognises. A SELF-reference is the most common cyclic shape in real specs (a tree node, a comment with replies) and is easy to drop accidentally: an early version deleted self-edges from the dependency set "to keep the sort tidy", which made exactly that shape invisible. (3) A module that INLINES rather than imports must carry the TRANSITIVE closure — emitting Order while leaving out the Customer it names produces a file that does not typecheck. The detection lesson is the sharpest part: every string-level assertion passed. The emitted source looks correct either way, so the regression test has to EVALUATE the module (new Function over the emitted body, then parse a value through it) rather than inspect it. Reference: packages/tools/lathe/src/core/graph.ts + tests/graph.test.ts.
Every string a SPEC controls is an injection surface for a code generator, and the comment sanitizers are the ones nobody writes
(the @pyreon/lathe instance, 2026-08; surfaced by CodeQL's js/bad-code-sanitization). A generator emits SOURCE, so a hostile (or merely careless) value in a spec's title, summary, description or enum lands in the emitted file. Three distinct holes, all real, none visible in the emitted string: (1) a // line comment ENDS at the first line terminator — a spec title of T\nglobalThis.pwned=1;// put executable code in EVERY generated file's banner, which is the severe one because a banner is the last place anyone looks; (2) a /* */ block ends at */ — a description containing it closed the JSDoc and dropped the remainder into code position; (3) \r, U+2028 and U+2029 are line terminators in JavaScript exactly as \n is, so a string-literal escaper that handles only \n emits a literal that a spec enum value can end. A fourth hid one layer down: JSON.stringify leaves U+2028/U+2029 RAW (they are legal inside a JSON string), so any emitter pasting its output into source — a mock fixture, a scenario args object, a quoted property key — inherits (3). That is the SAME trap the SSR loader-data serializer documents, in a different context: there it breaks an inline <script>, here it breaks the emitted module. Rules: (a) a value bound for a line comment must have every line terminator COLLAPSED (a comment has no escape syntax, so escaping is not available); (b) a value bound for a block comment must have */ broken (*\/ contains no adjacent * / and still reads as intended); (c) a string-literal escaper must cover all four line terminators plus the C0 controls, and must ROUND-TRIP — a dropped character is a wrong schema, which is worse than an ugly one; (d) never paste JSON.stringify output into source without re-escaping U+2028/U+2029. A fifth was the one the scanner ACTUALLY pointed at, and the audit above missed it by assuming identifiers were the safe part: parameter NAMES reached a TYPE position raw ({ params: { <name>: string } }), so a spec name of a: string }, INJECTED: () => void, z: { b closed the type and injected an arbitrary parameter into the generated function signature. It carried a correctness bug too — the path PLACEHOLDER was already ident()-normalized while the parameter name was not, so the two disagreed for any name that was not already an identifier, and the emitted call set a key the endpoint never read. Path parameter names now take the same normalization as their placeholder; QUERY names are WIRE names (?page=2) so they stay verbatim and are QUOTED at emit instead. The lesson about the scanner: it flagged one line, and the line was right — chasing the CLASS around it found four more, and the flagged one was the last to be believed, because the identifier path looked obviously safe. Detection: the regression test must EXECUTE the emitted module and assert no injected global was set. Every payload above produces output that reads entirely plausibly, so a string-level assertion passes — and the identifier path was already safe (ident() splits on [^A-Za-z0-9]+), which makes it tempting to assume the whole surface is. A SIXTH context, found in the 0.52 pre-release audit and closed the same way: the REGEX LITERAL. portableRegex refused a pattern containing / — its comment even says why, "the emit writes /amp;#123;pattern}/" — and stopped there. A regex literal is ALSO ended by all four line terminators: RegularExpressionChar is built from RegularExpressionNonTerminator, "SourceCharacter but not LineTerminator", so LF/CR/U+2028/U+2029 are illegal anywhere in one, character class included (a raw CONTROL character is not a terminator and stays legal, which is the discriminating case). {"pattern": "a\\nb"} is legal OpenAPI, so this needed no bad faith to reach, and the damage is worse than a dropped constraint: .regex(/a<LF>b/) is Unterminated regular expression literal '/a', which takes EVERY model in schemas.ts with it — a build-time DoS on the consumer from one spec field. Arbitrary-code injection is not reachable here while / stays refused (you cannot CLOSE the literal without one), which is exactly why a string-level assertion would have missed it and only "does the emitted module still PARSE" catches it. The generalisation: when a sanitizer refuses a terminator, enumerate the FULL terminator set of that lexical context — / alone is the terminator you can see, and this package already knew the line-terminator half in three other contexts (q, safeLineComment, jsonLiteral) without carrying it to the fourth. Reference: packages/tools/lathe/src/emit/writer.ts (safeLineComment / safeBlockComment / q / jsonLiteral) + emit/schema.ts (REGEX_LITERAL_TERMINATOR) + tests/injection.test.ts + tests/schema-emit-edges.test.ts.